@yaoxiu/marketing-dsl
v2.6.1
Published
营销弹窗 DSL 解释器核心,纯逻辑无框架依赖
Readme
@yaoxiu/marketing-dsl
营销弹窗 DSL 的解释器核心。运营用一段 JSON 描述弹窗 / Banner / 公告条的排版和交互,这个包负责把它解释成可渲染的数据。
纯逻辑,不依赖任何框架,也不碰 DOM。 换 Vue3 / React 时这个包一行都不用改。
为什么要有这个包
运营发活动时弹窗形态千奇百怪,最初的方案是让他们写 JS。这条路走不通——配置一旦变成可执行代码,就等于把 XSS 入口开在了自己家里。
所以改成 DSL:排版是数据,不是逻辑。表达式求值器是手写的词法 + 递归下降,绝不使用 eval / new Function;样式、链接协议、数据源名、可调用方法全部走白名单。
安装
pnpm add @yaoxiu/marketing-dsl渲染还需要对应框架的壳:
pnpm add @yaoxiu/marketing-dsl-vue2两种用法
1. 校验配置
编辑器和后端保存前都该跑一遍。这个包不依赖 DOM,Node 里可以直接用。
import { validate, formatIssues } from '@yaoxiu/marketing-dsl';
const result = validate(dsl);
if (!result.valid) {
throw new Error(formatIssues(result.errors));
}
// warnings 不阻断保存,但该提示运营,比如样式属性不在白名单里会被忽略可选:校验 {{ user.xxx }} 用到的字段
validate 的第二个参数是可选项,不传时行为完全不变。传了 userFields 就会检查配置里
所有插值位置引用的 user 字段是否在白名单内,不在的报 error(附最接近的候选字段名)。
import { validate, DEFAULT_USER_FIELDS } from '@yaoxiu/marketing-dsl';
const result = validate(dsl, { userFields: DEFAULT_USER_FIELDS });
// { path: 'nodes[0].content',
// message: '用户信息里没有 "user.remainDay" 这个字段,是不是想写 "user.remainDays"?…' }user 是宿主注入的登录用户信息对象,运营写错字段名(user.expireDays 而不是
user.remainDays)运行时只会求值为空——页面上就是一处空白、倒计时归零,投到线上才发现。
所以保存时拦一道。
DEFAULT_USER_FIELDS 是各平台通用的那套用户字段:
shopId / shopName / collectCount / albbCollectCount / isNewUser / levelId /
levelName / serviceExpireTime / remainDays / userTypeNew / serviceInfo
刻意排除 sourceData(后端原始响应整包,结构随平台漂移)和 mcToken(令牌,不该出现在
运营配置里)。字段的中文说明在 USER_FIELD_LABELS,编辑器做提示可以直接用。
只想单独跑这一项检查时用 validateUserFields(dsl, fields)。
检查覆盖全部字符串值里的 {{ }}(文案 content、to、src、style 的每个值、
动作的 params …),以及 visibleWhen 这种整串就是表达式的字段——
正向列举「哪些字段能写插值」必漏,所以是反过来做的。
埋点事件名(track 动作的 event)有格式要求
^[a-z][a-z0-9_]{2,63}$小写字母开头,之后只能是小写字母、数字、下划线,总长 3~64 个字符。
不合规是 error,直接阻断保存(导出为 TRACK_EVENT_PATTERN,编辑器可复用同一份正则做即时提示)。
| ✅ 正确 | ❌ 错误 | 为什么 |
| -------------------- | -------------------- | -------------------------- |
| banner_renew_click | 续费点击 | 不能用中文 |
| popup_show | bannerRenewClick | 不能用驼峰 / 大写字母 |
| countdown_end | banner-renew-click | 不能用中划线(下划线分词) |
| cta_click_v2 | cta(不足 3 字符) | 太短,聚合时看不出是什么 |
建议命名:坑位_业务_动作,如 popup_vip_renew_click。不做白名单——事件名由运营自由定义,
只统一格式;后端按前缀聚合,格式乱了就没法分组。这个名字最终会作为 extra.name 上报
(见 platform-adapters 的 base-service/marketing/README.md「上报字段契约」)。
2. 渲染
一般不直接用,交给框架壳。自己接的话是这样:
import { createRuntime } from '@yaoxiu/marketing-dsl';
const runtime = createRuntime(dsl, {
user: { shopName: '麦爆了旗舰店', version: '专业版' },
sources: { listRenewTiers: params => api.getTiers(params) },
handlers: { openRecharge: () => showRechargeModal() },
emit: (event, payload) => console.log(event, payload),
});
const unsubscribe = runtime.subscribe(() => {
render(runtime.getTree()); // 每次通知后重新取树画一遍
});
// 用户信息异步到了、或者要切换编辑态,用 update 就地更新。
// 不要为此重建 runtime——重建会重新拉数据源、重放曝光埋点,
// 还会把用户已经切到的 tab 打回默认值。
runtime.update({ user: { shopName: '麦爆了旗舰店' } });
// 卸载时必须调,否则倒计时定时器不会停
runtime.destroy();
unsubscribe();update 能改 user / handlers / editMode。换 dsl 本身要重建 runtime。
渲染树
getTree() 返回的是纯数据,所有解释工作都已经做完:插值算完了、条件判断过了、循环展开了、样式转成 CSS 了、坐标换算好了、动作绑好了。
{
ready: true, // 数据源没回来时是 false,什么都别画
hasPopup: true, // 当前视图栈里有弹窗(不是「配置里有弹窗视图」)
rootStyle: { ... }, // 恒 relative;弹窗铺满视口由 layerStyle 的 fixed 负责
layers: [{
name: 'main',
isTop: true,
mask: true, // 只有栈顶的弹窗层才画遮罩
onMaskClick: fn,
layerStyle: {...}, maskStyle: {...}, scrollStyle: {...},
stageStyle: {...}, clipStyle: {...},
// 默认图标是内联 SVG 画的两条交叉线('×' 字符的字形偏下,肉眼可见);
// 运营配了 icon 走 text,配了 image 走 img
closeButton: { key, tag, style, onClick, children },
nodes: [{ key: 'main-0', tag: 'div', style: {...}, onClick: fn, children: [...] }],
}],
countdownEndTimes: [], // 有倒计时时运行时会自己起定时器
countdownPrecision: 's',
css: '', // 要注入的 CSS 文本(keyframes + hover 规则),没有动画时是空串
rootClassName: 'dsl-renderer dsl-r3', // 根容器类名,后半截是这份物料的作用域前缀
}框架壳照着 layer 的结构套四层 div 即可,不需要自带任何 CSS,基础样式已经写进行内样式了。
动画与 hover:唯一需要一段真 CSS 的地方
@keyframes 和 :hover 行内表达不了,所以渲染树多带两个字段。框架壳的义务只有两条:
根容器的 class 用 tree.rootClassName(不要写死 dsl-renderer),tree.css 非空时把它原样塞进
一个 <style>、随变化更新、销毁时移除。壳里不做任何 CSS 生成或判断。
animate落的是行内animation简写(节点、stage、stage.maskAnimate、stage.closeButton都支持), 只有 keyframes 定义与 hover 规则进tree.css- keyframes 名带作用域前缀(
dsl-kf-{uid}-{name}),同一页面上多份物料不会互相覆盖 tree.css末尾恒带prefers-reduced-motion兜底:系统开了「减少动态效果」就全部静止- CSS 文本是字符串拼接,所以每个值都过
sanitizeCssValue——含{ } < > ; @、/*或反斜杠的 整条声明直接丢弃(行内那条路径不需要,框架自己会转义)
框架壳必须遵守的一条契约
节点有 onClick 时,挂事件要阻止冒泡。
父子都可点是常见排版——公告条整条点开弹窗、行末 × 关闭公告条——不阻止的话点 × 会连带触发外层,关掉的瞬间又弹出来。没有 onClick 的节点不要绑任何事件,让它正常冒泡到有 onClick 的祖先。
运行时事件(emit)
| 事件 | 何时抛 | 宿主拿它干嘛 |
| -------------- | -------------------------------------------- | --------------------------------------- |
| ready | 数据源全部返回 | 知道可以画了 |
| interaction | 一次手势 / 一个时机一条,见下 | 上报(曝光 / 点击 / 关闭只认它) |
| track | 每执行一个 track 动作 | 接自有埋点系统 |
| navigate | 每执行一个 navigate 动作 | 想自己接管跳转时用(core 默认已经跳了) |
| close | 物料整体关闭(点 × / 遮罩 / 倒计时 / close) | 把组件从页面上移除,并上报一条 close |
| call | 每执行一个 call 动作 | 宿主自己注册的方法已被调用 |
| open | 打开另一个视图 | 调试用 |
| view-change | 视图栈变化 | 调试用 |
| state-change | setState 改了状态 | 调试用 |
| error | 非法 URL / 未注册数据源或方法 / 未知动作 | 排查配置问题 |
interaction:动作粒度之外再来一条交互粒度
track / navigate 是动作粒度:一次点击里配 sequence: [track, navigate](先埋点再跳转)
是标准写法,按动作上报会把点击量翻倍;track 还能挂在 stage.onShow / onClose 上,
动作粒度看不出时机,一律算成点击就把曝光和关闭也计进了点击。
所以在交互粒度上再抛一条汇总事件,一次手势 / 一个时机只有一条:
interface DslInteractionEvent {
trigger: 'show' | 'close' | 'action'; // 触发时机
reason?: string; // 仅 trigger='close'
track?: { event: string; params?: Record<string, unknown> }; // 多个时取第一个
navigate?: { url: string; target?: string }; // 多个时取第一个
}stage.onShow触发 →{ trigger: 'show', ... }stage.onClose触发 →{ trigger: 'close', reason, ... }- 点了带
action的节点(含关闭按钮、tab、倒计时onEnd)→{ trigger: 'action', ... } - 没配动作的时机不抛(不制造空事件)
interaction 是新增的,既有事件语义一个都没变 —— 宿主该用 navigate 接管跳转、
用 track 接自有埋点系统的,照旧。上报侧只订 interaction,见下方「上报契约」。
安全边界
这几条是设计红线,不会因为「就差一点点」而放开:
| 边界 | 做法 |
| ---------------------- | -------------------------------------------------------------------- |
| 不执行任意代码 | 手写解析器,不用 eval / new Function;不支持函数调用和赋值 |
| 不能沿原型链逃逸 | constructor / __proto__ / prototype 一律读不到,函数值也读不到 |
| 不能通过样式作恶 | 只有白名单里的属性会输出,position: fixed 这类会被丢弃 |
| 不能执行 javascript: | 链接只允许 http / https / mailto / tel 和相对路径 |
| 不能打任意接口 | 数据源用注册名引用,配置里写不了 URL |
| 不能调任意方法 | call 只能调宿主注册在 handlers 里的方法 |
call 的适用边界
call 是「让宿主去做一件事」,不是「记一笔账」。
| ✅ 该用 call | ❌ 不该用 call |
| ---------------------------------------------------------- | --------------------------------------------- |
| 唤起充值弹窗、打开客服会话、复制到剪贴板、跳转应用内特定页 | 记录用户意图、写本地存储(localStorage 等) |
判断标准只有一条:这件事后端需不需要知道。
- 需要 → 走上报:
close的reason,或track动作。 - 纯粹是宿主的本地动作、后端不关心 → 才用
call。
典型反例是「不再提醒」。曾经的写法是 close 之外再配一条 call 让宿主写 localStorage,这是错的:
- 频控职责分裂。「这条还要不要出」由后端算,前端存一份频控依据后端看不见,两边依据不一致,规则就是假的。
- 换设备、清缓存就失效。这是用户的长期意图,该跟着账号走,不该跟着浏览器走。
- 每个宿主各写一份。
handlers由宿主注册,前台一份、调试台一份、将来 Vue3 宿主再一份,行为迟早分叉。
正确写法不需要任何新增代码,close 带上 reason 即可:
{ "type": "close", "reason": "user-dismiss" }上报出去就是 { "id": "...", "event": "close", "reason": "user-dismiss" },后端看到这个 reason
就永久压制这条物料。注意这个信息走 close 的 reason,不是点击详情 —— 带 trigger 的
点击 extra 只属于 click。
完整 DSL 语法
见调试台的「开发文档」页,或从那里导出 markdown / AI 提示词。
文档正文本身也在这个包里,走子路径引入——正文有几十 KB,业务侧只用运行时, 不该背这份体积,所以它不在主入口:
import {
sections,
meta,
examples,
toMarkdown,
buildAiPrompt,
} from '@yaoxiu/marketing-dsl/docs';
toMarkdown(sections, meta); // 导出 .md
buildAiPrompt(); // 生成喂给 AI 的提示词章节正文是数据(DocBlock 数组)不是字符串:调试台按块渲染成页面,toMarkdown 导出成 markdown,
同一份内容两边永远一致。节点类型 / 动作 / 样式白名单 / 埋点事件名正则等清单一律从解释器常量取,
并经 reconcileRows 对账——解释器加了东西而文档没跟上,会在文档页面上直接标出来。
上报契约(/report 子路径)
物料的曝光 / 点击 / 关闭要如实上报给后端。「运行时事件 → 上报载荷」这一步的映射是纯函数,
放在这个包的 /report 子路径里,前台(move-front)与后台调试台(moveback-front)共用同一份:
import {
mapRuntimeEventToReport,
buildExposureReport,
buildCloseReport,
pickInteractionTrack,
} from '@yaoxiu/marketing-dsl/report';
import type { MarketingReportParams } from '@yaoxiu/marketing-dsl/report';
// 点击:只认 interaction,且只认 trigger='action'
mapRuntimeEventToReport(materialId, 'interaction', {
trigger: 'action',
track: { event: 'banner_renew_click', params: { pos: 1 } },
navigate: { url: 'https://a.com/renew' },
});
// → { id, event: 'click',
// extra: { trigger: 'track', name: 'banner_renew_click', params: { pos: 1 },
// url: 'https://a.com/renew' } }
// 曝光 / 关闭由宿主主动发(解释器不知道自己被画出来了,也不该替宿主决定关不关)
buildExposureReport(materialId); // → { id, event: 'exposure' }
buildCloseReport(materialId, { reason: 'mask' }); // → { id, event: 'close', reason: 'mask' }
// 运营配在 stage.onShow / onClose 里的埋点名并进这两条,不另算点击
buildExposureReport(materialId, pickInteractionTrack(showInteraction));
// → { id, event: 'exposure', extra: { name: 'popup_show', params: {...} } }
// 动作粒度的事件一律不上报(按它们上报会重复计数)
mapRuntimeEventToReport(materialId, 'track', payload); // → null
mapRuntimeEventToReport(materialId, 'navigate', payload); // → null
mapRuntimeEventToReport(materialId, 'close', payload); // → null为什么上报只认 interaction
按动作粒度上报会放大点击量,实测一个弹窗的完整生命周期(1 曝光 + 1 点击 + 1 关闭)能报出 6 条:
| 重复来源 | 原因 |
| ----------------------------- | ----------------------------------- |
| stage.onShow 里的 track | 那是曝光埋点,被算成了点击 |
| sequence: [track, navigate] | 一次点击、两个动作,各报一条 → 翻倍 |
| stage.onClose 里的 track | 那是关闭埋点,也被算成了点击 |
所以口径改成:一次手势 = 一条 interaction = 至多一条上报。
| interaction.trigger | 上报 | 运营配的埋点名去哪 |
| ------------------- | ------------------- | -------------------------------------------- |
| action | 一条 click | extra.name(同时有跳转则并上 extra.url) |
| show | 不产生(返回 null) | 由宿主并进 exposure 的 extra.name |
| close | 不产生(返回 null) | 由宿主并进 close 的 extra.name |
action 里既没埋点也没跳转(比如纯 close / setState)时同样不产生上报。
上报载荷 MarketingReportParams 是可辨识联合:reason 只属于 close、
点击详情(带 trigger 的 extra)只属于 click,写错编译不过;exposure / close 的 extra
是另一种形态(只有 name / params),装的就是上表里那些埋点名。
不该出现的字段声明成 ?: never 而非省略,消费方仍可直接读,不必先窄化。
给谁用:接了后端上报的宿主(前台坑位组件),以及调试台的「上报预览」—— 预览要展示的就是真实会发给后端的 JSON,映射再写第二份,前台改了口径而调试台没跟上,预览就会骗人。
为什么不在主入口:主入口的身份是「DSL 解释器」,上报是可选的附加约定,
只有接了上报的宿主才需要。主入口不 re-export,边界才清楚,只用解释器的消费方也不背这份代码。
包根同样有一个物理 report/ 转发垫片,理由与 docs/ 一致(webpack 4 不认 exports)。
契约字段的完整说明(后端视角)见 move-front 仓库的 docs/marketing/README.md。
