Rust in Everything
基于 dioxus 0.7.10

WebsysDom 与 interpreter

主线位置:更新主线的末段「WebsysDom 实现 WriteMutations,把指令攒进 sledgehammer 缓冲,批量调 JS」,以及把 wait_for_work、render_immediate、flush_edits 串起来的 web 主循环。纯客户端应用的第一次渲染也走这里;水合那条分支(首屏主线的最后一步)在 水合。指令本身的语义在 Mutations。

flowchart TB
  launchfn["dioxus_web::launch"] --> spawnl["spawn_local run"]
  spawnl --> newdom["WebsysDom::new"]
  newdom --> first{"要水合?"}
  first -->|否| rebuild["rebuild + flush_edits"]
  first -->|是| hyd["skip_mutations 下 rebuild"]
  rebuild --> waitw["select 等 wait_for_work"]
  hyd --> waitw
  waitw --> render["render_immediate"]
  render --> enc["WriteMutations 写缓冲"]
  enc --> flushn["flush_edits"]
  flushn --> runjs["JS run 解码执行"]
  runjs --> waitw

本页的浏览器实验都用同一个纯客户端应用(exp-dioxus/web-csr,依赖 dioxus = "=0.7.10",dx build --platform web 的 debug 构建),在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 的 headless 模式里跑,用 Node 22 写的 Chrome DevTools Protocol 脚本驱动。原生实验用 VirtualDom::rebuild_to_vec() / render_immediate_to_vec() 打印 Mutations.edits。

启动:从 launch 到 run

dioxus::launch(app) 在打开 web feature、编译到 wasm 时调到 dioxus_web::launch。它建 VirtualDom、插入根 context、从 platform_config 里找出 Config,再交给 launch_virtual_dom:

pub fn launch_virtual_dom(vdom: VirtualDom, platform_config: Config) {
    wasm_bindgen_futures::spawn_local(async move {
        crate::run(vdom, platform_config).await;
    });
}

浏览器的主线程上没有可以阻塞的循环,整个应用就是一个交给 spawn_local 的 future。run 的返回类型是 !,这个 future 永远不会完成;launch 本身立刻返回,main 跟着返回,wasm 实例留在内存里,靠微任务和事件回调继续跑。

run 开头做四件事:

  1. 开着 devtools feature 且是 debug 构建时,连上 dx 的 WebSocket 收热重载消息(devtools::init)。
  2. Config 里给了 history 就提供到根 scope;开着 document feature 时 init_document 提供 Rc<dyn Document>(见 document 与 eval)。
  3. WebsysDom::new(web_config, runtime):下一节。
  4. 第一次渲染。should_hydrate = web_config.hydrate || cfg!(feature = "hydrate"):fullstack 的客户端构建打开了 hydrate feature,走水合分支(websys_dom.skip_mutations = true 下 rebuild,再 rehydrate,见 水合);纯客户端就是
    } else {
        virtual_dom.rebuild(&mut websys_dom);

        websys_dom.flush_edits();
    }

然后进入下面的循环。

主循环

去掉 devtools 分支以后,循环体是这样(run 的后半段):

    loop {
        // ...
        let mut hydration_work: Option<SuspenseMessage> = None;

        {
            let work = virtual_dom.wait_for_work().fuse();
            pin_mut!(work);

            let mut hydration_receiver_iter = futures_util::stream::iter(&mut hydration_receiver)
                .fuse()
                .flatten();
            let mut rx_hydration = hydration_receiver_iter.select_next_some();

            // ...
            #[cfg(not(all(feature = "devtools", debug_assertions)))]
            #[allow(unused)]
            {
                select! {
                    _ = work => {},
                    hyd = rx_hydration => {
                        #[cfg(feature = "hydrate")]
                        {
                            hydration_work = Some(hyd);
                        }
                    }
                }
            }
        }

        // ...
        #[cfg(feature = "hydrate")]
        if let Some(hydration_data) = hydration_work {
            websys_dom.rehydrate_streaming(hydration_data, &mut virtual_dom);
        }

        // Todo: This is currently disabled because it has a negative impact on response times for events but it could be re-enabled for tasks
        // Jank free rendering
        //
        // 1. wait for the browser to give us "idle" time
        // 2. During idle time, diff the dom
        // 3. Stop diffing if the deadline is exceeded
        // 4. Wait for the animation frame to patch the dom

        // wait for the mainthread to schedule us in
        // let deadline = work_loop.wait_for_idle_time().await;

        // run the virtualdom work phase until the frame deadline is reached
        virtual_dom.render_immediate(&mut websys_dom);

        // wait for the animation frame to fire so we can apply our changes
        // work_loop.wait_for_raf().await;

        websys_dom.flush_edits();
    }

select! 等两个来源,哪个先好就醒:

来源什么时候就绪醒来以后
virtual_dom.wait_for_work()有脏 scope(事件回调写了 signal、任务被唤醒后写了 signal……)直接往下 render_immediate
rx_hydration流式 SSR 送来一段 suspense 边界的水合数据(hydration_receiver 是 rehydrate 返回的通道,纯客户端时是 None,这个分支永远不就绪)rehydrate_streaming 先认领这段 DOM,再 render_immediate
devtools_next(只在 devtools + debug)收到热重载消息dioxus_devtools::apply_changes 换模板或打补丁

不管哪个分支醒来,后面都是同一对 render_immediate + flush_edits。select! 选中一个分支时另一个 future 被丢掉;wait_for_work 已经取出的消息已经记进 VirtualDom 的脏队列,没取的还在通道里(render_immediate 开头还会 process_events 一次),所以丢掉 future 不会丢工作。wait_for_work 内部怎么取消息、什么时候 poll 任务和 effect,见 VirtualDom 与 Runtime 和 调度。

注释掉的四步是「浏览器空闲时 diff、动画帧里改 DOM」。现在没有任何等待:render_immediate 返回后马上 flush_edits,两者和事件回调之后的那次 poll 在同一个微任务里,一次点击 的实验二证明了 click() 返回后、下一个微任务之前 DOM 已经改完。

📝NOTE
冷知识:「空闲时 diff、动画帧里打补丁」是 sledgehammer 进来时一起关掉的

这段注释来自 PR #640(「Optimize Web Template Implementation」,2022-12-09 合并)里的提交「disable rif raf」。在它之前,循环是 let deadline = work_loop.wait_for_idle_time().await; → dom.render_with_deadline(deadline).await → work_loop.wait_for_raf().await,每次更新至少要等一次 requestIdleCallback 和一次 requestAnimationFrame。这个 PR 同时把 interpreter 换成了 sledgehammer-bindgen(说明里写「Uses sledgehammer-bindgen to batch operations to js」「The Js is about one third the size of the previous interperter」),并贴了开关 rif-raf 两种情况下的基准截图,结论是「The Dioxus overhead benchmark shows a improvement of ~20%」;提交里的注释给出关掉的理由:「it has a negative impact on responce times for events」。批量已经由 sledgehammer 做了,再等一帧只会推迟响应。仓库里第一个包含它的 tag 是 v0.3.2。

WebsysDom:WriteMutations 的浏览器实现

WebsysDom 的字段:

pub struct WebsysDom {
    #[allow(dead_code)]
    pub(crate) root: Node,
    pub(crate) document: Document,
    pub(crate) templates: FxHashMap<Template, u16>,
    pub(crate) interpreter: Interpreter,

    #[cfg(feature = "mounted")]
    pub(crate) runtime: Rc<Runtime>,

    #[cfg(feature = "mounted")]
    pub(crate) queued_mounted_events: Vec<ElementId>,

    // ...
    #[cfg(feature = "hydrate")]
    pub(crate) skip_mutations: bool,

    #[cfg(feature = "hydrate")]
    pub(crate) suspense_hydration_ids: crate::hydration::SuspenseHydrationIds,
}

WebsysDom::new 按 Config 找根元素(默认 id="main";找不到就 console.error,用 create_element("body") 新建一个元素当根,这个元素并没有插进文档),建 Interpreter::default(),造事件闭包(见 事件委托),然后

        let _interpreter = interpreter.base();
        _interpreter.initialize(
            root.clone().unchecked_into(),
            handler.as_ref().unchecked_ref(),
        );

        dioxus_html::set_event_converter(Box::new(WebEventConverter));
        handler.forget();

Interpreter 是 sledgehammer 宏生成的 Rust 结构,里面有一个 JS 对象 js_channel;base() 把它当成 BaseInterpreter(ts/core.ts 里的基类)来用,initialize 把根节点和事件闭包交给 JS。handler.forget() 让闭包永远不被释放,因为 JS 侧一直拿着它。

impl WriteMutations for WebsysDom 的 15 个方法几乎都是一行转发,先看 skip_mutations(),再调 self.interpreter 上的同名方法:

WriteMutations 方法调 Interpreter 的JS 里执行的(unified_bindings.rs)
append_children(id, m)append_childrenthis.appendChildren($id$, $many$)
assign_node_id(path, id)assign_id(path.as_ptr() as u32, path.len() as u8, id)this.nodes[$id$] = this.loadChild($ptr$, $len$)
create_placeholder(id)create_placeholderdocument.createComment('placeholder'),入栈、记进 nodes
create_text_node(value, id)create_text_nodedocument.createTextNode($text$),入栈、记进 nodes
load_template(template, index, id)先查 templates,再 load_template(tmpl_id, index, id)this.templates[$tmpl_id$][$index$].cloneNode(true)
replace_node_with(id, m)replace_withroot.replaceWith(...els)
replace_placeholder_with_nodes(path, m)replace_placeholderloadChild 找到占位符,replaceWith
insert_nodes_after / insert_nodes_beforeinsert_after / insert_beforenode.after(...) / node.before(...)
set_attribute(name, ns, value, id)set_attribute 或(AttributeValue::None)remove_attributesetAttributeInner / 按属性名分情况删除
set_node_text(value, id)set_textthis.nodes[$id$].textContent = $text$
create_event_listener(name, id)new_event_listener(name, id, event_bubbles(name) as u8);mounted 例外写 data-dioxus-id,createListener
remove_event_listener(name, id)remove_event_listener见 事件委托
remove_node(id)removenode.remove()
push_root(id)push_rootthis.pushRoot(this.nodes[$root$])

set_attribute 把 Float、Int、Bool 都先 to_string() 再当文本发,Bool 发 "true" / "false",由 JS 侧的 setAttributeInner 处理布尔属性;Listener 和 Any 走不到这里(_ => unreachable!()),监听器只走 create_event_listener。mounted 不是浏览器事件,create_event_listener 把它的 id 记进 queued_mounted_events,flush_edits 之后节点已经在 DOM 里了,再逐个调 runtime.handle_event("mounted", ..)。

skip_mutations 是水合用的开关:水合时 rebuild 照常分配 ElementId、登记监听器,但不往 DOM 写任何东西。为什么不换一个不写 DOM 的 WriteMutations 实现,字段上的注释说得很直接:core 的 diff 对 WriteMutations 是泛型的,多一个实现就多一份单态化的 diff 代码,wasm 体积明显变大;一个布尔字段的检查便宜得多。

模板:第一次用时建 DOM,以后 cloneNode

load_template:

    fn load_template(&mut self, template: Template, index: usize, id: ElementId) {
        if self.skip_mutations() {
            return;
        }
        let tmpl_id = self.templates.get(&template).cloned().unwrap_or_else(|| {
            let mut roots = vec![];
            for root in template.roots {
                roots.push(self.create_template_node(root))
            }
            let id = self.templates.len() as u16;
            self.templates.insert(template, id);
            self.interpreter.base().save_template(roots, id);
            id
        });

        self.interpreter
            .load_template(tmpl_id, index as u16, id.0 as u32)
    }
  • Rust 侧的 templates: FxHashMap<Template, u16> 给每个模板分一个从 0 开始的 u16 编号。Template 的 Hash / PartialEq 在「相同的静态数据被合并」时按指针、否则按值(见 Template 与 VNode)。
  • 第一次遇到某个模板,create_template_node 用 web_sys 递归建出每个根的 DOM:元素用 create_element(有命名空间就 create_element_ns),静态属性用 setAttributeInner,静态文本用 create_text_node,动态节点的位置先放一个 <!--placeholder--> 注释。动态属性不在模板里,所以建出来的是一份「只有静态部分」的 DOM。
  • 这份 DOM 用 save_template 交给 JS(saveTemplate 就是 this.templates[tmpl_id] = nodes)。这是一次普通的 wasm-bindgen 调用,当场执行,不进缓冲;后面的 load_template 才进缓冲,等 flush 时它要的模板已经在 JS 里了。
  • LoadTemplate { index, id } 一次只克隆模板的一个根:load_template 的 JS 是 this.templates[$tmpl_id$][$index$].cloneNode(true),深拷贝,放进 nodes[id] 并压栈。

下面实验里第一次渲染的应用是一个有 15 个根的 rsx!,templates 里于是只有 1 项、15 个根;第一次渲染调了 15 次 cloneNode,createElement 的 21 次全花在建这份模板上。列表的 li { key: "{i}", "row {i}" } 是另一个模板,第一次加一行时才建(templates 变成 2 项),以后每加一行只多一次 cloneNode。

JS 侧的 nodes 和 stack

BaseInterpreter 拿着 DOM 和 VirtualDom 之间的全部映射:

  initialize(root: HTMLElement, handler: EventListener | null = null) {
    this.global = {};
    this.local = {};
    this.root = root;

    this.nodes = [root];
    this.stack = [root];
    this.templates = {};

    this.handler = handler;

    // make sure to set the root element's ID so it still registers events
    root.setAttribute("data-dioxus-id", "0");
  }
  • nodes 是按 ElementId 下标存的节点数组。ElementId(0) 就是根元素,所以第一次渲染的 AppendChildren { id: ElementId(0), m } 把栈上的节点挂到 #main 下。指令里只出现 id,从不出现 DOM 节点本身;core 只管分配 id,节点和 id 的对应全在这个数组里。
  • stack 是 Mutations 里那台栈机器的栈:LoadTemplate、CreateTextNode、CreatePlaceholder 压栈,AppendChildren、ReplaceWith、InsertAfter 这些从栈顶取 m 个。
  • 路径怎么走:assign_node_id(path, id) 传的是 path.as_ptr() as u32,即 &'static [u8] 在 wasm 线性内存里的地址。loadChild 用 this.m.getUint8(ptr) 直接从 wasm 内存读路径的每一字节,从栈顶节点起,每一字节是「先 firstChild,再 nextSibling 几次」。路径不复制、不进字符串缓冲,因为它是静态数据,地址一直有效。
📝NOTE
没有蠢问题

问: remove 的 JS 是 node.remove(),并没有把 nodes[id] 清掉。删掉的节点还被数组引用着,不会泄漏吗?

答: 会被留住,但留不久。core 在删除节点时把 ElementId 还给 slab,下一个新节点马上复用这个 id(见 diff),JS 侧执行到那条指令时覆盖 nodes[id],旧节点才能被回收。所以数组的长度跟着「同时活着的 id 的峰值」走,而不是跟着创建过的节点总数走。下面的实验里,加第一行时空列表的占位符被替换掉,它的 id 被新的节点用掉,nodes.length 从 19 只涨到 21。

sledgehammer:一次 flush,一次 run()

unified_bindings.rs 里的 mod js 不是普通的 Rust 模块,而是交给 #[sledgehammer_bindgen::bindgen(module)] 的描述:每个 fn 声明一种操作的参数类型,函数体是一段 JS 字符串,$name$ 标出参数在 JS 里的位置。

    fn set_text(id: u32, text: &str) {
        "{this.nodes[$id$].textContent = $text$;}"
    }
    fn set_attribute(id: u32, field: &str<u8, attr>, value: &str, ns: &str<u8, ns_cache>) {
        "{let node = this.nodes[$id$]; this.setAttributeInner(node, $field$, $value$, $ns$);}"
    }

宏(sledgehammer_bindgen_macro 0.6.5)为它生成两半:

  • Rust 侧:Interpreter 上每个 fn 一个同名方法,调用时不碰 JS,只往几块缓冲里追加:操作码(每个 1 字节,4 个凑成一个 u32 字)、按宽度分开的参数缓冲(u8 / u16 / u32)、一块所有字符串首尾相接的 str_buffer。&str<u8, attr> 这种带缓存名的字符串参数走一个 128 项的缓存:命中只发一个 u8 下标,没命中时下标的最高位(128)置位,后面跟着字符串本身。属性名、事件名、命名空间来回就那么几个,大多数时候只占 1 字节。
  • JS 侧:一个 class RawInterpreter extends BaseInterpreter,run() 先从一块固定的元数据区读出各缓冲在 wasm 内存里的地址,建好 Uint8Array / Uint16Array / Uint32Array 视图,把整块字符串解码成一个 JS 字符串,然后进入循环:每次读一个 u32,拆成 4 个操作码,switch 到对应的 JS 片段。

flush()(flush_edits 里调的 self.interpreter.flush())写入结束操作码、更新元数据里的指针,wasm 内存增长过就先 update_memory,然后调一次 js_channel.run(),最后清空缓冲。一次渲染不管产生多少条指令,执行它们的跨 wasm/JS 边界调用只有这一次;第一次见到新模板时 create_template_node 的那些 web_sys 调用和 save_template 另算。

set_text 在生成的 JS 里变成这样(从构建产物 wasm/snippets/dioxus-interpreter-js-*/inline0.js 里摘出,换了行):

case 12:{this.nodes[this.u32buf[this.u32bufp++]].textContent = this.s.substring(this.sp,this.sp+=this.u32buf[this.u32bufp++]);
}break;

id 从 u32 缓冲取,字符串是「从整块字符串 this.s 里按长度切一段」,长度也在 u32 缓冲里。case 27:return true; 是结束标记。字符串整块解码有两条路:Rust 侧在 flush 前检查 str_buffer 是否少于 100 字节且全是 ASCII,是就让 JS 用 String.fromCharCode 逐字拼(避开 TextDecoder.decode 的固定开销),否则整块交给一次 TextDecoder.decode。宏的文档把这几点列为它快的原因:字符串每批只解码一次、操作码按字节打包、常用字符串缓存。

实验:每次更新跨边界发了多少字节

为了在页面里看到 interpreter 对象,实验把构建产物里的 initialize(root,handler=null){ 改成先执行 window.__interp=this(只改了 public/ 里的这一个 JS 文件,Rust 代码和行为不变),再用 Page.addScriptToEvaluateOnNewDocument 在页面脚本之前装上计数器:包住 Document.prototype.createElement / createTextNode / createComment 和 Node.prototype.cloneNode 数调用次数,包住 __interp.run 在每次返回时记下这批数据的大小(this.p - this.ls 是操作码区读过的字节,u8bufp / u16bufp / u32bufp 是三个参数缓冲各读了几个元素,元数据标志位说明本批有字符串时 this.sl 是字符串字节数)。

应用里和这一节有关的部分:

fn app() -> Element {
  let mut count = use_signal(|| 0);
  let mut rows = use_signal(|| 0usize);
  // ...
  rsx! {
    div { id: "counter", "count = {count}" }
    button { id: "inc", onclick: move |_| count += 1, "+" }
    button { id: "add1", onclick: move |_| rows += 1, "add 1" }
    button { id: "add100", onclick: move |_| rows += 100, "add 100" }
    ul { id: "list",
      for i in 0..rows() {
        li { key: "{i}", "row {i}" }
      }
    }
    // ... 后面还有 11 个根元素,供事件委托一页的实验用
  }
}

脚本依次点击 #inc 两次、#add1 两次、#add100 一次,每次点击后等 200 毫秒再读计数并清零。输出(cdp_render.mjs):

--- first render (rebuild + flush_edits) ---
flushes: 1
  run(): ops 40 B, u8 39 B, u16 66 B, u32 156 B, str 34 B, total 335 B
DOM API: {"createElement":21,"createTextNode":13,"createComment":3,"cloneNode":15}
interpreter: templates=1 nodes.length=19
#main children: div#counter button#inc button#add1 button#add100 ul#list div#outer input#focusme a#pa a#pb form#f1 form#f2 button#toggle2 button#toggle3 div#two input#three
templates: 0: 15 roots, first: <div id="counter"><!--placeholder--></div>
--- click #inc (count 0 -> 1) ---
flushes: 1
  run(): ops 4 B, u8 0 B, u16 0 B, u32 8 B, str 9 B, total 21 B
DOM API: {"createElement":0,"createTextNode":0,"createComment":0,"cloneNode":0}
interpreter: templates=1 nodes.length=19
--- click #inc (count 1 -> 2) ---
...
--- click #add1 (rows 0 -> 1) ---
flushes: 1
  run(): ops 8 B, u8 1 B, u16 8 B, u32 20 B, str 5 B, total 42 B
DOM API: {"createElement":1,"createTextNode":1,"createComment":1,"cloneNode":1}
interpreter: templates=2 nodes.length=21
--- click #add1 (rows 1 -> 2) ---
flushes: 1
  run(): ops 8 B, u8 1 B, u16 8 B, u32 20 B, str 5 B, total 42 B
DOM API: {"createElement":0,"createTextNode":1,"createComment":0,"cloneNode":1}
interpreter: templates=2 nodes.length=22
--- click #add100 (rows 2 -> 102) ---
...
templates after: 0: 15 roots, first: <div id="counter"><!--placeholder--></div> | 1: 1 roots, first: <li><!--placeholder--></li>

(#add100 那一段留给本节末尾的动脑筋。)

  • 第一次渲染:一次 run(),335 字节。21 次 createElement 和 12 次 createTextNode 是 create_template_node 建模板时调的(21 个元素、12 段静态文字),第 13 次 createTextNode 是动态文本 count = 0 的 CreateTextNode 指令;3 次 createComment 里两次是模板里两个动态节点的占位、一次是空列表的 CreatePlaceholder。15 次 cloneNode 对应 15 条 LoadTemplate,每个根一条。
  • 一次 count += 1:21 字节。一个 u32 字装下了 SetText 和结束两个操作码(4 字节),u32 缓冲里是 id 和字符串长度(8 字节),字符串缓冲里是 count = 1(9 字节)。没有调任何建节点的 DOM API,只改了一个 textContent。
  • 加一行:原生实验打印的同一次更新是
-- rows 1 -> 2
LoadTemplate { index: 0, id: ElementId(4) }
CreateTextNode { value: "row 1", id: ElementId(5) }
ReplacePlaceholder { path: [0], m: 1 }
InsertAfter { id: ElementId(2), m: 1 }

四条指令加结束标记是 5 个操作码,占两个 u32 字(8 字节);u16 缓冲里是模板编号、根下标、两个 m(8 字节);u32 缓冲里是两个 id、文本长度、路径地址、InsertAfter 的 id(20 字节);u8 缓冲里是路径长度(1 字节);字符串是 row 1。第一次加行时多出的 createElement 和 createComment 各 1 次,是建 li 模板(li 和它里面动态文本的占位符);第二次就只剩 cloneNode 和动态文本的 createTextNode。

❗IMPORTANT
动脑筋:一次加 100 行,run() 被调了几次?cloneNode 和 createElement 各几次?

接着上面的状态(已经有 2 行,li 模板已经建过),点 #add100,rows 从 2 变成 102。这次更新的字符串一共多少字节,会不会走 TextDecoder?(M2 Max、macOS 27、Chrome 156,debug 构建。)

动脑筋答案

--- click #add100 (rows 2 -> 102) ---
flushes: 1
  run(): ops 304 B, u8 100 B, u16 602 B, u32 1604 B, str 594 B (TextDecoder), total 3204 B
DOM API: {"createElement":0,"createTextNode":100,"createComment":0,"cloneNode":100}
interpreter: templates=2 nodes.length=222
li count: 102
  • run() 一次。这次更新是 301 条指令:每行 LoadTemplate、CreateTextNode、ReplacePlaceholder 三条,最后一条 InsertAfter { m: 100 } 把 100 个 li 一起插到第二行后面。原生实验对同一次更新(2 行变 102 行)打印出 301 edits,最后一条是 InsertAfter { id: ElementId(4), m: 100 }。它们都在同一次 render_immediate 里写进缓冲,flush_edits 只调一次 run()。
  • cloneNode 100 次、createElement 0 次:li 模板早就在 templates[1] 里了,每行一次深拷贝。createTextNode 100 次是每行的动态文本。
  • 字符串是 row 2 到 row 101,594 字节,超过 100 字节,所以这次走 TextDecoder.decode;前面几次都不到 100 字节的 ASCII,是逐字拼的。
  • 各缓冲的大小正好对上:301 条指令加结束标记是 302 个操作码,76 个 u32 字,304 字节;u32 缓冲 1604 字节是 401 个数,每行 4 个(克隆的 id、文本长度、文本节点 id、路径地址)加 InsertAfter 的 id;u16 缓冲 602 字节是 301 个数,每行 3 个(模板编号、根下标、ReplacePlaceholder 的 m)加 InsertAfter 的 m;u8 缓冲 100 字节是每行一个路径长度。

小结

  • dioxus_web::launch 把 run 交给 spawn_local,run 是一个永不返回的 future:第一次渲染(或水合)之后,循环里 select! 等 wait_for_work、流式水合消息(debug 下还有热重载),醒来就 render_immediate + flush_edits。等空闲、等动画帧的两步自 #640 起注释掉了。
  • WebsysDom 实现 WriteMutations,15 个方法基本都是一行转发到 sledgehammer 生成的 Interpreter;水合时用 skip_mutations 跳过写入,而不是另写一个实现,为的是不多一份 diff 的单态化代码。
  • 模板第一次用时由 Rust 用 web_sys 建出静态部分的 DOM、save_template 存进 JS,以后每次 LoadTemplate 只是 cloneNode(true) 一个根。
  • JS 侧的 nodes 数组按 ElementId 存节点,nodes[0] 是根;路径直接从 wasm 内存里读。
  • sledgehammer 把操作码按字节打包、参数按宽度分缓冲、字符串拼成一块,每次 flush 只跨边界调一次 run()。实测一次 SetText 21 字节,加 100 行 3204 字节,都是一次调用。

💬 关联讨论

发起讨论
加载中...