Share
Embed
Transcript
75 slides
-
自己紹介 石田 尚幹 1回生 小中学生から自然科学の研究を行い、模型飛行機大会、発明工夫展や、探求発表会 などで複数回入賞。昼休みなどに不定期で学年全体を巻き込んだ大規模実験の企画 などを行った。 高校でJR四国と連携し一般販売ツアーを作成、その成果を県で発表 同時期、高校に導入された学食システムの脆弱性の可能性を指摘、システム作成会社 から認めていただいた
生徒会員として、体育祭・スポーツ祭での大規模なDXを成功させ、現在も同高校で運 用が維持(Celeritas-Sports-Event-Web-Manager) 高校3年生の時Pyxis-CodeCanvasを制作し、U22Proconにて、 経済産業大臣賞テクノロジー部門・アクセンチュア賞を受賞 同時に、Celeritas複数作品同時通過を達成 現在は摂津市立第一中学校にて、Pyxisなどのサービスを提供し、正式な技術指導員 として参画 他サークルでもメンター・運営などを行う 2 -
Advanced Frontend Tech Browser as a Runtime — フロントエンドをシステムプログラミングとして見る 主題:
Main Thread をどう守るか 01 Concurrency / Scheduling Advanced Frontend Tech 02 Rendering Pipeline / Offmain-thread 03 04 Storage / Local-first Frontend Architecture 1 -
4 章構成: Concurrency → Rendering → Storage → Architecture 01
02 03 04 Concurrency / Scheduling Rendering Pipeline / Off-main-thread Storage / Local-first Frontend Architecture CPU-bound な仕事を main から隔離するとき、message passing と shared memory のどちらを選ぶか どのステージが無効化 されるか。pixel を作る仕事 をどこで・いつ・誰がやるか ブラウザ上で永続的な application state を、複数 tab でどう一貫させるか 1 つのアプリを複数の runtime subsystem として 設計すると何が現れるか 25 min 20 min 20 min 17 min Advanced Frontend Tech 3 -
ブラウザは「実行場所が複数ある runtime」 Renderer Process(site ごとに分離) Browser Process UI · navigation
· 権限 Main Thread JS 実行 · DOM · Style · Layout · Paint · 入力イベント · GC GPU Process Compositor Thread scroll · transform / opacity animation · 合成 raster / composite の GPU 実行 · 表示 Raster Workers 描画命令 → tile の pixel Network Service HTTP · cache · cookie Worker Threads Worklet Threads Dedicated / Shared Worker AudioWorklet など ※ Chromium のモデル。Gecko / WebKit はスレッド・プロセス分割が異なる → 講義全体は、これらへの仕事の配り方の話 Advanced Frontend Tech 4 -
Main Thread は 1 本しかない Main Thread Input JS(app) Style
Layout Paint GC JS(3rd party) Input JS(app) time → 何を main に残し、何を追い出すか。これが 90 分の主題。 追い出す先 Worker / Compositor thread / Worklet / GPU process Advanced Frontend Tech 守る指標 frame budget(1 frame の締切)と input latency(入力から次の描画 まで) 払う代償 コピー、同期、backpressure、整合 性。並行化はタダではない 2 -
01 Concurrency / Scheduling 問い: CPU-bound な仕事を main から隔離するとき、message passing
と shared memory のどちらを選ぶか Long Task · scheduler.yield · postTask · Worker の種類 · Structured Clone · Transferable · SharedArrayBuffer · Atomics · ring buffer · Worker pool · backpressure 5 -
01 Concurrency / Scheduling Long Task は入力と描画を人質に取る vsync 1 つの
task: 180 ms(Long Task) Main h r Input click(20 ms)→ handler 開始(180 ms): 入力遅延 160 ms 0 ms 50 ms 100 ms 150 ms 200 ms h = handler, r = render。破線の vsync では frame を出せない(約 10 frame 落ち) • Long Task = main を 50 ms 以上占有する task • event loop は task を途中で奪わない(non-preemptive)。1 task の長さ = 応答遅延の下限 • 対策は 2 つだけ: 刻む(協調的スケジューリング)か、main から追い出す(Worker) Advanced Frontend Tech 6 -
01 Concurrency / Scheduling 刻む: 時間で区切って yield する Before 180
ms 連続(その間 入力も描画も不可) After 30ms 30ms 30ms 30ms = yield した隙間。入力処理と描画がここに入れる • 変わるのは合計時間ではなく最悪応答時間 30ms 30ms async function processAll(items) { let last = performance.now(); for (const item of items) { process(item); if (performance.now() - last > 5) { await yieldToMain(); // let input run last = performance.now(); } } } • 区切りは件数ではなく経過時間で判断する • 粒度の目安: 数 ms(frame budget の一部) Advanced Frontend Tech 7 -
01 Concurrency / Scheduling setTimeout(0) と scheduler.yield() は「戻る位置」が違う setTimeout(fn, 0)
前半 他の task A 他の task B 他の task C 続き 続き 他の task A 他の task B 他の task C await scheduler.yield() 前半 function yieldToMain() { if (globalThis.scheduler?.yield) { return scheduler.yield(); } return new Promise(r => setTimeout(r, 0)); } Advanced Frontend Tech • yield の続きは同優先度の他 task より先に走る • setTimeout は最後尾。入れ子 5 段目以降は最小 4 ms に clamp • 対応: Chromium 129+ / Firefox 142+。Safari は fallback 8 -
01 Concurrency / Scheduling scheduler.postTask: 優先度を宣言して積む priority 意味 例 user-blocking
入力への直接の応答。最優先 クリック直後の視覚フィードバック user-visible(default) 見えるが、即時でなくてよい 検索結果・一覧の描画 background 見えない・急がない prefetch、index 構築、ログ送信 const ctl = new TaskController({ priority: 'background' }); scheduler.postTask(buildSearchIndex, { signal: ctl.signal }); // user starts typing -> the index is now urgent ctl.setPriority('user-visible'); // or ctl.abort() Advanced Frontend Tech 注意 優先度は main 内の実行順を決めるだけ。実 行中の task を奪う preemption ではない。長 い task は依然として分割が必要 9 -
01 Concurrency / Scheduling Worker の種類は「寿命」と「共有範囲」で選ぶ Dedicated Worker SharedWorker Service
Worker 所有者 生成したページ 1 つ 同一 origin の複数 tab / iframe origin + scope(ページと独立) 寿命 生成元と同じ 最後の接続が切れるまで browser が event 駆動で起動・停止 典型用途 CPU-bound 計 算、OffscreenCanvas、WASM tab 間で 1 本の WebSocket / DB 接続を 共有 offline cache、push、network proxy 注意 起動コストと独立した JS heap Chrome Android は 148(2026-05)で 対応 いつ止められてもよい設計。state を持 たせない Advanced Frontend Tech 10 -
01 Concurrency / Scheduling postMessage はコピーである: Structured Clone のコスト Main
heap serialize { nodes: [...100k], meta } 送信側で同期 コストは O(graph の大きさ) 走査・型判定・確保をすべて行う。10 万要素の配列なら 10 万要素分 Advanced Frontend Tech bytes deserialize 受信側 Worker heap 複製された object graph 送信側も払う 運べないもの postMessage の呼び出し中、serialize は main 上で同期実行。巨大な clone は main を止める function、DOM node は不可。class instance は prototype を失い plain object になる 11 -
01 Concurrency / Scheduling Transferable: コピーではなく所有権を移す Before Main Worker ArrayBuffer
64 MB (なし) After: postMessage(buf, [buf]) Main Worker detached(byteLength 0) ArrayBuffer 64 MB コスト コピー O(n) → ほぼ O(1)。bytes は動かず所有権 だけが移る。代わりに送信側は二度と触れない (move semantics) 主な Transferable const buf = new ArrayBuffer(64 * 1024 * 1024); worker.postMessage(buf, [buf]); // transfer list buf.byteLength; // 0 (detached) Advanced Frontend Tech ArrayBuffer · MessagePort · ImageBitmap · OffscreenCanvas · ReadableStream / WritableStream · VideoFrame · AudioData 12 -
01 Concurrency / Scheduling SharedArrayBuffer: 同じ bytes を複数スレッドが見る 必要なレスポンスヘッダ Main
Worker 1 Worker 2 Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp // => crossOriginIsolated === true 0 3 7 1 0 9 2 5 SharedArrayBuffer(同じ bytes を全員が見る) • なぜ制限されるか: 共有メモリ + カウンタで高精度 タイマーが作れる → Spectre 系 side channel • プロセス分離を保証できる時だけ解禁 message passing は「送る」、shared memory は「同じ場 所を見る」。速いが、同期の責任が自分に来る Advanced Frontend Tech • 代償: CORP/CORS 非対応の第三者リソースが読 めなくなる。credentialless で緩和 13 -
01 Concurrency / Scheduling 共有したら同期が要る: Atomics data race(lost update) Worker
1 read 5 write 6 read 5 Worker 2 2 回 +1 したのに結果は 6(期待値 7) // not atomic: read, add, write counter[0]++; // atomic read-modify-write Atomics.add(counter, 0, 1); Advanced Frontend Tech write 6 API 用途 add / sub / and … 不可分な read-modify-write load / store 順序保証付きの読み書き(publish) compareExchange CAS。lock や lock-free 構造の基礎 wait / notify thread を眠らせる / 起こす。main では wait 不可 waitAsync Promise で待つ。main でも 可。Chromium 90+ / Safari 16.4+ / Firefox 145+ 14 -
01 Concurrency / Scheduling message passing vs shared memory message
passing shared memory データの動き コピー(clone)または移動(transfer) 同じ bytes を共有 同期 暗黙(メッセージの到着順) 明示(Atomics) 扱えるデータ cloneable な object graph TypedArray 上の bytes のみ 安全性 data race が起きない data race があり得る デバッグ 容易(message を log する) 困難(非決定的) 前提条件 なし cross-origin isolation 向く用途 粗粒度の仕事の受け渡し 高頻度・大容量の状態共有 原則は message passing。計測して clone / 転送がボトルネックの箇所だけ shared memory へ Advanced Frontend Tech 15 -
01 Concurrency / Scheduling SPSC ring buffer: lock なしで流すキュー 0
1 2 3 4 v v v head (consumer が進める) 5 6 7 tail (producer が進める) • producer は tail だけ、consumer は head だけを書く → lock 不 要 function push(v) { // producer const t = Atomics.load(ctrl, TAIL); const h = Atomics.load(ctrl, HEAD); if ((t + 1) % N === h) return false; // full data[t] = v; // 1. write slot // 2. publish (after the write) Atomics.store(ctrl, TAIL, (t + 1) % N); Atomics.notify(ctrl, TAIL); return true; } • empty: head == tail / full: (tail+1) % N == head • full で false を返す = backpressure の入口 Advanced Frontend Tech 16 -
01 Concurrency / Scheduling Worker Pool: command は message、データは共有メモリ Main
Thread(UI・入力) commands(小さい) results / patches • pool size: hardwareConcurrency - 1 を 上限に実測で決める Dispatcher(queue・優先度・上限) • task は小さく idempotent に(失敗時に再 投入できる) Worker 1 Worker 2 Worker N • queue には上限を持たせる(→ 次: backpressure) • Worker は起動時に作って使い回す。都度 new しない Shared State(SharedArrayBuffer) 大きなデータはここに置き、message は offset / id だけ運ぶ Advanced Frontend Tech 17 -
01 Concurrency / Scheduling Backpressure: 生産が消費を上回ると queue は伸び続ける queue が単調増加
→ memory と遅延が伸び続ける Producer Consumer 120 msg/s 60 msg/s Bounded queue 上限を設け、満杯なら producer を待たせる / 拒否 する Drop Coalesce 古いもの(or 新しいもの)を 捨てる。ログ・計測値向き 最新値だけ残す。pointer 位 置、scroll、進捗表示 Credit / ACK window 受け手が「あと N 件受 けられる」を返す。TCP の flow control と同型 postMessage は相手が詰まっていても失敗しない。上限は自分で作るしかない Advanced Frontend Tech 18 -
01 Concurrency / Scheduling 「Worker に投げれば速い」が成立しない 5 つのケース 1. 仕事が小さい
message 往復と clone の固定費が計算時間を上回る。1 ms 未満の仕事なら main で刻む方が 速い 2. 大きなデータが往復する serialize は送信側で同期実行。main で clone している限り main は救われない 3. DOM が必要 Worker は DOM に触れない。結果を DOM に反映する部分は main に残る 4. 結果を即座に待つ 並列化ではなく遅延の移動。main が await している間、UI 状態は止まったまま 5. 起動とメモリ Worker ごとに独立した JS heap と起動時間。大量生成・使い捨ては逆効果 Advanced Frontend Tech 19 -
01 Concurrency / Scheduling 第 1 章のまとめ: どこで実行するかの判断順 Yes DOM
/ layout 情報が必要か? Main に残す(DOM の読み書きを batch) No Yes Main に残す + scheduler.yield / postTask で刻む 1 回の仕事は数 ms 未満か? No Yes 大きなデータを高頻度で共有するか? Worker + Transferable / SharedArrayBuffer No Worker + message passing(既定はここ) Advanced Frontend Tech 20 -
02 Rendering Pipeline / Off-main-thread 問い: pixel を作る仕事を、どこで・いつ・誰が実行しているか。何を変 えると、どのステージが無効化されるか Pipeline
· invalidation · contain / content-visibility · forced sync layout · layout thrashing · Paint vs Raster · Layer / Compositing · frame budget · input latency · OffscreenCanvas · Worklet 21 -
02 Rendering Pipeline / Off-main-thread pixel ができるまで: 7 つのステージ Main
Thread Raster / Compositor threads GPU JavaScript Style Layout Paint Raster Composite Display DOM / CSSOM を 変更 要素ごとの computed style 幾何(x, y, w, h) 描画命令を記録 (display list) 命令 → pixel(tile 単位) layer を合成して frame に vsync で画面へ ※ 概念モデル。スレッド割当は Chromium 基準(Gecko: WebRender、WebKit も独自) 本章の焦点は名前ではなく 「何を変えると、どのステージから再実行されるか」 Advanced Frontend Tech 22 -
02 Rendering Pipeline / Off-main-thread 何を変えたかで、どこからやり直すかが決まる 幾何を変える width top font-size
見た目だけ変える color background box-shadow 合成だけで済む transform opacity(layer 上) Style Layout Paint Raster Composite 実行 実行 実行 実行 実行 実行 skip 実行 実行 実行 条件付き* skip skip skip 実行 「transform が速い」の正体 = Layout と Paint を飛ばせること(layer 化が前提) * JS で値を書き換えた場合は Style が走る。compositor 上の CSS animation なら毎 frame の Style も不要 Advanced Frontend Tech 23 -
02 Rendering Pipeline / Off-main-thread Style invalidation と containment: 影響範囲を切る
root div .card div .card { contain: layout paint; /* scoped */ } .feed-item { content-visibility: auto; /* skip offscreen */ contain-intrinsic-size: auto 320px; } contain の境界 • DOM 変更 → 影響しうる要素を判定 → その部分木だけ再計 算 変更の影響が境界の外へ漏れない → 再計算が部分木に閉 じる Advanced Frontend Tech • selector の形と DOM 構造が再計算範囲を左右する • contain は「外に影響しない」という約 束。ブラウザはそれを根拠に範囲を切る 24 -
02 Rendering Pipeline / Off-main-thread Forced synchronous layout: 読んだ瞬間に layout
が走る el.style.width = '500px'; // write: layout dirty const w = el.offsetWidth; // read: flush now 通常 読み取り API は正しい値を返す義務がある → dirty ならその場で style / layout を実行する JS read あり JS Style Style Layout JS Layout Paint Style Layout Paint 赤 = JS の途中で強制された style / layout layout を強制しうる読み取り(一部) offsetTop/Left/Width/Height · clientWidth · scrollTop · getBoundingClientRect() · getComputedStyle() · innerText Advanced Frontend Tech 25 -
02 Rendering Pipeline / Off-main-thread Layout thrashing: 読み書きの交互が layout を
N 回走らせる ✕ 交互: layout × N W R* W ◦ 分離: layout × 1 R* W R* for (const el of els) { el.style.width = calc(el) + "px"; total += el.offsetHeight; // flush } R R R W W W L const hs = els.map(el => el.offsetHeight); els.forEach((el, i) => { el.style.width = calc(el, hs[i]) + "px"; }); W = write, R = read, R* = forced layout を伴う read, L = frame 末尾の通常 layout 原則: 同じ frame 内で read を先に、write を後に。書いた結果は次の frame(rAF)で読む Advanced Frontend Tech 26 -
02 Rendering Pipeline / Off-main-thread Paint と Raster は別物 Paint
Layout tree drawRect(0,0,320,48,#fff) drawText("Inbox",16,30) drawImage(avatar,280,8) ... display list Raster Composite frame tiles(pixel) Paint が重い(命令の記録・main) Raster が重い(pixel 化・raster thread / GPU) 要素数が多い、描画命令が複雑(大量の text、border、shadow の記録) 面積が大きい、blur / shadow / filter、高 DPR、大きな画 像の decode Advanced Frontend Tech 27 -
02 Rendering Pipeline / Off-main-thread Layer と Compositing: main が止まっても動くもの
Layer: ページ本体 Layer: sticky header frame(layer の位置・alpha を変えて 合成) Compositor Layer: animation 中のカード(transform) Main JS の Long Task(main は busy) Compositor frame frame frame frame frame frame frame frame frame frame scroll と compositor 上の transform / opacity animation は、main を待たずに進む Advanced Frontend Tech 28 -
02 Rendering Pipeline / Off-main-thread Layer はタダではない bytes ≈ width
× height × 4 × DPR² 8.3 MB 1920 × 1080, DPR 1 の 全画面 layer 1 枚 33 MB 同じ layer を DPR 2 で × 層数 will-change を 20 要素に 付ければ、その分だけ • will-change を全要素に付けない。直前に付け、終わったら外す • overlap による暗黙の layer 昇格(layer explosion)に注意 • DevTools の Layers パネル / Rendering → Layer borders で確認する ※ 実際は tile 単位で可視域中心に raster される。上の数値は桁の感覚として Advanced Frontend Tech 29 -
02 Rendering Pipeline / Off-main-thread Frame budget: 16.7 ms は前提ではない
16.7 ms 8.3 ms 6.9 ms 60 Hz 120 Hz 144 Hz budget = 1000 ms / refresh rate 120 Hz の 1 frame In JS 3.3 ms Style 0.8 ms Layout 1.2 ms Paint 1 ms Raster+Comp. 1.5 ms 内訳は例示。JS に残るのは budget の一部でしかない • requestAnimationFrame は display refresh に同期する。120 Hz なら毎秒 120 回 • setInterval(fn, 16) で animation を書かない(refresh とずれて jank になる) Advanced Frontend Tech 30 -
02 Rendering Pipeline / Off-main-thread Throughput と latency は別の指標 frames
Main 他の task click paint INP ≈ input delay + processing + presentation delay • throughput(fps)と latency(入力 → 次の描画)は別 • compositor が滑らかでも main は詰まり得る → fps だけでは見落とす • LoAF は frame 単位で原因の script まで分解できる (Chromium 123+) Advanced Frontend Tech handler new PerformanceObserver((list) => { for (const e of list.getEntries()) { console.log(e.duration, e.blockingDuration, e.scripts.map((s) => s.sourceURL)); } }).observe({ type: 'long-animation-frame', buffered: true }); 31 -
02 Rendering Pipeline / Off-main-thread OffscreenCanvas: 描画そのものを Worker へ Main
Thread DOM · 入力 · UI transferControlToOffscreen() (1 回だけ) Worker simulation · layout 計算 Canvas 2D / WebGL / WebGPU で描画 requestAnimationFrame で frame 駆動 // main const off = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: off }, [off]); // worker onmessage = ({ data }) => { const ctx = data.canvas.getContext('2d'); const loop = () => { draw(ctx); requestAnimationFrame(loop); }; requestAnimationFrame(loop); }; main が詰まっても描画は続く(逆も) Advanced Frontend Tech 32 -
02 Rendering Pipeline / Off-main-thread 入力は main に届く: 最新値だけを渡す pointermove(120
Hz 以上) latest-only slot Worker(rAF) 上書きするだけ 1 frame に 1 回だけ読む ✕ 全イベントを postMessage → queue が詰まり、描画が過去の入力を追いかける(遅延が累積) let pending = null; canvas.addEventListener('pointermove', (e) => { pending = { x: e.clientX, y: e.clientY }; // overwrite }); requestAnimationFrame(function tick() { if (pending) { worker.postMessage(pending); pending = null; } requestAnimationFrame(tick); }); Advanced Frontend Tech 途中の点が必要なら e.getCoalescedEvents() で frame 間 の点をまとめて 1 回で送る。第 1 章の Coalesce 戦略そのもの 33 -
02 Rendering Pipeline / Off-main-thread Worklet は Worker ではない: pipeline
へのフック Worker Worklet アプリ browser の pipeline 実行タイミング 任意 pipeline の特定点(音声描画、paint など) global scope fetch、IndexedDB など大 半 極小(DOM も多くの非同期 API もない) 呼ぶのは instance 例 Advanced Frontend Tech 明示的に生成した数 計算、I/O browser が複数生成・破棄しうる → 状態を持てない AudioWorklet、CSS Paint API 2.67 ms 128 / 48000 s ≈ 2.67 ms AudioWorklet の 1 回の締切 • 締切を落とすと音切れ。allocation → GC pause も致命的 • lock・blocking・高頻度 message も禁止事項 • Paint / Animation / Layout Worklet は対応 がまちまち(思想として押さえる) 34 -
03 Storage / Local-first 問い: ブラウザ上で永続的な application state を、複数 tab
でどう一貫させるか Storage の地図 · IndexedDB の transaction model · auto-commit · schema migration · multi-tab · Web Locks · BroadcastChannel · OPFS · durability · Local-first · outbox · 競合解決 35 -
03 Storage / Local-first ブラウザの永続化手段は 4 つの性格に分かれる localStorage IndexedDB OPFS
Cache API API 同期 非同期 非同期(Worker では同期も) 非同期 単位 string の KV cloneable な object bytes / file Request → Response 検索 key のみ key range・index・cursor 自前(engine を載せる) URL の一致 transaction なし あり なし(engine 次第) なし 使いどころ 小さな設定値 アプリの state SQLite / WASM、大容量 offline の asset localStorage は main を同期ブロックする → アプリ state の置き場にしない Advanced Frontend Tech 36 -
03 Storage / Local-first IndexedDB は「index 付きの object store」 Database
"app"(version 5) Object store "tasks" Index "byProject" 1 → {title, projectId: p1} p1 → [1, 4] 2 → {title, projectId: p2} p2 → [2] 4 → {title, projectId: p1} p3 → [3, 5] const tx = db.transaction('tasks', 'readonly'); const req = tx.objectStore('tasks') .index('byProject') .getAll(IDBKeyRange.only(projectId)); req.onsuccess = () => render(req.result); 値は Structured Clone SQL / JOIN / query planner はない。key・key range・index・cursor で自分で引く Advanced Frontend Tech 第 1 章と同じ clone アルゴリズムで保 存。Date・Map・Blob・ArrayBuffer も入 る。function や DOM は入らない 37 -
03 Storage / Local-first Transaction の scope がスケジューリングを決める T1 rw
{tasks} 実行 T2 rw {tasks} 待機(T1 の終了まで) T3 ro {projects} 実行 T4 ro {projects} 実行(T3 と並行) 実行 time • readwrite: scope が重なる先行の transaction が終わるまで開始しない • readonly 同士は、scope が重なっても並行に走れる • scope(対象 store)を最小にするほど並行度が上がる Advanced Frontend Tech 38 -
03 Storage / Local-first await を挟むと transaction が死ぬ event loop
に戻る active 未完了 request が 0 inactive commit 完了 committing finished request の callback 中は active に戻る ✕ 外部 I/O を挟む const tx = db.transaction('items', 'readwrite'); const data = await (await fetch(url)).json(); tx.objectStore('items').put(data); // -> TransactionInactiveError ◦ I/O を先に、transaction は後に const data = await (await fetch(url)).json(); const tx = db.transaction('items', 'readwrite'); tx.objectStore('items').put(data); await txDone(tx); // resolve on 'complete' auto-commit: pending request が尽きると勝手に commit する。外部 I/O を transaction の中で await しない Advanced Frontend Tech 39 -
03 Storage / Local-first Schema migration は multi-tab 問題になる Tab
A(app v42, DB v4) IndexedDB Tab B(app v43) ① open('app', 5) ② versionchange イベント ③ db.close() ④ upgradeneeded → migration ③ が無ければ ④ は来ず、Tab B は blocked のまま db.onversionchange = () => { db.close(); showReloadBanner(); // new version available }; Advanced Frontend Tech デプロイ = 旧アプリ + 新アプリ + 永続 schema の共存問題 40 -
03 Storage / Local-first N タブ = N 個の sync
loop: leader election で 1 つにする 素朴な実装 Leader election WebSocket × 1 Tab A Tab A(leader) Server Tab B Tab B(待機) Tab C Server Tab D Tab E Tab C(待機) Broadcast Channel Tab D(待機) WebSocket × 5・sync loop × 5 navigator.locks.request('sync-leader', async () => { startSyncEngine(); // only the leader await new Promise(() => {}); // hold until tab dies }); Advanced Frontend Tech leader が死んだら lock が解放され、待機中の次の tab が自動で leader になる 41 -
03 Storage / Local-first OPFS + SyncAccessHandle: Worker なら同期 I/O
を許せる Main Thread(UI) message Dedicated Worker SQLite (WASM) VFS SyncAccess Handle const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('app.db', { create: true }); const h = await fh.createSyncAccessHandle(); // ^ dedicated worker only h.write(bytes, { at: offset }); // sync h.flush(); 同期 read / write(UI は止まらない) • main で同期 I/O は許せない → Worker 限定だから同期 API を提供できる OPFS: app.sqlite3 Advanced Frontend Tech • file を排他ロック → 複数 tab からは同時に開けない(再び multi-tab 問題) 42 -
03 Storage / Local-first IndexedDB と OPFS は役割が違う IndexedDB OPFS
単位 structured object(Structured Clone) bytes / file 検索 key range・index・cursor なし(上に載せる engine 次第) 整合性 transaction 内蔵 engine 次第(SQLite の journal / WAL) アクセス 非同期 非同期 + Worker では同期(SyncAccessHandle) 得意なもの アプリのドメインオブジェクト DB engine の backing store、大きな binary、random access SQLite(WASM)+ OPFS = SQL と JOIN をブラウザに持ち込む構成 Advanced Frontend Tech 43 -
03 Storage / Local-first ブラウザストレージは best-effort Eviction persist() estimate() storage
pressure 時、browser が origin 単位でデータを消しうる 消さないよう要求する。許可は保証 されない usage / quota は privacy のため近似 値 const granted = await navigator.storage.persist(); const { usage, quota } = await navigator.storage.estimate(); if (usage / quota > 0.8) compactOrWarn(); Advanced Frontend Tech 設計への帰結 local を primary にしても唯一の copy にしない。server に複製がある前提で同期 する(→ Local-first) 44 -
03 Storage / Local-first Local-first: source of truth を反転させる Server-first(普通の
SPA) Local-first UI UI 即時 HTTP(RTT を待つ) Server Local DB background Sync Engine protocol DB Server Local state = primary / Remote = synchronization target 得るもの: 即時の操作感とオフライン動作 払うもの: 同期・競合・障害時の整合性 Advanced Frontend Tech 45 -
03 Storage / Local-first 書き込みは「local transaction + outbox」 1 つの
IndexedDB transaction Command moveTask(id, to) tasks を更新 UI に即時反映 outbox に op を追加 background outbox Sync Engine Server ACK outbox から削除 state と outbox を同じ transaction で書く → 片方だけ残る状態が起きない(atomic) ここから必要になるもの: retry · idempotency · ordering · 重複排除 · ACK Advanced Frontend Tech 46 -
03 Storage / Local-first Local-first ≠ CRDT: 競合解決はスペクトラム 単純・中央集権 表現力・分散
Server authoritative Last-write-wins server が順序付けと検 証を行い、正を決める 新しい方が勝つ。失 われる更新を許容する OCC baseRevision が古 ければ reject → rebase / 再試行 CRDT OT 操作を相手の操作に合 わせて変換する 可換 にマージできるデータ 構造。server なしでも 収束 Figma の例 WebSocket + server authoritative。CRDT に着想を得つつ、server が順序付けと競合解決を担う独自方式。選ぶ基準 はデータ型と、競合の頻度と意味 Advanced Frontend Tech 47 -
04 Frontend Architecture 問い: 1 つのブラウザアプリを、複数の runtime subsystem として設計すると何が現れるか Runtime
architecture · UI projection · commands / patches · incremental computation · sync protocol · snapshot + log · WASM boundary · failure recovery · version skew · observability 48 -
04 Frontend Architecture 1 つのアプリを、複数の runtime subsystem として設計する UI Runtime
Main Thread · React / DOM / Input Application Engine Worker · domain state / indexes • 層の境界 = スレッド境界 = message / SharedArrayBuffer(第 1 章) Compute Engine Worker / WASM · parser / geometry / search • UI ↔ Engine: commands ↓ / patches ↑ • Engine ↔ Persistence: transaction(第 3 章) Local Persistence IndexedDB / OPFS • Sync ↔ Server: protocol(op log・ACK) Sync Engine Worker · WebSocket / HTTP • main には UI projection だけが残る(第 2 章の目 的) Server protocol の相手 Advanced Frontend Tech 49 -
04 Frontend Architecture UI は projection、canonical state は Engine が持つ
command: moveNode(id, x, y) Domain Engine(Worker) UI(React) canonical state(10 万 node) 派生計算: geometry · index 永続化 · sync 用 op の生成 見えている部分だけ購読して描画 patch: 変わった分だけ ✕ React state = application state ◦ Engine = canonical、UI = projection 10 万 node・index・派生 geometry を全部 React state に 載せる → 更新のたびに main で差分計算 UI は見えている分だけ購読し、Engine から差分 patch を 受け取る Advanced Frontend Tech 50 -
04 Frontend Architecture Snapshot を往復させない ✕ snapshot を往復させる 毎 frame
100 MB の document を postMessage → serialize が main で同期実行 → Worker 化した意味 が消える Commands Incremental patches 意図だけ送る(moveNode(id, x, y)) 変わった部分だけ送る cost per frame ∝ bytes cloned Transferable 大きな binary は所有権を移す 第 1 章の道具が、そのまま architecture の制約 になる SharedArrayBuffer 共有 view に置き、通知だけ送る Advanced Frontend Tech 51 -
04 Frontend Architecture Incremental computation: 変わった所だけ再計算する A1 A2 A3 A4
• dependency graph を持つ B1 B2 C7 B4 • dirty propagation: 変更点から下流へ dirty を伝播 • dirty だけ再計算(memoization) • index も parser も incremental に C1 D19 D2 D3 • spreadsheet: セル → 依存セル • design tool: node → geometry → layout → render object 変更 再計算 触らない • editor: 編集範囲だけ再 parse 100 万セルあっても、再計算は依存先の 3 つだけ Advanced Frontend Tech 52 -
04 Frontend Architecture Sync Engine はプロトコル設計である type Operation = {
opId: string; // unique -> idempotency clientId: string; seq: number; // per-client order baseRevision: number; // server state it assumed payload: Command; }; 起きること 扱い方 duplicate opId で重複排除 out-of-order seq で並べ直す / 欠番を待つ retry idempotent なら何度送っても安全 network partition outbox に滞留し、再接続で再送 stale client baseRevision 不一致 → rebase か reject server reject local を巻き戻して再適用(rollback) frontend に protocol design が入ってくる Advanced Frontend Tech 53 -
04 Frontend Architecture Snapshot + Log で復元コストを抑える replay だけ op
1 → op 2 → op 3 → … → op 103,003(全部 replay する) snapshot + log 103,001 snapshot(rev 103,000) 103,002 103,003 … restore = load(snapshot) + replay(ops after snapshot) compaction client にも現れる 定期的に新しい snapshot を作り、それ以 前の log を捨てる local-first では client の DB も同じ構造 (初回同期 = snapshot、以降 = op) Advanced Frontend Tech Figma の例 document を memory に保持し checkpoint を作る。信頼性のため writeahead log を導入 54 -
04 Frontend Architecture WASM の速さは境界の設計で決まる 1,000,000 calls × 10 B
1 call × 10 MB 境界の往復コストが支配的になる 境界を 1 回だけ越える • API の粒度: chatty な細かい呼び出しを避け、まとめて渡す • copy: linear memory への写しがコスト。TypedArray view で直 接書く • 文字列: encode / decode のコストがかかる • 共有: WASM threads + SharedArrayBuffer(第 1 章の前提が再 び) Advanced Frontend Tech Figma の例 UI は React / TypeScript、canvas engine は C++ → WebAssembly。本人たちいわく「Web stack より game engine に近い」。Code Layers では toolchain の大半を Web Worker 内で動かす 55 -
04 Frontend Architecture Tab はいつでも死ぬ: どこで落ちても回復できるか ① command ② Worker
の state 更 新 ③ IndexedDB commit A ④ WebSocket 送信 B ⑤ ACK 受信 ⑥ outbox 削除 C crash 地点 再起動時の状態 回復 A(② と ③ の間) memory 上の変更だけ消える。ユーザーには反映済 みに見えていた local commit と確定表示の順序を設計する B(③ と ④ の間) local には残る。server は未受信 起動時に outbox から再送 C(④ と ⑤ の間) 送ったか不明(ACK なし) 再送し、server が opId で重複排除 (idempotency) Advanced Frontend Tech 56 -
04 Frontend Architecture Version skew: デプロイしても全員は更新されない Tab A(昨日から開いたまま) Tab B(今日開いた)
Server UI v42 UI v43 protocol v15 Worker v42 Service Worker v43 v14 も受理 IDB schema v7 IDB schema v8 — • protocol は後方互換に変える(追加のみ) • 最低 version 未満は forced reload • schema は expand → migrate → contract • 旧 JS chunk を一定期間残す(古い tab の lazy load が 404 にならないように) • 接続時に version を交換(capability negotiation) Advanced Frontend Tech 57 -
04 Frontend Architecture Observability: 1 つの操作を thread を跨いで trace する
pointerdown = 0 ms command 作成 +2 ms 計測の要点 Worker が受信 +1 ms trace ID を command に付 けて thread を跨いで繋ぐ +14 ms 計算完了 timeOrigin + now() で Worker と時刻を揃える +3 ms patch を受信 +8 ms render → presented Main input latency · LoAF · busy time Advanced Frontend Tech Worker / Storage Sync / Memory queue latency · message size · clone 時 間 · tx latency backlog · RTT · local vs server revision · heap 58 -
4 章は 1 本の因果でつながっている 02 Rendering 01 Concurrency main を空けたい
Worker に出す main には UI projection だけが残る 04 Architecture 03 Storage ブラウザ内の分散システム Worker が state と DB を持つ Worker を使い始めた結果、FE に現れるもの message passing · backpressure · transaction · leader election · op log · failure recovery Advanced Frontend Tech 59 -
Takeaways 1 Main Thread は希少資源。frame budget と input latency で予算管理する
2 並行化はタダではない。コピー・同期・backpressure のコストまで設計に含める 3 大規模 FE は分散システム。local-first・protocol・障害・version skew を前提 に設計する Advanced Frontend Tech 60 -
References • MDN: Web Workers / Transferable objects / SharedArrayBuffer
/ Atomics • MDN: Critical rendering path / Animation performance and frame rate • Chrome for Developers: Use scheduler.yield() to break up long tasks • Chrome for Developers: Long Animation Frames API • MDN: OffscreenCanvas / AudioWorklet / Long animation frame timing • web.dev: Optimize long tasks • MDN: IndexedDB API / Web Locks API / Broadcast Channel API • Figma Blog: Making multiplayer more reliable • MDN: FileSystemSyncAccessHandle / StorageManager.persist() / estimate() • Figma Blog: How Figma draws inspiration from the gaming world • W3C: Indexed Database API 3.0(transaction scheduling / lifecycle) • Figma Blog: Building Figma’s Code Layers Advanced Frontend Tech • Figma Blog: How Figma’s multiplayer technology works 61