# 常见的结账页定制

> 每个 Shoplazza 结账扩展共用的骨架，以及各类常见改动对应的 CheckoutAPI 调用：订单自定义字段、商品行、地址表单、阻止提交和多语言。

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

## 背景

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

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

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

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

## 工作原理

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

```mermaid
flowchart TD
    A[shoplazza app extension create] --> B["extend() 把 UI 渲染到扩展点"]
    B --> C[轮询等待 window.CheckoutAPI 挂载]
    C --> D["调用 CheckoutAPI.命名空间.方法()"]
    D --> E{"state === 'success'?"}
    E -- 是 --> F[刷新你的 UI]
    E -- 否 --> G[读 message 和 errors 再恢复]
    F --> H[用 shoplazza app dev 在结账页上验证]
    G --> H
```

## 前置条件

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

## 第 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::RenderAfter` 或 `Checkout::TotalPrice::RenderAfter`；和配送有关的内容放 `Checkout::ShippingList::RenderAfter`；面向整页的提示放 `Checkout::RenderBefore`。同一个插槽可以被扩展多次，内容按调用顺序追加，不会互相覆盖。

:::note
像上面那样，把标记写成 `index.js` 里的内联模板字符串。`import template from './index.html'` 这种写法，只有当 `index.html` 是用一行行 `import './xxx.html'` 把其他片段拼起来的入口文件时才有效。一个只放普通标记、别的什么都没有的 `index.html`，会让构建报 `"default" is not exported`。片段结构的说明见 [HTML 模板](/docs/app/extensions/checkout/extension-reference#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 调用与事件监听](/docs/app/extensions/checkout/extension-recipes#场景三api-调用与事件监听)。

## 第 3 步：调用 API

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

| 名字规律 | 含义 |
|---|---|
| `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` 里，`message` 和 `errors` 里重复一遍，`data` 是 `null`。所以判断的写法永远是同一个形状：

```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`](/docs/app/extensions/checkout/checkout-api#自定义字段) 把你自己的键值对存到订单上——顾客在你的组件里选的期望送达日期、企业买家填的采购单号、你自己系统里的一个 id。用 [`order.getOrderCustomFields`](/docs/app/extensions/checkout/checkout-api#自定义字段) 读回来，它返回页面已经持有的字段，同步返回，不发请求。

```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'] });
```

以下限制都来自[自定义字段](/docs/app/extensions/checkout/checkout-api#自定义字段)参考页：键不能为空，也不能是 `*`；写入只在订单仍可编辑时有效，所以在感谢页上、在已取消的订单上、在订单还不存在时，调用会直接失败，连请求都不发；字段有总长度上限，超了 `message` 会说明。

### 增删商品行

[`order.addLineItems`](/docs/app/extensions/checkout/checkout-api#商品行) 和 [`order.removeLineItems`](/docs/app/extensions/checkout/checkout-api#商品行) 改变订单的内容，之后平台会整单重算价。加行传 `variantId` 和数量；删行传商品行的 `id`，不是变体 id。

难的不是这次调用，而是在顾客应用折扣、修改地址的过程中，让自己加的行和订单总价保持对账。[结账页增删订单商品行](/docs/app/use-cases/checkout/modify-order-at-checkout)以一项增值服务为例，把这件事从头到尾走了一遍。

### 定制地址表单

`address` 命名空间改写表单，而不是替换表单：调整字段顺序、改标签、隐藏可选字段、锁定已有值的字段，以及在内置校验之上加规则。到底有哪些字段，由这个国家的地址模板决定，可以用 [`address.isFieldShow`](/docs/app/extensions/checkout/checkout-api#收货地址) 查。

```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`](/docs/app/extensions/checkout/checkout-api#收货地址) 用来隐藏可选字段，`registerShippingAddressSchemaChangeDisable` 用来锁定已有值的字段。

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

### 阻止提交

[`order.registerBuyerJourneyIntercept`](/docs/app/extensions/checkout/checkout-api#提交与校验) 在顾客尝试进入下一步时执行。返回 `{ 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>',
});
```

弹窗的各个插槽——主体、底部和两个按钮——列在[动态扩展点](/docs/app/extensions/checkout/extension-points#动态扩展点)下。这也是一个 `register*` 方法：每一次调用都必须返回两种行为之一，包括那些你没什么要说的调用。

如果你要的是在地址提交之前做校验，而不是在流程关卡上拦截，用 [`order.addBeforeSubmitCb`](/docs/app/extensions/checkout/checkout-api#提交与校验)，它是 `add*Cb` 监听器，可以放心挂。

### 让 UI 支持多语言

[`base.registerLocaleMap`](/docs/app/extensions/checkout/checkout-api#多语言) 只有一个参数：以完整语言标签为 key 的文案 map。用 [`base.formatMessage`](/docs/app/extensions/checkout/checkout-api#多语言) 读回来，它按顾客的语言解析。

```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'` 读。见[多语言](/docs/app/extensions/checkout/checkout-api#多语言)。

## 验证

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()`。

## 下一步

- [扩展点](/docs/app/extensions/checkout/extension-points)——可以渲染进去的全部插槽，静态和动态
- [CheckoutAPI 参考](/docs/app/extensions/checkout/checkout-api)——全部命名空间，每个参数的类型
- [Checkout extension 场景教程](/docs/app/extensions/checkout/extension-recipes)——五个完整场景，含隐藏原生模块
- [结账页增删订单商品行](/docs/app/use-cases/checkout/modify-order-at-checkout)——用 `addLineItems` 和 `removeLineItems` 做出的完整功能
