@yaoxiu/marketing-dsl
v2.11.1
Published
营销弹窗 DSL 解释器核心,纯逻辑无框架依赖
Readme
@yaoxiu/marketing-dsl
营销弹窗 DSL 的解释器核心。运营用一段 JSON 描述弹窗 / Banner / 公告条的排版和交互,这个包负责把它解释成可渲染的数据。
纯逻辑,不依赖任何框架,也不碰 DOM。 换 Vue3 / React 时这个包一行都不用改。
为什么要有这个包
运营发活动时弹窗形态千奇百怪,最初的方案是让他们写 JS。这条路走不通——配置一旦变成可执行代码,就等于把 XSS 入口开在了自己家里。
所以改成 DSL:排版是数据,不是逻辑。表达式求值器是手写的词法 + 递归下降,绝不使用 eval / new Function;样式、链接协议、数据源名、可调用方法全部走白名单。
安装
pnpm add @yaoxiu/marketing-dsl渲染还需要渲染层:原生 DOM(如 Chrome 插件、Shadow DOM 里)用 dom 包,Vue 2 项目用 vue2 壳(它内部包装 dom 包, 「渲染树 → DOM」只有 dom 包这一份实现):
pnpm add @yaoxiu/marketing-dsl-dom # 原生 DOM:mountDsl(container, options)
pnpm add @yaoxiu/marketing-dsl-vue2 # Vue 2 组件 <dsl-renderer>两种用法
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。
宿主接管跳转(navigate)
不传时 core 自己跳:_self 改 location.href,_blank 走 window.open(url, '_blank', 'noopener,noreferrer')。
传了就只调它,core 不再跳(否则跳两次):
createRuntime(dsl, {
// 插件:一律开新标签页,不让用户被跳离平台后台
navigate: (url, target) => window.open(url, '_blank', 'noopener,noreferrer'),
});- 拿到的
url已过 core 的协议白名单(javascript:之类到不了宿主) target是配置里的值,缺省为_blank- 编辑态(
editMode)下不调;navigate/interaction事件照常派发 - 只在创建时传,不在
update的可改范围里
渲染树
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', // 根容器类名,后半截是这份物料的作用域前缀
onRootEnter: fn, // 可选:只有配了 pauseOnHover 的自动轮播才有,见下
onRootLeave: fn,
}框架壳照着 layer 的结构套四层 div 即可,不需要自带任何 CSS,基础样式已经写进行内样式了。
弹窗方位(stage.placement / stage.offset)
placement 默认 center,可选 top-left / top-right / bottom-left / bottom-right
(常量 PLACEMENTS),只对 popup 生效;offset 是角落浮层距两条边的 [水平, 垂直](px,
默认 DEFAULT_PLACEMENT_OFFSET = [24, 24]),center 时忽略。
center / 缺省时渲染树与引入方位之前逐字一致(placement.test.ts 用整层快照钉死)。
角落浮层的四层结构不变、壳不需要任何分支,只是样式不同(以 bottom-right、默认 offset、栈顶层为例):
{
mask: false, // 恒 false:不画遮罩
onMaskClick: undefined,
layerStyle: { position: 'fixed', top: '0', right: '0', bottom: '0', left: '0',
zIndex: '1000', pointerEvents: 'none' }, // 透明定位壳,点击穿透到宿主页面
scrollStyle: {}, // 不要铺满视口的滚动层
stageStyle: { position: 'fixed', bottom: '24px', right: '24px', pointerEvents: 'auto',
maxHeight: 'calc(100vh - 48px)', width: '320px', ... }, // 没有居中用的 margin:auto / flex:none
clipStyle: { ..., maxHeight: 'inherit', overflowX: 'hidden', overflowY: 'auto' },
}为什么这样:角落浮层挂在宿主页面上(插件注入的第三方平台后台),用户还要继续操作那个页面。
铺满视口的遮罩 / 滚动层哪怕是透明的也会吃掉整页点击,所以外层整层 pointer-events: none,
只有舞台自己打开。非栈顶的角落层舞台不打开 pointerEvents(与居中弹窗「下层点不动」一致)。
定位四项与 pointerEvents 在 stage.style 之后写入,配置盖不掉,挪位置用 offset。
没有滚动层,所以舞台封顶为「视口高 − 2 × 纵向偏移」,比视口高时由裁剪层内部滚动(stage.style.maxHeight 显式写了以配置为准);
否则 668px 的弹窗在 768 高的笔记本上顶部会被裁掉且滚不到。
实现在 src/placement.ts,resolve.ts 只在 resolveLayer / resolveStageStyle 里分流。
校验:placement 非法值、offset 不是两个有限数字报 error;角落 + mask: true 报 error
(角落浮层不支持遮罩);角落 + maskClosable / maskAction 给 warning(不生效);
center 写 offset、banner / notice 写方位给 warning(忽略)。
动画与 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 的祖先。
自动轮播(derived.xxx.autoplay)
"state": { "slide": 0 },
"derived": { "cur": { "list": "slides", "indexBy": "slide",
"autoplay": { "interval": 4000, "pauseOnHover": true } } }配了就开启、始终循环(没有 enabled / loop):每 interval 毫秒把 state[indexBy] 推到
(当前 + 1) % 列表长度。定时逻辑在 src/autoplay.ts,runtime.ts 只接线。
- 与点击同一条路径:到点调的就是运行时的
setState,state-change照常派发,文案 / 图片 / tabs 指示器 / 链接 / 运营配的transition与手动点击效果完全一致,不引入额外切换动画 - 任何
setState改了同一个 key(用户点 tab)都会重新计时 - 列表长度 ≤1、数据源未就绪或失败时不播;数据源回来后开始;
editMode下照样播 destroy()清掉全部轮播定时器,之后不会再setStatepauseOnHover需要壳配合:渲染树此时带onRootEnter/onRootLeave, 壳在根容器上绑mouseenter/mouseleave原样调用即可(有才绑、不拦冒泡、不加判断)
校验:autoplay 须是对象;interval 须是 ≥300 的数字(单位毫秒,否则 error);
pauseOnHover 须是布尔;其余字段只告警(运行时忽略)。字段表导出为 AUTOPLAY_FIELDS。
运行时事件(emit)
| 事件 | 何时抛 | 宿主拿它干嘛 |
| -------------- | -------------------------------------------- | ----------------------------------------------------- |
| ready | 数据源全部返回 | 知道可以画了 |
| interaction | 一次手势 / 一个时机一条,见下 | 上报(曝光 / 点击 / 关闭只认它) |
| track | 每执行一个 track 动作 | 接自有埋点系统 |
| navigate | 每执行一个 navigate 动作 | 调试 / 记录用;接管跳转请传 RuntimeOptions.navigate |
| 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,
pluginExamples,
toMarkdown,
buildAiPrompt,
} from '@yaoxiu/marketing-dsl/docs';
toMarkdown(sections, meta); // 导出 .md
buildAiPrompt(); // 生成喂给 AI 的提示词examples 是运营和 AI 的抄写模板,后台配置物料的下拉里能一键载入。
pluginExamples 是搬家助手插件在第三方页面(抖店服务市场 / 抖店后台 / 1688)各坑位的示例,
照《插件续费弹窗 · 多平台》原型做:进页右下角会员续费弹窗、店铺胶囊(点开会员弹窗)、
功能横条(标签点开可切版本 / 周期的功能弹窗)、右侧吸边入口(点开会员弹窗)。它们绑死了插件坑位的尺寸,
所以单独导出、不进 examples,后台调试台和投放编辑器的下拉里各自单列一组「插件页面示例」。
两份示例都由 examples.test.ts 校验零 error。
章节正文是数据(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。
宿主纯逻辑(/host 子路径)
坑位分组、随机投放、上报暂存合并、「不再提示」判定这些逻辑与框架无关,前台(ui-vue2 的
components-business/marketing/)与 Chrome 插件(原生 DOM 渲染)都要用,只保留这一份:
import {
sortMaterialsByPriority,
groupMaterials,
pickPopupsByStrategy,
resolveDslViewType,
createMaterialReporter,
toLocalDateKey,
resolveDismissKey,
pruneDismissRecord,
isDismissed,
markDismissed,
filterDismissed,
DISMISS_FOREVER,
} from '@yaoxiu/marketing-dsl/host';
import type {
HostMaterial,
HostDeliveryStrategy,
GroupedMaterials,
GroupMaterialsOptions,
MaterialReporter,
MaterialReportSender,
DismissRecord,
DismissScope,
} from '@yaoxiu/marketing-dsl/host';
// 物料处理:排序 → 剔除「不再提示」→ 分坑位(banner / 公告各最多一条,RANDOM 组只抽一条)
const visible = filterDismissed(sortMaterialsByPriority(list), record, shopId);
const { banners, notices, popups } = groupMaterials(visible, {
onDiscard: m => console.warn('视图类型无法判定', m.id), // core 不打日志
});
// 上报:发送方式由宿主注入,暂存 onShow / onClose 埋点名并进曝光 / 关闭
const reporter = createMaterialReporter(params => api.report(params));
createRuntime(material.dsl, {
emit: (event, payload) => {
if (event === 'interaction') reporter.onInteraction(material.id, payload);
if (event === 'ready') reporter.reportExposure(material.id); // 画出来才算曝光
if (event === 'close') reporter.reportClose(material.id, payload.reason);
},
});
// 坑位换一批物料 / 卸载时
reporter.reset();
// 不再提示:只有纯逻辑,存储由宿主读写(前台 localStorage、插件 chrome.storage)
const next = markDismissed(record, material, 'today', shopId); // 物料没标识时返回 null- 不依赖
platform-adapters:物料只要求最小结构HostMaterial(id/dsl/campaignNo?/priority?/deliveryStrategy?),前台的MarketingMaterial结构兼容;函数都是泛型,返回调用方自己的类型。 - 不碰存储、日志、网络:这些都由宿主注入(
onDiscard、send、自己读写DismissRecord)。 - 「不再提示」的日期按本地时区(
toLocalDateKey),不要换成toISOString()(UTC,东八区晚上就跨天)。 - 为什么不在主入口:「坑位」「投放」是宿主概念,解释器不该知道;只用解释器的消费方(后台投放编辑器)不背这份代码。
包根同样有物理
host/垫片(webpack 4 不认exports),理由与docs//report/一致。
