使用 @builder.io/personalization-utils 在边缘实现个性化内容交付与 A/B 测试
2026/9/16 17:07:13 网站建设 项目流程

使用 @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,且为可选),运行时依赖cheeriojson-stringify-deterministic。包通过 exports 字段暴露两个入口:

  • @builder.io/personalization-utils:主入口,导出getUserAttributestrimHtmlPersonalizedURL(见 src/index.ts);
  • @builder.io/personalization-utils/next:Next.js 专用入口,导出getPersonalizedURLparsePersonalizedURLgetUserAttributesFromHash(见 src/next.ts)。

核心原理:PersonalizedURL 类

一切重写逻辑都建立在PersonalizedURL类之上(见 src/personalized-url.ts)。它的工作方式非常直观:

  1. pathname与所有定位属性合并,并额外注入urlPath字段;
  2. 使用json-stringify-deterministic将属性对象序列化为确定性顺序的 JSON 字符串(保证相同属性始终生成相同 URL,这对缓存命中至关重要);
  3. 将 JSON 字符串编码(默认用Buffer的 base64,URL 安全场景下可覆盖为btoa);
  4. 拼出{prefix}/{encoded}形式的重写路径,默认prefixbuilder
// 摘自 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: truerevalidate: 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 的类型定义可见,userAttributesabTests通过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 并按下述顺序处理:

  1. 先应用 A/B 测试变体:根据abTestscontentId → winningVariantId的映射,在.builder-component-{contentId}内查找template[data-template-variant-id="{winningVariantId}"],命中则用该模板内容替换容器内容;未命中则移除所有template/script保留默认内容(processAbTest);
  2. 再评估个性化容器:对每个.builder-personalization-container,解析其内部script[id^="variants-script-"]中的var variants = [...]数据,用findWinningVariant匹配第一个通过的用户属性变体,替换容器内容;若无匹配则保留默认内容;
  3. 递归处理嵌套容器processContainer在替换完当前容器后会继续查找内部的.builder-personalization-container并递归处理;
  4. 最终返回$('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)无BuffergetPersonalizedURL已自动改用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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询