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

No more parser-inserted js - defer / async を今こそ...

Tatsuki Sugiura
March 15, 2024
57

No more parser-inserted js - defer / async を今こそ完全に理解する

Tatsuki Sugiura

March 15, 2024
Tweet

Transcript

  1. No more parser-inserted js defer / async を今こそ完全に理解する 2024-03-15 Tatsuki

    Sugiura <[email protected]> This document has been distributed under Creative Commons Attribution 4.0 International
  2. 自己紹介 - Tatsuki Sugiura • 現在 Repro で Booster という

    Web サイト高速化ツール の開発をしています • 個人では RoR を使った開発や和暦gem とか • ESR の 「ハッカーになろう」から OSS活動をはじめる • 過去に OSDN や スラド (/.j) の開発・運営 • お茶が大好きです!
  3. 今日のお題 - そろそろ1995年に別れを告げよう 1. SCRIPT 要素の実行モードの話 ◦ 使わない方がいいけど、知っておいたほうがいい parser-insterted のお話

    2. JavaScript 誕生時の昔話 + HTMLパーサとjs実行の話 ◦ 知ってる人は懐かしい気持ちになってください (ツッコミお願いします !) ◦ 過去のカオスに巻き込まれなかった人は、自信を持って避けられるようになってほしい 3. defer / async 属性の話とまとめ ◦ 自信を持って defer / async 属性を指定できるように ◦ document.write() を捨てられるように
  4. 質問: SCRIPT 要素の defer / async 属性使ってますか? • 属性自体はかなり昔からある ◦

    defer は HTML 4.0 (1998) から ◦ async は HTML 5 の最初 (2014) から ◦ (実は defer は HTML5 でちょっと意味が変わってい る) • みなさんどれですか? ◦ 1: 属性があるの知らなかった ◦ 2: 知ってるけどややこしいから使ってない ◦ 3: 一部簡単につけられるところには defer / async を設定しているよ ◦ 4: 基本 defer / async だけど、どうしても無理なとこ ろだけ除外してるよ ◦ 5: ツール/フレームワークが全部やってくれる!
  5. SCRIPT 要素の標準モードはどうなっているのか • <script src=”foo.js”></script> - シンプルな外部スクリプトの呼び出し • SCRIPT の標準モード

    = 同期実行 & parser-inserted mode ◦ 実はこの名前は HTML5 からついた • ダウンロード待ち+実行待ちでHTMLパーサが停止する • • このため、速度上かなり不利になる ◦ レンダリングがブロックされる時間が伸びる ◦ CoreWebVitals (FCP / LCP) の指標が悪化する ◦ UI / UX / 体感上良くない
  6. なんで標準でそんなことに? • 標準は効率良く動いてよ! と言いたいところだけど…… • Web標準はかなり初期からの互換性を維持しており、JavaScript 1.0 の機能が今 でも利用できる • 標準モードが

    parser-inserted なのはそのため • JavaScript 1.0 機能の代表: document.write() ◦ 効率が悪いので、Chrome は標準で document.write に対して警告を出す ◦ HTML Standard でもかなり激しく警告されている
  7. JavaScript 1.0 はどういうものだったのか • Netscape Navigator 2.0 (1996) によって世に出た •

    ブラウザのすべてをスクリプトで制御できる(目標) ◦ 今表示されているドキュメント - DOM API ◦ 入力ストリーム - document.write() / document.open() / document.close() ◦ 他: ウィンドウ / ヒストリ / プラグイン / ダウンロード状態... • 当時から Netscape による DOM API (Level 0) を持って いた ◦ DOM API による動的 HTML は DHTML と呼ばれていた ▪ ブラウザごとに DOM API の互換性がない悪夢 • ref: 1996: JavaScript Annoyances and Meeting the DOM From Internet archive
  8. まず、ブラウザによる通常の HTML パースを考えよう HTML stream <html> <head> <meta charset=.... Web

    Server Tokenizer Tree Construction <html> <head> <meta… DOM Tree html head meta link body
  9. ここまではごく普通のパース処理 • よくある言語処理系などとだいたい一緒 • 最近の JavaScript だと AST に親しみのある人 も多そう

    • HTMLの場合、基本的に SyntaxError がなく、 強力なエラーリカバリを行う • <style> エレメントの中は文法が変わるなどトー クナイズに多少クセはあるけども • ここまではそこまでは難しくはないかな? • ところが、これに SCRIPT 要素が挟まると……
  10. parser-inserted JavaScript execution HTML stream <html> <head> <meta charset=.... Web

    Server Tokenizer Tree Construction <html> <head> <meta… DOM Tree html head meta link body document.write() DOM API exec
  11. parser-inserted な js 実行と介入できること 絶対実装したくない... • パーサで SCRIPT 要素が処理され、 DOM

    Tree に追加さ れた直後に js の実行が開始される • 実行開始時にパーサを停止 • js から HTML入力ストリームのインサーションポイントに割 り込んで、任意の文字列を流し込むことができる ◦ 入力した文字列は即座にパースされて Treeに反映される ◦ 更に <script> タグを投入して実行コンテキストのネストも可 • js から今まさに構築途中の DOM Tree を改変できる ◦ 過去のパース結果による既存ツリーや、現在 (target)の包含エレメント (スタック)が変更される可能性が常にある • 改変が行なわれた場合、投機的パーサ (speculative parser) の結果の破棄が発生するかも • => 当然かなり遅い • しかし、DOM API Level 0 の時代はこれが必要だった
  12. DOM specification history • Netscape DOM Level 0 (1995) ◦

    Named access - DOM の状態をグローバルオブジェクトにマッピング ◦ <img name=”logo” src=”logo.gif”> なら、 js から document.images[‘logo’] や document.images[0] でアクセスできる • W3C DOM Level 1 (1998) ◦ ブラウザごとの差をなくし、 XML / HTML 共通のモデルとして勧告 ◦ getElementsByTagName() / createElement() • W3C DOM Level 2 (2003-2020) ◦ getElementById() ◦ id 属性をつければ勝手にグローバル変数に入る • W3C DOM Level 3 (2004) ◦ Level 2 が改定され続けたのでほとんど変わっていない ? • W3C DOM Level 4 => whatwg DOM Living Standard (2015-) ◦ 現行バージョン ◦ querySelector() / querySelectorAll()
  13. • HTML を解釈しながら • SCRIPTにあたったらパーサを止めて実行 • 続きを解釈 • SCRIPTにあたったらパーサを止めて実行 •

    続きを解釈 • …. つまり、標準 DOM API があれば parser-inserted は不要! • HTML を全部読んで DOM Tree を構築 • 途中で出現したスクリプトは随時ダウンロード • 非同期なスクリプトはロード次第実行 • それ以外は DOM 構築が終わったら実行 これをやってくれるのが defer と async
  14. defer 属性 • パーサがSCRIPT要素に達した 時点で並行ダウンロード開始 • HTML入力ストリーム終端後(≒ DOM 構築完了後)、 DOMContentLoaded

    が発火す る直前に順序を守って実行 • document.write() は無視される (input stream closed) async 属性 • パーサがSCRIPT要素に達した 時点で並行ダウンロード開始 • ダウンロードが終わり次第パー サを止めて実行 (順不同) • document.write() 実行不可 • 途中でもDOM改変はできる
  15. document.write() の書き換え例 • document.write が使えないじゃないか! という方に • 要素を追加したい ◦ document.createElement() と、

    Element#insertBefore() もしくは Element#appendChild() を使い ましょう • 今のスクリプトの位置に要素を追加したい or 位置を調べたい ◦ 実行中のスクリプト要素として document.currentScript を使いましょう ◦ もしくは、自身のスクリプトに id を振って document.getElementById() を使いましょう ◦ マーカーを document.write() で追加するのはやめましょう • 追加のスクリプトを実行したい ◦ document.createElement(‘SCRIPT’) と insertBefore()を使いましょう ◦ この時、強制的に async になるので気をつけてください
  16. 余談 • どうしても parser-inserted mode が必要なケースは極めてごく稀にあります ◦ 実は Repro Booster

    では必須で活用しています • 他に面白い使い方している人がいたら是非教えてください! もういちど: 今日から SCRIPT は defer か async!