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 开头做四件事:
- 开着
devtoolsfeature 且是 debug 构建时,连上 dx 的 WebSocket 收热重载消息(devtools::init)。 Config里给了 history 就提供到根 scope;开着documentfeature 时init_document提供Rc<dyn Document>(见 document 与 eval)。WebsysDom::new(web_config, runtime):下一节。- 第一次渲染。
should_hydrate = web_config.hydrate || cfg!(feature = "hydrate"):fullstack 的客户端构建打开了hydratefeature,走水合分支(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 已经改完。
这段注释来自 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_children | this.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_placeholder | document.createComment('placeholder'),入栈、记进 nodes |
create_text_node(value, id) | create_text_node | document.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_with | root.replaceWith(...els) |
replace_placeholder_with_nodes(path, m) | replace_placeholder | loadChild 找到占位符,replaceWith |
insert_nodes_after / insert_nodes_before | insert_after / insert_before | node.after(...) / node.before(...) |
set_attribute(name, ns, value, id) | set_attribute 或(AttributeValue::None)remove_attribute | setAttributeInner / 按属性名分情况删除 |
set_node_text(value, id) | set_text | this.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) | remove | node.remove() |
push_root(id) | push_root | this.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
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几次」。路径不复制、不进字符串缓冲,因为它是静态数据,地址一直有效。
问: 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。
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()。cloneNode100 次、createElement0 次:li模板早就在templates[1]里了,每行一次深拷贝。createTextNode100 次是每行的动态文本。- 字符串是
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()。实测一次SetText21 字节,加 100 行 3204 字节,都是一次调用。