Slide 1

Slide 1 text

Webエンジニアなのに ブラウザの仕組みがわからないので、 Pythonで自作してみた PyCon JP 2026 / 2026.08.21(金) 佐藤 樹 / Recustomer, Inc. #pyconjpD

Slide 2

Slide 2 text

問題:どちらが「自作」でしょう? A B 2

Slide 3

Slide 3 text

A 3

Slide 4

Slide 4 text

B 4

Slide 5

Slide 5 text

ブラウザを開かなくなった今こそ AI とターミナルで、ほとんどの情報が手に入るようになった 自分がブラウザを開いている時間は、確実に減った Web エンジニアなのに、ブラウザの仕組みはわからないままだった そんな今だからこそ、自作して仕組みを学ぶことで得られることがある 5

Slide 6

Slide 6 text

今まで経験してきた Python で、 作れるのだろうか? 6

Slide 7

Slide 7 text

自作することで得られたもの エンジニアとしての学び 字句解析・木構造・再帰・カスケード・並行処理 Python という言語の魅力の再発見 Enum / dataclass / match / 型パラメータ / ほとんどの機能はPythonの標準パッケージで作成できる 7

Slide 8

Slide 8 text

少しづつブラウザが出来ていく様子と一緒に、 仕組みを紹介します 8

Slide 9

Slide 9 text

自己紹介 佐藤 樹 Tatsuki Satoh Recustomer, Inc. / Engineering Manager Web エンジニアとしてキャリアを積んできた 普段は Python / TypeScript 9

Slide 10

Slide 10 text

対象聴者 想定している方 説明しないこと 普段ブラウザを触っているが、 内部の作りは詳しくない方 何かを自作してみたい方 Python の詳細な構文 HTML / CSS / JavaScript そのもの サンプルコードは、Pythonの経験が少ない方でも理解できるような内容にしております。 10

Slide 11

Slide 11 text

発表の流れ 1 2 3 4 5 6 7 ブラウザの定義と構成要素 HTTP HTML → DOM CSS レイアウトと描画 JavaScript スレッドとロック 11

Slide 12

Slide 12 text

Chapter 1 ブラウザの定義と構成要素 12

Slide 13

Slide 13 text

ブラウザの作り方は、全部文書になっている 誰が書いた 文書 決めていること IETF RFC 9110 / RFC 3986 ページを取ってくるところ WHATWG HTML Standard HTML を解釈するところ W3C CSS 2.1 / CSS Syntax Level 3 CSS を解釈して描くところ どれも無料で読める。このあと出てくる仕様の引用は、全部この 3 つのどれか。 13

Slide 14

Slide 14 text

ブラウザは「user agent」の1つ The term "user agent" refers to any of the various client programs that initiate a request. 訳 「user agent」という用語は、リクエストを開始するさまざまなクライアントプログラムを指す RFC 9110 (HTTP Semantics) §3.5 User Agents rfc-editor.org/rfc/rfc9110.html#name-user-agents A user agent is any program that interprets a document written in the document language and applies associated style sheets … 訳 user agent とは、文書言語で書かれた文書を解釈し、対応するスタイルシートを適用するあらゆるプログ ラムである CSS 2.1 §3.1 Definitions w3.org/TR/CSS21/conform.html#user-agent HTTP は「取ってくる人」、CSS は「解釈して描く人」として、同じ言葉を定義している。 14

Slide 15

Slide 15 text

user agent = ユーザーの代理人 取ってきて — RFC 9110「リクエストを開始する」 解釈して・描いて — CSS 2.1「文書を解釈し、スタイルシートを適用する」 操作を受け付ける — HTML Standard の適合クラスの名前そのもの Web browsers and other interactive user agents 訳 Web ブラウザ、および対話的に操作できるその他の user agent HTML Standard §2.1.8 Conformance classes html.spec.whatwg.org/multipage/infrastructure.html#conformance-classes 15

Slide 16

Slide 16 text

user agent は、ブラウザだけじゃない 適合クラス Web browsers and other interactive user agents Non-interactive presentation user agents Visual user agents that support the suggested default rendering User agents with no scripting support Conformance checkers Data mining tools Authoring tools and markup generators どういうものか 描いて、人間の操作を受け付ける 描くが操作させない。仕様の例はプリンタと電光掲示板 既定の見た目(UA スタイルシート)を実装する スクリプトを実行しない。JS を切ったブラウザもここ 適合性を検査する。いわゆる HTML validator 描画も検査もせず、中身だけ使う。クローラ HTML を書き出す側。エディタや SSG HTML Standard §2.1.8 Conformance classes html.spec.whatwg.org/multipage/infrastructure.html#conformance-classes 16

Slide 17

Slide 17 text

ブラウザは全部満たす必要があるから、全部自作する 7 つのうち、この 3 つを同時に満たすのはブラウザだけ。 画面に描く → CSS のカスケード、レイアウト、ペイント 操作を受け付ける → イベント、ヒットテスト、再描画 スクリプトを動かす → JavaScript エンジンとの往復、DOM の書き換え 17

Slide 18

Slide 18 text

Web ページは 5 つの段階を通って表示される ネットワーク HTML パーサ カスケード レイアウト ペイント CSS パーサ 18

Slide 19

Slide 19 text

開発環境 Python 3.13 以上 パッケージ管理: uv GUI: tkinter 検証環境: macOS / Apple Silicon 19

Slide 20

Slide 20 text

Chapter 2 HTTP でページを取ってくる 20

Slide 21

Slide 21 text

いま作るのは、5 段階の 1 つめ いまここ ネットワーク HTML パーサ カスケード レイアウト ペイント 21

Slide 22

Slide 22 text

httpx を使ってページ情報を取得する self._client = httpx.Client( http2=True, # ← HTTP/2 がこれだけで有効になる follow_redirects=True, timeout=self._timeout_seconds, headers={ "User-Agent": self._user_agent, "Accept": DEFAULT_ACCEPT, "Accept-Language": DEFAULT_ACCEPT_LANGUAGE, "Accept-Encoding": DEFAULT_ACCEPT_ENCODING, # brotli / gzip }, limits=httpx.Limits(max_connections=16, max_keepalive_connections=8), ) TLS も HTTP/2 も brotli もコネクションプールも、自分で書かなくていい。 22

Slide 23

Slide 23 text

URL の解決について HTML に書いてある ../style.css は、 いま開いているページを基準にして、初めて絶対 URL になる。 This section describes an algorithm for converting a URI reference that might be relative to a given base URI into the parsed components of the reference's target. 訳 本節では、ある基準 URI に対して相対的でありうる URI 参照を、その参照先の構成要素へと変換するアル ゴリズムを述べる RFC 3986 (URI: Generic Syntax) §5.2 Relative Resolution rfc-editor.org/rfc/rfc3986.html#section-5.2 仕様の側が「アルゴリズム」と言い切っている。 場合分けの手順まで決まっている。 23

Slide 24

Slide 24 text

取りに行くもの全部を、この手順に通す も も <img src> も、取りに行く前に絶対 URL へ直す。 いま https://docs.python.org/3/library/re.html を開いているとして、 <link href> HTML に書かれている値 実際に取りに行く先 images/logo.png https://docs.python.org/3/library/images/logo.png ../style.css https://docs.python.org/3/style.css /assets/app.js https://docs.python.org/assets/app.js //cdn.jsdelivr.net/npm/app.js — ホスト直下から https://cdn.jsdelivr.net/npm/app.js — スキームだけ引き継ぐ グレー = いま開いているページ由来 / 紫 = HTML に書かれていた値 24

Slide 25

Slide 25 text

取れた HTML は、まだただの文字列 '...' 25

Slide 26

Slide 26 text

【段階 1】HTTP で取ってきただけ まだ何も解釈していないので、ソースがそのまま出るだけ。 26

Slide 27

Slide 27 text

Chapter 3 HTML を 解釈する 27

Slide 28

Slide 28 text

いま作るのは、5 段階の 2 つめ いまここ ネットワーク HTML パーサ カスケード レイアウト ペイント 28

Slide 29

Slide 29 text

HTML パーサは「2 段のステートマシン」 HTML ⽂字列 HTML パーサ 1 トークナイザ 字句解析 1 ⽂字ずつ読んで、タグ境界でトークンを放出 StartTag / EndTag / Characters / EndOfFile 2 ツリー構築 構⽂解析 「⽂書のどこにいるか」で挿⼊先を決める DOM ツリー この 2 段は、実装の都合ではなく仕様の指定。 … passed through a tokenization stage followed by a tree construction stage. HTML Standard §13.2.1 html.spec.whatwg.org/multipage/parsing.html#overview-of-the-parsing-model 29

Slide 30

Slide 30 text

トークナイザ: ステートマシンの仕様書通りに解析 Implementations must act as if they used the following state machine to tokenize HTML. The state machine must start in the data state. 訳 実装は、HTML をトークン化するのに以下の状態機械を使ったかのように振る舞わなければならない。状態 機械は data state から開始しなければならない。 HTML Standard §13.2.5 Tokenization html.spec.whatwg.org/multipage/parsing.html#tokenization 13.2.5.1 13.2.5.6 13.2.5.32 13.2.5.36 13.2.5.53 ⋮ 13.2.5.84 Data state Tag open state Before attribute name state Attribute value (double-quoted) state DOCTYPE state Numeric character reference end state 状態は 84 個。ひとつずつ名前が付いていて、どの文字でどこへ移るかまで決まっている。 30

Slide 31

Slide 31 text

例:

84 状態のうち、

を読むのに通る 8 つ。矢印の文字が遷移の条件。 > なら、属性なしでそのまま放出 英字 < Data state > Tag open state 空⽩ Before attribute Tag name state name state 英字 StartTag を放出 値の終わり 値の始まり " " = After attribute value Attribute value Before attribute (quoted) state (double-quoted) state value state Attribute name state 31

Slide 32

Slide 32 text

状態は、仕様書の名前がそのまま Enum になる class TokenizerState(Enum): DATA = auto() TAG_OPEN = auto() END_TAG_OPEN = auto() TAG_NAME = auto() BEFORE_ATTRIBUTE_NAME = auto() ATTRIBUTE_NAME = auto() AFTER_ATTRIBUTE_NAME = auto() BEFORE_ATTRIBUTE_VALUE = auto() ATTRIBUTE_VALUE_DOUBLE_QUOTED = auto() ATTRIBUTE_VALUE_SINGLE_QUOTED = auto() ATTRIBUTE_VALUE_UNQUOTED = auto() AFTER_ATTRIBUTE_VALUE_QUOTED = auto() SELF_CLOSING_START_TAG = auto() # 他の state も定義する 32

Slide 33

Slide 33 text

1 状態 = 1 メソッド def tag_name_state(self) -> None: character = self._consume() if character is None: self._emit_eof() elif character in WHITESPACE: self.state = TokenizerState.BEFORE_ATTRIBUTE_NAME elif character == "/": self.state = TokenizerState.SELF_CLOSING_START_TAG elif character == ">": self._emit_tag() # ← タグが確定して放出 else: self._tag_name += character.lower() 仕様の「Tag name state」1 節の分岐が、そのままこの 12 行になる。 33

Slide 34

Slide 34 text

ここからは、2 段目の「ツリー構築」 HTML ⽂字列 HTML パーサ 1 トークナイザ 字句解析 1 ⽂字ずつ読んで、タグ境界でトークンを放出 StartTag / EndTag / Characters / EndOfFile 2 ツリー構築 構⽂解析 「⽂書のどこにいるか」で挿⼊先を決める DOM ツリー 34

Slide 35

Slide 35 text

ツリー構築の状態が insertion mode class InsertionMode(Enum): INITIAL = auto() BEFORE_HTML = auto() BEFORE_HEAD = auto() IN_HEAD = auto() AFTER_HEAD = auto() IN_BODY = auto() TEXT = auto() AFTER_BODY = auto() AFTER_AFTER_BODY = auto() トークナイザの TokenizerState にあたるもの。 「文書のどこまで読んだか」を表す状態で、実装したのは 9 つ。 35

Slide 36

Slide 36 text

ツリー構築:「文書のどこにいるか」で挿入先が決まる 実装した 9 つの insertion mode のうち、 から まで読むのに通る 8 つ。矢印のタグが 遷移の条件で、紫の 2 つが、読んだ要素の入る先。 INITIAL を書かなくても、勝⼿に作られて同じ道を通る BEFORE_HTML IN_HEAD BEFORE_HEAD の中へ挿⼊ AFTER_AFTER_BODY EOF ─ DOM ができあがる AFTER_BODY IN_BODY の中へ挿⼊ AFTER_HEAD 36

Slide 37

Slide 37 text

2 つの状態機械が会話して、HTML の解析が進む トークナイザ ツリー構築 state = DATA mode = IN_BODY StartTag("script") トークンを渡す mode = TEXT 戻り先を original_mode に退避 「 まで、タグとして読むな」 逆向きは、この 1 本だけ switch_to_rawtext("script") state = RAWTEXT Characters("if (a < b) { …") この < は、もうタグの始まりとして読まれない トークンを渡す の中の if (a < b) が壊れないのは、ツリー構築側がトークナイザの状態を切り替えているから。 37

Slide 38

Slide 38 text

閉じタグの書き忘れは、勝手に直る

one

two 閉じていないなら、入れ子のはず

one

two 実際は、隣に並ぶ

one

two

をブラウザが補完する 38

Slide 39

Slide 39 text

閉じタグの書き忘れは、直し方まで仕様に書いてある If the stack of open elements has a p element in button scope, then close a p element. Insert an HTML element for the token. 訳 開いている要素のスタックに p があれば、その p を閉じる。そのうえで、このトークンの要素を挿入する。 HTML Standard §13.2.6.4.7 The "in body" insertion mode html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody 壊れた HTML をどう直すかは、実装者の判断ではない。だから自作でも Chrome と同じ木になる。 39

Slide 40

Slide 40 text

【段階 2】HTML を解析して DOM にした CSS はまだ 1 行も読んでいない。リンクが青く縦一列なのは、ブラウザ内蔵の既定スタイル(UA スタイルシート)だけが効いてい るから。 40

Slide 41

Slide 41 text

Chapter 4 CSS を解析して装飾する 41

Slide 42

Slide 42 text

いま作るのは、3 つめと CSS パーサ いまここ ネットワーク HTML パーサ カスケード レイアウト ペイント スタイルルール CSS パーサ 42

Slide 43

Slide 43 text

CSS も、やることは HTML と同じ CSS ⽂字列 CSS パーサ 1 トークナイザ 字句解析 1 ⽂字ずつ読んで、識別⼦や記号のトークンを放出 IdentToken / HashToken / OpenBraceToken / … 2 ルール構築 構⽂解析 セレクタと宣⾔をまとめて 1 ルールにする スタイルルール 同じ一文が、CSS の仕様書にも記載がある。 … passed through a tokenization stage followed by a tree construction stage. HTML Standard §13.2.1 / CSS Syntax Module Level 3 §3.1 w3.org/TR/css-syntax-3/#parsing-overview 43

Slide 44

Slide 44 text

CSS 1 行が、Rule というデータになる .article-card { padding: 16px; border-radius: 8px } ↓ StyleRule( selector_text=".article-card", declarations=( Declaration(name="padding", value_text="16px"), Declaration(name="border-radius", value_text="8px"), ), ) 難しいのはパースではなく、この後の「どれが勝つか」の決定。 44

Slide 45

Slide 45 text

カスケード = 勝ち残り戦 CSS の "C" は Cascading、段々に流れ落ちる滝のこと。同じ要素の color に、 複数のルールが別の値を指定してくる。そこからどれを採るか決める工程が、カスケード。 #hero .card { color: blue } .card { color: red } .card { color: green } /* ← 先頭に書いてあるのに、これが勝つ */ /* ← red には勝つ。同じ詳細度なので後勝ち */ 45

Slide 46

Slide 46 text

どれが勝つかの順番は、CSS の仕様が決めている 1. 詳細度(specificity)— #id > .class > tag 2. 同じ詳細度なら、後に書かれたほうが勝つ 3. 決まらなければ親から継承する(プロパティによる) 4. それでもなければ初期値 CSS 2.1 §6.4.1 Cascading order(1・2)/ §6.1.1 Specified values(3・4) w3.org/TR/CSS21/cascade.html#cascading-order 46

Slide 47

Slide 47 text

プロパティごとに書いていたら、コード量がすごいことに 詳細度 → 後勝ち → 継承 → 初期値 の 4 段は、CSS のすべてのプロパティに要る。 color も width も font-size も、通す判定はまったく同じ 同じ処理を、100 種類以上のプロパティすべてに。 color 用、 width 用、 font-size 用 …… と書き分けていたら終わらない。 47

Slide 48

Slide 48 text

ジェネリクスで、1 関数にまとめる def value_of[T](self, name: str, parse: Callable[[str], T | None], initial: T, inherited: T, is_inherited: bool) -> T: default = inherited if is_inherited else initial for text in self._values.get(name, ()): match text.strip().lower(): case "inherit": return inherited case "initial": return initial case "unset" | "revert": return default parsed = parse(text) if parsed is not None: return parsed return default TypeVar の宣言も Generic[T] の継承も要らない。 [T] と書くだけ。 PEP 695 Type Parameter Syntax(Python 3.12〜) peps.python.org/pep-0695 48

Slide 49

Slide 49 text

読めない宣言は、その 1 行だけが捨てられる 実サイトの CSS は、同じプロパティをわざと 2 回書く場合がある .card { width: 100px; /* ← 保険. 解釈できないものがあった時に適用させる */ width: calc(100% - env(safe-area-inset-left)); /* ← 解釈できない */ } 仕様の「無視する(ignore)」は、その宣言が無かったことになるという意味。 捨てる範囲はエラーの種類ごとに決まっていて、値のエラーなら宣言 1 つ分 プロパティごと消えるわけではないので、残った width: 100px が採用される CSS 2.1 §4.2 Rules for handling parsing errors w3.org/TR/CSS21/syndata.html#parsing-errors 49

Slide 50

Slide 50 text

失敗談:読めない 1 行が、上の 1 行を消した 自作は、プロパティごとに値を 1 つしか持っていなかった。 2 行目で上書きされて、保険の 100px は残っていなかった。 50

Slide 51

Slide 51 text

Styleの候補を複数持つ プロパティごとに、書かれた宣言をカスケードで勝った順に全部持つ。 そして解釈できた最初の値を使う。 直す前 — 持っていたのは文字列 1 つ "width" → "calc(100% - env(...))" 直した後 — 候補が優先度順に並ぶ "width" → ("calc(100% - env(...))", "100px") … 読める 100px まで残る 51

Slide 52

Slide 52 text

Chapter 5 レイアウトと描画 52

Slide 53

Slide 53 text

いま作るのは、4 つめと 5 つめ いまここ ネットワーク HTML パーサ カスケード レイアウト ペイント 53

Slide 54

Slide 54 text

DOM の木は、そのまま描かれるわけじゃない 同じ HTML でも、DOM の木と、実際に描かれる木は形が違う。 書いた HTML
ブラウザを 自作

まだ内緒

DOM の⽊ レイアウトツリー(描かれる⽊)
BlockBox ←
は 1 つの箱になる InlineBox ← は箱にならない。 中の⽂字が親の⾏に並ぶ レイアウト "ブラウザを"

"自作" "まだ内緒" ブラウザを ⾃作 display: none → 右の⽊には来ない 54

Slide 55

Slide 55 text

display の値で表示方法を分岐させる DOM を上からなぞる for child in element.children: child_style = self.resolver.resolve(child, style) if child_style.display == "none": continue # ← 木に入らない if child_style.display in ("block", "flex"): self._flush_run(run, style, box) child_box = self._build_box(child, child_style) box.children.append(child_box) # ← 箱を 1 つ作る else: self._process_children(child, child_style, run, box) # ← 親の行に入れる display は見た目の指定に見えて、木の形そのものを決めている。 55

Slide 56

Slide 56 text

ページの高さは、全部の箱を 1 つに合体させて決める ⼦が親からはみ出すこともある 親 どの箱も⼊る、いちばん⼩さい 1 枚 1 つに合体 union 親の下端 畳み込み(fold) はみ出した子 箱 1 つが BoxFragment、その矩形が Rect この下端が、ページの⾼さ document_height これをコードにすると、自分の枠から始めて、子の結果も入る大きさに広げていくだけ。 def deep_overflow(fragment: Fragment) -> Rect: rect = fragment.rect # ← 自分の枠から始めて if isinstance(fragment, BoxFragment): # ← 子を持つのは箱だけ if fragment.style.overflow_y.clips and fragment.style.overflow_x.clips: return rect # ← overflow: hidden なら打ち切る for child in fragment.children: rect = rect.union(deep_overflow(child)) # ← 子孫を 1 つの枠に畳む return rect 56

Slide 57

Slide 57 text

木をたどるのも、 yield from で for 文になる def descendant_elements(self) -> Iterator[Element]: for child in self.children: if isinstance(child, Element): yield child yield from child.descendant_elements() # ← 子の分は子に任せる for element in document.descendant_elements(): # 木が、ただの for になる ... elements = sum(1 for _ in document.descendant_elements()) 再帰を書くのはこの 5 行だけ。使う側は、ただの for で回せる。 57

Slide 58

Slide 58 text

ペイントする レイアウト -> 箱がどこにあるか ペイント -> 描画する
Hello riff
[ SolidFill(rect=Rect(8, 8, 1264, 22.4), color=Rgba(241, 229, 254)), BorderPaint(rect=Rect(8, 8, 1264, 22.4), widths=Edges(2, 2, 2, 2)), TextPaint(origin_x=10.0, baseline_y=24.2, text="Hello"), TextPaint(origin_x=49.5, baseline_y=24.2, text="riff"), ] 上から順に 背景を塗る → 枠線を引く → 文字を置く。 この 1 個 1 個が描画命令( DisplayItem )。 58

Slide 59

Slide 59 text

描画命令 12 種類を、 match 1 つで分岐させる def draw(self, item: DisplayItem) -> None: match item: case SolidFill(): self._draw_solid(item) case GradientFill(): self._draw_gradient(item) case ImageFill(): self._draw_image(item) case BorderPaint(): self._draw_border(item) case BoxShadowPaint(): self._draw_box_shadow(item) case TextPaint(): self._draw_text(item) case PushClip(): self._push_clip(item) case PushOpacity(): self._push_opacity(item) case PopState(): self._pop_state() 描画命令( DisplayItem )は 12 種類の frozen dataclass の Union クラスパターンで分岐すると、case の中では mypy が型を絞ってくれる 59

Slide 60

Slide 60 text

命令を 1 つ足したら、書き忘れを mypy が名指しする case PushOpacity(): case PopState(): case _ as unreachable: self._push_opacity(item) self._pop_state() assert_never(unreachable) 13 種類目の PushFilter を足して、 draw に case を書き忘れると error: Argument 1 to "assert_never" has incompatible type "PushFilter"; expected "Never" [arg-type] 忘れた型の名前を、実行する前に mypy が言ってくれる。 AI 時代に学ぶ好きなルール・嫌いなルール Linter 編 根本翔多 mypy / ruff のどのルールを選ぶのか、AI 時代の判断軸で。8/22(土)10:30-11:00 / ダリア2 もっと詳しく 2026.pycon.jp/ja/talks/GTFVQZ 60

Slide 61

Slide 61 text

【段階 4】CSS を適用 ほぼ完成。ただし記事カードの中身が空のまま。 これは後で JavaScript の章で回収する。 61

Slide 62

Slide 62 text

Chapter 6 JavaScript を動かす 62

Slide 63

Slide 63 text

document は自作する JavaScript エンジン= JavaScript のコードを読んで実行するだけの部品。 エンジンに入っている / Map / JSON / Math RegExp / class / try-catch エンジンには無い / window / fetch setTimeout / addEventListener Array document = ECMAScript。JavaScript という言語そのもの = DOM / HTML の仕様。ブラウザが用意する document window などはブラウザが用意するものなので、そこは全部自分で作る必要がある。 63

Slide 64

Slide 64 text

JavaScript エンジンが提供するものはWrapperとして定義 借りているのは pythonmonkey 経由の SpiderMonkey(Firefox のエンジン)。 Python 側(自作の DOM 実装) JS 側(自作。エンジンが動かす) self._host = { "createElement": self._bridge.create_element, "setAttribute": self._bridge.set_attribute, } createElement(name) { return wrap(host.createElement(String(name))); } 64

Slide 65

Slide 65 text

JavaScript エンジンが提供しないものは自作で提供 def host_functions(self) -> dict[str, Callable[..., object]]: return { "documentHandle": self.document_handle, "createElementNS": self.create_element_ns, "bodyHandle": self.body_handle, "parentHandle": self.parent_handle, "querySelectorHandle": self.query_selector_handle, "boundingRectJson": self.bounding_rect_json, # ... 全 62 個 } エンジンに無かったもの( document / window / fetch )を、ここで全部足している エンジンで提供されているものと同様にWrapperとして提供する 65

Slide 66

Slide 66 text

【段階 5】JavaScript を実行 66

Slide 67

Slide 67 text

Chapter 7 スレッドとロックでパフォーマンスを向上させる 67

Slide 68

Slide 68 text

速くするためにまずはここから いまここ ネットワーク HTML パーサ カスケード レイアウト ペイント 68

Slide 69

Slide 69 text

実際にページを開こうとしたら、 全然表示されない 69

Slide 70

Slide 70 text

画像の取得に時間がかかっていた 0s 2s 0.82s HTML + CSS 取得 ネットワーク待ち 4s 6s 2.31s 4.98s JS 実⾏ 画像 132 枚(逐次) CPU 8.11s 全体の 6 割が、この待ち キャッシュ済みなら 132 枚で 0.1 秒。 つまり CPU ではなくネットワーク待ちがボトルネック。 70

Slide 71

Slide 71 text

原因: 各スレッドで同じ画像を取得しに行こうとする 時間 スレッド A 置き場を⾒る → まだ無い スレッド B 取ってきた 画像の置き場 A が取ってきているあいだ、置き場はずっと空のまま ダウンロードする ネットワーク待ち 置き場に⼊れる 置き場を⾒る ダウンロードする → やっぱり無い {} この置き場が self._entries 同じ画像を、もう⼀度 まだ空っぽ 同じ画像を 2 回 取ってきてしまう {"logo.png": 画像} A が⼊れ終わって、やっと⼊る 置き場を見るのと入れるのは、別々の操作。 そのあいだに別のスレッドが入ると、同じ画像を 2 回取りに行く。 71

Slide 72

Slide 72 text

ロックには 2 つの役割がある def load(self, url: str) -> DecodedImage | None: while True: with self._lock: # ① 置き場を壊さないため if url in self._entries: return self._entries[url] waiting = self._inflight.get(url) if waiting is None: arrival = threading.Event() # ② 二度手間を防ぐため self._inflight[url] = arrival break waiting.wait(_TIMEOUT) # 先客がいるので待つ decoded = self._fetch_and_decode(url) with self._lock: self._entries[url] = decoded self._inflight.pop(url, None) arrival.set() # 待っている全員を起こす return decoded ① 置き場を壊さないため( Lock )と、② 同じ画像を何度も取りに行かないため( Event )。 72

Slide 73

Slide 73 text

結果:画像の取得が 9.8 倍速くなった with ThreadPoolExecutor(max_workers=min(workers, len(pending))) as pool: for future in [pool.submit(self.load, url) for url in pending]: with contextlib.suppress(Exception): future.result() 4.98s → 0.51s 画像 132 枚 / 9.8 倍 で順序を保ったまま重複除去 ThreadPoolExecutor を with で使い、後始末を任せる dict.fromkeys 2 万文字で語る Python の with 文で始めるリソース管理 curekoshimizu with が何を保証しているのかを、 contextlib / ExitStack / async with から 他言語のリソース管理技法まで横断して書いた 1 本。 もっと詳しく zenn.dev/recustomer/articles/675db47214c2b8 73

Slide 74

Slide 74 text

まとめ 74

Slide 75

Slide 75 text

Python だからできたこと 使ったもの Enum + 1 状態 1 メソッド match + assert_never PEP 695 def value_of[T] frozen dataclass 再帰 + yield from threading / Event skia-python / httpx / pythonmonkey ブラウザのどこで効いたか HTML / CSS のトークナイザがそのまま仕様の写経になる 12 種類の描画命令のディスパッチ。命令を足して書き忘れると mypy が止める CSS 全プロパティのカスケードを 1 関数に集約 カスケードの出力。CSS の初期値を、デフォルト値としてそのまま書ける ツリーを畳む処理が数行で書ける 画像取得 4.98s → 0.51s、二重取得も防ぐ C++ の巨大な部品を pip で連れてきて繋ぐ 外部依存はたった 3 つ。 残りは全部標準の Python で書けた。 75

Slide 76

Slide 76 text

AI 時代に、自作する意味 「作れる」が簡単になった 自分に特化したブラウザだって、形にできる時代になった 76

Slide 77

Slide 77 text

作ることより、どう見せるかで悩むようになった 動くものを形にするのは、思っていたより早かった 手が止まったのは、それをどう見せるかのほう 77

Slide 78

Slide 78 text

ブラウザは、エンジニアの教材として最高だった 字句解析・構文解析・ツリー構造・再帰・カスケード・ レイアウト・並行処理 人間が Web を見るために必要な技術にはたくさんの技術要素があった。 そして、そのどれもが PureなPythonで書けた。 78

Slide 79

Slide 79 text

改めて:どちらが「自作」でしょう? A B 79

Slide 80

Slide 80 text

Recustomer からは、5 名が登壇します 2026年8月21日(金) 11:45 Pythonの実行はどこまで賢くなったのか:CPythonとPyPyから見 る最適化のしくみ 眞鍋 秀悟 | ダリア2 16:15 PyO3 で既存 Python 評価器を Rust core にする — wasmbindgen でブラウザにも配るための設計 古川 祐希 | ダリア2 17:00 Webエンジニアなのにブラウザの仕組みがわからないので、Python で自作してみた 本セッション 佐藤 樹 | 会議運営事務室 2026年8月22日(土) 10:30 AI 時代に学ぶ好きなルール・嫌いなルール Linter 編 SPONSOR BOOTH スポンサーブースでも お待ちしております 根本 翔多 | ダリア2 15:45 raiseをやめて得たもの、失ったもの 加藤 雅也 | ダリア2 80

Slide 81

Slide 81 text

参考文献 仕様 HTML Standard(WHATWG / Living Standard) html.spec.whatwg.org/multipage CSS Syntax Level 3(W3C / 候補勧告ドラフト 2021) w3.org/TR/css-syntax-3 CSS 2.1(W3C 勧告 2011) w3.org/TR/CSS21 RFC 9110(IETF / STD 97, 2022) rfc-editor.org/rfc/rfc9110 RFC 3986(IETF / STD 66, 2005) rfc-editor.org/rfc/rfc3986 書籍・資料 Web Browser Engineering Pavel Panchekha, Chris Harrelson 邦訳『Webブラウザエンジニアリング』 (オライリー・ジャパン 2026) browser.engineering 『[作って学ぶ]ブラウザのしくみ』 土井麻未(技術評論社 2024) gihyo.jp/book/2024/978-4-297-14546-0 「ブラウザ」リクルート エンジニアコース新人研修(2021) speakerdeck.com/recruitengineers 「2 万文字で語る Python の with 文で始めるリソース管理」 curekoshimizu zenn.dev/recustomer/articles/675db47214c2b8 81