
一次看懂 lit-html 模板引擎Prepare 只跑一次增量更新为何比全量 diff 快【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit一个渲染了上千行的列表点击按钮改一个数字页面却卡了一下——多半是 diff 又遍历了整棵树。lit-html 模板引擎的解法反其道而行结构解析只做一次之后每次更新只触碰变化的那个动态位。这篇文章直接拆源码讲清它首次渲染和后续更新各执行了什么以及为什么这样设计比全量 diff 便宜读完全文你会知道 Lit 里快到底快在哪些行代码上。两个原生特性撑起了整个渲染管线把 lit-html 的设计摊开看支点其实只有两个 Web 原生能力标签模板字面量和template元素。标签模板函数tagged template把一段html字符串自动切成静态字符串数组 动态值数组。这是关键静态部分天然与动态部分分离模板引擎无需在运行时再解析 HTML 找出插值位置位置信息在编译期就定死了。template元素则是个惰性容器——innerHTML塞进去的内容不立即进入文档只存一份可反复克隆的片段。两者配合用字符串数组标记出所有动态槽位用template存下打好标记的结构。官方对 Lit 的定位是 Lit is a simple library for building fast, lightweight web components.README.md轻就轻在把重活都压在了这两件原生的事上。首次渲染与增量更新两条时间线各走了什么首次渲染把模板烧成一份可复用的结构入口是render()它只干一件事在容器上找一个根 Part没有就建然后把模板结果交给它packages/lit-html/src/lit-html.ts// packages/lit-html/src/lit-html.ts L2262-L2283伪代码 let part container[_$litPart$]; // 容器上已有的渲染痕迹 if (part undefined) { // 首次渲染建一个根 ChildPart用两个 marker 注释节点圈出它的地盘 part new ChildPart(container.insertBefore(createMarker()), endNode, ...); } part._$setValue(value); // 剩下的全部委托给 Part根 Part 拿到TemplateResult后进入_commitTemplateResult这是首帧最贵的一段packages/lit-html/src/lit-html.ts// lit-html.ts L1612-L1668伪代码 const template this._$getTemplate(result); // ① 查模板缓存 const fragment instance._clone(options); // ② 克隆 template 片段 instance._update(values); // ③ 填第一次值 this._commitNode(fragment); // ④ 片段进文档 this._$committedValue instance; // 记住这份实例①就是所谓 Prepare拆开看是两步packages/lit-html/src/lit-html.ts// getTemplateHtml状态机扫描strings 之间插标记伪代码 // 文本位插注释标记 !--?lit$...--属性位改写属性名 // div class${x} → div class$lit$...!--?lit$...-- // 再 new Template templateEl.innerHTML markedHtml; // 静态结构交给浏览器解析一遍 // TreeWalker 走一遍 el.content记录每个标记的节点下标 → this.parts为什么要把标记写进 HTML 再让浏览器解析因为 HTML 解析是浏览器 C 级别的重活比任何 JS 手写解析器都快而 TreeWalker 一趟走下来记录各槽位的下标这些parts信息就固化在 Template 对象里供之后每次克隆对照使用。克隆发生在TemplateInstance._clone用importNode把片段复制出来按上面记录的下标逐位实例化真正的Part对象packages/lit-html/src/lit-html.ts// lit-html.ts L1223-L1253伪代码 const fragment d.importNode(content, true); walker.currentNode fragment; while ((templatePart parts[partIndex])) { if (nodeIndex templatePart.index) { // 命中槽位按类型 new 出 ChildPart / AttributePart / ElementPart this._$parts.push(part); } }至此文档里是活 DOM内存里是一组 Part 对象精确锚在各自位置——更新的基础设施全部就位。后续更新走一遍 Part只写变化的 DOM换个角度看第二次调用render(counterUi(count 1), container)。render()里part已存在跳过创建_$setValue一路传到_commitTemplateResult此时走的是同模板分支packages/lit-html/src/lit-html.ts// lit-html.ts L1631-L1641伪代码 if (committedValue._$template template) { // 模板没变不克隆、不解析、不建 Part committedValue._update(values); // 只把新值灌进已有 Part }每个 Part 的_$setValue先拿新值与_$committedValue比较相同则直接返回不碰 DOM不同的 ChildPart 也只是把片段内的文本节点替换掉// ChildPart._$setValue 核心逻辑伪代码 _$setValue(value) { // ...省略 iterable / TemplateResult 分支 if (value this._$committedValue) return; // 值没变零 DOM 操作 this._commitText(value); // 只改自己这片区间 this._$committedValue value; }这就是比全量 diff 快的答案全量 diff 的成本与树规模成正比而这里成本与本次变化的槽位数成正比与树多大多小无关。首次渲染后续更新模板解析getTemplateHtml标记 innerHTML解析 TreeWalker 记录槽位不执行DOM 来源importNode克隆template片段无新节点复用已有 DOMPart 对象按槽位全部new一遍全部复用写入 DOM整段片段插入容器仅值变化的槽位做定点写入下面这段演示里初始渲染走完上面全部流程之后每次点击 Increment 都只走右列Part 系统每种绑定语法各归其位Prepare 阶段记录的不只是有几个槽位还有每个槽位长在哪。克隆时据此实例化不同 Part各管一段绑定语义Part 类型职责触发语法ChildPart管理节点区间内的内容/文本html\div\${x}/div\AttributePart拼接更新属性值保留其他部分html\div classa \${c}\BooleanAttributeParttrue 加属性、false 移除html\input ?disabled\${d}\PropertyPart直接读写元素 JS 属性绕过属性字符串html\input .value\${v}\EventPartadd/remove 事件监听html\button click\${fn}\ElementPart整段元素动态绑定html\\${tag}content/\${tag}\最容易混的是后三者。AttributePart和PropertyPart名字只差一个点前者改的是class、href这类属性字符串.value这种必须落到 DOM 状态上的场景比如输入框回填要用PropertyPart直接写element.value属性写法可能被浏览器忽略。BooleanAttributePart则完全不拼字符串语义就是存在/缺席二态。EventPart看似是属性实际干的是addEventListener/removeEventListener旧监听不残留。源码都在同一文件packages/lit-html/src/lit-html.tsChildPartL1338、AttributePartL1806、EventPartL2032 起。这些 Part 共同支撑的能力一句话概括同一段模板任意数量的动态位都能被独立、原地地更新而模板结构本身永远不动。模板缓存如何让 Prepare 只跑一次Prepare 贵所以必须只跑一次。缓存实现在模块顶层packages/lit-html/src/lit-html.ts// lit-html.ts L688 const templateCache new WeakMapTemplateStringsArray, Template();缓存键是标签模板产出的字符串数组本身。JS 引擎对同一个字面量模板返回同一个strings引用所以html\...无论调用多少次查的都是同一个 Template。命中缓存后只创建新的TemplateInstance对应一份克隆 DOM 和一套 PartPrepare 分文不花弱引用还解决了另一个问题模板没人再用时随strings一起被回收不会成为内存泄漏源。所以一次点击里真正发生的就是三件事的叠加槽位锚定让更新永远只落在变化处模板缓存让解析和结构记录一生只付一次Part 分型让每处绑定按最小语义精确写入。再往远看预编译模板可以连运行时解析都省掉浏览器原生的模板实例化 API 则可能进一步压掉克隆开销——管线方向已经写在 dev-docs/design/how-lit-html-works.md 的设计文档里。如果这篇拆解对你有用欢迎点赞、收藏关注后续更新下一篇我们看怎么写一个自定义 directive把 Part 的更新行为再往外扩一层。【免费下载链接】litLit is a simple library for building fast, lightweight web components.项目地址: https://gitcode.com/GitHub_Trending/li/lit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考