EN
Shopify 知识库 · 指南

Dawn 的 HTMLUpdateUtility:替换 DOM 时要处理的四件事

基于 Dawn v15.3.0 源码,逐行拆解 HTMLUpdateUtility 的双缓冲替换、旧节点 ID 去重、脚本重建与前后处理钩子,说明它为什么只用在商品信息区,以及 500ms 延迟删除、只改旧节点 ID 等写法的取舍。
历史资料
请结合文中的适用版本和来源阅读。

本文基于 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);

(global.js#L49-L54)

源码注释称之为 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}`);
});

(global.js#L42-L47)

新旧节点会在页面上共存 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);
  });
}

(global.js#L57-L68)

通过 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 窗口内新旧组件生命周期回调的顺序。

源码基线