diff
主线位置:更新主线里
run_scope拿到新VNode之后、WriteMutations之前的那一步。点击让count变了,组件重跑,新旧VNode用的是同一个模板,diff 只看到一段动态文本不同,发出一条SetText。
flowchart TB
start["old.diff_node new"] --> same{"模板相同"}
same -->|"否"| repl["建新节点 ReplaceWith 旧根"]
same -->|"是"| mv["挂载信息移给新 VNode"]
mv --> attrs["逐个比动态属性"]
attrs --> dyn["逐个比动态节点"]
dyn --> txt["Text 与 Text 值不同发 SetText"]
dyn --> frag["Fragment 与 Fragment 比列表"]
dyn --> comp["Component 与 Component 比 props"]
dyn --> other["种类不同 建新的 替换旧的"]
comp --> memo{"memoize 为真"}
memo -->|"是"| skip[不重跑]
memo -->|"否"| rerun["run_and_diff_scope 子组件"]
这一页讲新旧两个 VNode 怎么比、比出什么指令。VNode、Template、DynamicNode 的结构在 Template 与 VNode;列表(DynamicNode::Fragment)的比较在 带 key 的列表;每条指令的含义和第一次渲染的创建路径在 Mutations。代码在 diff/node.rs、diff/component.rs、arena.rs。
入口:diff_scope
render_immediate 每取出一个脏 scope,就调 run_and_diff_scope:先 run_scope 得到新的 Element,再进 diff_scope:
fn diff_scope<M: WriteMutations>(
&mut self,
to: Option<&mut M>,
scope: ScopeId,
new_nodes: Element,
) {
self.runtime.clone().with_scope_on_stack(scope, || {
// We don't diff the nodes if the scope is suspended or has an error
let Ok(new_real_nodes) = &new_nodes else {
return;
};
let scope_state = &mut self.scopes[scope.0];
// Load the old and new rendered nodes
let old = scope_state.last_rendered_node.take().unwrap();
// If there are suspended scopes, we need to check if the scope is suspended before we diff it
// If it is suspended, we need to diff it but write the mutations nothing
// Note: It is important that we still diff the scope even if it is suspended, because the scope may render other child components which may change between renders
let mut render_to = to.filter(|_| self.runtime.scope_should_render(scope));
old.diff_node(new_real_nodes, self, render_to.as_deref_mut());
self.scopes[scope.0].last_rendered_node = Some(LastRenderedNode::new(new_nodes));
if render_to.is_some() {
self.runtime.get_state(scope).mount(&self.runtime);
}
})
}
两件事值得记住。第一,组件返回 Err(出错或挂起)时根本不 diff,旧节点原样留着,错误交给边界去处理(Scope 与 hooks 里 hook 出错的实验看到的就是这个:出错那一轮页面没变)。第二,to 是个 Option:在挂起的 suspense 边界下面,diff 照样走一遍(子组件该建的建、该删的删),只是 to 变成 None,一条指令都不写。下面所有 diff 函数都收 Option<&mut impl WriteMutations>,凡是「只是副作用」的地方(文本、属性)在 to 为 None 时直接跳过。
挂载信息:VNode 之外的那张表
VNode 是组件返回的不可变数据,Rc<VNodeInner> 加一个 Cell<MountId>。它被挂到 DOM 上之后,「这个模板的第 i 个根是哪个 ElementId」「第 j 个动态节点挂在哪」这些信息不放在 VNode 里,而是放在 Runtime.mounts 这个 slab 里的 VNodeMount:
pub(crate) struct VNodeMount {
/// The parent of this node
pub parent: Option<ElementRef>,
/// A back link to the original node
pub node: VNode,
/// The IDs for the roots of this template - to be used when moving the template around and removing it from
/// the actual Dom
pub root_ids: Box<[ElementId]>,
/// The element in the DOM that each attribute is mounted to
pub(crate) mounted_attributes: Box<[ElementId]>,
/// For components: This is the ScopeId the component is mounted to
/// For other dynamic nodes: This is element in the DOM that each dynamic node is mounted to
pub(crate) mounted_dynamic_nodes: Box<[usize]>,
}
mounted_dynamic_nodes 一格两用:动态文本和占位符存的是 ElementId,组件存的是 ScopeId,还没建 scope 时是 usize::MAX(ScopeId::PLACEHOLDER)。diff 里频繁出现的 get_mounted_dyn_node(mount, idx) 读的就是这一格。node 是一条反向链接,事件冒泡时由 ElementId 找到挂载、再找到模板里的监听器属性,见 事件。
diff_node:同一个模板只比动态部分
pub(crate) fn diff_node(
&self,
new: &VNode,
dom: &mut VirtualDom,
mut to: Option<&mut impl WriteMutations>,
) {
// ...
// If the templates are different, we need to replace the entire template
if self.template != new.template {
let mount_id = self.mount.get();
let parent = dom.get_mounted_parent(mount_id);
return self.replace(std::slice::from_ref(new), parent, dom, to);
}
self.move_mount_to(new, dom);
// If the templates are the same, we don't need to do anything, except copy over the mount information
if self == new {
return;
}
// If the templates are the same, we can diff the attributes and children
// Start with the attributes
// Since the attributes are only side effects, we can skip diffing them entirely if the node is suspended and we aren't outputting mutations
if let Some(to) = to.as_deref_mut() {
self.diff_attributes(new, dom, to);
}
// Now diff the dynamic nodes
let mount_id = new.mount.get();
for (dyn_node_idx, (old, new)) in self
.dynamic_nodes
.iter()
.zip(new.dynamic_nodes.iter())
.enumerate()
{
self.diff_dynamic_node(mount_id, dyn_node_idx, old, new, dom, to.as_deref_mut())
}
}
- 先比模板。
Template的PartialEq在「相同的静态项会被合并」的构建里比较三个&'static切片的指针,否则按值比较(Template 与 VNode 讲为什么)。同一处rsx!每次渲染产出的模板是同一个static,所以相等。 - 模板相同就把挂载信息移给新节点。
move_mount_to把旧VNode的MountId交给新VNode,并把VNodeMount.node换成新节点。从这一刻起,事件冒泡时找到的监听器就是新闭包。 self == new是Rc指针相等。 同一个VNode再次出现(比如所有占位符共用的线程局部VNode::placeholder())时,挂载信息搬完就结束。- 然后只看动态部分。 静态元素、静态文本、静态属性根本不在比较范围内:模板里有多少静态节点都不影响 diff 的成本。动态属性和动态节点都按下标一一配对,因为模板相同,两边的槽位数一定相同。
实验:同一个模板
本卷 03-core 的实验都用一个自己实现 WriteMutations 的记录器 Log 打印指令(每个方法一行,名字和 Mutation 的分支一致,LoadTemplate 多写出模板根的标签),见 VirtualDom 与 Runtime 的实验说明。组件读线程局部的 STEP,每一步改 STEP、mark_dirty(ScopeId::APP)、render_immediate(&mut log):
// 1. one template, a dynamic attribute and a dynamic text
fn same_template() -> Element {
let n = step();
let cls = if n >= 2 { "big" } else { "small" };
rsx! {
div { class: "{cls}", id: "static-id",
"count {n}"
span { "static text" }
}
}
}
===== 1 same template
--- rebuild (step 0): 5 edits
LoadTemplate <div> index=0 id=1
SetAttribute class=Text("small") id=1
CreateTextNode "count 0" id=2
ReplacePlaceholder path=[0] m=1
AppendChildren id=0 m=1
--- step 1: 1 edits
SetText "count 1" id=2
--- step 2: 2 edits
SetAttribute class=Text("big") id=1
SetText "count 2" id=2
M2 Max、macOS 27、rustc 1.99.0,debug。id: "static-id" 和 span { "static text" } 是静态的,从来不出现在指令里:它们在 LoadTemplate 克隆的模板里就已经有了。第 1 步 class 没变,没有 SetAttribute;只有文本变了,一条 SetText。第 2 步两者都变,各一条。
模板不同:先建新的,再替换旧的
模板不同时 replace 走到 replace_inner:
pub(crate) fn replace_inner(
&self,
right: &[VNode],
parent: Option<ElementRef>,
dom: &mut VirtualDom,
mut to: Option<&mut impl WriteMutations>,
destroy_component_state: bool,
) {
let m = dom.create_children(to.as_deref_mut(), right, parent);
// Instead of *just* removing it, we can use the replace mutation
self.remove_node_inner(dom, to, destroy_component_state, Some(m))
}
先把新节点整棵创建出来(它们留在渲染器的栈上,共 m 个),再移除旧节点,移除到最后一个根时不发 Remove,而是发 ReplaceWith id=旧根 m,让栈上的新节点顶替它的位置。旧模板里的组件 scope 全部销毁(destroy_component_state = true),状态不保留。
rsx! 里的 if / else 就是这种情况:两个分支是两个不同的模板,放在外层模板的同一个动态槽位里。
// 2. if / else: two different templates in one dynamic slot
fn if_else() -> Element {
let n = step();
rsx! {
if n % 2 == 0 {
div { "even {n}" }
} else {
p { "odd {n}" }
}
}
}
===== 2 if/else
--- rebuild (step 0): 4 edits
LoadTemplate <div> index=0 id=1
CreateTextNode "even 0" id=2
ReplacePlaceholder path=[0] m=1
AppendChildren id=0 m=1
--- step 1: 4 edits
LoadTemplate <p> index=0 id=3
CreateTextNode "odd 1" id=4
ReplacePlaceholder path=[0] m=1
ReplaceWith id=1 m=1
--- step 2: 4 edits
LoadTemplate <div> index=0 id=1
CreateTextNode "even 2" id=2
ReplacePlaceholder path=[0] m=1
ReplaceWith id=3 m=1
--- step 3: 4 edits
LoadTemplate <p> index=0 id=3
CreateTextNode "odd 3" id=4
ReplacePlaceholder path=[0] m=1
ReplaceWith id=1 m=1
每一步都是「建 <p> 或 <div> 整块 + 填文本 + ReplaceWith 旧根」,哪怕两个分支都只差一个标签名。if 分支的 VNode 经 IntoDynNode for VNode 变成 DynamicNode::Fragment(vec![node]),外层模板的这个槽位是 Fragment 对 Fragment,进 列表的 diff,长度都是 1,再对这一对调 diff_node,在这里因为模板不同而替换。注意 id 的变化:第 1 步新节点拿到 3、4(1、2 还没释放),旧节点删掉后 1、2 被回收;第 2 步新节点又拿到 1、2。下面讲 ElementId 时再说。
动态节点的五种配对
模板相同时,每对动态节点交给 diff_dynamic_node:
match (old_node, new_node) {
(Text(old), Text(new)) => {
// Diffing text is just a side effect, if we are diffing suspended nodes and are not outputting mutations, we can skip it
if let Some(to) = to {
let id = ElementId(dom.get_mounted_dyn_node(mount, idx));
self.diff_vtext(to, id, old, new)
}
}
(Placeholder(_), Placeholder(_)) => {}
(Fragment(old), Fragment(new)) => dom.diff_non_empty_fragment(
to,
old,
new,
Some(self.reference_to_dynamic_node(mount, idx)),
),
(Component(old), Component(new)) => {
let scope_id = ScopeId(dom.get_mounted_dyn_node(mount, idx));
self.diff_vcomponent(
mount,
idx,
new,
old,
scope_id,
Some(self.reference_to_dynamic_node(mount, idx)),
dom,
to,
)
}
(old, new) => {
// ...
let old_mount = dom.get_mounted_dyn_node(mount, idx);
dom.set_mounted_dyn_node(mount, idx, usize::MAX);
let new_nodes_on_stack =
self.create_dynamic_node(new, mount, idx, dom, to.as_deref_mut());
// Restore the mount for the scope we are removing
let new_mount = dom.get_mounted_dyn_node(mount, idx);
dom.set_mounted_dyn_node(mount, idx, old_mount);
self.remove_dynamic_node(mount, dom, to, true, idx, old, Some(new_nodes_on_stack));
// Restore the mount for the node we created
dom.set_mounted_dyn_node(mount, idx, new_mount);
}
};
| 旧 → 新 | 做什么 | 指令 |
|---|---|---|
Text → Text | diff_vtext:字符串不等才写 | SetText,或什么都没有 |
Placeholder → Placeholder | 什么都不做 | 无 |
Fragment → Fragment | 列表 diff:都带 key 走 keyed,否则按位置 | 见 带 key 的列表 |
Component → Component | diff_vcomponent:比函数指针和 props | 子组件重跑后的 diff 结果,或无 |
| 种类不同 | 先建新的,再用 ReplaceWith 移除旧的 | 新节点的创建指令 + ReplaceWith |
最后一行里那几次 set_mounted_dyn_node 是在玩一个小把戏:这一格同一时刻只能存一个值,可创建新节点要读这一格(是 usize::MAX 才会新建 scope),移除旧节点也要读这一格(拿旧的 ElementId 或 ScopeId)。于是先填 usize::MAX 让创建走「新建」分支,创建完把旧值放回去供移除使用,移除完再写入新值。
Option<String> 在 None 和 Some 之间切换,就是占位符和文本互换:
// 3. Option<String>: placeholder <-> text
fn option_text() -> Element {
let n = step();
let label = if n % 2 == 1 { Some(format!("label {n}")) } else { None };
rsx! {
div { {label} }
}
}
===== 3 Option text
--- rebuild (step 0): 4 edits
LoadTemplate <div> index=0 id=1
CreatePlaceholder id=2
ReplacePlaceholder path=[0] m=1
AppendChildren id=0 m=1
--- step 1: 2 edits
CreateTextNode "label 1" id=3
ReplaceWith id=2 m=1
--- step 2: 2 edits
CreatePlaceholder id=2
ReplaceWith id=3 m=1
--- step 3: 2 edits
CreateTextNode "label 3" id=3
ReplaceWith id=2 m=1
None 也要在 DOM 里占一个位置(CreatePlaceholder,web 上是一个注释节点),这样换回 Some 时才知道新文本插在哪。外层 div 的模板没变,所以只动这一个槽位。
属性:按名字归并
一个动态属性槽位里可以有不止一个属性(比如 ..spread 展开出的一组),diff_attributes 假定两边都按属性名排好序,像归并排序那样同时走两个列表:名字相同就比值,旧的多一个就移除,新的多一个就写入。比值的那一段:
// Volatile attributes are attributes that the browser may override so we always update them
let volatile = old.volatile;
// We only need to write the attribute if the attribute is volatile or the value has changed
// and this is not an event listener.
// Interpreters reference event listeners by name and element id, so we don't need to write them
// even if the closure has changed.
let attribute_changed = match (&old.value, &new.value) {
(AttributeValue::Text(l), AttributeValue::Text(r)) => l != r,
(AttributeValue::Float(l), AttributeValue::Float(r)) => l != r,
(AttributeValue::Int(l), AttributeValue::Int(r)) => l != r,
(AttributeValue::Bool(l), AttributeValue::Bool(r)) => l != r,
(AttributeValue::Any(l), AttributeValue::Any(r)) => {
!l.as_ref().any_cmp(r.as_ref())
}
(AttributeValue::None, AttributeValue::None) => false,
(AttributeValue::Listener(_), AttributeValue::Listener(_)) => {
false
}
_ => true,
};
- 事件监听器永远不算变化。 每次渲染
onclick: move |_| ..都是一个新闭包,但渲染器那边只按「事件名 +ElementId」登记监听,闭包本身留在 Rust 侧;move_mount_to已经把挂载信息里的node换成了新VNode,事件到来时找到的就是新闭包。所以不发任何指令。 volatile属性总是重写。 浏览器自己会改的属性(注释里说 "the browser may override"),不管新旧值是否相等都写一遍:用户在输入框里打了字,DOM 上的value已经不是上次写的那个,只比较新旧VNode会漏掉这次更新。dioxus-html的元素定义里标了volatile的有四个:input的value、option的selected、select的value、textarea的value;rsx!展开时把这个标记放进Attribute.volatile。- 移除属性(
remove_attribute):监听器发RemoveEventListener,普通属性发一条值为AttributeValue::None的SetAttribute,渲染器把它当作删除。
条件属性 title: if cond { "odd" } 不是「有时有、有时没有」,而是同一个槽位里的同一个属性,值在 Text 和 None 之间切换:
// 5. a conditional attribute and an event listener
fn attr_listener() -> Element {
let n = step();
rsx! {
button {
title: if n % 2 == 1 { "odd" },
onclick: move |_| println!("clicked at step {n}"),
"btn"
}
}
}
===== 5 conditional attribute + listener
...
--- step 1: 1 edits
SetAttribute title=Text("odd") id=1
--- step 2: 1 edits
SetAttribute title=None id=1
onclick 的闭包每一步都换了(它捕获了不同的 n),指令里却一次都没有出现。
组件:比函数指针和 props
pub(crate) fn diff_vcomponent(
&self,
mount: MountId,
idx: usize,
new: &VComponent,
old: &VComponent,
scope_id: ScopeId,
parent: Option<ElementRef>,
dom: &mut VirtualDom,
to: Option<&mut impl WriteMutations>,
) {
// Replace components that have different render fns
if old.render_fn != new.render_fn {
return self.replace_vcomponent(mount, idx, new, parent, dom, to);
}
// copy out the box for both
let old_scope = &mut dom.scopes[scope_id.0];
let old_props: &mut dyn AnyProps = old_scope.props.deref_mut();
let new_props: &dyn AnyProps = new.props.deref();
// If the props are static, then we try to memoize by setting the new with the old
// The target ScopeState still has the reference to the old props, so there's no need to update anything
// This also implicitly drops the new props since they're not used
if old_props.memoize(new_props.props()) {
tracing::trace!("Memoized props for component {:#?}", scope_id,);
return;
}
// Now diff the scope
dom.run_and_diff_scope(to, scope_id);
let height = dom.runtime.get_state(scope_id).height;
dom.dirty_scopes.remove(&ScopeOrder::new(height, scope_id));
}
- 函数不同就替换。 同一个槽位换了一个组件函数(
render_fn是函数指针),旧 scope 整个销毁,新建一个 scope。 memoize为真就什么都不做。 子组件不重跑,它上一次的VNode和挂载信息原样保留。memoize是#[derive(Props)]生成的:没有 signal 类字段时就是PartialEq;有ReadSignal之类字段时会把旧 props 里的 signal 指向新值再比较(见 #[component] 与 Props)。没有参数的组件,props 是(),memoize恒为true:父组件怎么重跑,它都不跟着跑。- 否则就地重跑子组件,然后把它从
dirty_scopes里删掉:如果它本来也被 signal 标脏了,这一次已经顺带跑过,调度器不会再跑一次。
// 4. a component's props
fn props_app() -> Element {
let n = step();
// label changes at step 2 only
let label = if n >= 2 { "B" } else { "A" };
rsx! {
h1 { "parent {n}" }
Child { label }
}
}
#[component]
fn Child(label: String) -> Element {
CHILD_RUNS.with(|c| c.set(c.get() + 1));
rsx! { p { "child {label}" } }
}
===== 4 component props
--- rebuild (step 0): 7 edits
LoadTemplate <h1> index=0 id=1
CreateTextNode "parent 0" id=2
ReplacePlaceholder path=[0] m=1
LoadTemplate <p> index=0 id=3
CreateTextNode "child A" id=4
ReplacePlaceholder path=[0] m=1
AppendChildren id=0 m=2
--- step 1: 1 edits
SetText "parent 1" id=2
Child has run 1 times
--- step 2: 2 edits
SetText "parent 2" id=2
SetText "child B" id=4
Child has run 2 times
第 1 步父组件重跑,label 还是 "A",Child 没有跑(累计仍是 1 次);第 2 步 label 变了,Child 重跑一次,它自己的 diff 发出 SetText "child B"。子组件的指令和父组件的指令混在同一批里,顺序就是 diff 走到它的顺序。
ElementId:从 slab 里分配,删了就复用
渲染器那边每个需要被引用的 DOM 节点(模板的根、动态文本、占位符、带动态属性的元素)都有一个 ElementId,Rust 和 JS 两侧都用它当下标。分配和回收是 arena.rs 里的 next_element 和 reclaim:
pub(crate) fn next_element(&mut self) -> ElementId {
let mut elements = self.runtime.elements.borrow_mut();
ElementId(elements.insert(None))
}
pub(crate) fn reclaim(&mut self, el: ElementId) {
if !self.try_reclaim(el) {
tracing::error!("cannot reclaim {:?}", el);
}
}
pub(crate) fn try_reclaim(&mut self, el: ElementId) -> bool {
// We never reclaim the unmounted elements or the root element
if el.0 == 0 || el.0 == usize::MAX {
return true;
}
let mut elements = self.runtime.elements.borrow_mut();
elements.try_remove(el.0).is_some()
}
elements 是一个 Slab,insert 优先复用最近释放的下标。0 号永远是根容器,不回收。上面 if/else 实验里 id 在 1、2 和 3、4 之间来回跳就是这个原因:替换时先建新节点再删旧节点,所以新节点总拿不到刚要释放的那两个 id,要等下一轮。
回收顺序也是有意安排的。remove_node_inner 依次:回收挂在模板内部元素上的属性 id(reclaim_attributes),递归移除路径长度 ≥ 2 的内部动态节点(remove_nested_dyn_nodes,这些不发指令,因为它们会随根一起从 DOM 上消失,但 id 要回收、组件要销毁),最后处理各个根(reclaim_roots,只有根才发 Remove / ReplaceWith)。注释说 "This is done last in order to preserve Node ID reclaim order (reclaim in reverse order of claim)":创建时先给根分配、后给内部节点分配,回收时倒过来,slab 的空闲链表里 id 的顺序就和原来一致,下一次同样的结构会拿回同样的 id(实验第 2 步的 id=1、id=2)。
remove_nested_dyn_nodes 移除内部动态节点时传的是 to = None,不发指令。在 #3782("Reclaim element id for all removed nodes")之前,remove_dynamic_node 里的 dom.reclaim(id) 写在 if let Some(to) = to { .. } 里面,to 为 None 时 id 就不回收;reclaim_roots 对不发指令的静态根也不回收。PR 说明:"element ids associated with nodes removed via fn remove_nested_dyn_nodes are never reclaimed. This causes references to them to hang around, leading to unbounded growth of detached DOM nodes for applications that dynamically add and remove nodes frequently",修的是 #3760(列表长度来回变化时浏览器里脱离文档的 DOM 节点不断增长)。JS 侧按 ElementId 存着节点引用,id 不回收,节点就永远不会被垃圾回收。改动是把 reclaim 挪到 if 外面、给静态根补上 else 分支。首次发布在 v0.7.0-alpha.2,正式版 v0.7.0。
ElementId 被复用意味着它不是稳定标识:渲染器不能假设「id 3 永远是那个 <p>」。web 渲染器每次收到 LoadTemplate、AssignId、CreateTextNode 等指令时都会把新节点写进 JS 侧数组的那个下标,覆盖旧引用,见 WebsysDom 与 interpreter。
rebuild 时,n = 0,title 的值是 None。rebuild 会为 title 发一条指令吗? rsx! {
button {
title: if n % 2 == 1 { "odd" },
onclick: move |_| println!("clicked at step {n}"),
"btn"
}
}
数一数 rebuild 一共发几条指令(Log 的记法:LoadTemplate、SetAttribute、NewEventListener、AppendChildren……)。
动脑筋答案
===== 5 conditional attribute + listener
--- rebuild (step 0): 4 edits
LoadTemplate <button> index=0 id=1
SetAttribute title=None id=1
NewEventListener click id=1
AppendChildren id=0 m=1
会,4 条里有一条 SetAttribute title=None。创建路径(write_attrs → write_attribute)对每个动态属性无条件写一次,不看值是不是 None;diff 路径才比较新旧值。所以第一次渲染时一个值为 None 的条件属性也会产生一条「删除属性」指令,对一个刚克隆出来、本来就没有这个属性的元素是空操作。NewEventListener click:监听器只在创建时登记一次,名字去掉了 on 前缀,之后的 diff 永远不再碰它。
小结
diff_scope:组件返回错误或挂起时不 diff;在挂起的边界下 diff 照走,但to是None,不写指令。- 挂载信息(根的
ElementId、动态节点的ElementId或ScopeId、父节点)不在VNode里,在Runtime.mounts;模板相同时move_mount_to把它交给新VNode,事件因此找到新闭包。 - 模板相同只比动态属性和动态节点,静态部分零成本;模板不同就先建新节点、最后一个旧根用
ReplaceWith顶替,旧模板里的组件状态全部销毁。if/else的两个分支就是两个模板。 - 动态节点:文本不等才
SetText,占位符对占位符不动,Fragment 交给列表 diff,组件比 props,种类不同就创建加替换(Option<String>的None/Some切换即此)。 - 属性按名字归并;监听器永远不重发,
volatile属性总是重写,删除就是写None。 - 组件:函数指针不同就替换;
memoize为真不重跑(无参数组件恒为真);否则就地重跑并移出脏队列。 ElementId是 slab 下标,先进后出地复用;回收顺序和分配顺序相反,所以同样的结构会拿回同样的 id。