跳到主要内容

常见的结账页定制

结账页定制的做法每次都一样。你创建一个结账扩展,在扩展点上渲染 UI,等 CheckoutAPI 就绪。不管你要做哪一种定制,这几步都不变。区别只在最后一步:调用哪一个方法。本篇把共用的几步讲一遍,再给出每一类改动对应的调用。

背景

结账页定制走的是同一套流程。不论你要加的是订单上的一个字段、一条额外的商品行,还是地址表单上的一条校验规则,经过的步骤都一样,最后都落到一次 CheckoutAPI 调用上。这套流程本身不长,难在两点:

  • 每项定制在动手做正事之前,都要重复同样的四步——搭扩展、选插槽、等 API 对象、读返回结果。每加一个功能,就要把这四步重推一遍。
  • 名字以 register 开头的方法,长得和普通的读取方法一样,参考页也把两类方法列在同一张表里。但 register 方法接收的回调,返回值会被平台消费。给 registerUiShippingLinesChange 挂一个只打日志、不返回物流方案列表的回调,页面拿到的就是 undefined,接着去读它的 length,整个结账页在顾客面前崩成报错界面。光看那张表,分不出哪些方法有这个风险。

这两个问题来自同一处:参考页回答的是「有什么」,而「具体如何使用」没有地方回答。

本篇的目标就是回答这些:一份能跑的骨架、一条调用前先读方法名的规则,以及应用最常做的几类改动各自的可运行片段。

工作原理

  1. 创建结账扩展。 CLI 会搭好项目并关联到开发店铺——见构建结账扩展
  2. extend() 渲染 UI。 传入扩展点名称和一段 HTML 字符串,平台就把它渲染到那个插槽上。名称列表见扩展点
  3. 等待 window.CheckoutAPI 平台要等结账页加载完成才挂载它,所以脚本要轮询等待,不能在启动时直接读。
  4. 调用改动对应的方法。 每次调用的形式都是 CheckoutAPI.<命名空间>.<方法>(),方法名的前缀说明了它是读、是监听,还是改写。
  5. 读返回结果。 这些方法不抛异常。成功和失败都以一个带 state 字段的对象返回。
  6. 在真实结账页上验证。 运行 shoplazza app dev,在关联店铺打开结账页,看控制台。

前置条件

  • 一个用 CLI 创建的结账扩展项目——见构建结账扩展
  • 一个放 UI 的插槽。从扩展点里挑一个。extend() 必须指定一个扩展点,但有些扩展根本不需要往页面上放东西:它们只是跑代码、注册监听或者调自己的后端。这类扩展挂到 Checkout::LogicContainer::RenderAfter,这个容器不渲染可见内容,也不在页面上占位置。
  • 你打算调用的方法,在 CheckoutAPI 参考里查好。本篇的每个签名和类型都来自那里。

第 1 步:在扩展点上渲染 UI

extend()shoplazza-extension-ui 导入,参数是插槽名称加上要放进去的 HTML。在 src/index.js 的顶层调用它,放在其他代码之前。

javascript
import { extend } from 'shoplazza-extension-ui';

// UI 用内联模板字符串写。把标记留在 index.js 里:
// 只需要读一个文件,也没有会出错的构建步骤。
const template = `
<div id="my-widget" style="display:none;padding:12px;border:1px solid #e5e7eb;border-radius:6px;">
<label>
<input type="checkbox" id="my-toggle" />
<span id="my-label">Gift wrap this order</span>
</label>
</div>
`;

// extensionPoint 决定 HTML 落在哪里。这个插槽在订单摘要栏里,
// 位置紧挨着优惠码输入框下方。
extend({
extensionPoint: 'Checkout::Reductions::RenderAfter',
component: template,
});

选插槽主要看你的内容该挨着哪个模块。和订单总价有关的内容放 Checkout::Reductions::RenderAfterCheckout::TotalPrice::RenderAfter;和配送有关的内容放 Checkout::ShippingList::RenderAfter;面向整页的提示放 Checkout::RenderBefore。同一个插槽可以被扩展多次,内容按调用顺序追加,不会互相覆盖。

备注

像上面那样,把标记写成 index.js 里的内联模板字符串。import template from './index.html' 这种写法,只有当 index.html 是用一行行 import './xxx.html' 把其他片段拼起来的入口文件时才有效。一个只放普通标记、别的什么都没有的 index.html,会让构建报 "default" is not exported。片段结构的说明见 HTML 模板

第 2 步:等待 CheckoutAPI

你的脚本在页面加载的早期就开始跑,此时有两样东西可能还不存在:window.CheckoutAPI,平台要等页面完全加载后才挂载它;以及你自己的 DOM,因为 extend() 是异步注入插槽的。轮询等到两者都就绪。

javascript
const MOUNT_INTERVAL_MS = 100;
const MOUNT_MAX_ATTEMPTS = 50; // 100ms * 50 ≈ 5 秒

// 轮询等待 CheckoutAPI 和 extend() 注入的元素。两者完成的先后顺序不定,
// 所以要等到都就绪,再跑业务逻辑。
function mount(onReady, attempt = 0) {
if (window.CheckoutAPI && document.getElementById('my-widget')) {
onReady(window.CheckoutAPI);
return;
}
if (attempt >= MOUNT_MAX_ATTEMPTS) {
console.warn('CheckoutAPI 没有在预期时间内挂载');
return;
}
window.setTimeout(() => mount(onReady, attempt + 1), MOUNT_INTERVAL_MS);
}

mount((api) => {
document.getElementById('my-widget').style.display = 'block';
// 第 3 步之后的所有代码都写在这里。
});

这个模式还有一个更完整的版本,会在重新注册回调之前先取消注册,这样热更新不会把回调挂两遍,见场景三:API 调用与事件监听

第 3 步:调用 API

每次调用都是 CheckoutAPI.<命名空间>.<方法>()。全部命名空间和它们各自的方法,见 CheckoutAPI 参考。调用之前先读方法名的前缀——它说明了这是哪一类调用,而五类里有一类会搞崩页面。

名字规律含义
get* / is* / has*同步读一个值并返回。不发请求,没有副作用,调多少次都安全。
on* / remove*on* 注册回调,remove* 移除回调。两者成对使用,并且传同一个函数引用。
register* / unregister*注册一个改写器。回调的返回值会被平台消费并渲染。只在明确要改平台行为时用。
dispatch*主动触发一次 UI 刷新,用在你改了某个页面还没察觉的东西时。
返回 Promise会发请求或做校验,结果稍后才拿到。

真正要分清的是 on*register*onShippingChange 观察顾客选了哪个物流方案,你的回调返回什么都被忽略。registerUiShippingLinesChange决定页面渲染哪些物流方案,它什么都不返回,页面就会去读 undefined 上的属性。参考页里这两个方法在同一张表上,只隔一行。

on* 注册的回调会一直留到页面卸载。把它存成变量,对应的 remove* 才能取消——写成内联箭头函数的回调永远取消不掉。

第 4 步:处理返回结果

这里几乎没有方法会抛异常。在这些调用外面包 try/catch 什么都捕获不到,反而把失败盖住了。要读返回的对象:调用成功时 state'success',失败时它带的是错误码。

order.addLineItems 成功时:

json
{
"state": "success",
"message": "success",
"errors": [],
"data": {
"orderId": "2446407205415840740905",
"lineItems": [
{
"id": "18b3f672-8c94-42fc-80c6-8b68ba2b4ebf",
"variantId": "tier-variant-id-290",
"productTitle": "Shipping protection",
"price": "2.90",
"quantity": 1,
"requiresShipping": false
}
]
}
}

order.removeLineItems 移除被平台拒绝时:

json
{
"state": "bundled_product_requires_real_item",
"message": "bundled_product_requires_real_item",
"errors": ["bundled_product_requires_real_item"],
"data": null
}

错误码在 state 里,messageerrors 里重复一遍,datanull。所以判断的写法永远是同一个形状:

javascript
const res = await api.order.addLineItems(params);
if (res.state !== 'success') {
// 把 UI 回退到订单的真实内容,并告知顾客。
console.warn('line mutation failed:', res.state, res.errors);
return;
}

常见定制

往订单上写数据

order.updateOrderCustomFields 把你自己的键值对存到订单上——顾客在你的组件里选的期望送达日期、企业买家填的采购单号、你自己系统里的一个 id。用 order.getOrderCustomFields 读回来,它返回页面已经持有的字段,同步返回,不发请求。

javascript
// 写。字段会合并进订单上已有的内容,没提到的键保持不变,
// 已存在的键被覆盖。
const res = await api.order.updateOrderCustomFields({
customFields: {
delivery_date: '2026-10-08',
// 值只能是字符串——结构化数据自己 stringify。
gift_options: JSON.stringify({ wrap: true, note: 'Happy birthday' }),
},
});
if (res.state !== 'success') console.warn(res.message);

// 读。返回 Record<string, string>,订单上没有字段时返回 {}。
const fields = api.order.getOrderCustomFields();
const wrap = JSON.parse(fields.gift_options || '{}').wrap;

// 删。传要移除的键;['*'] 删掉所有字段。
await api.order.updateOrderCustomFields({ deleteCustomFields: ['delivery_date'] });

以下限制都来自自定义字段参考页:键不能为空,也不能是 *;写入只在订单仍可编辑时有效,所以在感谢页上、在已取消的订单上、在订单还不存在时,调用会直接失败,连请求都不发;字段有总长度上限,超了 message 会说明。

增删商品行

order.addLineItemsorder.removeLineItems 改变订单的内容,之后平台会整单重算价。加行传 variantId 和数量;删行传商品行的 id,不是变体 id。

难的不是这次调用,而是在顾客应用折扣、修改地址的过程中,让自己加的行和订单总价保持对账。结账页增删订单商品行以一项增值服务为例,把这件事从头到尾走了一遍。

定制地址表单

address 命名空间改写表单,而不是替换表单:调整字段顺序、改标签、隐藏可选字段、锁定已有值的字段,以及在内置校验之上加规则。到底有哪些字段,由这个国家的地址模板决定,可以用 address.isFieldShow 查。

javascript
// 改标签。回调返回一个 map,key 是字段 id,值是你要覆盖的标签;
// 没写进去的字段保持平台原来的标签。
api.address.registerShippingAddressSchemaChangeLabel(() => ({
company: 'Company (required for invoicing)',
address1: 'Floor / suite',
}));

// 把字段设为必填,并加一条自己的规则。required 和 validates
// 叠加在平台的内置校验之上。
api.address.registerShippingAddressSchemaChangeValidateRule(() => ({
company: {
required: true,
validates: [
{ id: 'company-min', message: 'Enter the full legal company name', regexp: '.{3,}' },
],
},
}));

// 调整顺序。回调收到全部字段,也必须把它们全部返回。
api.address.registerShippingAddressSchemaSort((items) =>
[...items].sort((a, b) => (a.id === 'company' ? -1 : b.id === 'company' ? 1 : 0)),
);

// 注册完这些之后,通知表单重新渲染。
api.address.dispatchShippingAddressSchemaChange();

同一个命名空间里还有两个改写器,同样接收以字段 id 为 key 的 map:registerShippingAddressSchemaChangeVisibility 用来隐藏可选字段,registerShippingAddressSchemaChangeDisable 用来锁定已有值的字段。

注意

这里每个回调都是 register* 改写器。每次都要返回完整的 map 或完整的列表——回调返回 undefined,或者排序回调漏掉了条目,交给表单的就是一个它渲染不了的值,结账页会崩。返回值要从传给你的参数上构造,不要自己手写一份列表。

阻止提交

order.registerBuyerJourneyIntercept 在顾客尝试进入下一步时执行。返回 { behavior: 'block' } 拦住他——用于年龄校验、限购、商家不发货的国家——返回 { behavior: 'allow' } 放行。

javascript
// pointId 指定拦截时平台打开的弹窗。弹窗的内容由你渲染到
// 对应的动态扩展点里。
const POINT_ID = 'age-gate';

api.order.registerBuyerJourneyIntercept(() => {
const confirmed = document.getElementById('age-confirm').checked;
if (confirmed) return { behavior: 'allow' };
return { behavior: 'block', pointId: POINT_ID, hideFalseBtn: true };
});

// 弹窗主体是一个以该 pointId 命名的动态扩展点。
extend({
extensionPoint: `Checkout::Dialog-${POINT_ID}::RenderAfter`,
component: '<p>Confirm you are over 18 before placing this order.</p>',
});

弹窗的各个插槽——主体、底部和两个按钮——列在动态扩展点下。这也是一个 register* 方法:每一次调用都必须返回两种行为之一,包括那些你没什么要说的调用。

如果你要的是在地址提交之前做校验,而不是在流程关卡上拦截,用 order.addBeforeSubmitCb,它是 add*Cb 监听器,可以放心挂。

让 UI 支持多语言

base.registerLocaleMap 只有一个参数:以完整语言标签为 key 的文案 map。用 base.formatMessage 读回来,它按顾客的语言解析。

javascript
// 只有一个参数——整个 map,key 是完整的语言标签。
api.base.registerLocaleMap({
'en-US': { 'giftwrap.label': 'Gift wrap this order', 'giftwrap.fee': 'Adds {fee}' },
'zh-CN': { 'giftwrap.label': '为此订单添加礼品包装', 'giftwrap.fee': '加收 {fee}' },
});

// 第二个参数是当前语言下缺这个 key 时的兜底文案;
// 第三个参数填充 {占位符}。
const label = api.base.formatMessage('giftwrap.label', 'Gift wrap this order');
const fee = api.base.formatMessage('giftwrap.fee', 'Adds {fee}', {
// formatPrice 把金额转成带货币符号的展示字符串。
fee: api.base.formatPrice(4.5),
});

document.getElementById('my-label').textContent = label;

不传 defaultMessage 时,缺失的 key 原样返回 key 本身,顾客屏幕上出现一个生的 giftwrap.label 就是这么来的。兜底文案一定要传。嵌套的 key 会用点号拍平,所以 { giftwrap: { label: … } } 要按 'giftwrap.label' 读。见多语言

验证

  1. 启动扩展。 在扩展项目里运行 shoplazza app dev。它会构建并推送到关联店铺——这一步报构建错误,通常是第 1 步说的 index.html 导入问题。
  2. 在关联店铺打开结账页。 在店面加购一个商品,一路走到结账页。你的 UI 出现在 extend() 指定的那个插槽上,别的地方都没有。
  3. 确认 API 已挂载。 打开控制台,你的组件应该显示出来,而不是停在 display:none。一直不出现,说明 mount() 超时了,告警在控制台里。
  4. 执行那次改动。 做一次扩展要做的改动,再用对应的 get* 方法读回来,确认值和你写进去的一致。
  5. 看控制台。 没有报错,任何交互之后也没有出现「Oops, there is an error」界面——出现这个界面,通常意味着某个 register* 回调返回了错的值。
  6. 试一下感谢页。 完成下单。扩展在感谢页上不应该报错,也不应该发起注定失败的写入。

改完源码要重启 shoplazza app dev,改完 extension.json 也要重启——dev server 不监听这个文件。

注意事项

  1. register* 回调什么都不返回会让页面崩掉。 平台会消费这个返回值。一个挂到 registerUiShippingLinesChange 上的打日志函数返回的是 undefined,页面接着去读 undefined.length,React 就把整个结账页卸载成报错界面,顾客没法完成下单。只想观察时挂 on* 监听器;确实要注册改写器时,让回调的每一条分支都返回签名要求的类型。
  2. 价格事件触发时,商品行价格可能还是旧的。 store.onPricesChange 触发时,总价已经是最新的,但 summary.getProductList() 返回的折后行价可能还是上一轮的值。事件一到就读 finalLinePrice 的代码,拿到的是折前的数。在回调里把事情做完,几百毫秒后再复查一次。
  3. 商品行 id 会变,properties 返回的是字符串。 删掉一行再重新加上,新行的 id 就变了,所以不要缓存它——从调用返回的 data.lineItems 里读最新值。另外 properties 传入时是对象,返回时是 JSON 字符串,读键之前先 JSON.parse
  4. 改一次商品行要花几秒,不是几毫秒。 每次增删都会触发订单整单重算。一次实质上是两步的改动,比如删一行再加一行,全程约 2 到 4 秒。整个来回期间要禁用你的控件并显示加载状态,否则顾客再点一次,你就有两次变更在同时跑。
  5. 不是每种写入在感谢页上都有效。 感谢页上也挂着 CheckoutAPI,读取方法照样有返回值,看上去什么都能用。其实不然:订单已经不可编辑,所以 order.updateOrderCustomFields 这类写入在那里会失败,而且连请求都不发。写入之前先判断 config.isThankyouPage()

下一步