GAE
jsonp
IE6
ES3
hyperapp

MSを偲び、ここにIE6対応SPAの作り方を記す

JavaScript2 シェアフル Advent Calendar 2018 10 日目の記事です。

Microsoft EdgeがChromiumベースになるということで賑わっていますので、私も話に乗っかりたいと思い、IE6の話を書くことにしました。

BabelとParcelとHyperappでIE6対応SPA作ってみました

デモ: https://boiyaa-ie6-compatible-spa.appspot.com
ソース: https://github.com/boiyaa/ie6-compatible-spa

これを見ているほとんどのみなさんはIE6を持っていないと思うので、↑を開いてもただの簡素なSPAでしかありませんが。。
記事の最後に載せた画面キャプチャのようになります。

IE6で見る方法

Windows XP マシンを入手するか、BrowserStack の有料プランに入るとか、です。
BrowserStack の有料プランは一番安くて月39ドル1という、個人には手の出しづらい価格ですが、私は以前誤って年間プランに入会して348ドル支払ってしまっているので、IE6で見る手段を持っています。
というか、そもそもIE6対応サイトを作ったのも、なんとかBrowserStackの元を取らねばと思ったからで、仕事には全く関係ありません。

作るにあたっての縛り

当時の物を使って作るなら一定情報もありますが、それだと当時を思い出すだけのただの苦行になってしまうので、
少しでも作業を楽しくするために、できる限り今の技術を使って作ることにしました。
なので、SPAであることもそうですが、データを非同期で取得したり、コードはES6以降で書いたり、バンドラを使うことをルールにしました。

では、結局苦行になったIE6対応方法をご覧ください。

IE6対応Tips

IE6-8のJSはES3ベース+IE独自の実装で、CSSはめちゃくちゃですので、それらを1つ1つ対処していく作業になります。

ES3への変換

BabelもTypeScriptもES3にトランスパイルすることができます。
Babelであれば、以下のプラグインを使います。

Babel6用もあります。

バンドラを使った上でのES3への変換

Parcelやwebpackなどで、上記プラグインを使ってBabelしても、バンドラがコードを結合するために書き足している部分がES5のため、完全にES3にはなりません。
なので、バンドルする際に上記プラグインを使うのではなく、バンドル後のJSに上記プラグインでBabelする必要があります。

今回は Parcel を採用していますが、ParcelのJSPackagerを拡張することで、バンドルした最終的なコードに対して処理をかけます

bundler.js
class CustomJSPackager extends JSPackager {
  async setup() {
    const result = await super.setup();

    this.dest = {
      _buffer: "",
      path: this.dest.path,
      bytesWritten: 0,
      async write(data) {
        this._buffer += data;
      },
      async end() {
        // バンドルコードをES3に変換する処理を追加
        const code = babel.transformSync(this._buffer, {
          inputSourceMap: false,
          retainLines: true,
          minified: true,
          plugins: [
            "@babel/plugin-transform-member-expression-literals",
            "@babel/plugin-transform-property-literals",
            "@babel/plugin-transform-property-mutators",
            "@babel/plugin-transform-reserved-words"
          ]
        }).code;

        this.bytesWritten = code.length;
        return writeFileAsync(this.path, code);
      }
    };

    return result;
  }
}

const bundler = new Bundler("./src/index.html");

// jsのPackagerをCustomJSPackagerに変更
bundler.addPackager("js", CustomJSPackager);

コード全文
ゼロコンフィグが台無しですが、これでES3になります。

ES5のpolyfillは必要

上記でES3に変換というのは、シンタックス的に有効にさせるというだけなので、ES5で追加されたメソッドなどをいいように変換するわけではないので、polyfillを入れる必要があります。

@babel/polyfillが主流ではありますが、今回はES5 Shim/Shamで事足りるコードなのでより軽いこちらを使いました。
以下をHTMLに追加します。

<!--[if lt IE 9]>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/4.5.7/es5-shim.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/es5-shim/4.5.7/es5-sham.min.js"></script>
<![endif]-->

Getter/Setter系は諦める

get/set構文は変換できませんでした。
また、Object.definePropertyは、polyfillはありますが、get/setプロパティが使われているとエラーになります。

export from構文を使うと、Object.definePropertyのgetプロパティにトランスパイルされ、動作不能になります。
が、この場合は以下のプラグイン設定をすることで回避できます。

["@babel/plugin-transform-modules-commonjs", { "loose": true }]

Babel6の場合

["transform-es2015-modules-commonjs", { "loose": true }]

それでも、外部モジュールでexport from構文が使われてしまっていると対応できないので、
そこがライブラリ選定のポイントになります。

参考: https://babeljs.io/docs/en/caveats#getters-setters-8-and-below

モダンなUI系では Hyperapp が使われていなかったので、これで構築することにしました。

IE7以下ではEventListerのpolyfillができない

IE7以下にはElementがない = Element.prototype.addEventListerなどを作れないので、
Hyperappの中で使われているaddEventListerやremoveEventListerを動くようにできません。

対応方法として、HyperappではHTMLエレメント生成時にDocument.createElementをつかっているので、
Document.createElement_を作って、addEventListerメソッドを追加し、
バンドラでDocument.createElementをDocument.createElement_に書き換えます。

iefix.js
document.createElement_ = function(nodeName) {
  var element = document.createElement(nodeName);

  var registry = [];

  element.addEventListener = function(type, listener) {
    var target = this;

    registry.unshift([
      target,
      type,
      listener,
      function(event) {
        event.currentTarget = target;
        event.preventDefault = function() {
          event.returnValue = false;
        };
        event.stopPropagation = function() {
          event.cancelBubble = true;
        };
        event.target = event.srcElement || target;

        listener.call(target, event);
      }
    ]);

    this.attachEvent("on" + type, registry[0][3]);
  };

  element.removeEventListener = function(type, listener) {
    for (var index = 0, register; (register = registry[index]); ++index) {
      if (
        register[0] == this &&
        register[1] == type &&
        register[2] == listener
      ) {
        return this.detachEvent("on" + type, registry.splice(index, 1)[0][3]);
      }
    }
  };

  return element;
};

コード全文

bundler.js
      async end() {
        // さきほどのCustomJSPackagerに置換処理を追加する
        let code = this._buffer.replace(".createElement(", ".createElement_(");

        code = babel.transformSync(code, {
          inputSourceMap: false,
          retainLines: true,
          minified: true,
          plugins: [
            "@babel/plugin-transform-member-expression-literals",
            "@babel/plugin-transform-property-literals",
            "@babel/plugin-transform-property-mutators",
            "@babel/plugin-transform-reserved-words"
          ]
        }).code;

        this.bytesWritten = code.length;
        return writeFileAsync(this.path, code);
      }

IE9以下にはHistory APIが無いし、IE7以下にはhashchangeイベントすらない

サーバーを介さないページ遷移こそSPA足らしめるものですが、
通常は History API を使用し、非対応ブラウザであれば hash を使って実現するわけですが、表題の通りhashchangeイベントがありません。

なので、location.hashの変更をsetIntervalで検知することにしました。
History対応ブラウザは @hyperapp/router を使用し、それ以外はsetInterval方式でやるように@hyperapp/routerを少し改造します。

location.js
export default (window.history.pushState
  ? location
  : {
      state: {
        pathname: window.location.hash.slice(1),
        previous: window.location.hash.slice(1)
      },
      actions: {
        go: pathname => {
          location.hash = `#${pathname}`;
        },
        set: state => state
      },
      subscribe(actions) {
        const intervalID = setInterval(() => {
          const pathname = window.location.hash.slice(1);
          if (this.state.pathname !== pathname) {
            actions.set({
              pathname: pathname,
              previous: this.state.pathname
            });
          }
        }, 100);

        return () => clearInterval(intervalID);
      }
    });

コード全文

i18nextは普通に動作する

IE6のシェアは0.13%(あれ、今みたら0.29%に上がってる。。)とわずかしかないので世界をターゲットにしないと見てもらえないと思い、無駄に国際化対応を考えました。
そして意外にi18nextはすんなり動きました。i18next-browser-languagedetectorも動きました。
さすがにbackend系は無理だと思って試しませんでした。

Material design icons は対応している

Icon系はFontAwesomeが昔からあるイメージなので対応してるかなとおもいましたが、古いのでもIE7まででした。
これまた意外にも Material design icons が対応していました。以下のタグを挿入するだけです。

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

通常アイコンを使う時は<i class="material-icons">face</i>と書きますが、
IE9以下は合字非対応なので数値文字参照で<i class="material-icons">&#xE87C;</i>という風に書きます。

対応表

IE7以下には特定属性のsetAttributeの独自仕様がある

普通にHTMLでMaterial design iconsを書く分には問題ないのですが、Hyperappコンポーネント内で書くとなぜか表示されませんでした。

例えばHyperappコンポーネント内で<i class="icon material-icons">&#xe5d5;</i>と書くと、
Hyperappの内部でsetAttributeでclassを付ける処理を行いますが、
この時独自仕様にぶち当たっていました。

// なんと
setAttribute("class", "foo");
// は、以下のように書かないと適用されない・・!
setAttribute("className", "foo");

参考:
http://mashimonator.weblike.jp/blog/2009/07/jsie6ie7getattributesetattribute.html
http://nakawake.net/blog/web/javascript/iesetattributeremoveattribute.php

対処方法として、先ほど作ったDocument.createElement_にelement.setAttribute_を追加して、IE7以下用の処理を作り、バンドラでsetAttributeを書き換えました。

iefix.js
document.createElement_ = function(nodeName) {
  ...

  element.setAttribute_ = function(name, value) {
    if (name === "class") {
      return element.setAttribute("className", value);
    } else {
      return element.setAttribute(name, value);
    }
  };

  element.removeAttribute_ = function(name) {
    if (name === "class") {
      return element.removeAttribute("className");
    } else {
      return element.removeAttribute(name);
    }
  };

  ...
};

コード全文

bundler.js
        ...

        let code = this._buffer
              .replace(".createElement(", ".createElement_(")
              .replace("setAttribute", "setAttribute_")
              .replace("removeAttribute", "removeAttribute_");

        ...

コード全文

クロスオリジンで非同期にデータ取得する時、IE7以下はCORS非対応なのでJSONP

現実的にデータ取得元のAPIが別ドメインであることは多いので、通常はCORSで通信を実現しますが、IE7以下はCORS非対応なのでJSONPでやりとりすることにしました。

全てのIEで動作してJSONPにも対応したXHRライブラリとか今のものである気がしないので、諦めてjQueryのバージョン1世代を使うことにしました。
セキュリティアラートが出るようになって悲しいです。

$ npm install jquery@^1.12.4
WARN notice [SECURITY] jquery has the following vulnerability: 1 high. Go here for more details: https://nodesecurity.io/advisories?search=jquery&version=1.12.4 - Run `npm i npm@latest -g` to upgrade your npm version, and then `npm audit` to get more info.

ExpressはJSONPに変換するプロキシを作りやすい

ExpressはJSONPでのレスポンスをサポートしていました。 https://expressjs.com/en/api.html#res.jsonp

今時のAPIはJSONPに対応していないので、例えばGitHub APIに繋げたい場合、以下のようにExpressで簡単にJSONPを返すAPIにできました。

main.js
const express = require("express");
const p = require("phin");

express()
  .get("/", (_, response) =>
    p("https://api.github.com/users/boiyaa/repos?sort=updated").then(repos =>
      response.jsonp(JSON.parse(repos.body.toString()))
    )
  )
  .listen(8080);

コード全文

クライアントサイドで以下のように書けばデータが取得できます。

$.ajax({
  url: "//localhost:8080",
  jsonp: "callback",
  dataType: "jsonp"
}).done(repos => {
  //     ↑取得結果
});

コード全文

CSSハックはIE6だけにする

IE6ではほとんどのセレクタが使えないので、何が使えるか調べるよりスタイル当てたいところにクラスを指定するか直接スタイルを付ける方が楽ですね。
一周回ってCSS in JSと相性がいいとも言えますかね。とはいえ、styled-componentsやPicostyleなどのCSSクラスを生成するCSS-in-JSライブラリは中でIE8以下非対応のCSSStyleSheetを使っているので、使えませんでした。

あとはIEのバージョン毎にスタイルを適用するハックがありますが、そんなの使いこなしても今後のキャリアになんのメリットもない(この記事を書くこと自体もメリットないが)ので、IE6用ハックのみ使うことにし、CSSはIE7以上なら100%動くように書き、IE6非対応の部分にハックを使う感じでいきます。

.foo {
  position: fixed;
  _position: absolute; // IE6のみ適用されるプロパティハック
}
* html .foo { // IE6以下に適用されるセレクタハック
  position: absolute;
}

今回さらに Cascade Framework というIE6対応のCSSフレームワークを使いました。
もう数年メンテされていませんが、IE6以上のブラウザの差異を吸収してレイアウトを作るには十分だったので、これをベースに上記ハックで組み立てました。

<link href="https://cdnjs.cloudflare.com/ajax/libs/cascade-framework/1.5.0/css/build-full-no-icons.min.css" rel="stylesheet">

HyperappのinsertBeforeでInvalid argumentが発生する場合がある

IE8以下では、insertBeforeの第二引数にはDOM elementかnullを渡さないとエラーが発生するようになっているようですが、コンポーネントを書き進めていくうちに、Hyperapp内の処理で不正な第二引数が渡されるケースがありました。

ということで https://stackoverflow.com/questions/9377887/ie-doesnt-support-insertbefore を参考に、バンドラで変換しました。

        ...

        let code = this._buffer
              .replace(".createElement(", ".createElement_(")
              .replace("setAttribute", "setAttribute_")
              .replace("removeAttribute", "removeAttribute_")
              .replace(
                "insertBefore(newElement, element)",
                "insertBefore(newElement, element || null)"
              );

        ...

XPは最近のHTTPSにアクセスできない

IE6対応にはインフラ的観点も必要でした。。

最近はGoogle検索がHTTPSのサイトを優先的に扱うこともあってHTTPのサイトが減っていて、
XP(SP2)のIEはSSL 2.0, SSL 3.0, TLS 1.0に対応していますが、サイト側が脆弱性対応のため左記プロトコルをブロックしていることが多くて、
さらにXPはSNI非対応なので、閲覧できるサイトが少ないです。

なのでどこにでもホスティングできるわけではありませんでした。
検証した中では、Netlify と Firebase Hosting は非対応で、Google App EngineAmazon CloudFront (+ S3 / Lambda) は対応していました。

AWSだと長大な構成管理サンプルコードを作ることになってしまうので、今回は前述のJSONPプロキシ含め Google App Engine にしました。

Webサイト側設定API側設定

エビデンス

ホーム画面

Screen Shot 2018-12-09 at 7.51.51.png

技術スタック画面

Screen Shot 2018-12-09 at 7.52.22.png

非同期通信画面・ロード前

Screen Shot 2018-12-09 at 7.52.52.png

非同期通信画面・ロード中

Screen Shot 2018-12-09 at 7.53.52.png

非同期通信画面・ロード完了

Screen Shot 2018-12-09 at 7.53.52.png

言語変更

Screen Shot 2018-12-09 at 7.55.34.png

まとめ

金も時間も無駄に浪費してすごく後悔している。


  1. 時間制限のある低価格なフリーランスプランや、制限なし無料のオープンソースプランもあります。