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

[JavaScript2](https://qiita.com/advent-calendar/2018/javascript_02)  [シェアフル](https://adventar.org/calendars/3523) 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でしかありませんが。。
記事の最後に載せた[画面キャプチャ](#%E3%82%A8%E3%83%93%E3%83%87%E3%83%B3%E3%82%B9)のようになります。


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

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


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

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


## IE6対応Tips

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


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

- [@babel/plugin-transform-member-expression-literals](https://babeljs.io/docs/en/babel-plugin-transform-member-expression-literals)
- [@babel/plugin-transform-property-literals](https://babeljs.io/docs/en/babel-plugin-transform-property-literals)
- [@babel/plugin-transform-property-mutators](https://babeljs.io/docs/en/babel-plugin-transform-property-mutators)
- [@babel/plugin-transform-reserved-words](https://babeljs.io/docs/en/babel-plugin-transform-reserved-words)

Babel6用もあります。

- [babel-plugin-transform-es3-member-expression-literals](https://babeljs.io/docs/en/babel-plugin-transform-es3-member-expression-literals)
- [babel-plugin-transform-es3-property-literals](https://babeljs.io/docs/en/babel-plugin-transform-es3-property-literals)
- [babel-plugin-transform-es5-property-mutators](https://babeljs.io/docs/en/babel-plugin-transform-es5-property-mutators)


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

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

今回は [Parcel](https://parceljs.org/) を採用していますが、ParcelのJSPackagerを拡張することで、バンドルした最終的なコードに対して処理をかけます

```js: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);
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/scripts/bundler.js)
ゼロコンフィグが台無しですが、これでES3になります。


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

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

```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プロパティにトランスパイルされ、動作不能になります。
が、この場合は以下のプラグイン設定をすることで回避できます。

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

Babel6の場合

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

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

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

モダンなUI系では [Hyperapp](https://github.com/jorgebucaran/hyperapp) が使われていなかったので、これで構築することにしました。


### IE7以下ではEventListerのpolyfillができない
IE7以下にはElementがない = Element.prototype.addEventListerなどを作れないので、
[Hyperappの中で使われているaddEventListerやremoveEventLister](https://github.com/jorgebucaran/hyperapp/blob/master/src/index.js#L189-L192)を動くようにできません。

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

```js: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;
};
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/src/iefix.js)

```js: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](https://developer.mozilla.org/ja/docs/Web/API/History) を使用し、非対応ブラウザであれば hash を使って実現するわけですが、表題の通りhashchangeイベントがありません。

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

```js: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);
      }
    });
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/tree/master/web/src/router)


### i18nextは普通に動作する
[IE6のシェア](https://en.wikipedia.org/wiki/Internet_Explorer_6#Market_share)は0.13%(あれ、今みたら0.29%に上がってる。。)とわずかしかないので世界をターゲットにしないと見てもらえないと思い、無駄に国際化対応を考えました。
そして意外に[i18next](https://www.i18next.com/)はすんなり動きました。[i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector)も動きました。
さすがに[backend](https://www.i18next.com/overview/plugins-and-utils#backends)系は無理だと思って試しませんでした。


### Material design icons は対応している
Icon系はFontAwesomeが昔からあるイメージなので対応してるかなとおもいましたが、古いのでもIE7まででした。
これまた意外にも [Material design icons](https://google.github.io/material-design-icons/) が対応していました。以下のタグを挿入するだけです。

```html
<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>`という風に書きます。

[対応表](https://github.com/google/material-design-icons/blob/master/iconfont/codepoints)


### IE7以下には特定属性のsetAttributeの独自仕様がある
普通にHTMLでMaterial design iconsを書く分には問題ないのですが、Hyperappコンポーネント内で書くとなぜか表示されませんでした。

例えばHyperappコンポーネント内で`<i class="icon material-icons">&#xe5d5;</i>`と書くと、
[Hyperappの内部でsetAttributeでclassを付ける処理](https://github.com/jorgebucaran/hyperapp/blob/master/src/index.js#L205)を行いますが、
この時独自仕様にぶち当たっていました。

```js
// なんと
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を書き換えました。

```js: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);
    }
  };

  ...
};
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/src/iefix.js#L54-L72)


```js:bundler.js
        ...

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

        ...
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/scripts/bundler.js#L26-L27)


### クロスオリジンで非同期にデータ取得する時、IE7以下はCORS非対応なのでJSONP
現実的にデータ取得元のAPIが別ドメインであることは多いので、通常は[CORS](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)で通信を実現しますが、IE7以下はCORS非対応なので[JSONP](https://ja.wikipedia.org/wiki/JSONP)でやりとりすることにしました。

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

```sh
$ 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](https://developer.github.com/v3/)に繋げたい場合、以下のようにExpressで簡単にJSONPを返すAPIにできました。

```js: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);
```

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/api/main.js)

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

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

[コード全文](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/src/actions.js#L6-L16)


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

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

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

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

```html
<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://github.com/jorgebucaran/hyperapp/blob/master/src/index.js#L307)で不正な第二引数が渡されるケースがありました。

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

```js
        ...

        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](https://ja.wikipedia.org/wiki/Server_Name_Indication)非対応なので、閲覧できるサイトが少ないです。

なのでどこにでもホスティングできるわけではありませんでした。
検証した中では、Netlify と Firebase Hosting は非対応で、[Google App Engine](https://cloud.google.com/appengine/) と [Amazon CloudFront](https://aws.amazon.com/jp/cloudfront/) (+ S3 / Lambda) は対応していました。

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

[Webサイト側設定](https://github.com/boiyaa/ie6-compatible-spa/blob/master/web/app.yaml)、[API側設定](https://github.com/boiyaa/ie6-compatible-spa/blob/master/api/app.yaml)


## エビデンス
#### ホーム画面
<img width="640" alt="Screen Shot 2018-12-09 at 7.51.51.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/ca06ba56-abed-3f99-8cba-49b652f86899.png">

#### 技術スタック画面
<img width="640" alt="Screen Shot 2018-12-09 at 7.52.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/3f12f8f1-3aa7-91e7-0ef1-4b70cb06285e.png">

#### 非同期通信画面・ロード前
<img width="640" alt="Screen Shot 2018-12-09 at 7.52.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/847da7e7-6d07-83ef-4e21-caea2b6e5d33.png">

#### 非同期通信画面・ロード中
<img width="640" alt="Screen Shot 2018-12-09 at 7.53.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/226f24ea-2712-f359-c9ef-bd49375f0f7d.png">

#### 非同期通信画面・ロード完了
<img width="640" alt="Screen Shot 2018-12-09 at 7.53.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/aa0be1e7-8ad5-4870-a5f1-b74d02305390.png">

#### 言語変更
<img width="640" alt="Screen Shot 2018-12-09 at 7.55.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/142668/f75d9ca9-b240-465b-a723-97fec36be4fc.png">



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