Dawn 的 HTMLUpdateUtility:替换 DOM 时要处理的四件事
本文基于 Dawn v15.3.0,源码固定到 ecb06c3(核验于 2026-10-01)。结论限于此提交;本篇为静态源码分析,未完成店铺运行验证。Dawn 上游已发布 v16.0.0,本库 Dawn 正文统一以 v15.3.0 为基线,升级前需重新核对。
拿到服务端返回的分区 HTML 后,最省事的写法是 el.innerHTML = html。Dawn 大多数地方就是这么做的:在 assets/*.js 中,直接赋值 innerHTML、outerHTML 或调用 replaceWith 的代码约有 50 行。只有商品信息区换了一种写法,使用 HTMLUpdateUtility,全仓库共 4 个调用点:
| 调用点 | 替换对象 |
|---|---|
| product-info.js#L99-L104 | 跨商品时整个 <main> |
| product-info.js#L106-L111 | 跨商品时的 product-info |
| product-info.js#L160 | 同商品内的 variant-selects |
| quick-add.js#L35 | 快速加购弹窗内容(只用 setInnerHTML) |
这几处的共同点是:被替换的区域里有表单、可聚焦控件、内联脚本和第三方按钮。工具类处理的正是直接赋值 innerHTML 时在这些地方出现的四个问题。
一、先插入新节点,再隐藏、延迟删除旧节点
oldNode.parentNode.insertBefore(newNode, oldNode);
oldNode.style.display = 'none';
postProcessCallbacks?.forEach((callback) => callback(newNode));
setTimeout(() => oldNode.remove(), 500);
源码注释称之为 double buffer(双缓冲),并说明 View Transitions API 普及后应改用它(#L29-L34)。注意旧节点是立即隐藏的,这里并没有视觉过渡;500ms 后才从 DOM 中移除。源码没有说明延迟的原因。合理的推测是:让旧节点里正在执行的事件处理和组件的 disconnectedCallback 晚于新节点初始化,避免两者交错时引用到已经被删除的元素。
二、给旧节点的 ID 加后缀
const uniqueKey = Date.now();
oldNode.querySelectorAll('[id], [form]').forEach((element) => {
element.id && (element.id = `${element.id}-${uniqueKey}`);
element.form && element.setAttribute('form', `${element.form.getAttribute('id')}-${uniqueKey}`);
});
新旧节点会在页面上共存 500ms。如果 ID 相同,getElementById、label[for]、input[form]、aria-controls 都可能指向隐藏的旧节点。Dawn 选择只改旧节点:新节点保持服务端输出的原始 ID,其他代码不需要知道替换发生过。变体切换后按 ID 恢复焦点(product-info.js#L127-L130)就依赖这一点。
[form] 那一行处理的是放在 <form> 外、通过 form 属性关联表单的控件。Dawn 的商品表单经常这样写,数量输入框就在表单外。
三、重建 <script> 标签
static setInnerHTML(element, html) {
element.innerHTML = html;
element.querySelectorAll('script').forEach((oldScriptTag) => {
const newScriptTag = document.createElement('script');
// 复制属性与文本
oldScriptTag.parentNode.replaceChild(newScriptTag, oldScriptTag);
});
}
通过 innerHTML 插入的 <script> 不会执行,这是 HTML 规范的规定。商品分区里有一批按条件输出的外链脚本,例如 product-model.js、media-gallery.js、pickup-availability.js(main-product.liquid#L702-L710、buy-buttons.liquid#L149)。跨商品替换后,新商品可能需要旧商品没有加载过的脚本,所以要用 createElement('script') 重新创建,浏览器才会加载并执行。代价是已加载过的脚本也会再执行一次,这些文件因此都用 if (!customElements.get(...)) 防止重复注册。type="application/json" 的数据脚本也会被重建,但浏览器本来就不执行这种类型,因此无副作用。
另外,新内容先通过 setInnerHTML 写进一个用 document.createElement 创建的临时 div(#L38-L40),而不是直接移动 DOMParser 文档里的节点。重新在主文档中创建的节点插入页面后,自定义元素才会作为当前页面的组件运行 connectedCallback。
四、前后处理钩子
viewTransition(oldNode, newContent, preProcessCallbacks, postProcessCallbacks):前置钩子作用于尚未插入的响应节点,后置钩子作用于已插入的新节点(#L36、#L52)。ProductInfo 注册了两个默认钩子(product-info.js#L53-L61):
- 前置:给新内容中的
.scroll-trigger加scroll-trigger--cancel,避免替换后滚动显现动画重播; - 后置:调用
Shopify.PaymentButton.init()和ProductModel.loadShopifyXR(),重新挂载动态结账按钮与 AR 按钮。这两者由平台脚本渲染,不在分区 HTML 里。
addPreProcessCallback 对外开放(#L32-L34),应用或主题定制可以在替换前改写响应。后置钩子没有对应的公开方法,只能直接操作 postProcessHtmlCallbacks 数组。
同文件里的 SectionId
SectionId 负责解析 template--123__main 这类带前缀的分区 ID,并拼出同一模板中其他分区的 ID。全仓库搜索只发现两个使用者:ProductInfo 的 relatedProducts 和 quickOrderList 两个 getter(product-info.js#L395-L409),而主题内未找到调用这两个 getter 的代码。它更像预留给外部集成的入口。
局限
- 每次替换都会重建整个子树。已加载的图片节点会被丢弃,所以媒体区没有走这个工具,而是单独做了对齐(见变体更新协议)。
- 旧节点里注册在
document或window上的监听器,只有组件在disconnectedCallback中自己清理时才会释放。 - 前置钩子收到的是
DOMParser文档中的节点,不能依赖组件实例方法。
迁移到自己主题时
需要替换含表单或脚本的区域时,可以直接沿用这四步:插入新节点、给旧节点 ID 加后缀、重建脚本、执行钩子。如果目标浏览器已支持 View Transitions,可以把“插入并隐藏”这一步包进 document.startViewTransition,其余三步仍然需要。
建议的验证范围
本篇均未执行:在变体切换前后检查页面中重复 id 的数量;确认替换后动态结账按钮可用;用 Performance 面板观察 500ms 窗口内新旧组件生命周期回调的顺序。