Chrome M60 で Native ES Modules + ServiceWorker を試して未来へのマイグレーションを見積もる

  • 3
    いいね
  • 0
    コメント

目的

Chrome M60(Canary) でフラグ付きで ES 2015 の ES Modules が動くようになったので、試す。

ServiceWorker と Babel 前提で、エッジな構成で今のバンドル環境を無理矢理シミュレートしてみて、今との比較で現実的なマイグレーションパスを探しておくことにした。

成果物

https://github.com/mizchi-sandbox/native-modules-and-sw

uupaaさんの WebApp2 をスケルトンとしてお借りしました。

なにができるか

まるで browserify/webpack でビルドしてるかのようにこのコードが動く。ブラウザ上だけど babel も動く。

/* @flow */
import { combineReducers } from 'redux' // import external
import sub from './sub' // import internal

sub()
console.log('run main.js')

// can parse flow vi babel
function twice(val: number): number {
  return val * 2
}
console.log(twice(8))

// can call external modules
const reducer = combineReducers({ home: () => ({}) })
console.log('redux', reducer())

まだ serivce-worker のホスト周りで gh-pages に対応しきれてない、が、Canaryでしか動かないので試せる人が限られるし、気になる人は自分でフォークして動かしてください。

何をしたか

  • service-worker で JSの読み込みをプロキシして babel でコンパイルして返す。.jsの拡張子もつけて返す。
  • npm の依存ライブラリは app/modules に rollup でビルドしておく(ので、完全にバンドルツールを使わないわけではない)
  • babel plugin で相対パス以外を app/modules/redux.js に回す

Cache aware server push はまだ試してない。

Babel in ServiceWorker

こんなコードを書いて browserify でビルドして、ServiceWorker として register した。

/* @flow */
const { parse } = require('url')
const babel = require('babel-core')
const rewriteModulePath = require('./babel-plugin-rewrite-import-path')

const babelConfigWithPath = filename => ({
  presets: [require('babel-preset-flow')],
  plugins: [
    require('babel-plugin-syntax-object-rest-spread'),
    [
      rewriteModulePath,
      {
        base: '/app',
        filename
      }
    ]
  ]
})

const fetchTransformedJS = url => {
  return fetch(url).then(res => res.text()).then(source => {
    const transformed = babel.transform(
      source,
      babelConfigWithPath(parse(url).path)
    )
    return new Response(transformed.code, {
      headers: { 'Content-Type': 'text/javascript' }
    })
  })
}

self.addEventListener('fetch', ev => {
  const url = ev.request.url
  if (url.indexOf('/app/js/') > -1 || url.indexOf('/app/modules') > -1) {
    console.info('sw: handle fetch', url)
    return ev.respondWith(fetchTransformedJS(url))
  }
})

長いけど、要は worker 内で babel コンパイラをビルドしておいて、 app/js か app/modules に来たら、babel で変形してから返却する。app/modules を変換するのはパス書き換えのため。

そんで、変形時にパスを解決できるような babel transformer をその場ででっち上げた。

sw/babel-plugin-rewrite-module-path.js
/* eslint-disable */
const url = require('url')
const path = require('path')

module.exports = function rewriteModulePath({ types }) {
  return {
    pre(file) {
      this.types = types
      this._dirname = path.dirname(this.opts.filename || file.opts.filename)
      this._base = this.opts.base || '/'
    },

    visitor: {
      ImportDeclaration(nodePath) {
        const importTarget = nodePath.node.source.value
        const importTargetWithExt =
          importTarget + (importTarget.indexOf('.js') > -1 ? '' : '.js')
        const isRelative = importTarget[0] === '.'
        if (isRelative) {
          const relToDir = path.relative('./' + this._base, this._dirname)
          nodePath.node.source.value = path.join(
            this._base,
            relToDir,
            importTargetWithExt
          )
        } else {
          nodePath.node.source.value = path.join(
            this._base,
            'modules',
            importTargetWithExt
          )
        }
      }
    }
  }
}

最初は babel-plugin-module-resolver を使おうとしたんだけど browser上だと実行できない絶対パスに変換しようとして動かない箇所があった。発想だけ借りた。

依存モジュールの事前ビルド

rollup だと 単体ビルドしか出来ないので、 複数ビルドを作れる rollem を使った。

https://github.com/bahmutov/rollem

/* eslint-disable */
import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'

export default [
  {
    format: 'es',
    plugins: [
      resolve({
        jsnext: true
      })
    ],
    entry: 'node_modules/redux/es/index.js',
    dest: 'app/modules/redux.js'
  },
  {
    format: 'es',
    plugins: [
      commonjs(),
      resolve({
        main: true
      })
    ],
    entry: 'node_modules/xtend/immutable.js',
    dest: 'app/modules/xtend.js'
  }
]

ダサいのは、ファイルごとにエントリポイントとそのビルド方法を自分で指定している。

この環境で node をエミュレートするには、 node_modules/<pkg-name>/package.json を 開いて、 main か jsnext:main プロパティを読んで、それを依存ツリーを解析しながら再取得、という、すごい面倒臭いことをやらないといけなくなる。さすがに大変なのでサボった。ライブラリの更新はそんなに頻繁ではないはず…

構成

app
├── index.html
├── js
│   ├── main.js
│   └── sub.js
├── modules # yarn build:deps
│   ├── redux.js
│   └── xtend.js
└── sw.js # yarn build:sw

2 directories, 6 files

雑感

  • commonjsの資産も多いので、バンドラを完全になくすのは無理だが、rollup を薄く使うのが、現実的に可能だと思われるマイグレーションパスではないか
  • ライブラリが、使われる側で事前にES Modules用のビルドもしておかないといけない
    • nodeをエミュレートするんじゃなくて、ライブラリ側でフレンドリな形に変換しておく必要がある
  • SW でプロキシして Babel を変換させる場合、 SW 初期化を待たないと Babel 前提の JS が読み込めなくなってしまうので、SW の初期化を待った上で dynamic import しないといけない。Chrome M60 にもまだ載ってないので、初回はSWの初期化を待ってリロードした。
  • rollup の es ビルド を使っても、Native の ES Modules は 拡張子を省けないので、それを SW の層で解決する必要があった
  • ServiceWorker の手動更新がめんどかった
  • パフォーマンスの為には サーバーで Cache aware server push が必要だと思っていたが、静的に依存解析して、エントリポイントをある程度束ねて全部 import するだけの warmup.js とか生成しておいたら、それで終わりなんじゃないだろうか。勿論副作用がないように書く必要があるが。
  • 頑張ったけど、どうせ後で専用のビルドツールが出る

ServiceWorker 周りは趣味で使ったんで、別にES2015の範囲内で書いてれば不要なんだけど、Native ES Modules は正しく使おうとするとやることが多いので、またフロントエンド界が一荒れ来そう、という予感だけが来ました。頑張っていきましょう。