Dawn 的 Section Rendering 用法:前端拿 HTML,不拿 JSON
本文基于 Dawn v15.3.0,源码固定到 ecb06c3(核验于 2026-10-01)。结论限于此提交;本篇为静态源码分析,未完成店铺运行验证。Dawn 上游已发布 v16.0.0,本库 Dawn 正文统一以 v15.3.0 为基线,升级前需重新核对。
Dawn 几乎所有异步交互都遵循同一个思路:数据变化后,让服务端重新渲染受影响的分区,前端只把返回的 HTML 换进页面。浏览器端没有模板引擎,也不在 JS 里格式化金额或拼接文案。本文不重复各功能的完整流程(见文末链接),只横向比较这套做法在六处的写法差异、约定与代价。
两种取法
| 取法 | 请求形态 | Dawn 中的使用者 |
|---|---|---|
| GET 单分区 | 页面 URL 加 ?section_id=<分区>,响应是该分区的 HTML | 变体切换 buildRequestUrlWithParams、数量规则 fetchQuantityRules、商品推荐 loadRecommendations、筛选 renderPage、预测搜索 getSearchResults、购物车被动刷新 onCartUpdate |
| 写操作附带分区 | 向 /cart/add、/cart/change 提交时附加 sections 与 sections_url,JSON 响应的 sections[<分区>] 是 HTML 字符串 | 加购 onSubmitHandler、改量与删除 updateQuantity |
第二种是 Dawn 最值得借鉴的一处:数据写入和界面刷新合并成一次请求。加购后不必再请求一次抽屉或通知面板,响应里已经带着渲染好的片段。sections_url 传的是 window.location.pathname,让服务端按当前页面的上下文渲染这些分区。
分区标识有两种来源。模板内分区用元素上的 data-id(如 document.getElementById('main-cart-items').dataset.id、筛选的 #product-grid 的 data-id),即 JSON 模板里的区段 key;cart-drawer、cart-icon-bubble、cart-notification-product 这类没有 schema 的独立分区文件,直接写文件名。
getSectionsToRender 约定
谁需要刷新,由各个购物车组件自己声明;发请求的一方(product-form)不关心具体是哪些分区:
| 组件 | 返回值 | 字段含义 |
|---|---|---|
CartItems(购物车页) | { id, section, selector } ×4 | section 是请求键,id 是 DOM 挂载点,selector 从响应中截取片段 |
CartDrawerItems | 同上 ×2 | 同上 |
CartDrawer | { id, selector? } ×2 | id 同时作为请求键和挂载点 |
CartNotification | { id, selector? } ×3 | 同上;selector 依赖响应返回的 key |
两套字段语义并存。product-form 取的是 section.id(product-form.js#L38),它只会拿到 cart-notification 或 cart-drawer(product-form.js#L11),这两者的 id 恰好就是分区名,所以能正常工作;CartItems 自己发请求时取的是 section.section。新增一种购物车组件时,必须先确认它会被哪一方调用,再决定 id 的含义。
CartNotification 的写法很巧:发请求时只需要 id;selector 里的 cartItemKey 在 renderContents 拿到响应后才赋值,因此同一个方法在请求时和渲染时返回的选择器不同。通知面板借此只截取刚加入的那一行,而服务端分区 cart-notification-product 渲染的是整个购物车。
为什么值得这样做
- 只有一份模板。价格格式、多币种、翻译、折扣行、数量规则说明都留在 Liquid 里,JS 中没有金额运算。改展示只需改 Liquid,异步刷新和首屏渲染自动保持一致。
- 前端几乎不需要了解数据模型。变体切换不必下发变体矩阵,筛选不必知道有哪些筛选项,服务端返回什么就展示什么(筛选的
renderFilters会删除响应里已不存在的筛选组)。 - 商家可配置项自动生效。区块顺序、开关和文案都在服务端渲染时应用,JS 不需要读取 section 设置。
代价与 Dawn 留下的坑
| 问题 | 位置 | 说明(均为静态阅读推断) |
|---|---|---|
| 重复请求 | product-form 发布 cartUpdate 后,CartDrawerItems 的订阅者再请求一次 ?section_id=cart-drawer(cart.js#L30-L37) | 抽屉模式下,加购响应已带抽屉 HTML,订阅者又取一遍;详见购物车篇 |
| 乱序覆盖 | 筛选 renderSectionFromFetch 没有 AbortController;预测搜索只在重置时中止 | 先发出、后返回的旧响应会覆盖新结果;变体切换在 product-info.js#L116-L118 做了中止,是三者中唯一处理了竞态的 |
| 缓存无上限 | 筛选 FacetFiltersForm.filterData 以完整 URL 为键(facets.js#L49-L56);预测搜索 cachedResults 在所有实例间同步 | 一直存到页面卸载,不过期,不受加购等写操作影响 |
| 重复解析 | 购物车三个组件的 getSectionInnerHTML 对每个分区各调用一次 DOMParser | 分区少时可以忽略;renderFilters、renderProductGridContainer、renderProductCount 对同一份响应各解析一次 |
| 错误静默 | 筛选的 fetch 没有 catch;推荐只 console.error | 失败时加载状态可能不复位,用户看不到提示 |
| 意外的参数 | facets.js#L70 把字符串的 .innerHTML(undefined)传给 initializeScrollAnimationTrigger | 参数退化为默认值 document,结果是重新扫描整页,碰巧可用 |
迁移到自己主题时
- 写操作一律带上
sections,把需要刷新的分区交给组件自己声明;统一{ section, mount, selector }三个字段的语义,不要沿用 Dawn 的两套含义。 - 所有 GET 刷新都加上中止逻辑(或给响应加序号校验),尤其是由输入事件触发的。
- 缓存需要设上限,并在购物车或库存变化时作废。
- 分区 HTML 里的元素
id是 JS 与 Liquid 之间的契约;重命名时需要两侧一起搜索(参见局部替换工具)。
建议的验证范围
本篇均未执行:抓包核对抽屉、通知、页面三种 cart_type 下一次加购的请求数;限速下连续勾选筛选、连续输入搜索词,观察乱序;在 Performance 面板记录 DOMParser 耗时与分区 HTML 体积。