@yaoxiu/marketing-dsl-dom
v1.0.0
Published
营销弹窗 DSL 的原生 DOM 渲染层,只负责把渲染树画成 DOM,零框架依赖
Readme
@yaoxiu/marketing-dsl-dom
营销弹窗 DSL 的原生 DOM 渲染层,零框架依赖。
整个包只做一件事:把 @yaoxiu/marketing-dsl(下称 core)算出来的渲染树画成 DOM。 它是「渲染树 → DOM」的唯一实现:
- Chrome 插件(第三方平台后台,原生 DOM + Shadow DOM)直接用它;
- Vue 2 壳 @yaoxiu/marketing-dsl-vue2 只是把它包成组件。
DSL 加新能力时只改 core(解释)和本包(映射,如果渲染树结构变了)两处,各框架壳不用动。
与 core 的分工
| 谁 | 做什么 |
| ---- | -------------------------------------------------------------------------------------------------------- |
| core | 全部解释逻辑:插值、条件、循环、样式白名单、层级与方位、动作派发、倒计时 / 轮播定时器、CSS 文本生成 |
| 本包 | 照着渲染树建元素、改 class / style / attrs / 文本、挂点击、按 key 复用元素、把 tree.css 落成 <style> |
本包不含任何解释逻辑。往里加 if 判断之前先想想是不是该加在 core;
若发现 core 缺能力、本包不得不自己「解释」配置,应先补 core。
安装
pnpm add @yaoxiu/marketing-dsl @yaoxiu/marketing-dsl-domcore 是 peer 依赖,必须由宿主自己装(理由同 marketing-dsl-vue2:宿主自己也会直接用 core 做校验, 必须共用同一个 core 实例,否则「后台预览正常、线上渲染不对」)。
API
function mountDsl(
container: Element | ShadowRoot,
options: {
dsl: Dsl; // 必填。配置 JSON
user?: Record<string, unknown>; // 宿主注入的用户上下文,配置里用 {{ user.xxx }} 读
sources?: Record<string, DslSource>; // 数据源白名单
handlers?: Record<string, DslHandler>; // call 动作的方法白名单
editMode?: boolean; // 编辑态:动作只上报不真正执行
navigate?: (url: string, target: '_self' | '_blank') => void; // 宿主接管跳转
onEvent?: (name: RuntimeEventName, payload: RuntimeEvents[name]) => void; // 全部运行时事件
},
): {
runtime: DslRuntime; // 底层 runtime,调试台读状态用
update(patch: { user?; handlers?; editMode? }): void; // 就地更新,不重建、不重放曝光
destroy(): void; // 卸载,可重复调用
};除 dsl / onEvent 外的选项原样交给 core 的 createRuntime,语义见 core README
(navigate 见「宿主接管跳转」,事件见「运行时事件」)。
- 挂载点里只多出一个根元素(
div.dsl-renderer dsl-rN),挂载点原有内容不动。 - 数据源没就绪 / 加载失败时什么都不画(挂载点里没有根元素),曝光要等
ready事件再报。 - 关闭由宿主决定:
close事件只是通知,要不要移除、记不记「不再提醒」都是宿主的事,移除就调destroy()。 - 换配置要
destroy()后重新mountDsl(重新拉数据源、重放曝光);user/handlers/editMode用update。 sources/navigate只在挂载时生效。
在 Shadow DOM 里使用(插件)
import { mountDsl } from '@yaoxiu/marketing-dsl-dom';
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const handle = mountDsl(shadow, {
dsl: material.dsl,
user,
sources,
// 插件:一律开新标签页,不让用户被跳离平台后台
navigate: url => window.open(url, '_blank', 'noopener,noreferrer'),
onEvent: (name, payload) => {
if (name === 'ready') reporter.reportExposure(material.id);
if (name === 'interaction') reporter.onInteraction(material.id, payload);
if (name === 'close') {
reporter.reportClose(material.id, (payload as { reason: string }).reason);
handle.destroy();
host.remove();
}
},
});
// 用户信息后到
handle.update({ user: nextUser });挂载点传 ShadowRoot 本身或 shadow 里的任意元素都行。
CSS 写到哪
tree.css(keyframes + hover 规则 + reduced-motion 兜底)非空时,本包建一个
<style data-dsl-renderer>,写到挂载点所在的根:
- 挂载点在 ShadowRoot 里 → 写进那个 ShadowRoot(宿主页面看不到、也影响不到);
- 否则 → 写进
document.head。
每个实例一个 <style>,规则都挂在各自的作用域类名下,同页多份物料互不覆盖;
tree.css 变空或 destroy() 时移除。判断 ShadowRoot 不用 instanceof——插件内容脚本与页面主环境是两套全局对象。
<style> 在第一次有 CSS 时按挂载点当时所在的根决定位置,挂载后再把挂载点挪进 / 挪出 Shadow DOM 不会跟着搬。
其余样式全部行内,不需要引任何样式文件。
渲染契约(与 core 类型注释一致)
结构照 RenderLayer 的注释套四层:
div.dsl-renderer(rootClassName / rootStyle,有 onRootEnter/Leave 才绑 mouseenter/mouseleave)
div.dsl-layer(每个视图一层,key = 视图名)
div.dsl-mask ← layer.mask 为 true 时才有,点击调 onMaskClick
div.dsl-scroll ← 点它自身的空白处等同点遮罩,弹窗内部冒泡上来的不算
div.dsl-stage
div.dsl-stage-clip > 节点…
关闭按钮(有配才有)- 节点有
onClick时挂 click 并stopPropagation;没有的不绑任何事件,正常冒泡。 attrs一律setAttribute;svg/line及其子孙用createElementNS(SVG 命名空间)。img写src和空alt。- 角落方位(
stage.placement)只是样式不同,本包没有任何方位分支。
更新策略
core 每次通知(状态变化、倒计时每秒、轮播到点、update)后,在同一个微任务里合并成一次渲染:
- 按 key(同级唯一)+ 标签复用元素,class / style / attrs / 文本逐项比对,只改变了的;
- 监听器每个事件只挂一个,回调每轮换成最新的;
- 不会
innerHTML整树重建——那样会让入场动画重播、hover 态丢失,倒计时每秒闪一次。
弹出二次挽留层时下层的遮罩 / 节点是同一个 DOM 对象(只是遮罩变透明),关掉后也还是它,入场动画不会重播。
测试
pnpm --filter @yaoxiu/marketing-dsl-dom test runjsdom 环境。patch.test.ts 直接测打补丁器,其余三个文件经 core 端到端测映射、交互和生命周期。
