使用 @builder.io/personalization-utils 在边缘实现个性化内容交付与 A/B 测试
【免费下载链接】builderVisual Development for React, Vue, Svelte, Qwik, and more项目地址: https://gitcode.com/GitHub_Trending/bu/builder
本文是基于 packages/personalization-utils/README.md 的技术指南,深入介绍 Builder 平台在边缘(Edge)场景下交付个性化内容的官方工具集@builder.io/personalization-utils。你将掌握如何在 Next.js middleware 中把定位属性编码进 URL 以复用缓存、如何用parsePersonalizedURL解析回属性渲染页面、以及如何用trimHtml在服务端对个性化容器与 A/B 测试变体做最终裁剪,同时结合仓库源码理解其底层实现原理。
为什么需要"边缘个性化"
Builder 的内容可以被静态生成或缓存到 CDN 边缘,但个性化内容天然依赖每个访客的实时属性(如所属受众分段、所在地区、购物车状态等)。若直接为每个用户动态渲染,就无法充分利用边缘缓存与静态站点生成(SSG)。
@builder.io/personalization-utils的解决思路是:把定位属性编码为 URL 路径的一部分。中间件(middleware)在请求到达时读取用户属性、生成一个携带属性的重写 URL,之后该 URL 的渲染结果就可以被缓存;真正的内容裁剪发生在渲染端(或后续的trimHtml),从而兼顾个性化与缓存。
安装
npm install @builder.io/personalization-utils从仓库的 packages/personalization-utils/package.json 可以看到,当前包版本为 4.0.0,其 peerDependencies 要求@builder.io/sdk(^1.1.20)与next(>=14.2.25,且为可选),运行时依赖cheerio与json-stringify-deterministic。包通过 exports 字段暴露两个入口:
@builder.io/personalization-utils:主入口,导出getUserAttributes、trimHtml与PersonalizedURL(见 src/index.ts);@builder.io/personalization-utils/next:Next.js 专用入口,导出getPersonalizedURL、parsePersonalizedURL、getUserAttributesFromHash(见 src/next.ts)。
核心原理:PersonalizedURL 类
一切重写逻辑都建立在PersonalizedURL类之上(见 src/personalized-url.ts)。它的工作方式非常直观:
- 把
pathname与所有定位属性合并,并额外注入urlPath字段; - 使用
json-stringify-deterministic将属性对象序列化为确定性顺序的 JSON 字符串(保证相同属性始终生成相同 URL,这对缓存命中至关重要); - 将 JSON 字符串编码(默认用
Buffer的 base64,URL 安全场景下可覆盖为btoa); - 拼出
{prefix}/{encoded}形式的重写路径,默认prefix为builder。
// 摘自 src/personalized-url.ts rewritePath() { const stringified = stringify(this.options.attributes); const encoded = this.options.encode(stringified); const prefix = this.options.prefix; return [prefix.startsWith('/') ? prefix.slice(1) : prefix, encoded].filter(Boolean).join('/'); } static fromRewrite(rewrite: string, prefix = 'builder', decode = defaultOptions.decode) { const stringified = decode(rewrite); const attributes = JSON.parse(stringified); return new PersonalizedURL({ prefix, pathname: attributes.urlPath, attributes }); }反向解析由静态方法PersonalizedURL.fromRewrite完成:对编码串 base64 解码、JSON.parse后即还原出完整属性(含原始urlPath)。rewritePath()中prefix.startsWith('/') ? prefix.slice(1) : prefix的处理意味着传入带斜杠或不带斜杠的前缀均可。
实战一:在 Next.js middleware 中生成个性化重写 URL
中间件是改写请求的入口。先在middleware.ts中调用getPersonalizedURL(request),它读取请求的 cookies 与 query 中的用户属性,构造携带这些属性的重写 URL,并返回一个NextResponse.rewrite(personalizedURL)可用的 URL 对象:
import { getPersonalizedURL } from '@builder.io/personalization-utils/next' const excludededPrefixes = ['/favicon', '/api']; export default function middleware(request) { const url = request.nextUrl if (shouldRewrite(url.pathname)) { const personalizedURL = getPersonalizedURL(request) return NextResponse.rewrite(personalizedURL) } return NextResponse.next(); }从 src/next.ts 的源码可以看到getPersonalizedURL的细节:
- 遍历
request.cookies,对每个 cookie 值尝试JSON.parse(解析失败则保留原始字符串),汇总为allCookies; - 通过
getUserAttributes({ ...allCookies, ...query }, options.cookiesPrefix)提取builder.userAttributes前缀下的属性(query 参数优先级更高,可用于覆盖); - 关键点:middleware 环境没有
Buffer(截至 next 12.2),因此这里显式用btoa覆盖默认编码:encode: url => btoa(url), - 默认
prefix取当前请求的pathname,也可通过options.urlConfig覆盖。
getPersonalizedURL还接受options.attributes(额外合并的静态属性)与options.cookiesPrefix(自定义 cookie 前缀,默认builder.userAttributes)。
实战二:在 catch-all 页面解析属性并渲染
重写后的请求会落到 catch-all 页面(如pages/[[...path]].jsx),此时用parsePersonalizedURL(params.path)从 URL 中还原属性,再携带这些属性请求 Builder 内容:
import { parsePersonalizedURL } from '@builder.io/personalization-utils/next' // in pages/[[...path]].jsx export async function getStaticProps({ params }) { const { attributes } = parsePersonalizedURL(params?.path); const page = (await builder .get('page', { apiKey: builderConfig.apiKey, userAttributes: attributes, // cachebust is not advised outside static rendering contexts. cachebust: true, }) .promise()) || null return { props: { page, }, // Next.js will attempt to re-generate the page: // - When a request comes in // - At most once every 1 second revalidate: 1, } } export function getStaticPaths() { return { paths: [], fallback: true, } } export default function Path({ page }) { return <BuilderComponent model="page" content={page} /> }parsePersonalizedURL(src/next.ts)的实现要点:
export function parsePersonalizedURL(paths: string[]) { const hash = paths.slice(-1)[0]; try { const attrs = getUserAttributesFromHash(hash); return { isPersonzlied: true, attributes: attrs, pathname: attrs.pathname }; } catch { return { isPersonalized: false, attributes: null, pathname: '/' + (paths.join('/') || '') }; } }它取路径最后一段作为编码哈希尝试解码:成功则返回isPersonzlied: true、还原的attributes与原始pathname;失败(普通路径)则返回isPersonalized: false,此时应渲染默认内容。配合fallback: true与revalidate: 1,即可实现"请求进入 → 重新生成 → 结果缓存"的增量静态再生(ISR)模式。
设置用户属性:cookie 驱动分段
用户的定位属性通过builder.setUserAttributes持久化到名为builder.userAttributes的 cookie 中。例如从你的 CDP 识别出受众分段后:
const audience = await myCDP.identifyAudience(userID); // this will include the `audience` in all api calls and save it in a cookie `builder.userAttributes` builder.setUserAttributes({ audience })一旦 cookie 设置成功,此后所有 Builder 内容匹配都会把当前受众分段纳入考量(包括 SDK 的 API 调用与getPersonalizedURL的重写过程)。
getUserAttributes的读取逻辑在 src/utils.ts:默认以builder.userAttributes为前缀,兼容prefix字符串 JSON 解析失败时的容错(返回空对象),也支持builder.userAttributes.audience这类点号分隔的子键展开:
export const getUserAttributes = (attributes, cookiePrefix?) => { let prefix = cookiePrefix || 'builder.userAttributes'; // 以 "builder.userAttributes." 开头的键会被剥掉前缀,作为独立属性返回 };实战三:trimHtml —— 在边缘/SSR 裁剪动态容器与 A/B 变体
当页面 HTML 已经生成(例如整页被静态缓存),真正按用户裁剪的工作可以放到边缘或 SSR 阶段完成,这就是trimHtml的用途。它适用于处理 Builder 输出的个性化容器(.builder-personalization-container)与A/B 测试变体(.builder-component-{contentId}内的template[data-template-variant-id])。
基本用法
import { trimHtml } from '@builder.io/personalization-utils' const fullHTML = '... your full HTML string with personalization containers and A/B test variants ...'; const userAttributes = { audience: 'segment-a', date: '2023-06-15T12:00:00Z' }; const abTests = { 'content-id-1': 'variant-a', 'content-id-2': 'variant-b' }; const { html } = trimHtml(fullHTML, { userAttributes, abTests });trimHtml(html, options)返回{ html }。从 src/utils.ts 的类型定义可见,userAttributes与abTests通过OptionalXOR约束为"至少提供一个":
type TrimHtmlOptions = OptionalXOR< { userAttributes: UserAttributes; abTests: Record<string, string> }, 'userAttributes' | 'abTests' >;从 cookie 解析 userAttributes
要获取userAttributes,解析builder.userAttributescookie 即可:
import { parse } from 'cookie' function getUserAttributes(req) { const cookies = parse(req.headers.cookie || ''); const builderAttributes = cookies['builder.userAttributes']; return builderAttributes ? JSON.parse(builderAttributes) : {}; } // Then in your request handler: const userAttributes = getUserAttributes(req);从 cookie 解析 abTests
A/B 测试的胜出变体同样记录在 cookie 中(键以builder.tests开头,末段为 content id,值为变体 id):
import { parse } from 'cookie' function getAbTests(req) { const cookies = parse(req.headers.cookie || ''); const abTests = Object.entries(cookies).reduce((acc, [cookieName, cookieValue]) => { if (cookieName.startsWith('builder.tests')) { return { ...acc, [cookieName.split('.').slice(-1)[0]]: cookieValue } } return acc; }, {}); return abTests; } // Then in your request handler: const abTests = getAbTests(req); const { html } = trimHtml(fullHTML, { userAttributes, abTests });处理顺序与源码实现
trimHtml内部用 cheerio 加载 HTML 并按下述顺序处理:
- 先应用 A/B 测试变体:根据
abTests中contentId → winningVariantId的映射,在.builder-component-{contentId}内查找template[data-template-variant-id="{winningVariantId}"],命中则用该模板内容替换容器内容;未命中则移除所有template/script保留默认内容(processAbTest); - 再评估个性化容器:对每个
.builder-personalization-container,解析其内部script[id^="variants-script-"]中的var variants = [...]数据,用findWinningVariant匹配第一个通过的用户属性变体,替换容器内容;若无匹配则保留默认内容; - 递归处理嵌套容器:
processContainer在替换完当前容器后会继续查找内部的.builder-personalization-container并递归处理; - 最终返回
$('body').html()。
// 摘自 src/utils.ts if (options.abTests) { Object.entries(options.abTests).forEach(([contentId, winningVariantId]) => { const $content = $(`.builder-component-${contentId}`); if ($content.length) processAbTest($, $content, winningVariantId); }); $ = load($.html() || ''); } if (options.userAttributes) { $('.builder-personalization-container').each((_, element) => { processContainer($, $(element), options.userAttributes!); }); }值得注意:A/B 变体应用后会重新loadHTML,因此嵌套在 A/B 变体内部的个性化容器也能被后续处理命中——这正是文档所说"先 A/B、再个性化"顺序的意义所在。
定位查询运算符
变体匹配依赖variants数组中的query列表,每个查询形如{ property, operator, value }。仓库 src/utils.ts 定义了完整运算符集合:
| 运算符 | 语义 | 实现要点 |
|---|---|---|
is | 严格相等 | value === testValue |
isNot | 不相等(数组时要求全部不匹配) | 逐项取反 |
contains | 包含子串/数组包含 | 仅对 string/array 生效 |
startsWith/endsWith | 前缀/后缀匹配 | 仅对 string 生效 |
greaterThan/lessThan | 数值比较 | 仅双方为 number 时生效 |
greaterThanOrEqualTo/lessThanOrEqualTo | 数值比较(含等号) | 同上 |
匹配时还支持日期窗口:变体可带startDate/endDate,判断基准时间取userAttributes.date(未提供则用当前时间);urlPath属性的查询值若以/结尾会被去掉尾斜杠再比较;属性值为数组时使用includes语义。所有查询条件需全部满足(query.every)才算变体命中。
测试验证:trim-html.test.ts
仓库提供了完整的行为测试(src/trim-html.test.ts),可运行npm test验证,覆盖了文档描述的全部关键行为:
- 变体命中时输出胜出变体内容、移除
template/script与默认内容; - 无变体命中时保留默认内容;
- 多个个性化容器同时处理且互不影响;
- 保留容器 div 上的额外 class 与自定义属性(
data-test等); - 无容器时原样返回、不修改内容;
- 空
variants数组与畸形 JSON 时的容错(回退默认内容); - 基于
startDate/endDate的日期窗口选择(含仅起始、仅结束两种边界); - 多个 A/B 测试并行裁剪;
- A/B 测试先于个性化容器处理的复合场景;
- 胜出变体不存在时保留默认内容。
这些测试既是对trimHtml行为的规格说明,也可作为你接入时的回归保障。
进阶:configurator 调试工具
仓库在 packages/personalization-utils/configurator 下还附带一个前端调试工具ContextMenu(React 组件),方便开发者在本地右键切换不同的定位属性,快速预览个性化效果:
targetingAttributes:直接传入属性定义({ [key]: Input });不传时默认请求/api/attributes拉取;getAttributes(privateKey):使用@builder.io/admin-sdk通过私钥查询空间的customTargetingAttributes(见 configurator/src/get-attributes.ts);- 通过右键菜单为属性赋值,写入
builder.userAttributes.{attr}cookie 并刷新页面(cookiesPrefix可自定义);菜单也提供 Reset 一键清除所有相关 cookie。
使用建议与边界
- cachebust 仅用于静态渲染上下文:文档明确指出
cachebust: true不推荐在非静态场景使用,请按需取舍; - middleware 中的编码差异:Node 环境默认用
Buffer编码,而 Next middleware(Edge Runtime)无Buffer,getPersonalizedURL已自动改用btoa;自行实现时需注意这一环境差异; - URL 长度:属性被整体编码进路径,过长的属性集合会显著增加 URL 长度,建议只编码必要的最小定位属性集;
- 确定性序列化:
json-stringify-deterministic保证相同属性集合始终生成相同编码串,这是重写 URL 可缓存、可命中 ISR 的前提。
综上,@builder.io/personalization-utils为"缓存友好的个性化"提供了一条清晰链路:middleware 用getPersonalizedURL把属性编码进 URL → catch-all 页面用parsePersonalizedURL还原属性请求内容并缓存 → 边缘/SSR 阶段用trimHtml对静态 HTML 做最终个性化与 A/B 裁剪。理解其源码实现(src/personalized-url.ts、src/next.ts、src/utils.ts)能帮助你在接入时规避环境差异、写出可缓存的个性化路由。
【免费下载链接】builderVisual Development for React, Vue, Svelte, Qwik, and more项目地址: https://gitcode.com/GitHub_Trending/bu/builder
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考