省ビルドサイズ要求環境でモダンフロントエンドをやる (主に preact の話)
モダンフロントエンド = 宣言的 UI = 仮想 DOM
ターゲット
- npm ツールチェインが使えない環境で、パフォーマンスを悪化させずにモダンフロントエンドをやりたい人
- サードパーティスクリプトを提供する人
方向性
- 省ビルドサイズを目指す
- とくに外部から読み込まれる 3rd party script は、サイズ要求が厳しい
- lighthouse で 100 点の環境の点数を落とさないためには、おそらく 3rd は 20~30kb 未満を目指す必要がある
- 今後パフォーマンスが SEO に関わってくるので、このへんは重要
- 実行時パフォーマンス要求
- よほど複雑なアルゴリズムを実行するので無い限り、省ビルドサイズ制限を満たせば十分
- モバイルで重いほとんどのケースでは、バンドルサイズによるダウンロードと、その重い JS を CPU でパースする時間に起因
- 実行環境の古さと向き合うか
- IE を含むか含まないかで、ビルドが必要かどうかが変わる
- ターゲットに IE 含まず、RTT や 参照するライブラリ数が問題ないなら、CDN に対して dynamic import を使うのも可
ESM を扱う CDN
- jspm.dev Documentation - jspm.org
- Pika CDN
- UNPKG で
?module
を付けて取得する - jsDelivr - A free, fast, and reliable CDN for open source の prebuild されたもの
環境(バンドラ)
ESM を読み込み、それを ESM としてビルドして、dynamic import 経由で読み込むことを考慮すると、だいたい rollup 一択になる。
webpack は、現状は ESM 吐き出しと treeshake をサポートしていない。
自分は、 rollup ベースで自分用のコンパイラを作った。
mizchi/uniroll: Opinionated universal frontend bundler in browser
- ブラウザ上のオンメモリでビルドできる
- pika.cdn から npm module を解決
- TypeScript Support
ここで遊べる。
ブラウザ内でビルドしてプレビューしているので、他のバンドラと比べてだいぶ速いのがおわかりいただけると思う。
ライブラリ選定
とにかく軽量かつ複雑性に耐えられるものを選ぶ。この分野で著名なのは、preact の developit 氏。
- Preact | Preact: Fast 3kb React alternative with the same ES6 API. Components & Virtual DOM.
- cristianbote/goober: 🥜 goober, a less than 1KB 🎉css-in-js alternative with a familiar API
- Polymer/lit-html: An efficient, expressive, extensible HTML templating library for JavaScript.
- developit/redaxios: The Axios API, as an 800 byte Fetch wrapper.
基本的に、vanilla
(0kb) or preact
(10kb) or lit-html
(11kb) の三択になる。
vue も考えたがランタイムだけでも 63kb なのでアウト。vue3 でもあまり小さくならなさそうだった。
preact + goober
例えば、 次のコードをビルドすると 11kb になる。
/* @jsx h */
import { h, render } from "preact@10.4.4";
import { useState } from "preact@10.4.4/hooks";
import { styled, setup } from "goober@2.0.0";
setup(h);
const Container = styled("div")`
width: 100px;
height: 100px;
background: wheat;
`;
function App(props: { text: string }) {
const [state, setState] = useState(0);
return (
<Container onClick={() => setState((n) => n + 1)}>
Hello, {props.text}, {String(state)}
</Container>
);
}
const el = document.createElement("div");
document.body.append(el);
render(<App text="xxx" />, el);
react がわかる人なら、 css in js + hooks で、だいたいのものが実装できるのがわかると思う。preact 独特の挙動はあるが、使っているうちになんとなく察する程度。
省ビルドサイズ環境での goober のような CSS in JS の良い点として、treeshake + terser による最適化時に、そのコンポーネントが実行パスに含まれるかどうかで、CSS の未使用コードの削除が自動的に行われる。ソースを解析する purgecss のようなアプローチもあるが、こちらのほうが手軽に最適化が掛かる。
preact + goober + shadow root 環境
サードパーティスクリプトを書いていると、CSS 干渉が問題になることがある。モダン環境では shadowRoot を使ってこの問題を回避できる。
styled-components では、 document.head
に style タグを挿入するので、親スコープを参照できない shadowRoot 下では CSS が適用されない。
が、goober が任意のルートへの style の吐き出しでこれをサポートしている。
/* @jsx h */
import { h, render } from "preact@10.4.4";
import { styled as _styled, setup } from "goober@2.0.0";
// setup for shadow root
setup(h);
const root = document.createElement("div");
document.body.append(root);
const shadowRoot = root.attachShadow({ mode: "closed" });
const styled = _styled.bind({ target: shadowRoot });
const Container = styled("div")`
width: 100px;
height: 100px;
background: wheat;
`;
function App(props: { text: string }) {
return <Container>Hello, {props.text}</Container>;
}
render(<App text="xxx" />, shadowRoot);
lit-html を使う (+htm)
react, preact と同じように差分更新を行う View ライブラリ。polymer の中で使われている。
import { html, render } from "lit-html";
// A lit-html template uses the `html` template tag:
let sayHello = (name) => html`<h1>Hello ${name}</h1>`;
// It's rendered with the `render()` function:
render(sayHello("World"), document.body);
// And re-renders only update the data that changed, without VDOM diffing!
render(sayHello("Everyone"), document.body);
構文に多少の癖はあるが、基本的に template literal なので、覚えることはそう多くない。
vscode で lit-html プラグインを入れると、ハイライトされて便利。
preact を使っているが tsx(jsx) が使えない環境向けに、 lit-html の構文で preact のコンポーネントを生成してくれる htm というライブラリがある。
npm のフロントエンドツールチェインがない環境かつ IE を落としている環境で、自分が html のインラインでベタで ES201x を書くなら lit-html + htm かなという感じ。
ビルドを絞るテクニック: TreeShake
// 使う
export function foo() {
//...
}
// import のみ
export function bar() {
//...
}
// 読み込まない
export function baz() {
//...
}
import { foo, bar } from "./xxx";
foo();
このとき、rollup の treeshake は、bar と baz の関数をビルドから消す。
ビルドを絞るテクニック: Dead Code Elimination
まず、 terser や rollup は次のコードをバンドル時に消し去る。
if (false) {
console.log(1);
}
webpack ではビルドが production 向けかどうかで process.env.NODE_ENV
を development
か production
に置換する、という挙動がある。(これは uniroll でも実装している)
なので、次のコードは production ビルドで、定数置換のち定数同士が常に一致しないので、if(false){...}
と同等と解釈され、消される。
if (process.env.NODE_ENV === "development") {
console.log(1);
}
terser が解釈できそうな定数置換を意識してコードを書くことで、ビルドサイズを減らすことができる。
実際どういうコードが削られるかは、 npm install -g terser
して terser foo.js -c -m
して確かめてみると良い。