$30 off During Our Annual Pro Sale. View Details »

極限環境で最終ビルドを絞るためのフロントエンド設計

 極限環境で最終ビルドを絞るためのフロントエンド設計

Koutarou Chikuba

August 01, 2023
Tweet

More Decks by Koutarou Chikuba

Other Decks in Technology

Transcript

  1. 自己紹介 @mizchi | 竹馬光太郎 株式会社 Plaid ソフトウェアエンジニア Node.js / TypeScript

    フロントエンドというより CI やビルドパイプライン、静的解析
  2. 経緯 : フリーランス => Plaid に入る時 某社の偉い人「KARTE 便利だけど重いから速くしておいて」 俺「気が向いたら... 」

    社内「解析サーバー再設計と同時に埋め込みタグも見直す(2019) 」 俺「やるか... 」 KARTE タグV2( 社内コード: Edge) と呼ばれているものを実装した話 https://support.karte.io/post/7E5yZwHWroaDTDmd4f0SDx
  3. KARTE について マーケター向けの分析と接客施策のツール エンジニア向けの( 端折った) 説明 KARTE における接客 = 何らかのスクリプト実行

    リアルタイムに 全ユーザー個別の各種指標を計算する ユーザー個別に 条件を満たした時にスクリプトを配信できる ( 内部的にはアクションと呼称)
  4. 3rd party のパフォーマンスバジェット 例えば webpack 推奨の 244kb ... はアプリケーション全体の話 Core

    Web Vitals への影響は可能な限り避けたい。結論からいうと ~30kb(gzip 前 ) ほどを目安に考える $ webpack WARNING in asset size limit: The following asset(s) exceed the recommended size limit (244 KiB). This can impact web performance. Assets: main.js (2.82 MiB) WARNING in entrypoint size limit: The following entrypoint(s) combined asset size exceeds the recommended limit (244 KiB). This can impact web performance.
  5. KARTE の埋め込みタグの機能 ( ※一部 ) 送信 : ユーザーとページの情報を KARTE のサーバーに送信

    再送 : ネットワーク状態が悪いときに再試行 アクション : 管理画面で設定された条件を満たすと、指定された UI を表示 プラグイン : KARTE の契約状態に基づいて、各種プラグインの実行
  6. KARTE の埋め込みタグの機能 : 技術的に分解 document.cookie や location 等からデータを取り出す MutationObserver で

    DOM 操作を検知 ブラウザストレージに送信キューを保存 fetch() で解析サーバーに届いたことを確認してキューを削除 解析サーバーのレスポンスに応じて DOM に UI を展開
  7. v2 の意思決定 デッドコードの排除 利用単位ごとに .js を個別にビルドする仕組みを提供 管理画面の設定変更や契約状態に応じて再ビルドする ブロッキングの排除 初回リクエストを送るまでのクリティカルパスを必ず1RTT に

    内部をプラグインに分割し 遅延 かつ 並列 に起動させる パフォーマンス目標の設定 解析: 空ページに読み込んで Lighthouse 100 点 UI: LCP に関与しても Lighthouse 90 点台
  8. デッドコードの排除 利用単位で必要なコードだけに絞って事前ビルド プロジェクト設定を RemoteConfig というインターフェースで抽象 RemoteConfig を元に定数展開しつつ rollup + terser

    でビルド 本体更新時には全スクリプトの再ビルド= リリース速度になるの で、ビルドパイプラインを可能な限り高速化しておく
  9. デッドコードの排除 : 共通テンプレート部 import { loadFeatA, loadFeatB } from "./features";

    async function main() { if ($USE_FEAT_A) await loadFeatA(); if ($USE_FEAT_B) await loadFeatB(); } main(); 定数展開 + rollup + terser によって不要 import が取り除かれてビル ドされる 高速化のために共通テンプレート以外は事前にビルドしておく
  10. デッドコードの排除 : 定数展開 // 管理画面の更新からリリースまでの擬似コード await updateRemoteConfig(config as RemoteConfig); const

    constants = expandConstantsFromRemoteConfig(config); const builtJs = await build({ constants, ... }); await release(config.apiKey, builtJs); terser ではネストしたオブジェクトを追跡しきれないのでフラット な定数に展開( 詳しくは後述) 内部的には @rollup/plugin-replace を使っているが、最終的にDCE できればなんでもいい
  11. ブロッキングの排除 : プラグインシステムの設計 // 社内用の共通型定義ファイルから型を提供 type PluginOptions = {/* 共通機能の定義*/,

    storage: Storage; }; type Plugin = (options: PluginOptions) => () => void; // 実装側 export default (options) => () => {}; 各プロジェクトでこのインターフェースを満たしたスクリプトを CDN にデプロイしておき、RemoteConfig に書き込む const { default: plugin } = await import(plugin.url); plugin(opts);
  12. ブロッキングの排除 : タグ部分 <script type=module src="https://cdn-edge.karte.io/{client_id}/edge.js"> <script> // 簡略化したもの window.krt=(...args)=>{

    // krt.x を非同期に初期化。初期化までは krt.q のキューに保持 krt.x?.call(null,...args) ?? krt.q.push(args) }; krt.q = []; </script> 自身も同期ブロックせずに非同期で起動(module は常に async) 初期化前の呼び出しを内部のキューに貯めておく
  13. UI コンポーネント含むパフォーマンス目標 経路: HTML => <script> => KARTE: 解析サーバー =>

    アクション 起動まで最低でも 4RTT 掛かっているのでほとんど猶予がない DOM に介入することで LCP に関与する可能性が高い とにかく頑張る
  14. UI ライブラリの選定 : Preact React 風 API のライブラリ React と基本同じだが、React

    資産と混ぜられるわけではない SSR 周りが React と別路線 ( というかReact|Next が特殊すぎる) /** @jsx h */ import {h} from "preact"; import {useState} from "preact/hooks"; function Counter() { const [value, setValue] = useState(0); return <button onClick={() => setValue(value + 1)}>{value}</button>; }
  15. UI ライブラリの選定 : Lit(-Html) WebComponents API に近い命令セットをもつ軽量ランタイム Tagged Template Literal

    で宣言的なテンプレートを記述する import {html, css, LitElement} from 'lit'; import {customElement, property} from 'lit/decorators.js'; @customElement('simple-greeting') export class SimpleGreeting extends LitElement { static styles = css`p { color: blue }`; @property() name = 'Somebody'; render() { return html`<p>Hello, ${this.name}!</p>`; } }
  16. 検討結果 : Svelte の採用 ランタイム svelte/internal が非常に小さい (6.7k) rollup の作者だけあってビジュアルエディタを作るための静的解析

    ツールが揃っている 動的要素を使わないテンプレートが素の HTML/CSS に近い JS に詳しくなくとも心理的抵抗が少ない Scoped CSS と shadowRoot オプションがある
  17. ビジュアルエディタの設計を考える ローカルプレビュー rollup + svelte をブラウザに埋め込んでコンパイル 双方向編集 ソースコードをマスターデータとする 特定の AST

    のパターンを満たす場合、フォームに変換する KARTE 公式に提供可能するものはこのパターンを満たす 直接編集でパターンを崩した場合、直接編集のみ可
  18. ビジュアルエディタ : レイアウトとエレメント レイアウト : CSS Grid Grid 構造を素朴なデータ構造に変換しエディタのフォームに変換 CSS

    Flex では縦横の切り替えに入れ子が必要になり複雑 Grid 要素にコンポーネントを割り当てる 割り当てられたComponent の Attibute に対する操作をエディタ で実装 https://zenn.dev/mizchi/articles/programmable-grid
  19. ビジュアルエディタの生成コード <script lang="ts"> // 組み込みコンポーネント。未使用のものは DCE で消える。 import { Grid,

    GridArea, ImageElement, TextElement } from "./components"; </script> <!-- グリッド座標データをビジュアルエディタ上で操作 --> <Grid rows={16} columns={16} background="wheat"> <GridItem x1={3} y1={3} x2={9} y2={5}> <!-- Component/Attribute をビジュアルエディタで操作 --> <ImageElement src="/image.png" /> </GridItem> <GridItem x1={5} y1={1} x2={6} y2={7}>...</GridItem> </Grid> コード自体に静的解析によるビルド最適化を織り込んでおく
  20. 余談 : Qwik の紹介 SSR ファーストなライブラリ JSX + 独自API セット

    理論上は最小のコードを出力できる 初回リクエストにHTML だけ返しつつJS 配信を遅延 onclick や onhover で初めて JS ロジックを注入して発火
  21. 余談 : Qwik のコード例 import { component$, useStore } from

    '@builder.io/qwik'; export default component$(() => { return ( <> <br /> <button onClick$={() => alert('Hello')}>greet!</button> <hr /> <Counter /> </> ); });
  22. E2E Test playwright でクロスブラウザテスト MS 製の E2E テストランナー 雑な wait

    を書かずに waitFor で制御できれば実行が速い Safari 環境は playwright の webkit で代用 E2E には素直に専用の @playwright/test を使った方がいい flaky tests の再実行や snapshot の組み込み等が便利 アサーションの expect() が独自なのがちょっと残念
  23. エラートラッカー sentry や bugsnag のクライアントが重い @sentry/browser : 267.3kB @bugsnag/js :

    43.5kB そもそも3rd party なので window.onerror を全部収集されても困る 自前で try-catch して error.stack を文字列としてサーバーに送信 stcaktrace-js でサーバー側で元エラーを復元 https://www.npmjs.com/package/stacktrace-js
  24. Lighthouse の計測 Core Web Vitals の計測 GitHub Actions 週次実行などして slack

    に貼る CI でやるにはくどいかも CLI でもいい https://github.com/GoogleChrome/lighthouse-ci
  25. 3rd party script 実装 : 結果 次に解説するビルド時最適化と合わせて 25kb ( 最小設定)

    を達成 時間経過で膨らむので、社内の啓蒙が大事(CI も大事)
  26. typescript: tslib tsconfig.json を importHelpers: true にすると TS が生成するヘル パを

    tslib から解決するようになる 何度も似たようなコードを展開しているときに有用
  27. typescript: tslib の実例 export async function request() { const res

    = await fetch("/get"); return res.text(); } importHelpers: true, target: 'es2015' import { __awaiter } from "tslib"; export function request() { return __awaiter(this, void 0, void 0, function* () { const res = yield fetch("/get"); return res.text(); }); }
  28. tslib: __awaiter の中身 var __awaiter = (this && this.__awaiter) ||

    function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; tslib ではない場合、async/await を使うファイルごとに展開される
  29. terser: プロパティアクセスに弱い obj.foo は getter で副作用が起きる可能性がある const obj = {

    cnt: 0, get foo() { this.cnt++; return cnt; } } 実行回数を指定する passes で複数実行すると展開されることがあ るが、制御は困難
  30. BAD パターン : オブジェクト // BAD: 参照にプロパティアクセスを経由するので Treeshake が効かない export

    const Constants = { FOO: 1, BAR: 2, }; // BAD: defalut を経由するので同上 export default { BAZ: 3, QUX: 4 }; // BAD: TS が冗長なオブジェクトに展開するのでバンドルサイズに悪い export enum MyEnum { XXX, YYY }
  31. GOOD パターン : オブジェクト // GOOD: 個別に import できるので treeshake

    可能 export const FOO = 1; export const BAR = 2; export const BAZ = 3; export const QUX = 4; // const enum はビルド時に定数置換される // ただし MyEnum[MyEnum.XXX] で元キー名を取得することができない export const enum MyEnum { XXX, YYY } 基本、定数は const で直接宣言する 型レベルの READONLY 属性は terser には伝わらない
  32. terser: Class の最適化は辛い export class Foo { #hard: number =

    1; public foo() { return this.#hard } private bar() {} } // 展開後: target: es2021 , importHelpers: true var _Foo_hard; import { __classPrivateFieldGet } from "tslib"; export class Foo { constructor() { _Foo_hard.set(this, 1); } foo() { return __classPrivateFieldGet(this, _Foo_hard, "f"); } bar() { } } _Foo_hard = new WeakMap();
  33. クラス最適化 : 内部アクセスパターン export class Foo { public getValue() {

    return new Internal().getInternalValue(); } } class Internal { // 他クラスからアクセスされるので public だがモジュール外からアクセスされない public getInternalValue() { return { internal: 1 }; } } よくある内部クラス Rust の pub(crate) fn func() {...} 相当がほしいね...
  34. クラス最適化 : 内部アクセスパターン terser で mangle.properties.regex: /^(_|\$)/ を設定 export class

    Foo { public getValue() { return new Internal().$getInternalValue(); } } class Internal { public $getInternalValue() { return { $internal: 1 }; } } // minify 後: `$` ではじまるものは mangle される export class Foo{getValue(){return(new e).t()}}class e{t(){return{l:1}}}
  35. terser のルールづくり モジュール内 public は $foo , クラス内部プロパティは _foo とする

    そもそも自分が公開API に $.* を使ってないことを保証する必要 { mangle: { properties: { regex: /^(__|\$)/ } } }
  36. 余談 : JS に class は必要 ? 個人的には全く不要 クラスベースの別の言語から移行してくる人の受け皿でしかない フロントエンドはJSON

    にシリアライズする頻度が高いのでJSON サ ブセットの型+ 関数で十分 type MyData = { foo: number; bar: string; } export function createMyData(foo: number, bar: string) { return {foo, bar} }
  37. 実験 1: クラスを分解するコンバータ export class Point { x: number; y:

    number; constructor(x: number, y: number) { this.x = x; this.y = y; console.log("Point created", x, y); } distance(other: Point) { return Math.sqrt(Math.pow(this.x - other.x, 2) + Math.pow(this.y - other.y, 2)); } } こういうコードを分解するための TS Transformer を書いてみた
  38. 実験 : クラスを分解するコンバータ export type Point = { x: number;

    y: number; }; export function Point$new(x: number, y: number): Point { const self: Point = { x: x, y: y }; console.log("Point created", x, y); return self; } export function Point$distance(self: Point, other: Point) { return Math.sqrt(Math.pow(self.x - other.x, 2) + Math.pow(self.y - other.y, 2)); } $ npm install @mizchi/declass $ npx declass input.ts # -o output.ts
  39. 実験 2: dts-analazyer TypeScript の .d.ts に出現するキーを公開API として、 terser の予

    約語として使えないか? mangle.properties.regex: /.*/ ( 要は全部) と mangle.properties.reserved の明示的な mangle 回避を組み合わ せる やってみた
  40. 実験 2: dts 解析の結果 ESM インターフェースの範囲では安全だが、内部副作用の型も必要 ビルドに含められない external な import

    への引数 環境ビルトインへの操作 (window, Node のシステムコール等) fetch() , Worker.postMessage , workerThreads の外部通信 内部副作用型はエントリポイントで export type ... の運用でカバ ーできるが...
  41. 実験 3: Packelyze Transformer https://github.com/mizchi/packelyze/tree/main/transformer TypeScript の LanguageService(IDE との対話API) を使って、型レベ

    ルで解析する TS から TS に変換する中間トランスフォーマー vite(rollup) plugin を想定
  42. 実験 3: Packelyze Transformer のアプローチ ビルド時のエントリポイントに関与する型シンボルを列挙 export function foo(input: Input):

    Output {...}; 副作用を起こす API に関与する型シンボルを列挙 fetch({ body: JSON.stringify({/* here */}) }) GlobalVar.xxx = {/* here! */} ; 外界と関わらないインターフェースを全部 mangle
  43. 実験 3 型レベル解析 - 進捗 単純に難しい! TypeScript Compiler API に詳しくなる

    https://zenn.dev/mizchi/articles/typescript-code-reading トップレベル以外の export されたシンボル解析に苦戦中 そもそも TS の rename 処理は型安全ではない 機能を絞れば、もうちょっとでリリースできる( かも)