[GAS] スプレッドシートとHtmlServiceでHTMLパーツを文字列データとして量産する

## はじめに
商品情報をスプレッドシートで管理されている方からご相談を受けたときのお話。スプレッドシートから商品情報を参照し、商品ごとのＨＴＭＬパーツを自動生成したい、というもの。生成したＨＴＭＬはブラウザ表示させるのではなく、商品ごとの文字列データとして、スプレッドシートで管理したい、という追加の要望もありました。

今回はそんな要望に適した、ＧＡＳをテンプレートエンジン的に使用する小ネタです。

基本的なロジックとしては、テンプレートとなるＨＴＭＬパーツを取得し、目印として埋め込んだマーカー（以降、`handler`）を差し替えるという流れになります。この際ＧＡＳのHtmlServiceを利用しますが、そのままレンダリングするのではなく文字列として生成する点で、通常の利用用途とは異なります。

### HtmlServiceについて
利用用途の多くはGoogleサイトやGoogleフォームでは使えない機能を実装したい場合やＳＰＡ（Single Page Application）など、より自由度の高いＵＩ構築が必要な場合かと思います。そのため、通常はスタンドアロンスクリプトとして構築し、デプロイ、httpsレスポンス表示、もしくはコンテナバインドスクリプトにおけるカスタムプロンプト表示用という使い方がメインです。

## 目的
プロジェクト内にＨＴＭＬパーツを登録するため、クライアント様によっては管理しづらいと敬遠されることもありますが、本記事では、HtmlServiceを使ってＨＴＭＬデータを生成します。

なお、同様の相談を受けた際、データ内容によってテンプレートを変更したいという要望も多いため、ケースに応じてテンプレートも変更するようにします。

## ロジック
１００件のダミー商品データ[^1] に対し、差し替える項目は６項目、テンプレートは２種類としました。どちらのテンプレートを使用するかはデータ内で指定しているものとします。以下、ダミーデータのキャプチャです。
![スクリーンショット 2021-04-06 210749.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1111613/476cc528-0364-c0cc-09bc-93c6a0ff016e.png)

[^1]: [Mockaroo](https://www.mockaroo.com)でダミーのCSVデータを生成。

### プロジェクトへテンプレート登録
スクリプトエディタより、プロジェクト内にHTMLを追加し、それぞれ「typeA」、「typeB」としておきます。

```HTML:typeA.html
<section>
  <div>
    <h1><?=NAME ?></h1>
    <div>
      <img src="<?=IMG ?>" alt="<?=NAME ?>">
    </div>
    <div><span><?=PRICE ?></span></div>  
    <div>
      <span>カラー:<?=COLOR ?></span>
    </div>
    <div>
      <span>サイズ:<?=SIZE ?></span>
    </div>
    <hr>
    <div>
      <p><?=DESCRIPTION ?></p>
    </div>
  </div>
</section>
```

```HTML:typeB.html
<section>
  <div>
    <h1><?=NAME ?></h1>
    <div>
      <img src="<?=IMG ?>" alt="<?=NAME ?>">
    </div>
    <div><span><?=PRICE ?></span></div>
    <div>
      <p><?=DESCRIPTION ?></p>
    </div>
  </div>
  <div>
    <div>
      <label>カラー:</label>
      <select>
        <?!=COLOR ?>
      </select>
    </div>
    <div>
      <label>サイズ:</label>
      <select>
        <?!=SIZE ?>
      </select>
    </div>
  </div>
</section>
```

サンプルのため小規模かつそれほど差異はないですが、それぞれ`handler`の名称は同一とし、テンプレート固有の`handler`は設置していません。相違点としては、テンプレート`typeB.html`についてはサイズとカラーにバリエーションがあり、`<option>`を生成する必要があるということです。なお、シート上の当該データはカンマ区切りとなっています。

補足ですが、`typeB.html`上で使用している`handler`の内、`<?!=XXXXX ?>`はタグ（＜、＞）も含めて出力されるため、[^2] セキュリティの観点からＷＥＢ公開するもの、特にユーザー入力結果を出力するものについては使用しないほうが良いです。[^3]

[^2]: `<?=XXXXX ?>(Printing scriptlets)`とは異なり、`contextual escaping`（出力先のコンテキストを解釈し、適切なエスケープ処理を施す）がされない。
[^3]: `Force-printing scriptlets`…注意事項として[公式ガイド](https://developers.google.com/apps-script/guides/html/templates)にも明記されています。

### GASの記述
```javascript
function generateHTML() {
  const SS       = SpreadsheetApp.getActive()
  const ShData   = SS.getSheetByName('XXXXX') // データシート
  const ShDeploy = SS.getSheetByName('XXXXX')　// HTML展開用シート
  const HTMLs    = [] // 生成HTMLセット用配列

  // テンプレート読み込み
  const HtmlTmps = [
    HtmlService.createTemplateFromFile('typeA'),
    HtmlService.createTemplateFromFile('typeB')
  ]

  ShData.getDataRange() // データ取得
  .getValues()
  .slice(1) // ヘッダー行削除
  .forEach(row => { // 順次テンプレートへ展開
    let flag = row[3] === 'A'? true: false
    let html = flag? HtmlTmps[0]: HtmlTmps[1] // テンプレートの選択
    html.NAME        = row[2]
    html.IMG         = row[4]
    html.PRICE       = row[5]
    html.COLOR       = flag? row[6]: genOption(row[6])
    html.SIZE        = flag? row[7]: genOption(row[7])
    html.DESCRIPTION = row[8]

    HTMLs.push([html.evaluate().getBlob().getDataAsString()]) // 生成されたHTMLパーツを配列へセット
  })

  // 指定列へ展開
  ShDeploy.getRange(2, 3, HTMLs.length, 1).setValues(HTMLs)
}

function genOption(val){
  let opt = ''
  val.split(',')
  .forEach(val => {
    opt += `<option value="${val}">${val}</option>\n`
  })
  return opt
}
```

#### 1-テンプレートの読み込み
事前にテンプレートを読み込み、配列`HtmlTmps`へセットしておきます。

#### 2-データを取得し順次テンプレートへ展開
ヘッダー行を削除した`ShData.getDataRange().getValues().slice(1)`を`forEach`で順次テンプレートへ展開します。この際、やりがちなミスは、大きく分けて以下の２点かと思います。当然エラーになります。

+ テンプレートに設定していない`handler`への値セット
概ねタイプミスかデバッグ時の消去・追加漏れが原因です。

+ 設定した`handler`へ値をセットできていない（もしくはセットしていない）
ループ時や条件分岐のロジック不備の他、単純ミスも多いと思います。

テンプレート選択は２択なので、今回はフラグをセットし、３項演算子で分岐しています。テンプレートが３つ以上の場合、`switch文`を使うなど適宜修正の必要があります。

#### 3-テンプレートを文字列データとして配列へセット
本記事の肝の部分となります。`html.evaluate()`で`HtmlOutput`として通常展開した後、文字列として取得するには、`getBlob()`で`Blob`変換を経由[^4] して`getDataAsString()`とする必要があります。

[^4]: `getAs('text/html')`で直接取得できればよいのですが、こちらも`Blob`が返って来ます。

## 最後に
今回は単純な構造で短い記述のＨＴＭＬパーツということもあり、１００件生成で約１３秒ほどでした。当然、テンプレートの規模が大きい場合や何十種類もあるようなケースでは、同じ件数でも処理時間が変わってきます。特にテンプレートの規模によってはリソースの無駄遣いが気になりますので、最大公約数部分を抽出するなどの配慮も必要かと思います。

余談ですが、テンプレート内にJavascriptも記述できますが、執筆時点ではコード末尾の「;」は省略不可です。

### [参考]
+ [Class HtmlService  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/html/html-service)
+ [HTML Service: Templated HTML  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/guides/html/templates)