ちいさなWebブラウザを作ろうを元にブラウザの作り方を学んだ時の学習記録。
HTMLを取り扱う
Goal: <div id = "header"><p>hello world</p></div>
のようなHTMLをparseして、DOMを生成する。
上の例だったら、以下のようなDOMが返されるはず。
Node{
NodeType:{
Element: {tag_name: "div", attributes:{"id": "header"}},
Text: ""
},
children: {
Node {
NodeType:{
Element: {tag_name: "p", attributes:{}},
Text: "hello world"
},
children: {}
},
}
}
CSSを取り扱う
以下の4種類のセレクタに対応
- Universal selector
- Type selector(e.g., div, h1)
- Attribute selector(e.g., a[href="https://example.org"])
- Class selector(e.g., .my-class)
pub enum SimpleSelector {
UniversalSelector,
TypeSelector {
tag_name: String,
},
AttributeSelector {
tag_name: String,
op: AttributeSelectorOp,
attribute: String,
value: String,
},
ClassSelector {
class_name: String,
},
// TODO (enhancement): support multiple attribute selectors like `a[href=bar][ping=foo]`
// TODO (enhancement): support more attribute selectors
}
例えば、test [foo=bar] { aa: bb; cc: dd; }
だったら、以下のようなCSSOMを生成。
StyleSheet{
Rule{
Selectors{
AttributeSelector {
tag_name:"test",
attribute:"foo",
op: Eq,
value:"bar"
},
},
Declarations{
Declaration{
name: "aa",
value:"bb"
}
Declaration{
name:"cc",
value:"dd"
}
}
}
}
レンダリングツリーを作る
DOMとCSSOMは、独立して管理されているデータ構造なので、画面にCSSを反映しながらDOMの内容を描画するのは若干面倒。そこで、一旦これらのデータ構造からレンダリングツリーと呼ばれる中間構造を構築し、それをその先の画面描画のステップに利用する。
CSSとDOMの紐付け
レンダリングツリー: DOMツリー中の各ノードに、最低限それに対応するCSSプロパティを紐づけたもの。
3ステップ
- Filetering
- DOMツリーの各ノードに対してCSS内の宣言のリストのうちそれに紐づいているものを抽出するためのプロセス。
- Cascading
- 意味が衝突するような宣言を解消する。
- Defaulting
- 初期値とか継承とか。
Goal: Node
オブジェクトとStylesheet
オブジェクトから、適当なOption<StyledNode>
オブジェクトを生成する。
step1: セレクタのマッチを実装する
<div class = "header">~</div>
が、ClassSelector {class_name: header}
と一致することを判定。
step2: DOMツリーを変換する。
ここまででRule
オブジェクトが表現しているルールにNode
オブジェクトが表現するノードがマッチするかを確認することができた。Node
オブジェクトとStyleSheet
オブジェクトからOption<StyledNode>
オブジェクトを生成する。
pub fn to_styled_node<'a>(node: &'a Box<Node>, stylesheet: &Stylesheet) -> Option<StyledNode<'a>> {
let mut properties: HashMap<String, CSSValue> = [].iter().cloned().collect();
// order of appearance のみを考慮した filtering & cascading
// styledsheet内の各ルールについて、matchしているものだけを抽出し、CSSのdeclaration(e.g., height:120px)をinsertしていく。
for matched_rule in stylesheet.rules.iter().filter(|r| r.matches(node)) {
for declaration in &matched_rule.declarations {
properties.insert(declaration.name.clone(), declaration.value.clone());
}
}
// defaulting は一旦考慮しないことにしていたのでスキップ
// display: none が指定されているノードはレンダリングツリーに含めない
if properties.get("display") == Some(&CSSValue::Keyword("none".into())) {
return None;
}
let children = node
.children
.iter()
.filter_map(|x| to_styled_node(x, stylesheet))
.collect();
Some(StyledNode {
node_type: &node.node_type,
properties,
children,
})
}
StyledNodeの構造。NodeにCSSのプロパティを組み込んだもの。
pub struct StyledNode<'a> {
pub node_type: &'a NodeType,
pub children: Vec<StyledNode<'a>>,
pub properties: PropertyMap,
}
レンダリングする
レイアウト
Webブラウザ画面内でのレンダリングツリーの各要素の位置や、幅高さなどが決定される。
レイアウト処理の結果はbox treeと呼ばれる。
上のセクションで定義したStyledNode
をLayoutBox
に変換する処理。
pub struct LayoutBox<'a> {
pub box_type: BoxType<'a>,
pub children: Vec<LayoutBox<'a>>,
}
CSSでdisplay: block
とかdisplay: none
とかがあったら、それによって、BoxTypeを変更してStyledNode
からLayoutBox
を作る。
pub fn to_layout_box<'a>(snode: StyledNode<'a>) -> LayoutBox<'a> {
let mut layout = LayoutBox {
box_type: match snode.display() {
Display::Block => BoxType::BlockBox(BoxProps {
node_type: snode.node_type,
properties: snode.properties,
}),
Display::Inline => BoxType::InlineBox(BoxProps {
node_type: snode.node_type,
properties: snode.properties,
}),
Display::None => unreachable!(),
},
children: vec![],
};
for child in snode.children {
match child.display() {
Display::Block => {
layout.children.push(to_layout_box(child));
}
Display::Inline => {
match layout.children.last() {
Some(&LayoutBox {
box_type: BoxType::AnonymousBox,
..
}) => {}
_ => layout.children.push(LayoutBox {
box_type: BoxType::AnonymousBox,
children: vec![],
}),
};
layout
.children
.last_mut()
.unwrap()
.children
.push(to_layout_box(child));
}
Display::None => unreachable!(),
}
}
layout
}
ペイント
レイアウトを画面に描画する。レイアウト結果LayoutBox
をcursiveを用いてターミナルに描画する。
LayoutBoxをCursiveライブラリのUIコンポーネントに変換するコード:
pub fn to_element_container<'a>(layout: LayoutBox<'a>) -> ElementContainer {
match layout.box_type {
// box_typeがBlockBoxかInlineBoxの場合に処理する。
BoxType::BlockBox(p) | BoxType::InlineBox(p) => match p {
BoxProps {
node_type: NodeType::Element(ref element),
..
} => {
let mut p = Panel::new(LinearLayout::vertical()).title(element.tag_name.clone());
match element.tag_name.as_str() {
_ => {
for child in layout.children.into_iter() {
p.with_view_mut(|v| v.add_child(to_element_container(child)));
}
}
};
p.into_boxed_view()
}
BoxProps {
node_type: NodeType::Text(ref t),
..
} => {
// NOTE: This is puppy original behaviour, not a standard one.
// For your information, CSS Text Module Level 3 specifies how to process whitespaces.
// See https://www.w3.org/TR/css-text-3/#white-space-processing for further information.
let text_to_display = t.data.clone();
let text_to_display = text_to_display.replace("\n", "");
let text_to_display = text_to_display.trim();
if text_to_display != "" {
TextView::new(text_to_display).into_boxed_view()
} else {
(DummyView {}).into_boxed_view()
}
}
},
BoxType::AnonymousBox => {
let mut p = Panel::new(LinearLayout::horizontal());
for child in layout.children.into_iter() {
p.with_view_mut(|v| v.add_child(to_element_container(child)));
}
p.into_boxed_view()
}
}
}
最終的なコード
fn main() {
let mut siv = cursive::default();
let node = html::parse(HTML);
let stylesheet = css::parse(&format!(
"{}\n{}",
DEFAULT_STYLESHEET,
collect_tag_inners(&node, "style".into()).join("\n")
));
let container = to_styled_node(&node, &stylesheet)
.and_then(|styled_node| Some(to_layout_box(styled_node)))
.and_then(|layout_box| Some(to_element_container(layout_box)));
if let Some(c) = container {
siv.add_fullscreen_layer(c);
}
siv.run();
}
JavaScriptとWeb
Webブラウザ内のJavaScriptの実行環境に対してWebブラウザが様々なAPIを提供している。
バインディング
Webブラウザ実装が持つJSエンジンとWebブラウザ実装を繋げるbindingを通じてJSから多岐にわたる機能を呼び出せるようにしている。
??????
JSエンジンを組み込む
ミニDOM APIを作る
Webページ中のJSが簡単なDOM操作を行えるように、V8エンジンとWebブラウザ実装を接続する。
JSからDOM操作をする際には、DocumentのgetElementById関数、appendChild関数を取り扱う。
JSエンジンを組み込んだ上で、追加で以下の実装が必要。
-
src/main.rs
で生成したDOMの情報の描画中の保持をうまくやる - DOM操作関連のRust実装を用意する。(e.g., get_element_by_idとかset_inner_htmlとか)
- DOM操作関連のV8バインディングを実装する
Webブラウザの状態保持の実装
今までのHTMLのパースと描画。
fn main() {
let mut siv = cursive::default();
// HTMLのparse
let node = html::parse(HTML);
// CSSのparse
let stylesheet = css::parse(&format!(
"{}\n{}",
DEFAULT_STYLESHEET,
collect_tag_inners(&node, "style".into()).join("\n")
));
// htmlとCSSからレンダリングツリーを作る→それをもとにレイアウト
→ペイントする
let container = to_styled_node(&node, &stylesheet)
.and_then(|styled_node| Some(to_layout_box(styled_node)))
.and_then(|layout_box| Some(to_element_container(layout_box)));
if let Some(c) = container {
// 実際の描画
siv.add_fullscreen_layer(c);
}
siv.run();
}
現在のままだと、画面描画が始まった後は、main()関数のみがDOMツリーへの参照を有していて、画面に描画されているCursiveビューや、JS処理系はDOMツリーへの参照を持たない。これだと画面に描画されているCursiveビューが表示内容の変更とDOMツリーの状態を同期したり、JS側からDOMツリーの操作をするのが難しいはず。
mainから出発して何が行われているのか見ていく。
fn main() {
let mut siv = cursive::default();
// ①HTMLをparseして、DOMを作る。
let node = html::parse(HTML);
// ②下記の構造体のインスタンスを生成。
// pub struct Renderer {
// view: ElementContainer,
// document_element: Rc<RefCell<Box<Node>>>,
// js_runtime_instance: JavaScriptRuntime,
// }
let mut renderer = Renderer::new(Rc::new(siv.cb_sink().clone()), node);
// ③JavaScriptを実行。
renderer.execute_inline_scripts();
siv.add_fullscreen_layer(renderer);
siv.run();
}
②let mut renderer = Renderer::new(Rc::new(siv.cb_sink().clone()), node);
を詳しく見ていく。
ここでやっていることは大きく分けて2つ。
1 現在のDOMをもとにviewを作る。
let view = to_styled_node(&document_element, &stylesheet)
.and_then(|styled_node| Some(to_layout_box(styled_node)))
.and_then(|layout_box| Some(to_element_container(layout_box)))
.unwrap();
今までと同じ。
2 DOM APIを作る。
js_runtime_instance: JavaScriptRuntime::new(
document_element_ref,
Rc::new(RendererAPI::new(ui_cb_sink)),
),
Runtimeのインスタンスを作る際に、globalオブジェクトにdocument
を追加。
// JavaScriptのグローバルオブジェクト(global)に新しいプロパティを追加し、そのプロパティとしてdocumentオブジェクトを設定
let global = context.global(handle_scope);
{
let scope = &mut v8::ContextScope::new(handle_scope, context);
// この文字列は後で、グローバルオブジェクトのプロパティキーとして使用
let key = v8::String::new(scope, "document").unwrap();
let document = create_document_object(scope);
// グローバルオブジェクトに新しいプロパティを設定。
global.set(scope, key.into(), document.into());
}
document
オブジェクトのプロパティを定義
let document = create_document_object(scope);
の中身を見ていく。
-
documentオブジェクトを作成。
let document = v8::ObjectTemplate::new(scope).new_instance(scope).unwrap();
-
documentオブジェクトに
getElementById
プロパティを追加。
2.1 getElementByIdの関数テンプレートtmpl
を定義。
2.2tmpl
から、実際の関数オブジェクトを生成。
2.3 作成したgetElementById関数をV8のdocumentオブジェクトにプロパティとして追加する
pub fn create_document_object<'s>(
scope: &mut v8::ContextScope<'s, v8::EscapableHandleScope>,
) -> v8::Local<'s, v8::Object> {
// V8内で新しいオブジェクトインスタンスを作成しています。このオブジェクトがdocumentオブジェクトになる。
let document = v8::ObjectTemplate::new(scope).new_instance(scope).unwrap();
{
// create `getElementById` property of `document`
// V8 JavaScriptエンジン内で "getElementById" という文字列を表す v8::String オブジェクトを作成
let key = v8::String::new(scope, "getElementById").unwrap();
// tmplが実際の関数の定義。
// v8::FunctionTemplate::newで新しい関数テンプレートを作成。
let tmpl = v8::FunctionTemplate::new(
scope,
// 第2引数として渡されるクロージャ(|...| { ... }の部分)は、JavaScriptからこの関数が呼び出されたときに実行されるコードを定義して
|scope: &mut v8::HandleScope,
args: v8::FunctionCallbackArguments,
mut retval: v8::ReturnValue| {
// getElementByIdに渡されるID
let id = args
.get(0)
.to_string(scope)
.unwrap()
.to_rust_string_lossy(scope);
// JavaScriptランタイム内で現在のdocumentオブジェクトを取得。
let document_element = JavaScriptRuntime::document_element(scope);
// document_elementを可変参照として借用
let document_element = &mut document_element.borrow_mut();
// 最終的に作成されたV8オブジェクトを、JavaScriptの戻り値として設定
retval.set(
// 実際の処理。
document_element
// idに対応する要素をドキュメント内から検索
.get_element_by_id(id.as_str())
// もし要素が見つかった場合、NodeType::Elementに該当するかをチェックします。
// 要素がDOMのエレメント(要素)であれば、tag_name(タグ名)やattributes(属性)を取り出す
.and_then(|n| {
if let NodeType::Element(ref mut e) = n.node_type {
let tag_name = e.tag_name.clone();
let attributes = e.attributes();
Some((n, tag_name, attributes))
} else {
None
}
})
// Rustのデータ構造からV8のJavaScriptオブジェクトに変換する
.and_then(|(n, tag_name, attributes)| {
Some(to_v8_element(scope, tag_name.as_str(), attributes, n).into())
})
.unwrap_or_else(|| v8::undefined(scope).into()),
);
},
);
// これは、前のステップで作成した関数テンプレートtmplから、実際のJavaScript関数オブジェクトを生成。
// get_function(scope)は、V8コンテキスト内でこの関数テンプレートに基づいた関数オブジェクトを作成します。
let val = tmpl.get_function(scope).unwrap();
// 作成したgetElementById関数をV8のdocumentオブジェクトにプロパティとして追加する操作
document.set(scope, key.into(), val.into());
}
document
}
getElementById
の定義が、以下。
document_element
// idに対応する要素をドキュメント内から検索
.get_element_by_id(id.as_str())
// もし要素が見つかった場合、NodeType::Elementに該当するかをチェックします。
// 要素がDOMのエレメント(要素)であれば、tag_name(タグ名)やattributes(属性)を取り出す
.and_then(|n| {
if let NodeType::Element(ref mut e) = n.node_type {
let tag_name = e.tag_name.clone();
let attributes = e.attributes();
Some((n, tag_name, attributes))
} else {
None
}
})
// Rustのデータ構造からV8のJavaScriptオブジェクトに変換する
.and_then(|(n, tag_name, attributes)| {
Some(to_v8_element(scope, tag_name.as_str(), attributes, n).into())
})
.unwrap_or_else(|| v8::undefined(scope).into()),
getElementById
より取り出したElement(tagName(h1とか)とattributes(id = "test"
))にtagNameとinnerHTMLっていうプロパティを追加。
// RustのDOMノードデータをV8のJavaScriptオブジェクトに変換し、tagName(読み取り専用)とinnerHTML(取得・設定可能)というプロパティを追加。
fn to_v8_element<'s>(
scope: &mut v8::HandleScope<'s>,
tag_name: &str,
_attributes: Vec<(String, String)>,
node_rust: NodeRefTarget,
) -> v8::Local<'s, v8::Object> {
let node = to_v8_node(scope, node_rust);
// create properties of the node
{
// create `tagName` property
let key = v8::String::new(scope, "tagName").unwrap();
let value = v8::String::new(scope, tag_name).unwrap();
// nodeオブジェクトに対してtagNameプロパティを追加
node.define_own_property(scope, key.into(), value.into(), READ_ONLY);
}
{
// create `innerHTML` property
// "innerHTML"というプロパティ名をV8の文字列オブジェクトとして作成
let key = v8::String::new(scope, "innerHTML").unwrap();
// innerHTMLプロパティを定義し、その取得時と設定時に特定の処理を行う
node.set_accessor_with_setter(
scope,
key.into(),
// innerHTMLプロパティに対応するゲッター(getter)とセッター(setter)を定義.
move |scope: &mut v8::HandleScope,
_key: v8::Local<v8::Name>,
args: v8::PropertyCallbackArguments,
mut rv: v8::ReturnValue| {
let this = args.this();
// 対応するRust側のDOMノードを取得
let node = to_linked_rust_node(scope, this);
// Rust側のDOMノードからinnerHTMLの内容を取得し、V8の文字列オブジェクトとして生成
let ret = v8::String::new(scope, node.inner_html().as_str()).unwrap();
rv.set(ret.into());
},
move |scope: &mut v8::HandleScope,
_key: v8::Local<v8::Name>,
// JavaScript側から設定された新しい値です。element.innerHTML = "new content";のような操作で渡される値を指す
value: v8::Local<v8::Value>,
// プロパティのコールバックに渡される引数
args: v8::PropertyCallbackArguments| {
let this = args.this();
// thisからRust側の対応するDOMノードを取得
let node = to_linked_rust_node(scope, this);
// 渡されたvalueをRustの文字列に変換し、その文字列をRust側のDOMノードのinnerHTMLとして設定
node.set_inner_html(value.to_rust_string_lossy(scope).as_str());
// DOMノードのinnerHTMLが更新された後、レンダリングをトリガーしてブラウザの表示を更新
JavaScriptRuntime::renderer_api(scope).rerender();
},
);
}
node
}
mainでやってることをまとめる。
fn main() {
let mut siv = cursive::default();
// HTMLをparseしてDOMを作る。
let node = html::parse(HTML);
// DOMを元に最初のviewを作る。JavaScriptランタイムのインスタンスを作って、DOMとJavaScriptランタイムのバインディングを行う。(e.g., JSの`getElementById`関数にRustの`get_element_by_id`を紐づける。)
let mut renderer = Renderer::new(Rc::new(siv.cb_sink().clone()), node);
// 上のコードで作ったJSランタイムを使用してscriptタグを実行。
renderer.execute_inline_scripts();
// 描画。
siv.add_fullscreen_layer(renderer);
siv.run();
}