GASとJSを使ってサイドバーで進行状況や途中結果を表示させる

## TL;DR
やたら時間のかかるGASの処理に対して、サイドバーを使って進行状況や途中結果を表示させるようにします。

## 大まかな方針
私が考える限り、実現方法はだいたい3通りありそうです。

1. 計算がある程度進むたびにサイドバーを起動しなおす
2. サイドバーの更新と計算を非同期に実行する
3. 計算のキックをサイドバーに任せる

で試したところ、3.が一番良さげな結果でした。
ひとまず1から順に説明していきます。ここではGoogleスプレッドシート上で何かやる、という想定です。

## 1. 計算がある程度進むたびにサイドバーを起動しなおす
この方法は以下の記事でほぼ解説されています。

- [GASのサイドバーで進行状況を表示させる](https://qiita.com/touta/items/ae58ae7693863e931151)

ただこれ、やってみるとわかるんですが__サイドバーが再起動するたびチラつく__んです。
単純に見づらいのもありますが、サイドバーを表示に使うだけじゃなくて、そこからコピペしたりサイドバー上で何か操作するような用途まで考えるとちょっと厳しいです。

## 2. サイドバーの更新と計算を非同期に実行する
なので再起動はせず、サイドバーはサイドバーで動かし、サイドバー内で表示を更新することを考えます。
つまり、以下の2つを同時にやります。

1. GASで計算をする
2. サイドバー上のJavascriptで計算を読み取り、表示する。

この場合当たり前ですが、表示部分はGASではなくJSでやることになります。なのでそっち側のデバッグにはブラウザのデベロッパーツールなんかを使います。

先に全部のコードを書いてしまいます。まずはGAS側。

```javascript:gas1.gs
const sheet = SpreadsheetApp.getActive().getSheetByName('作業用シート');
const cell = sheet.getRange(1, 1);

// サイドバーからキックされて進捗を返す
function getProgress() {
  return cell.getValue();
}

// 実際にする計算
function calc() {
  for (let i = 0 ; i < 10 ; i++) {
    Utilities.sleep(2000);  // 何か計算してる代わり
    cell.setValue(cell.getValue() + 1);
  }
}

// 状態の初期化
function init() {
  cell.setValue(0);
}

// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー');
  SpreadsheetApp.getUi().showSidebar(html);

  // 計算  
  calc();
}
```
こっちはサイドバーのHTMLです。

```html:サイドバー.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      function run() {
        id = setInterval(function() {
          google.script.run.withSuccessHandler(function(response) {
            if (response >= 10) {
              clearInterval(id);
              document.getElementById("progress").innerHTML = '終了';
            } else {
              document.getElementById("progress").innerHTML = response.toString();
            }
          }).getProgress();
        }, 1000);
      }
    </script>
  </head>
  <body onload="run()">
    現在: <span id="progress"></span>
  </body>
</html>
```
実行途中のスナップショット。
![スクリーンショット 2021-02-13 13.30.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202213/daf909f0-32e5-f7f1-95bd-b28a98b68136.png)

非同期なのでたまにシート上の内容とサイドバーの表示がずれますが、それはそういうものということで。
以下はコードの説明です。

### GAS側
計算本体はこの`calc()`です。ここにやりたいことを書きます。

```javascript

// 実際にする計算
function calc() {
  for (let i = 0 ; i < 10 ; i++) {
    Utilities.sleep(2000);  // 何か計算してる代わり
    cell.setValue(cell.getValue() + 1);
  }
}
```

一方で、サイドバーから叩かれたら途中結果を返すのがこの`getProgress()`です。

```javascript
// サイドバーからキックされて進捗を返す
function getProgress() {
  return cell.getValue();
}
```
そして、すべての計算の開始はここです。サイドバーを起動しつつ、計算を開始します。

```javascript
// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー');
  SpreadsheetApp.getUi().showSidebar(html);

  // 計算  
  calc();
}
```

### サイドバー側
サイドバー側はちょっとややこしいので、多めに説明します。

### サイドバーからGASを叩く仕組み
サイドバー内のJavascriptからGASの`foo()`を叩きたい場合、`google.script.run.foo()`と書けば可能です。
ただし、この関数は`foo()`の返り値を返しません。返り値を受け取る場合、`withSuccessHandler()`にハンドラとなるコールバック関数を登録して、そのコールバックの引数として返り値を受け取ります。
つまり、コールバック関数`function success(response) {...}`みたいなものを用意したとすると、`google.script.run.withSuccessHandler(success).foo()`と呼び出すことで`success()`の引数に`foo()`の結果が返ります。

この例ではGAS側の`getProgress()`を使って状況を取得したいので、`google.script.run.withSuccessHandler(success).getProgress()` を呼び出すことになります。

### `setInterval()`で定期的に状況をチェックする
`setInterval(bar, time)` とやれば`time`（ミリ秒単位）ごとに`bar`がキックされます。
これと、さっきの`google.script.run`を組み合わせて、定期的にGAS側の`getProgress()`を叩き、状況をチェックします。
なお処理終了が確認できたら`clearInterval()`を叩きます。使用方法は上のコードを参照。

### 問題点とかハマりポイントとか
この方法で済んでしまうことも多々あると思うのですが、1つだけ注意点があります。
`google.script.run`で叩かれる関数は__別プロセスで走ります__。つまり、GAS側と同じコードを叩いても__GAS側の計算途中の状態を`google.script.run`で叩いても直接読み取ることはできません__。
ただし__スプレッドシートの状態は共有できます__。なので、GAS側で途中の状態を返す関数（この例では`getProgress()`）はスプレッドシートから現在の計算状態を読み取る必要があります。

計算の状態がスプレッドシートにすぐ出ないか、出てもぱっと読み取れない場合、途中経過の値をどこか専用の別シートに書き出す方法もありますが、__それできるんなら別にサイドバー要らない__って場合も多いです。

## 3. 計算のキックをサイドバーに任せる
2の方法だとうまくハマらない場合に使えるのがこちらの方法です。私はこれでやることが多いです。
簡単に言うと、計算の主導権をサイドバー側にしてしまう方法です。

こちらも先にコードを貼ります。まずはGAS側。

```javascript:gas2.gs
// 実際にする計算
function calc(value) {
  Utilities.sleep(2000);  // 何か計算してる代わり
  value += 1;
  return value;
}

// 状態の初期化
function init() {
}

// スタート
function start() {
  // 初期化
  init();

  // サイドバー表示
  const html = HtmlService.createHtmlOutputFromFile('サイドバー2');
  SpreadsheetApp.getUi().showSidebar(html);
}
```
随分スッキリしました。ここではスプレッドシートを直接読み書きしていません。
（実際には`calc()`内で色々操作するんでしょうが）

次はサイドバーのHTMLです。

```html:サイドバー2.html
<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <script>
      function iterate(value) {
        google.script.run.withSuccessHandler(function(response) {
          if (response >= 10) {
            document.getElementById("progress").innerHTML = '終了';
          } else {
            document.getElementById("progress").innerHTML = response.toString();
            iterate(response);
          }
        }).calc(value);
      }
      function run() {
        iterate(0);
      }
    </script>
  </head>
  <body onload="run()">
    現在: <span id="progress"></span>
  </body>
</html>
```

### 考え方
この方法の基本的な考え方は、以下のようになります。

1. サイドバーから`calc()`をキック
2. `calc()`では1区切りごとの計算だけやって結果を返す
3. サイドバーは`calc()`の結果を受け取って表示、続きがあるなら`calc()`をまたキック
4. 2〜3を繰り返す

で、これを何も考えずにwhile文で書くとこんな感じでしょう。

```javascript
function run() {
  while (現在の状態が終了条件を満たさない) {
    var r = calc();
    表示(r);
    var 現在の状態 = なんか計算(r);
  }
}
```
これを、敢えて再帰で書き直します。

```javascript
function iterate(前の状態) {
  if (終了条件を満たさない) {
    var r = calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
}
function run() {
  iterate(初期状態）;
}
```
で、これをサイドバー内のスクリプトで実現します。
しかし`calc()`はGAS側なので、`google.script.run`を使って呼び出します。

```javascript
function iterate(前の状態) {
  if (終了条件を満たさない) {
    var r = google.script.run.calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
}
function run() {
  iterate(初期状態）;
}
```
しかしこれは動きません。
上の2の方法のところで説明したとおり、`google.script.run`でGAS側関数を呼び出したときの返り値は`withSuccessHandler()`を使ってコールバックで受け取らないといけません。
つまり、

```javascript

    var r = google.script.run.calc(前の状態);
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
```

この返り値__`r`を受け取って以降の処理はコールバック関数として別にする__必要があります。
すると、こうなります。

```javascript
function iterate(前の状態) {
  // コールバック関数
  var success = function(r) {
    var 現在の状態 = なんか計算(r);
    iterate(現在の計態);
  }
  if (終了条件を満たさない) {
    google.script.run.withSuccessHandler(success).calc(前の状態);
  }
}
function run() {
  iterate(初期状態）;
}
```

最初に示したコードと多少形が違いますが、理屈は同じです。


## さらなる工夫
### 途中でユーザーの確認を入れてもいい場合
で、3の方法も穴がないかと言うとそんなことはないです。
自分が色々やってみると、全体の計算がやたら重くて時間がかかる場合に意味不明のエラーで止まることがあります。
これは原因を突き止められていなくてあくまで想像なのですが…G Suiteではなく無料でGoogleスプレッドシートを使っている場合、GASで一度に6分以上計算をするとタイムアウトします。それと同様の原因で、計算リソースを食いすぎると止められてしまうのかもしれません。

で、そういう場合の対処として、計算途中でユーザとのインタラクションを入れていい場合はこんな工夫ができます。
コールバック関数で`iterate()`を呼ぶ代わりに、計算を継続するか確認するボタンを表示する`showButton()`を呼び出します。

```javascript
  var success = function(r) {
    var 現在の状態 = なんか計算(r);
    // iterate(現在の計態);     <- 以前のもの
    showButton(現在の計算);  // <- こうする
  }
```
`showButton()`の中では、

```html
<button data-status=現在の状態 onClick="iterate(this.dataset.status)">続きを計算する</button>
```
みたいなボタンを生成します。

これで、`calc()`を呼び出す前にユーザに続きを計算するか確認できるようになります。
経験上、これを入れると意味不明なエラーで落ちることはほぼないです。
また、元々全部の計算をする必要がなくて、必要な結果が得られたら止めたい（検索とか）場合にもこの方法は有効です。

### `withFailureHandler()`は積極的に使ったほうがいい

上のコードでは一切使っていませんが、`google.script.run`でGAS側の関数がエラーで落ちた場合のエラーハンドラを登録する `withFailureHandler()` があります。
単純な異常処理のためだけでなく、これをちゃんと使ったほうがデバッグの生産性も格段に上がるので、実際にここにあるようなコードを書くときは積極的に使った方がいいです。