[初心者向け] GoogleAppsScript(GAS)の開発環境をインクリメンタルに構築(TypeScript / Module / Polyfill)

# 本記事について

GoogleAppsScript(以下GAS)の開発環境を初心者でも分かるように順番に整えていきます。
https://developers.google.com/apps-script

この記事の内容と同じ開発工程をGithubに残してあります。
[cajonito/qiita_gas_local_develop](https://github.com/cajonito/qiita_gas_local_develop)

Dockerを使って構築したので読者の方々の環境にあまり依存せずに構築出来ると思います。

## 対象

- GASは開発環境が悪いから触りづらいと思ってる方
- GASでGit使いたい、好きなエディタ使いたい、TypeScript使いたい等の方
- GASで環境を作ろうとして挫折した方
- 難しい事はいいからとっととソースコードよこせな方

## 各段階のゴール

以下の順番で実現していきます。
気になるところだけでもどうぞ。

- [ローカル開発](https://qiita.com/cajonito/items/3a5c7da8965e28e485bf#%E3%83%AD%E3%83%BC%E3%82%AB%E3%83%AB%E9%96%8B%E7%99%BA)
    - Gitや好きなエディタが使えるようになります
- [TypeScript利用](https://qiita.com/cajonito/items/3a5c7da8965e28e485bf#typescript%E5%B0%8E%E5%85%A5)
    - 型が使えるようになります
- [Module利用](https://qiita.com/cajonito/items/3a5c7da8965e28e485bf#module%E5%88%A9%E7%94%A8)
    - 複数ファイルに分割して開発しやすくなります
- [最新のES構文をGASで使えるように](https://qiita.com/cajonito/items/3a5c7da8965e28e485bf#%E6%9C%80%E6%96%B0%E3%81%AEes%E6%A7%8B%E6%96%87%E3%82%92gas%E3%81%A7%E4%BD%BF%E3%81%88%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB)
    - GASで使えない最新の構文を使えるようになります

## 出てくるもの

- GAS
- Docker
- TypeScript
- WebPack

## 前提知識

GASを既に利用している方向けです。

また、Dockerを利用するのでどんなものかだけでも知っていると読みやすいと思います。
とりあえずDockerをインストールして同じように記述すれば同じ環境が作れるはずです。

## ソースコード

この記事のソースコードは下記のリポジトリで公開しています。
[cajonito/qiita_gas_local_develop](https://github.com/cajonito/qiita_gas_local_develop)

## 環境

macOS Catalina 10.15.3
Docker on mac 2.2.0.4

# ローカル開発

ローカル開発環境を整える事で色々なカスタマイズを行うことが可能になります。
特にGitが使えるようになるのは嬉しいですね。

## Claspの導入

Google公式ツールのClaspを導入すると簡単にGASとローカル環境間でコード転送を行えます。
[Command Line Interface using clasp](https://developers.google.com/apps-script/guides/clasp)
[google/clasp](https://github.com/google/clasp/)

### Claspのインストール

今後環境構築は全てDockerを利用していきます。
環境をコードとして残せますし、利用も簡単です。

まずDockerfileを作成します。
今回は構築当時最新だったnode:13.7-alpineというコンテナを利用しました。

```Dockerfile:Dockerfile
FROM node:13.7-alpine
WORKDIR /workdir
RUN yarn global add @google/clasp
```

Docker Composeも使います。
ボリュームの設定とか書いておけるので便利です。
コンテナは立ち上げっぱなしにして利用します。
VSCodeでコンテナに入って開発すると色々便利です。


```docker-compose.yml:docker-compose.yml
version: "3"
services:
  gas:
    build: .
    volumes:
      - .:/workdir
    working_dir: /workdir
    command: tail -f /dev/null
```

それではdocker-compose.ymlのあるディレクトリでコンテナを起動してClaspを使えるか確認してみましょう。

```
$ docker-compose up -d --build
(実行内容省略)

$ docker-compose exec gas clasp -v
2.3.0
```

これでDocker経由でClaspを利用出来るようになりました。

ここまでのディレクトリ構造

```
.
├── .git
├── docker-compose.yml
├── Dockerfile
└── README.md
```

### Claspの認証

ClaspでGAS上のプロジェクトと連携するためにはアカウント認証が必要です。
認証情報はコンテナ上の/root/.clasprc.jsonに配置される事を踏まえてdocker-compose.ymlを更新します。

```diff:docker-compose.yml
version: "3"
services:
  gas:
    build: .
    volumes:
      - .:/workdir
+     - ./.clasprc.json:/root/.clasprc.json
    working_dir: /workdir
    command: tail -f /dev/null
```

また、ボリュームをマウントする際に存在しないファイルはディレクトリとして新規作成されるので、予めファイルを作成しておきます。
この後の認証でファイルがうまく作成出来ない場合は同名のディレクトリが作られていないかご確認ください。

```
$ touch .clasprc.json
```

それでは早速認証をしてみましょう。

```
$ docker-compose exec gas clasp login --no-localhost
Warning: You seem to already be logged in *globally*. You have a ~/.clasprc.json
Logging in globally...
🔑 Authorize clasp by visiting this url:
https://accounts.google.com/o/oauth2/v2/auth...(省略)

Enter the code from that page here:(入力)
Authorization successful.

Default credentials saved to: ~/.clasprc.json (/root/.clasprc.json).
```

既に.clasprc.jsonがあると言われてますが、認証後上書きされるだけなので無視して大丈夫です。
表示されたURLにブラウザからアクセスし、GASのアカウントでログインして権限を許可します。
ログイン後表示されたコードをコピーしてEnter the code from page there:に入力します。
問題なければ.clasprc.jsonに認証内容が書き込まれています。

```.clasprc.json
{"token":{"access_token":"...(省略)
```

今後省略しますが.clasprc.json等Gitで取り扱いたくないものは適宜.gitignoreに追加していきます。

ここまでのディレクトリ構造

```
.
├── .clasprc.json
├── .git
├── .gitignore
├── docker-compose.yml
├── Dockerfile
└── README.md
```

### Clasp clone

認証ができたので早速GAS上のコードをローカルにcloneしましょう。
--rootDirを利用するとソースコードを取り扱うディレクトリを決定出来ます。

```
$ docker-compose exec gas clasp clone --rootDir ./src
? Clone which script? 

(GAS上のプロジェクトを選ぶ)

Warning: files in subfolder are not accounted for unless you set a '.claspignore' file.
Cloned 2 files.
└─ ./src/appsscript.json
└─ ./src/コード.js
Not ignored files:
└─ src/appsscript.json
└─ src/コード.js

Ignored files:
```

cloneすると.clasp.jsonというファイルが作成されます。
中に対応するスクリプトのIDなどが書かれています。

```.clasp.sjon
{"scriptId":"(省略)","rootDir":"./src"}
```


src/コード.jsに何か書き足してみます。

```javascript:src/コード.js
function myFunction() {
  // コード追加 
}
```

次はGASにローカルの変更を反映させるためにpushしてみましょう。

```
$ docker-compose exec gas clasp push
└─ src/appsscript.json
└─ src/コード.js
Pushed 2 files.
```

GAS上で確認してみるとローカルでの変更が反映されています。
<img width="412" alt="スクリーンショット 2020-03-22 17.13.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/00704a0b-a09e-7cb8-0a7e-f00be7b3d352.png">

注意点としては、claspはGAS上とローカルの競合の解決まで行いません。
GAS上のコードの上書きだけでなく、ローカルに存在しないファイルもpushの時点で削除されるのでご注意ください。

ここまでのディレクトリ構造

```
.
├── .clasp.json
├── .clasprc.json
├── .git
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── README.md
└── src
   ├── appsscript.json
   └── コード.js
```

さあこれでローカル環境での開発が可能になりました。
claspには他にも色々な機能があるのでご興味があればGithubの公式リポジトリを御覧ください。
[google/clasp](https://github.com/google/clasp/)

# TypeScript導入

[TypeScript: JavaScript For Any Scale.](https://www.typescriptlang.org/)
TypeScriptを使って開発したいニーズも多いと思います。
私は型によるデバッグの簡単さとコード補完が欲しくて導入しました。

実はClaspはTypeScriptに対応しており、特に新しいツールを導入せずともこの時点でTypeScriptは利用可能です。
[clasp/typescript.md at master · google/clasp](https://github.com/google/clasp/blob/master/docs/typescript.md)

早速試してみましょう。
既存のコード.jsは削除して、claspの公式サンプルコードをpushしてみます。

```typescript:src/main.ts
const greeter = (person: string) => {
  return `Hello, ${person}!`;
};

function testGreeter() {
  const user = "Grant";
  Logger.log(greeter(user));

  const age = 30;
  Logger.log(greeter(age)); // Argument of type '30' is not assignable to parameter of type 'string'.ts(2345)
}
```

下記の通りsrc/main.tsはmain.gsに変換されました。

<img width="412" alt="スクリーンショット 2020-03-22 18.11.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/0220324b-0325-bb7a-1f14-afa2589dccd0.png">

ただし、ご覧の通り引数personの型指定は無視されてしまいました。
claspはtsconfig.jsonの内容を反映させるらしいので、カスタマイズすることでpush時に型チェックされたりするのかな？
TypeScriptに対応したエディタを使っていればエディタ側で注意されるのでこの状態でも十分かもしれません。

# Module利用

下記のように現在GASはModule機能をサポートしておりません。

>Currently, Google Apps Script does not support ES modules.
[Modules, exports and imports - clasp/typescript.md at master · google/clasp](https://github.com/google/clasp/blob/master/docs/typescript.md#modules-exports-and-imports)

GASにはプロジェクト内に複数のスクリプトを作成出来ますが、スコープは共有されています。
Module機能を使って開発したい場合は、ローカル環境で構築する必要があります。

## webpack導入

私は最初はBrowserifyを使っていましたがwebpackの方が簡単だったのでこちらを紹介します。
[webpack](https://webpack.js.org/)

webpackでModuleの解決も行いつつ、TypeScriptのコンパイルもやってしまい、生成されたjsファイルをclasp pushする形になります。

### Node.jsパッケージ取り扱いのための準備

今後webpackを含め様々なNode.jsパッケージを導入するのでそのための準備をします。
まずはpackage.jsonとyarn.lockを作成しておきましょう。
インストールしたパッケージはpackage.jsonとyarn.lockに記述されていきます。
パッケージを削除してもこの2つのファイルがあればyarn installで復元出来ます。

package.jsonはyarn initすると対話的に作成出来ます。

```
$ docker-compose exec gas yarn init                                                                                                                      master
yarn init v1.21.1
question name (workdir): qiita_gas
question version (1.0.0):
question description:
question entry point (index.js):
question repository url:
question author:
question license (MIT):
question private:
success Saved package.json
Done in 8.91s.

$ touch yarn.lock
```

Dockerfileを修正し、ビルド時にpackage.jsonとyarn.lockを用いてパッケージをインストールするようにしましょう。
こうする事で、色々インストールしたコンテナを削除してしまってもビルド時に全パッケージをバージョンを含めて再構築することが出来ます。
Dockerのキャッシュはpackage.jsonの中身の更新まで認識してくれないのでビルド時にはキャッシュを使わないようにご注意ください。


```diff:Dockerfile
FROM node:13.7-alpine
WORKDIR /workdir
RUN yarn global add @google/clasp
+ COPY package.json yarn.lock /workdir/
+ RUN yarn install
```

Node.jsパッケージはnode_modulesディレクトリに置かれます。

筆者はVSCodeでコンテナに入ってコーディングするため、コンテナ上のnode_modulesにのみパッケージを置いて、ローカルマシンのnode_modulesは空にしようと思います。
現在はルートディレクトリを全てコンテナ上にマウントしていますが、このままだとビルド時に作成されたnode_modulesディレクトリをローカルの空のnode_modulesで上書きしてしまいます。
これを回避するためにdocker-compose.ymlを修正します。

```diff:docker-compose.yml
version: "3"
services:
  gas:
    build: .
    volumes:
      - .:/workdir
      - ./.clasprc.json:/root/.clasprc.json
+     - /workdir/node_modules
    working_dir: /workdir
    command: tail -f /dev/null

```

このように記述するとnode_modulesには毎回空のVolumeがマウントされます。
空のVolumeをマウントする場合、DockerはDockerImage上で既に存在していたファイルを維持するようです。
既に中身が存在するVolumeをマウントする場合はその内容で上書きするので要注意です。

これについては以下の記事が参考大変参考になりました。
[node_modulesを隔離したい - Qiita](https://qiita.com/hyamatan/items/0d8c0b318f71aa130fce)

docker-compose.ymlの変更を反映させましょう。
試しにコンテナ上のnode_modulesディレクトリ内にファイルを作成しても、ローカル環境には現れないことが確認出来ます。

### webpackのインストール

準備が出来たのでwebpackをインストールしていきます。
今回はTypeScriptを取り扱うのでtypescriptとts-loaderと型定義ファイルもインストールします。

```
$ docker-compose exec gas yarn add --dev webpack webpack-cli typescript ts-loader @types/google-apps-script
```

```diff:package.json
{
  "name": "qiita_gas",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
+ "devDependencies": {
+   "@types/google-apps-script": "^1.0.11",
+   "ts-loader": "^6.2.1",
+   "typescript": "^3.8.3",
+   "webpack": "^4.42.0",
+   "webpack-cli": "^3.3.11"
+ }
}
```

[TypeScript | webpack](https://webpack.js.org/guides/typescript/)
上記公式ドキュメントを参考にwebpack.config.jsを作成します

```js:webpack.config.js
const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/main.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  output: {
    filename: 'main.gs',
    path: path.resolve(__dirname, 'dist'),
  },
}
```

tsconfig.jsonも作成します。

```json:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
  }
}
```

webpackのモジュールをまとめる挙動を確認するためにコードを2つに分けてみます。

```typescript:main.ts
import { greeter } from "./module";

function testGreeter() {
  const user = "Grant";
  Logger.log(greeter(user));
}
```

```typescript:module.ts
export const greeter = (person: string) => {
  return `Hello, ${person}!`;
};
```

ここまででディレクトリ構成は以下のようになっています。

```
.
├── .clasp.json
├── .clasprc.json
├── .git
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── node_modules
├── package.json
├── README.md
├── src
│  ├── appsscript.json
│  ├── main.ts
│  └── module.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
```

webpackを実行してみましょう。

```
$ docker-compose exec gas yarn webpack
yarn run v1.21.1
$ /workdir/node_modules/.bin/webpack
Hash: 87d76effee625fe5918e
Version: webpack 4.42.0
Time: 2883ms
Built at: 03/22/2020 12:04:56 PM
  Asset      Size  Chunks             Chunk Names
main.gs  4.03 KiB       0  [emitted]  main
Entrypoint main = main.gs
[0] ./src/main.ts 204 bytes {0} [built]
[1] ./src/module.ts 155 bytes {0} [built]
Done in 4.76s.
```

webpack.config.jsの設定に則ってdist/main.gsさ生成されているはずです。

```js:dist/main.gs
/******/ (function(modules) { // webpackBootstrap
/******/        // The module cache
/******/        var installedModules = {};

(省略)
```

今後GASにアップロードしたいのはコンパイル後のdist/main.gsです。
clasp pushでdist内のファイルをアップロードするように変更していきます。
.clasp.jsonを下記のように編集します。

```diff:.clasp.json
- {"scriptId":"(省略)","rootDir":"./src"}
+ {"scriptId":"(省略)","rootDir":"./dist"}
```

次に、src/appsscript.jsonをdistディレクトリに移動させます。

```
$ mv src/appsscript.json dist
```

これでclasp pushでdistディレクトリの内容をpushできるようになりました。

```
$ docker-compose exec gas clasp push
└─ dist/appsscript.json
└─ dist/main.gs
Pushed 2 files.
```

<img width="451" alt="スクリーンショット 2020-03-22 21.12.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/73ba49e8-2646-7006-99e3-ef406bc855a5.png">

ここまでのディレクトリ構成は以下のようになっています。

```
.
├── .clasp.json
├── .clasprc.json
├── .git
├── .gitignore
├── dist
│  ├── appsscript.json
│  └── main.gs
├── docker-compose.yml
├── Dockerfile
├── node_modules
├── package.json
├── README.md
├── src
│  ├── main.ts
│  └── module.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock

```

## webpackでアップしたファイルをGAS上で利用できるようにする(重要)

実はこの時点ではGAS上から関数を実行出来ません。
webpackを利用するとwebpackのスコープ内に関数が含まれる形になりますが、GASではglobalスコープの関数のみを実行できます。
gas-webpack-pluginを導入することでこれを解決することができます。
[fossamagna/gas-webpack-plugin: Webpack plugin for Google Apps Script](https://github.com/fossamagna/gas-webpack-plugin)


早速インストールします。

```
$ docker-compose exec gas yarn add --dev gas-webpack-plugin
```

```diff:package.json
{
  "name": "qiita_gas",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "@types/google-apps-script": "^1.0.11",
+   "gas-webpack-plugin": "^1.0.2",
    "ts-loader": "^6.2.1",
    "typescript": "^3.8.3",
    "webpack": "^4.42.0",
    "webpack-cli": "^3.3.11"
  },
}

```

webpack.config.jsを編集してgas-webpack-pluginを導入します。

```diff:webpack.config.js
const path = require('path');
+ const GasPlugin = require("gas-webpack-plugin");

module.exports = {
  mode: 'production',
  entry: './src/main.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  output: {
    filename: 'main.gs',
    path: path.resolve(__dirname, 'dist'),
  },
+ plugins: [
+   new GasPlugin()
+ ]
}
```

main.tsを編集してGASから呼び出したい関数をglobalのメソッドにします。

```typescript:main.ts
import { greeter } from "./module";

declare var global: any;

global.testGreeter = () => {
  const user = "Grant";
  Logger.log(greeter(user));
};
```

再度webpackをして結果を確認します。

```js:dist/main.gs
function testGreeter() {
}/******/ (function(modules) { // webpackBootstrap
/******/ 	// The module cache
/******/ 	var installedModules = {};

(省略)
```

先頭にtestGreeter()関数が追加されました。
これでGAS上からtestGreeter()関数を実行することができます。


### gas-webpack-pluginが上手く動かない場合

もし上手く行かなかったら以下の点を確認してください。

- webpackで吐き出すファイルの拡張子が".gs"である必要がある
- productionモードである必要がある


# 最新のES構文をGASで使えるように

最近のアップデートでV8ランタイムを使うようになったことでGASでもモダンな書き方が出来るようになりました。
[V8 Runtime Overview  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/guides/v8-runtime/migration)

V8以前はletやconstすら使えなかったので、トランスパイルする必要がありました。
V8になって殆ど気にならなくなりましたが、最新の構文をGASで使いたいというニーズは今後もあると思うので、トランスパイルの方法をご説明します。

今回は例のためにあえてV8を無効にして古いGASの記述に合わせる形で検証していこうと思います。

先程から使っているsrc/main.tsをサンプルに使います。

```typescript:src/main.ts
import { greeter } from "./module";

declare var global: any;

global.testGreeter = () => {
  const user = "Grant";
  Logger.log(greeter(user));
};
```

tsconfig.jsonのtargetを指定する事でコンパイル後のバージョンを指定する事が出来ます。
まずはes2019にしてみましょう。

```json:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es2019"
  }
}
```

<img width="720" alt="スクリーンショット 2020-03-23 01.17.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/5c4ca575-1793-2ac9-60ae-8fcb2114eb62.png">

V8を無効にするとエラーになってしまいました。
当時はアロー演算子が使えなかったのです。

次はtargetをes5にしてみます。

```diff:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
-   "target": "es2019"
+   "target": "es5"
  }
}
```

<img width="605" alt="スクリーンショット 2020-03-23 01.19.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/f9ef3a03-37d5-5fd5-aa2d-bc0dfe5769e3.png">

今度は無事実行できました。
上記のようにアロー演算子が置き換えられています。

ただし、この状態でもArray.prototype.flat()のようなES5には存在しなかった関数は使うことが出来ません。
TypeScriptの場合、ts-polyfillを使えばtargetに存在しなかった関数の定義を埋め込む事で利用可能になります。
[ts-polyfill - npm](https://www.npmjs.com/package/ts-polyfill)

```
$ yarn add --dev ts-polyfill
```

tsconfig.jsonのlibパラメータを設定すると利用するライブラリを指定することが出来ます。


```diff:tsconfig.json
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "es5",
+   "lib": [
+     "DOM",
+     "DOM.Iterable",
+     "ES2015",
+     "ES2016.Array.Include",
+     "ES2017.Object",
+     "ES2017.String",
+     "ES2018.AsyncIterable",
+     "ES2018.Promise",
+     "ES2019.Array",
+     "ES2019.Object",
+     "ES2019.String",
+     "ES2019.Symbol",
+     "ES2020.Promise",
+     "ES2020.String",
+     "ES2020.Symbol.WellKnown"
+   ]
  }
}
```

```diff:main.ts
import { greeter } from "./module";
+ import 'ts-polyfill/lib/es2019-array';

declare var global: any;

global.testGreeter = () => {
  const user = "Grant";
  Logger.log(greeter(user));
};

+ global.testArrayFlat = () => {
+   const a = [[1, 2], 3, 4];
+   Logger.log(a.flat());
+ };

```

この結果、webpackで出力されたdist/main.gsの行数がとんでもないことになります。
どうやらこの中にArray.prototype.flat()の実装が含まれているようです。

<img width="340" alt="スクリーンショット 2020-03-23 01.35.36.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77401/f21c5c7d-1f11-c96f-709d-3722e8cdba08.png">

このように、V8以前には存在しなかったArray.prototype.flat()の恩恵に預かることができました。

# まとめ

いかがでしたでしょうか。
この記事で少しでもGAS利用者の生産性が向上すれば幸いです。