Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

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

Avatar for Koutarou Chikuba

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 処理は型安全ではない 機能を絞れば、もうちょっとでリリースできる( かも)