GAS を npm パッケージ + Webpack + TypeScript で開発する

## 概要

npm パッケージを使いつつ TypeScript で GAS を書いていく環境を作ったので、整理を兼ねてメモします。

## 前提

- clasp を使って GAS を開発している。
- モダンなフロントエンドの開発手法を理解している。

## ゴール

- GAS をローカル環境上で Babel, TypeScript を使って開発していける環境の構築
- Spreadsheetの `onOpen`, `onInstall` 等にも関数をバインドできる

## 説明しない事

- clasp とは
- Node.js, npm, Babel, TypeScript
- Webpack の使い方

## 環境構築手順

### clasp に管理させるディレクトリを変更する

GASの開発は普通にやっていくと root ディレクトリ直下に全ファイルが配置される形になるかと思いますが、npmのエコシステムを使う場合は `package.json` や `node_modules/` が配置されたりして管理が難しくなっていくので、以下のようにディレクトリ構造を変更してしまいます。

    ~/
     ├ dist/
     │  └ appsscript.json
     ├ src/
     ├ .clasp.json
     ├ .claspignore
     ├ ...

clasp には `dist/` 配下をリリース対象のスクリプトと認識させるため、 `.clasp.json` に `"rootDir"` というフィールドを追加します。 (https://github.com/google/clasp#project-settings-file-claspjson)

```json:.clasp.json
{
  "scriptId": "<SCRIPT_ID>",
  "rootDir": "./dist"
}
```

`clasp push` で `dist/` 配下のみが push されるようになります。
確認してませんが、多分 pull も dist 配下に降ってくるんじゃないかと思います。

### Babel, TypeScript 周り

最低限以下を入れます。 (Webpack, loader その他は適宜入れてください)

- `@babel/core`
- `@babel/preset-env`
- `@babel/preset-typescript`
- `@types/google-apps-script`
- `typescript`

### Webpack 環境構築

#### 必要なプラグイン

##### gas-webpack-plugin

[fossamagna/gas\-webpack\-plugin: Webpack plugin for Google Apps Script](https://github.com/fossamagna/gas-webpack-plugin)

GAS の関数は `global` に登録されるようトップレベルで関数宣言をする必要がありますが、 Webpack で 1 ファイルにバンドルすると Webpack の関数スコープに閉じ込められてしまい `global` から参照出来ない形で出力されてしまいます。

`gas-webpack-plugin` はこれを解決する物で、コード中の `global` オブジェクトへの代入を検出して Webpack のバンドルファイルにトップレベルの関数宣言を注入してくれます。

##### es3ify-webpack-plugin

[BryceHQ/es3ify\-webpack\-plugin: A simple webpack plugin to es3ify your code for old versions of ie, such as ie8\.](https://github.com/BryceHQ/es3ify-webpack-plugin)

GAS は ECMAScript の 5 とか 5.1 とかで書かないと動作しないみたいなんですが、以下のようにビルドツール、バンドラーを使って ES5 として出力されるコード自体の一部が動作しない場合があります。

```js
exports.default = foo;
```

これは次のように書き直す事で GAS のエディタに怒られなくなります。

```js
exports['default'] = foo;
```

しかし自動生成ファイルを手作業で修正するのはさすがに無いので、 es3ify を使って予約語まわりをいい感じにしてもらいます。

ちなみに `es3ify-webpack-plugin` の README には `es3ify-loader` 使えって書いてありますが、今回そっちは試さなかったのでここでは `es3ify-webpack-plugin` を前提に書いていきます。

#### 設定ファイル

こんな感じになりました。 TypeScript のトランスパイルには `@babel/preset-typescript` を使っています。

```js:webpack.config.js
const path = require('path');
const GasPlugin = require('gas-webpack-plugin');
const Es3ifyPlugin = require('es3ify-webpack-plugin');

module.exports = {
  mode: 'development',
  devtool: 'inline-source-map',
  context: __dirname,
  entry: {
    main: path.resolve(__dirname, 'src', 'index.ts')
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.js'
  },
  resolve: {
    extensions: ['.ts', '.js']
  },
  module: {
    rules: [
      {
        test: /\.[tj]s$/,
        loader: 'babel-loader'
      }
    ]
  },
  plugins: [new GasPlugin(), new Es3ifyPlugin()]
};
```

```json:.babel.rc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "browsers": ["ie 8"]
        }
      }
    ],
    "@babel/preset-typescript"
  ]
}
```

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "lib": [
      "es2015",
      "es2016",
      "es2017",
      "es2018"
    ],
    "allowJs": true,
    "declaration": false,
    "outDir": "./dist",
    "rootDir": "./src",
    "downlevelIteration": true,

    "strict": true,
    "noImplicitAny": true,

    "noUnusedLocals": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,

    "moduleResolution": "node",
    "esModuleInterop": true,

    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["./src/**/*"],
  "exclude": [".git", "node_modules"]
}
```

tsconfig.json はまだ理解してない部分も多いから冗長かも。

環境構築は以上になります。

## 開発

これでモダンなフロントエンドと同じやり方で開発を進められるようになりましたが、エントリーポイントだけちょっと対応が必要になります。

GAS で登録したい関数を `global` オブジェクトのプロパティに代入していきます。
この時に型定義が無いかもなのですが、今回そこらへんは適当にやってしまいました。

```src/index.ts
import { MyFunc } from "./my-func"

// プロパティが無いと言われるのを防ぐ程度の型定義
declare const global: {
  [x: string]: any ;
}

global.onOpen = function(e: any) {
  return MyFunc(e)
}

global.onInstall = function(e: any) {
  global.onOpen(e)
}
```

`webpack` コマンドでビルドし、 `clasp push` でデプロイ完了です。

### watch

npm パッケージの `watch` を使うことで、 `webpack` の `watch` モードのように変更の度にビルドと `clasp push` を行えるようになります。

```json:package.json
{
  "scripts": {
    "build": "node_modules/.bin/webpack",
    "watch": "node_modules/.bin/watch 'npm run build && clasp push' ./src"
  }
}
```

以上です。