跳到主要内容

Checkout extension 场景教程

本页收录基于 Checkout Extension 的实战场景。基础开发流程见 创建一个结账扩展;扩展点与 API 速查见 Checkout extension 参考

场景一:在所有扩展点插入自定义内容

  • 适用场景:开发调试阶段,快速确认每个扩展点在页面上的实际位置。

  • 实现方式:遍历所有扩展点,在每个位置渲染一个显示自身名称的红色标签。

src/index.js

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

const extensionPoints = [
'Checkout::RenderBefore',
'Checkout::RenderAfter',
'Checkout::Head::RenderAfter',
'Checkout::FilledInformation::RenderAfter',
'Checkout::SpecialInstruction::RenderAfter',
'Checkout::Summary::RenderBefore',
'Checkout::Navigate::RenderBefore',
'Checkout::Navigate::RenderAfter',
'Checkout::ContactInformation::RenderBefore',
'Checkout::ContactInformation::RenderAfter',
'Checkout::ShippingLinesTitle::RenderBefore',
'Checkout::ShippingLinesTitle::RenderAfter',
'Checkout::ShippingList::RenderAfter',
'Checkout::ProductList::RenderBefore',
'Checkout::ProductList::RenderAfter',
'Checkout::Reductions::RenderBefore',
'Checkout::Reductions::RenderAfter',
'Checkout::SectionPayment::RenderBefore',
'Checkout::SectionPayment::RenderAfter',
'Checkout::ThankyouHeader::RenderBefore',
'Checkout::ThankyouContent::RenderBefore',
];

function renderLabel(extensionPoint) {
return `
<div style="color:#dc2626;font-size:12px;">
${extensionPoint}
</div>
`;
}

extensionPoints.forEach(extensionPoint => {
extend({
extensionPoint,
component: renderLabel(extensionPoint),
});
});

确认各扩展点的位置后,将 renderLabel 返回的 HTML 替换为实际业务内容即可。

示例截图:

Image

场景二:API 调用与事件监听

  • 适用场景:全面了解 CheckoutAPI 的可用方法与事件,可作为开发新扩展时的调试工具和参考起点。

  • 实现方式:在页面顶部渲染一组操作按钮,触发各类 API;同时监听价格、地址、步骤变化并在控制台输出。页面加载完成时打印所有 API 的初始值。

src/index.html

<style>
.checkout-btn {
margin: 4px;
padding: 2px 4px;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
background-color: #f0f0f0;
color: #333;
font-size: 12px;
line-height: 1.4;
transition: all 0.3s ease;
}
.checkout-btn:hover {
background-color: #e0e0e0;
}
</style>
<div data-coe-toolbar>
<button class="checkout-btn" data-action="stepNavToInformation">跳转至联系信息</button>
<button class="checkout-btn" data-action="stepNavToShipping">跳转至运输信息</button>
<button class="checkout-btn" data-action="stepNavToPayment">跳转至支付信息</button>
<button class="checkout-btn" data-action="doLogin">登录</button>
<button class="checkout-btn" data-action="doRegister">注册</button>
<button class="checkout-btn" data-action="doLogout">退出登录</button>
<button class="checkout-btn" data-action="goToHomePage">跳转到首页</button>
<button class="checkout-btn" data-action="locationHref">跳转到指定路径</button>
</div>

src/index.js

import { extend } from 'shoplazza-extension-ui';
import template from './index.html';

extend({
extensionPoint: 'Checkout::RenderBefore',
component: template,
});

// ⚠️ 以下 log / console.log 仅用于演示,生产请删除或改为可关闭的日志开关
const LOG = '[checkout-event-api]';
const REGISTRY_KEY = '__CHECKOUT_EVENT_API__';

function log(message, ...args) {
console.log(`${LOG} ${message}`, ...args);
}

function getRegistry() {
if (!window[REGISTRY_KEY]) {
window[REGISTRY_KEY] = { shippingAddress: {} };
}
return window[REGISTRY_KEY];
}

function getApi() {
return window.CheckoutAPI;
}

function makeAction(name, fn) {
return async () => {
log(`[action] ${name} → 开始执行...`);
await fn();
log(`[action] ${name} → 完成`);
};
}

const actions = {
stepNavToInformation: makeAction('跳转至联系信息步骤', () => getApi().step.stepNavToInformation()),
stepNavToShipping: makeAction('跳转至运输信息步骤', () => getApi().step.stepNavToShipping()),
stepNavToPayment: makeAction('跳转至支付信息步骤', () => getApi().step.stepNavToPayment()),
doLogin: makeAction('登录', () => getApi().user.doLogin()),
doRegister: makeAction('注册', () => getApi().user.doRegister()),
doLogout: makeAction('退出登录', () => getApi().user.doLogout()),
goToHomePage: makeAction('跳转至首页', () => getApi().step.goToHomePage()),
locationHref: makeAction('跳转至指定路径', () => getApi().step.locationHref('/')),
};

// 用事件委托替代 inline onclick,避免全局命名空间污染
document.addEventListener('click', (event) => {
const btn = event.target.closest('[data-action]');
if (!btn || !btn.closest('[data-coe-toolbar]')) return;
const action = actions[btn.dataset.action];
if (action) action();
});

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

function mount(attempt = 0) {
const api = getApi();
if (!api) {
if (attempt >= MOUNT_MAX_ATTEMPTS) {
console.warn(`${LOG} CheckoutAPI 未在预期时间内挂载,已放弃等待`);
return;
}
// 用 setTimeout 而不是 requestAnimationFrame,避免 60fps 忙等占用结账页 CPU
window.setTimeout(() => mount(attempt + 1), MOUNT_INTERVAL_MS);
return;
}

const reg = getRegistry();

if (reg.handlePricesChange) api.store.removePricesChangeCb(reg.handlePricesChange);
if (reg.handleAddressChange) api.address.removeShippingAddressChangeCb(reg.handleAddressChange);
if (reg.handleStepChange) api.step.removeStepChangeCb(reg.handleStepChange);

reg.handlePricesChange = (prices) => log('[prices-change]', prices);
reg.handleAddressChange = (patch) => {
reg.shippingAddress = { ...reg.shippingAddress, ...patch };
log('[address-change]', patch);
};
reg.handleStepChange = () => log('[step-change]', api.step.getStep());

api.store.onPricesChange(reg.handlePricesChange);
api.address.onShippingAddressChange(reg.handleAddressChange);
api.step.onStepChange(reg.handleStepChange);

console.group(`${LOG} 初始化完成`);
log('step:', api.step.getStep());
log('orderInfo:', api.store.getOrderInfo());
log('orderStatus:', api.store.getOrderStatus());
log('referInfo:', api.store.getReferInfo());
log('prices:', api.store.getPrices());
log('products:', api.summary.getProductList());
log('user.isLogin:', api.user.isLogin());
log('user.info:', api.user.getUserInfo());
log('shippingAddress:', api.address.getShippingAddress());
console.groupEnd();
}

mount();

关键点说明:

  • 事件委托替代 inline onclick:所有按钮挂 data-action="xxx",统一在 document 监听 click 事件并通过 closest('[data-action]') 定位目标。

  • makeAction:统一包装异步操作,执行前后打印日志。

  • mount:用 setTimeout + 最大重试次数(约 5 秒)等待 CheckoutAPI 挂载,避免 requestAnimationFrame 60fps 忙等占用结账页 CPU。

  • 注册事件监听前先调用对应的 remove* 方法清除旧回调,防止热更新时重复注册。

  • getRegistry:将回调函数引用存储在 window 全局对象上,确保 remove* 时传入的是同一个函数引用。

场景三:地址自动填充到信用卡姓名

  • 适用场景:买家在联系信息步骤填写收货地址后,跳转到支付步骤时,自动将姓名填入信用卡持卡人姓名输入框,减少重复输入。

  • 结账布局说明:Shoplazza 支持三种结账布局,可在店铺后台「结账页编辑器 → 结账页基础配置 → 结账布局」中切换。本扩展通过同时监听 onShippingAddressChangeonStepChange 两个事件,并在 mount 时判断当前步骤主动触发填充,覆盖三种布局下的不同填充时机,无需针对各布局单独适配。

核心逻辑:

  1. 监听收货地址变化,将每次变化的字段合并到本地缓存

  2. 监听步骤变化,当切换到 payment_method 时触发填充

  3. mount 时若当前已经处于 payment_methodcontact_information 步骤,直接尝试填充(覆盖刷新落到这两步的场景)

  4. 填充时通过 DOM 查询信用卡姓名输入框,若输入框尚未渲染则每 100ms 重试,最多等待 5 秒

⚠️ DOM 选择器警告: #card_first_name / #card_last_name 等是 Shoplazza 结账页当前版本的内部 DOM 结构,可能随平台升级而变化。生产扩展应:(1) 尽量通过 CheckoutAPI 等公开接口实现需求;(2) 必须直接操作 DOM 时,加版本检测和优雅降级,并定期回归。

src/index.js

// ⚠️ 以下 log / console.log 仅用于演示,生产请删除或改为可关闭的日志开关
const LOG = '[checkout-name-autofill]';
const REGISTRY_KEY = '__CHECKOUT_NAME_AUTOFILL__';

function log(message, ...args) {
console.log(`${LOG} ${message}`, ...args);
}

function getRegistry() {
if (!window[REGISTRY_KEY]) {
window[REGISTRY_KEY] = { shippingAddress: {} };
}
return window[REGISTRY_KEY];
}

function getApi() {
return window.CheckoutAPI;
}

function trimText(value) {
return typeof value === 'string' ? value.trim() : '';
}

function setInputValue(input, value) {
if (!input) return;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}

function tryFillCardholderName(address, remaining = 50) {
const firstName = trimText(address?.firstName);
const lastName = trimText(address?.lastName);

if (!firstName && !lastName) return;

const firstNameInput =
document.querySelector('#card_first_name') ||
document.querySelector('input[name="card_first_name"]');
const lastNameInput =
document.querySelector('#card_last_name') ||
document.querySelector('input[name="card_last_name"]');

if (!firstNameInput || !lastNameInput) {
if (remaining <= 0) return;
window.setTimeout(() => tryFillCardholderName(address, remaining - 1), 100);
return;
}

if (firstName) setInputValue(firstNameInput, firstName);
if (lastName) setInputValue(lastNameInput, lastName);

log('[cardholder-autofill] 填充完成', { firstName, lastName });
}

function handleAddressChange(patch) {
const reg = getRegistry();
reg.shippingAddress = { ...reg.shippingAddress, ...patch };
log('[address-change]', patch);
tryFillCardholderName(reg.shippingAddress);
}

function handleStepChange() {
if (getApi().step.getStep() === 'payment_method') {
tryFillCardholderName(getRegistry().shippingAddress);
}
}

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

function mount(attempt = 0) {
const api = getApi();
if (!api) {
if (attempt >= MOUNT_MAX_ATTEMPTS) {
console.warn(`${LOG} CheckoutAPI 未在预期时间内挂载,已放弃等待`);
return;
}
// 用 setTimeout 替代 requestAnimationFrame,避免 60fps 忙等占用结账页 CPU
window.setTimeout(() => mount(attempt + 1), MOUNT_INTERVAL_MS);
return;
}

const reg = getRegistry();

if (reg.handleAddressChange) api.address.removeShippingAddressChangeCb(reg.handleAddressChange);
if (reg.handleStepChange) api.step.removeStepChangeCb(reg.handleStepChange);

reg.handleAddressChange = handleAddressChange;
reg.handleStepChange = handleStepChange;

reg.shippingAddress = api.address.getShippingAddress();

api.address.onShippingAddressChange(handleAddressChange);
api.step.onStepChange(handleStepChange);

// 当前已在支付步骤或联系信息步骤时,主动尝试一次填充(覆盖刷新/直接进入这些步骤的场景)
const currentStep = api.step.getStep();
if (currentStep === 'payment_method' || currentStep === 'contact_information') {
tryFillCardholderName(reg.shippingAddress);
}
}

mount();

关键点说明:

  • tryFillCardholderName:查找信用卡姓名输入框,若未渲染则每 100ms 重试,最多尝试 50 次(约 5 秒)。同时兼容 ID 选择器和 name 属性选择器两种 DOM 结构。

  • setInputValue:填充 input 值后手动触发 inputchange 事件,确保页面框架能感知到值的变化。

  • handleAddressChange:地址每次变化时合并最新 patch,并尝试立即填充(覆盖单步结账布局场景)。

  • handleStepChange:步骤切换到支付页时触发填充(覆盖多步结账布局场景)。

  • mount 中的初始填充:处理页面直接加载在特定步骤时的填充(覆盖直接进入支付页的场景)。

  • 本扩展无需渲染 UI,不调用 extend,仅在后台运行逻辑。

场景四:根据购物车金额展示免运费进度条

  • 适用场景:引导买家凑单。当商品小计低于阈值时,提示还差多少金额解锁免运费;达标后展示已享免运费。购物车变化时进度条实时更新。

  • 实现思路:通过 CheckoutAPI.store.getPrices() 读取商品小计,在某个结账扩展点渲染提示条,并在 CheckoutAPI.store.onPricesChange() 回调里重新渲染。

本场景聚焦业务逻辑。CheckoutAPI 的挂载等待写法(mount)和完整 API 巡览,参见上文场景二。

备注

本场景只是展示提示条,并不会真的免运费。真正的免运费规则需由商家在店铺后台中配置。请让 FREE_SHIPPING_THRESHOLD 与该规则保持一致,否则可能出现“进度条说免运、结账却照收运费”的不一致。

src/index.js

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

// 免运费阈值(按店铺币种)。请按你的活动修改
// (例如改为从扩展设置读取,而非写死)。
const FREE_SHIPPING_THRESHOLD = 120;

// `CheckoutAPI` 只有在结账页完全加载后才挂载到 `window` 上。
// 过早访问 `window.CheckoutAPI` 会得到 `undefined`,
// 因此用轮询等待,而非直接读取。
function mount(cb) {
let tries = 0;
const timer = setInterval(() => {
if (window.CheckoutAPI) {
// 已就绪:停止轮询,把 API 交给回调。
clearInterval(timer);
cb(window.CheckoutAPI);
} else if (++tries > 50) {
// 约 5 秒后(50 次 × 100ms)放弃,避免无限循环。
clearInterval(timer);
console.warn('CheckoutAPI not mounted in time');
}
}, 100);
}

// 根据当前商品小计生成提示条的 HTML 字符串。
function render(subtotal) {
const remaining = FREE_SHIPPING_THRESHOLD - subtotal;
// 低于阈值:提示还差多少金额;达标:提示已解锁免运费。
const text =
remaining > 0
? `Spend $${remaining.toFixed(2)} more to unlock free shipping`
: 'You have unlocked free shipping!';
return `<div style="padding:12px;background:#f5f5f5;">${text}</div>`;
}

mount((api) => {
// 在固定的结账扩展点渲染(或重渲染)提示条。
// `subtotalPrice` 是字符串,运算前先 `parseFloat`。
const draw = (prices) =>
extend({
extensionPoint: 'Checkout::Summary::RenderBefore',
component: render(parseFloat(prices.subtotalPrice)),
});

// 先用初始价格画一次……
draw(api.store.getPrices());
// ……之后每当购物车金额变化就自动重画。
api.store.onPricesChange((prices) => draw(prices));
});

免运费进度条预览