Google Closure Library と Closure Compiler を思い出しながら ES6 を書く


Google Closure Tools

https://developers.google.com/closure/
https://developers.google.com/closure/compiler/
https://developers.google.com/closure/library/
8年ぐらい前に話題になったやつ。
Closure Library は YUIとかExtJSとか、JavaScriptでGUIアプリを作るためのライブラリ。
Closure Compiler はES6をトランスパイルするのにも使えるようになった。未使用関数を削除したりインライン展開したりもするのでトランスパイルより名前の通りコンパイルが近いイメージ。

今でも淡々と更新されていて、特に宣伝はされていないので流行っていない。8年ぐらい前も流行るところまでいけてなかった気がする。
フレームワークを作るためのライブラリ集という感じで、UIですぐ使える便利機能というものは無い。特にUI系ライブラリのデザインは当初のものと同じなので、そのまま使うと8年前のGoogle系サービスの管理画面のようなデザインになる。

5年ぐらい触ってなかったのでリハビリする。
ES6で書くのはChrome拡張以外では初めてなのでそれも込みで。

アコーディオンコンポーネントを作る

jQueryのslideToggleのようなもの。
goog.ui.Component を継承して書く。

goog.module('app.anim.Accordion');

const Component = goog.require('goog.ui.Component');
const EventHandler = goog.require('goog.events.EventHandler');
const EventType = goog.require('goog.events.EventType');
const AnimEvent = goog.require('goog.fx.Animation.EventType');
const PredefinedEffect = goog.require('goog.fx.dom.PredefinedEffect');
const style = goog.require('goog.style');
const log = goog.require('app.log');

const CSS_CLASS_BODY = goog.getCssName('js-accordion-body');

class Accordion extends Component
{
  /**
   * @param {Element} inputEl hidden switch input element
   * @param {number} time mili sec
   * @param {goog.dom.DomHelper=} opt_domHelper Optional DOM helper.
   */
  constructor(inputEl, time, opt_domHelper){
    log('Accordion#constructor');

    super(opt_domHelper);

    /**
     * radio or checkbox
     * @type {Element}
     * @private
     */
    this.inputEl_ = inputEl;

    /**
     * duration
     * @type {number}
     * @private
     */
    this.time_ = time;

    /**
     * animation logic
     * @type {AccordionAnim}
     * @private
     */
    this.anim_ = null;

  }

  /**
   * TODO: 動作未確認
   * @override
   */
  createDom(){
    this.setElementInternal(
      this.getDomHelper().createDom(
        goog.dom.TagName.DIV, CSS_CLASS_BODY));
  }

  update(e){
    if (this.anim_)
      this.anim_.dispose();

    const el = this.getElement();
    this.anim_ = new AccordionAnim(el, this.time_, this.inputEl_.checked);
    this.anim_.play();
  }

  /**
   * @override
   */
  enterDocument(){
    log('Accordion#enterDocument');
    super.enterDocument();

    this.getHandler().listen(this.inputEl_, EventType.CHANGE, this.update);
  }

  /**
   * @override
   */
  exitDocument(){
    log('Accordion#exitDocument');

    this.getHandler().unlisten(this.inputEl_, EventType.CHANGE, this.update);
    if (this.anim_)
      this.anim_.dispose();

    super.exitDocument();
  }


  /**
   * @override
   */
  disposeInternal(){
    log('Accordion#disposeInternal');
    this.inputEl_ = null;
    this.anim_ = null;

    super.disposeInternal();
  }
}

初期からあまり変わっていないgoog.ui.Componentextendsで継承しても特に問題なさそう。prototypeベースの親クラスにもgoog.baseの代わりにsuperで動く。

重いと嫌なので終了処理は細かく書いた。この辺の解説記事はほぼ無いと言っても過言ではない。
goog.ui.Componentの継承で気をつけるべきこと一覧
goog.ui.Componentのはぐれかた
昔の記事で、この二つぐらい。
まあ構造はずっと変わっていないようなので昔の本を読むのが良いのかもしれない。

このコードは上記リンク先と違って出力したHTMLに後からdecorateで対応させる用。HTMLが無い状態でDOM生成にも対応させるなら他のコントロール系コンポーネントのようにコンストラクタでinputElじゃなくて何らかのコントローラークラスを受け取るのが良さそう。今回は使わないので非対応。

アニメーションクラス。

/**
 * @extends {goog.fx.dom.PredefinedEffect};
 */
class AccordionAnim extends PredefinedEffect {

  /**
   * @param {Element} el content element
   * @param {number} time mili sec
   * @param {boolean} toOpen if true then open animation
   */
  constructor(el, time, toOpen){
    log('AccordionAnim#constructor');

    const start = toOpen ?
          0 : goog.style.getSize(el).height;
    const end = toOpen ?
          goog.style.getSize(el).height : 0;

    goog.style.setStyle(el, 'display', 'block');
    goog.style.setStyle(el, 'height', start + 'px');

    super(el, [start], [end], time);



    this.el_ = el;
    this.eh_ = new EventHandler(this);
    this.eh_.listenOnce(this, AnimEvent.END, function(e){
      if (!toOpen)
        goog.style.setStyle(el, 'display', '');
      goog.style.setStyle(el, 'height', '');
    });
  }

  /**
   * @override
   */
  updateStyle(){
    const height = this.coords[0];

    goog.style.setStyle(this.el_, 'height', height + 'px');
  }

  /**
   * @override
   */
  disposeInternal(){
    log('AccordionAnim#disposeInternal');

    this.eh_.dispose();
    super.disposeInternal();
  }
}

exports = Accordion;
/** @const {string} */
exports.CSS_CLASS_BODY = CSS_CLASS_BODY;

ここでしか使わないヘルパー的な処理なので同じファイルの下側に追記した。
DOMいじってる感がすごくある。でもES6でJavaっぽくなって、Closure Libraryの設計自体が元々Javaっぽいから、これはこれで書きやすくなったのではないかな?

このクラスを使うコードはこんな感じ。

// ... snip ...

const dom = goog.require('goog.dom');

const CSS_CLASS_AREA = goog.getCssName('js-accordion-area');
const CSS_CLASS_INPUT = goog.getCssName('js-accordion-input');

class App {

  // ... snip ...

  decorateAccordion(el){
    this.accs_ = [];
    goog.array.forEach(dom.getElementsByClass(CSS_CLASS_AREA, el), function(a){
      const inputEl = dom.getElementByClass(CSS_CLASS_INPUT, a);
      const bodyEl = dom.getElementByClass(Accordion.CSS_CLASS_BODY, a);

      if (!inputEl || !bodyEl)
        return;

      const acc = new Accordion(inputEl, 500);
      acc.decorate(bodyEl);

      this.accs_.push(acc);
    }, this);
  }
}

ES6が使えると言ってもArray.fromとかmapとかを直接使うのは危険。ユーティリティ系関数を使うとブラウザサポートに合わせて切り替えるコードが出力されるのでそっちを使う。

元コードはかなり冗長だけれど、コンパイル後は

        var c = Ua("w-x-y", b || a.a);
        b = Ua("w-x-z", b || a.a);
        c && b && (c = new Z(c,this.l),
        Wb(c, b),
        this.c.push(c))

のように展開される。これは最後の呼び出し部分。
goog.getCssName が展開され、その定数もインライン展開されている。

ここのCSSは

.js-accordion-area
{
    margin: 0;
}
.js-accordion-input
{
    display: none;
}
.js-accordion-input:not(:checked) ~ .js-accordion-body
{
    display: none;
}

.js-accordion-body
{
    overflow: hidden;
}

このような、よくあるJavaScript無しで表示/非表示を切り替えるCSS。

jQueryなら3行くらいで書けるものがこんなに長大になった。大抵のUI系ライブラリは用意されている機能を使って楽して早く作りたい人向け、Closure Libraryは自前実装したい人向けというか。
一応似たようなコンポーネントでgoog.ui.Zippyというクラスはあるんだけれど、これはなんか動きが違う。

HTMLは

<div class="@css('css-accordion-area')">
  <input type="checkbox" id="ac1" value="1" class="@css('js-accordion-input')">
  <label for="ac1" class="@css('title')">タイトル1</label>
  <div class="@css('js-accordion-body')">
    コンテンツ1
  </div>
</div>
<div class="@css('css-accordion-area')">
  <input type="checkbox" id="ac2" value="1" class="@css('js-accordion-input')">
  <label for="ac2" class="@css('title')">タイトル2</label>
  <div class="@css('js-accordion-body')">
    コンテンツ2
  </div>
</div>

こんな感じ。
@cssはサーバー側の関数呼び出しでClosure Stylesheetsの短縮形を出力している。使わずに普通のテキストでも良い。その場合はgoog.getCssNameも使わない。
JavaScript無しだと単なる表示/非表示切り替えのところを、スライドアニメーションを後から追加する形になる。

メモ

基本的に goog.providegoog.moduleexports に、goog.require はそのまま使える。

モジュールからスクリプトを呼び出す場合は普通にgoog.requireで良い。
スクリプトからモジュールを呼び出すにはgoog.scopeの中でgoog.module.getが必要。
https://github.com/google/closure-library/wiki/goog.module:-an-ES6-module-like-alternative-to-goog.provide

closure-compiler.jar のオプションはかなりややこしい。これは試すしかない。

モジュールはthiswindowではないが、Closure Tools はグローバル変数を結構使う。
モジュールじゃない設定系のファイルを作って読み込んだり、Closure Stylesheetsで出力したrenaming_map.jsを読み込むためにgoog.provide('renamingcss')を追記したりした。グローバルの処理は全部実行してくれると思っていたんだけど、ES6と組み合わせるとrequireしないと使えない?この辺の情報は全然分からなくて、推理力が試される。

あと素っ気ないAPIドキュメントがあるだけでマニュアルのようなものは無いので、困った時はソースコードを読むのが結局は早い。コード量は膨大だが昔ながらのJavaScriptで1ファイルが短いのですぐ読める。