一、背景
在电商与运营驱动的业务中,"触达"是连接用户与业务目标的关键环节——弹窗通知用户领券、Banner 推送活动信息、气泡引导用户操作……这些触达能力散布在系统的各个角落,支撑着促销转化、功能引导、活动推广等核心场景。
然而在大多数团队的实际工程中,触达逻辑并没有被作为一个独立的能力来管理,而是由各业务线在自己的代码中"就地实现":
- A 页面需要弹窗?研发同学在组件里写一段弹窗逻辑
- B 页面需要 Banner?研发同学在模板里硬编码一个 Banner 区域
- C 页面需要引导气泡?研发同学在页面生命周期里手动控制显示隐藏
触达逻辑与业务逻辑深度耦合,没有统一协议、没有调度能力、没有复用可能。随着业务规模扩大,这种"各自为政"的模式开始暴露出严重的效率和成本问题。
本文将分享我们从零构建ReachSDK(统一触达SDK)的实践过程,核心目标是:解耦业务逻辑与触达逻辑,通过标准化协议和调度内核,实现"一次接入,处处运行"。
二、痛点分析:触达能力散落在业务代码中
2.1 触达逻辑与业务逻辑深度耦合
这是最核心的痛点。在没有统一管控的情况下,每个触达需求都是一个"定制开发"过程:
<!-- 典型场景:运营需要在商品详情页加一个促销弹窗 -->
<template>
<div class="product-detail">
<!-- 业务代码 -->
<ProductInfo :data="product" />
<!-- 触达代码:硬编码在业务组件里 -->
<PromoPopup
v-if="showPromo"
:config="promoConfig"
@close="handlePromoClose"
/>
</div>
</template>
// 触达逻辑散落在业务生命周期中
export default {
data() {
return {
showPromo: false,
promoConfig: null // 写死在代码里,或者从某个接口单独获取
}
},
async mounted() {
const config = await fetchPromoConfig();
if (config && this.checkShowCondition(config)) {
this.showPromo = true;
this.promoConfig = config;
}
},
methods: {
hasShownToday() { /* 自己实现展示频控 */ },
handlePromoClose() { /* 自己处理关闭逻辑 */ }
}
}
这段代码暴露了什么问题?
- 运营改一次配置,开发就要改一次代码——弹窗内容、展示条件、页面位置全部写死
- 无法跨页面复用——换个页面加弹窗,整段逻辑要重写一遍
- 展示策略无法统一管控——每个页面各自判断"今天是否展示过",逻辑不一致
- 多触达点之间无法协调——同一页面如果同时有弹窗和 Banner,两个开发各写各的,互不知道对方存在
2.2 重复建设:每个团队都在"重新发明轮子"
调研各业务团队的触达实现,我们发现一个惊人的事实——几乎每个团队都在独立实现相似的基础能力:
| 基础能力 | A团队 | B团队 | C团队 | D团队 |
| 弹窗渲染 | Vue弹窗组件 | iframe 嵌入 | UMD 动态加载 | Web Component |
| 展示频控 | localStorage 手写 | cookie 判断 | 无 | 接口控制 |
| 路由感知 | watch $route | 无 | popstate 监听 | 手动调用 |
| 优先级队列 | 无 | 无 | 简单排序 | 无 |
| 请求管理 | 页面内单独请求 | 页面内单独请求 | 页面内单独请求 | 页面内单独请求 |
每个团队花1-2周实现一套自己的触达方案,而这些方案的核心逻辑高度相似——获取配置 → 判断条件 → 渲染触达内容 → 处理关闭。
更关键的是:这些硬编码在业务中的触达逻辑,每次需要变更时都需要发版上线。运营调整一次弹窗策略,就需要拉上开发排期→开发→测试→上线,整个周期通常需要 3-5 天。
2.3 多形态触达无法协同
当一个页面同时存在弹窗、Banner、引导气泡时,由于各自独立实现,完全没有编排能力:
- 弹窗叠弹窗:两个独立实现的弹窗可能同时弹出,相互遮挡
- Banner 和弹窗抢占:Banner 渲染在页面中,弹窗覆盖在页面上,没有先后顺序
- 无优先级概念:高优先级的新人引导被低优先级的促销弹窗抢先展示
- SPA 场景状态残留:路由切换后,旧页面的触达内容未清理,出现"幽灵弹窗"
2.4 性能开销不可控
由于没有统一管控,每个触达点都在业务代码中独立发起请求:
- 10个页面配了触达点,30个页面没有——但所有页面都可能在代码中嵌入请求逻辑,无效请求不可控
- 同一页面多个触达点,各自请求——没有合并和缓存的机制
- SPA 反复切换同一页面——每次都重新请求,没有缓存复用
这些性能问题不是某一个团队能解决的,因为根源在于缺乏一个统一的请求和缓存层。
三、方案定位:解耦、标准化、调度
面对上述痛点,我们的方案目标不是"优化某个弹窗",而是构建一套触达基础设施,从根本上改变触达能力的生产方式:
| 维度 | 现状(硬编码) | 目标(SDK 标准化) |
| 触达逻辑归属 | 散落在各业务代码中 | 统一收敛到 SDK |
| 触达内容管理 | 写死在代码里 | 服务端配置化 |
| 变更发布周期 | 开发排期→发版(2-3天) | 运营自助配置(分钟级) |
| 多形态协同 | 各自为战 | 统一编排调度 |
| 请求与缓存 | 各业务独立管理 | SDK 统一管控 |
| 业务接入成本 | 每次 1-2 周开发 | 一次接入,零代码运营 |
核心设计理念:一次接入,处处运行。
业务方只需在项目中初始化一次 SDK,后续所有触达需求(弹窗、Banner、挂件、事件触发)均通过服务端配置下发,无需业务代码变更。
四、架构设计

ReachSDK 采用四层架构设计,每一层各司其职:
+------------------------------------------------------+
| 业务接入层(一次接入) |
| Vue / React / 原生HTML / UMD CDN |
| 唯一入口: SDK.init({ siteId, clientType }) |
+------------------------------------------------------+
| 调度内核层(核心引擎) |
| ConfigMgr | QueueMgr | RouteMon |
| 配置管理器 | 双队列调度 | 路由监听器 |
| EventBus | EventTrig | UrlMatcher |
| 事件总线 | 事件触发器 | 路由匹配引擎 |
+------------------------------------------------------+
| 混合渲染引擎(策略模式) |
| Builtin | Iframe | Dynamic(UMD) | WebComponent |
| Custom(业务注入) | Banner | Widget | Callback(兼容) |
+------------------------------------------------------+
| 协议与数据层 |
| 接口一: /pageConfig -> 路由规则+触达点摘要(缓存30min)|
| 接口二: /infoShow -> 具体内容数据(按需请求) |
+------------------------------------------------------+
关键设计原则:
- 业务零感知:业务代码不包含任何触达逻辑,SDK 自动监听路由、自动匹配配置、自动渲染
- 协议驱动:触达点的类型、渲染方式、展示条件全部由服务端协议定义
- 渲染器可插拔:内置 5 种渲染类型,同时支持业务注入自定义渲染器
- 调度可编排:Banner 优先、弹窗排队、挂件并行、事件按需
五、核心方案详解
5.1 两阶段交互:从"每页硬编码请求"到"精准按需"

在传统硬编码模式下,每个页面的触达逻辑都在业务组件中独立发起请求,没有全局的请求管控。而 SDK 方案将所有请求收敛到一个统一的两阶段交互流程中:
第一阶段(SDK 初始化,仅一次):拉取全量"页面配置列表"——包含所有页面的路由匹配规则和触达点摘要(touchInfo),但不包含具体展示内容,数据量极小。
// ConfigManager.fetchPageList() -- 全量路由规则,一次拉取,30分钟缓存
async fetchPageList(): Promise<PageConfig[]> {
const cacheKey = `page_configs_${xxx}_${xxx}_${xxx}`;
// 优先命中缓存
const cached = this.storage.get<PageConfig[]>(cacheKey);
if (cached) return cached;
// 缓存未命中才请求服务端
const data = await this.request.postSafe<PageConfig[]>(
'xxx/touch/pageConfig',
{
// 请求入参
key: value
}
);
// 写入缓存,30分钟 TTL
this.storage.set(cacheKey, data, Date.now() + this.cacheExpire);
this.pageConfigs = data;
return data;
}
第二阶段(路由变化时,按需执行):SDK 自动监听路由变化,用本地缓存的路由规则在前端匹配当前页面。未命中 -> 零请求直接跳过;命中 -> 才请求具体内容数据。
// UrlMatcher:支持精确匹配、前缀匹配、正则匹配三种模式match(rule: PageConfig, pageInfo: PageInfo):boolean{
match(rule: PageConfig, pageInfo: PageInfo): boolean {
switch (rule.matchType) {
case MatchType.EXACT: return this.matchExact(rule.pageUrl, pageInfo);
case MatchType.PREFIX: return this.matchPrefix(rule.pageUrl, pageInfo);
case MatchType.REGEX: return this.matchRegex(rule.pageUrl, pageInfo);
}
}
对比传统硬编码模式的收益:
| 场景 | 硬编码模式 | SDK 两阶段模式 | 说明 |
| 用户访问无触达的页面 | 取决于业务代码是否请求 | 零请求 | 前端路由匹配即可过滤 |
| 用户访问有触达的页面 | 每个触达点独立请求 | 统一按需请求 | 请求合并管控 |
| 30分钟内重复访问 | 可能重复请求 | 命中缓存 | 配置列表自动缓存 |
| 运营修改触达配置 | 需要改代码+发版 | 配置即时生效 | 缓存过期后自动刷新 |
5.2 渐进式加载:AsyncGenerator 驱动的首帧加速

当一个页面匹配到多个触达点时,如果等所有数据加载完再展示第一个,用户会感受到明显的延迟。我们引入AsyncGenerator(异步生成器)实现渐进式加载——第一份数据就绪即展示,不等后续。
生产端——每加载完一个就 yield:
// ConfigManager.loadContentData() -- AsyncGenerator 模式
async *loadContentData(pageInfo, touchInfoList) {
for (let i = 0; i < touchInfoList.length; i++) {
const touchInfo = touchInfoList[i];
const contentData = await this.fetchContentData(
pageInfo, touchInfo
);
// 立即 yield,不等后续请求
yield { touchInfo, contentData, index: i, total: touchInfoList.length };
}
}
消费端——边消费边展示:
// ReachSDK.progressiveLoadAndShow()
private async progressiveLoadAndShow(pageInfo, touchInfoList) {
let isFirstPopup = true;
for await (const { touchInfo, contentData } of
this.configManager.loadContentData(pageInfo, touchInfoList)) {
// 动态添加到队列
this.queueManager.addToQueue(touchInfo, contentData);
// 首个数据就绪 -> 立即展示
if (isFirstPopup && !this.isShowing) {
isFirstPopup = false;
this.showNextPopup();
}
}
}
为什么选择 AsyncGenerator?
| 方案 | 首帧时间 | 额外依赖 | 背压控制 |
| Promise.all(全量等待) | 取决于最慢请求 | 无 | 无 |
| 逐个 await + callback | 首个请求耗时 | 无 | 需手动实现 |
| AsyncGenerator | 首个请求耗时 | 无(原生语法) | 内置 |
| RxJS Observable | 首个请求耗时 | 需引入 RxJS | 强 |
AsyncGenerator 的优势:零依赖、不增加包体积、语义清晰(yield = 数据就绪,for-await-of = 按需消费)、单个请求失败不影响后续。
5.3 分级触达编排:不同形态,不同策略

统一管控的核心挑战之一是:不同触达形态有本质不同的展示逻辑。弹窗要排队、Banner 要立即渲染、挂件要并行、事件触发要按需。如果用一种策略处理所有形态,必然顾此失彼。
我们的方案是按touchType分级编排,每种形态使用最适合自己的调度策略:
private async loadAndShowPopups(pageInfo: PageInfo): Promise<void> {
const { banners, popups } = this.configManager.extractTouchInfoByType(pageInfo);
// P0: Banner -- 并发加载 + 立即渲染(嵌入内容流,不能让用户等)
if (banners.length > 0) {
await this.loadAndShowBanners(pageInfo, banners);
}
// P1: 弹窗 -- 渐进式加载 + 权重排队展示(避免同时弹出多个)
if (popups.length > 0) {
this.progressiveLoadAndShow(pageInfo, popups);
}
// P2: 挂件 -- 逐个加载 + 多实例并行展示(不占据主视觉空间,支持hover/click/manual触发)
const widgetConfigs = pageConfigs.filter(pc => pc.touchType === 3);
for (const widgetConfig of widgetConfigs) {
await this.loadAndShowWidgets(pageInfo, widgetConfig.touchInfo, widgetConfig.touchType);
}
// P3: 事件触发型 -- 仅注册到事件池,零请求零渲染(等待用户行为)
const { event } = this.configManager.extractTouchInfo(pageInfo);
if (event.length > 0) {
this.eventTriggerManager.registerEventTouchInfo(event);
}
}
为什么 Banner 用并发、弹窗用渐进?
Banner 嵌入页面内容流,用户进入页面时应已可见——用Promise.all并发加载所有 Banner 数据,一次性渲染完毕,避免布局抖动。弹窗覆盖在页面上方,同一时间只展示一个——用 AsyncGenerator 渐进加载,第一个就绪即展示,后续动态入队。
事件触发的懒加载——真正的"按需":
// 页面加载时:仅注册事件,零网络请求
registerEventTouchInfo(touchInfoList) {
touchInfoList.forEach(touchInfo => {
this.eventPool.set(touchInfo.triggerFun, touchInfo);
this.ensureEventListener(touchInfo.triggerFun);
});
}
// 用户触发事件时:才发起请求并展示
private async onEventTriggered(eventName) {
const touchInfoList = this.eventPool.get(eventName);
for await (const { touchInfo, contentData } of
this.configManager.loadContentData(pageInfo, touchInfoList)) {
this.queueManager.addToQueue(touchInfo, contentData, 'event');
this.queueManager.switchToEventQueue();
this.showNextPopup();
}
}
5.4 混合渲染引擎:策略模式统一多种渲染方式
解耦业务与触达的关键环节是渲染——不同业务团队使用不同技术栈,SDK 必须支持多种渲染方式,同时保持统一的调度接口。
我们的方案是策略模式:所有渲染器实现统一的CustomRenderer接口,SDK 根据服务端配置的renderType自动分发:
// 统一接口——所有渲染器的契约
interface CustomRenderer {
render(config: PopupConfig, close: () => void, next: () => void): void;
close?(): void;
destroy?(): void;
}
// 5 种内置渲染类型
enum RenderType {
IFRAME = 1, // iframe 沙箱隔离
DYNAMIC = 2, // UMD 动态加载(远程组件)
CUSTOM = 3, // 业务自定义渲染器
BUILTIN = 4, // SDK 内置弹窗模板
WEBCOMPONENT = 5 // Web Component 原生组件
}
业务方接入自定义渲染器只需两步:
// 第一步:注册渲染器(项目初始化时)
sdk.registerRenderer('VuePromoPopup', {
render(config, close, next) {
const app = createApp(PromoPopup, {
data: config.content,
onClose: close, onNext: next
});
app.mount(container);
},
destroy() { app.unmount(); }
});
// 第二步:服务端配置指定使用该渲染器
// { "renderType": 3, "customRenderer": "VuePromoPopup", "content": {...} }
这种设计的好处是:渲染方式由服务端协议决定,而不是硬编码在业务代码中。同一个页面可以同时使用内置弹窗模板(零代码)和业务自定义渲染器(高度定制),由 SDK 统一调度。
渲染器的容错设计:
private getCustomRenderer(config): CustomRenderer {
const renderer = this.customRenderers.get(config.customRenderer);
if (!renderer) {
// 不崩溃,创建错误处理渲染器:记录日志 -> 跳过当前 -> 展示下一个
return this.createErrorRenderer(config.customRenderer, config.id);
}
return renderer;
}
此外,BannerRenderer 和 WidgetRenderer 作为独立的触达形态渲染器,分别处理 Banner(平铺/轮播)和挂件(相对元素定位的气泡组件)的渲染,它们直接由编排流程调用,不走弹窗的策略分发。
5.5 双队列调度:主动触发 + 事件触发
统一管控需要一个智能的调度机制,处理"同时有多个触达点需要展示"的问题。QueueManager 维护两个独立的队列:
class QueueManager {
private activeQueue: PopupQueueItem[] = []; // 主动触发队列:页面加载自动展示
private eventQueue: PopupQueueItem[] = []; // 事件触发队列:用户操作后展示
getNext(): PopupQueueItem | null {
// 事件队列优先——用户主动触发的意图应该被尊重
if (this.currentQueueType === 'event') {
const next = this.getNextFromQueue(this.eventQueue, this.eventIndex, 'event');
if (next) return next;
// 事件队列耗尽 -> 自动切回主动队列
this.currentQueueType = 'active';
}
return this.getNextFromQueue(this.activeQueue, this.activeIndex, 'active');
}
}
双队列的调度策略:
- 主动触发队列按权重排序,高优先级的弹窗先展示
- 事件触发时插队优先展示(用户明确表达了意图)
- 事件队列耗尽后自动恢复主动队列
- 弹窗关闭后延迟 300ms 展示下一个,避免视觉突兀
- 弹窗渲染异常时自动移除并展示下一个,保证队列不卡死
5.6 路由感知的生命周期管理
SPA 场景下,路由切换不等于页面刷新。SDK 通过劫持 History API + 监听原生事件,实现完整的路由感知,并在每次路由切换时执行五步清理,确保页面间状态完全隔离:
// RouteMonitor:全方位监听
start(callback) {
window.addEventListener('popstate', this.handleRouteChange);
window.addEventListener('hashchange', this.handleRouteChange);
// 劫持 pushState / replaceState,覆盖 Vue Router / React Router 等
history.pushState = (...args) => {
originalPushState.apply(history, args);
this.handleRouteChange();
};
}
// 路由切换时的五步清理
private onRouteChange = async (pageInfo: PageInfo) => {
this.queueManager.clear(); // 1. 清空弹窗队列
this.eventTriggerManager.clearEventListeners(); // 2. 清空事件监听
if (this.isShowing) this.popupRenderer.close(); // 3. 关闭当前弹窗
if (this.bannerRenderer) this.bannerRenderer.close(); // 4. 清理 Banner
await this.loadAndShowPopups(pageInfo); // 5. 加载新页面触达点
};
六、接入效果
6.1 业务接入成本
// 业务方的全部代码——仅此而已
import ReachSDK from 'reach-sdk';
await ReachSDK.init({
siteId: 0,
clientType: 1,
apiUrl: 'https://your-api.com',
});
// 之后的一切由 SDK + 服务端配置驱动
// 无需在业务组件中编写任何触达逻辑
| 对比维度 | 之前(硬编码) | 之后(SDK 接入) |
| 新增一个触达点 | 开发 2-3 天 + 测试 + 发版 | 运营配置,分钟级生效 |
| 修改触达内容 | 改代码 + 发版 | 修改配置,实时生效 |
| 新增一个页面 | 拷贝触达逻辑 + 适配 | 添加路由规则即可 |
| 多形态协同 | 无法实现 | SDK 自动编排 |
6.2 性能收益
| 指标 | 硬编码模式 | SDK 模式 | 说明 |
| 无触达页面请求 | 不可控(取决于业务代码) | 零请求 | 路由不匹配直接跳过 |
| 多弹窗首帧时间 | 取决于实现方式 | 首个请求返回即展示 | AsyncGenerator 渐进加载 |
| 重复访问请求 | 大概率重复请求 | 30min 缓存命中 | 统一缓存层管控 |
| 事件触发型 | 页面加载时可能预请求 | 事件触发时才请求 | 真正的按需加载 |
6.3 SDK 体积
| 指标 | 数据 |
| 核心代码(gzip) | < 20KB |
| 外部运行时依赖 | 零 |
| Tree-shaking | 支持(未使用的渲染器不打包) |
七、总结
ReachSDK 的核心价值不在于"优化了某个弹窗的性能",而在于将触达能力从业务代码中解耦出来,形成一套标准化的触达基础设施。
通过两阶段交互实现请求的统一管控与按需分发;通过混合渲染引擎兼容多种技术栈的渲染需求;通过双队列调度和分级编排协调多种触达形态的展示时序。最终实现:
- 业务侧:一次接入 SDK,此后所有触达需求通过配置驱动,无需改代码、无需发版
- 运营侧:触达内容和策略可实时配置调整,分钟级生效
- 性能侧:统一的请求管控、缓存复用、渐进式加载,相比硬编码模式显著降低无效请求和首帧延迟
- 架构侧:触达逻辑与业务逻辑彻底解耦,降低维护成本,杜绝重复建设,方便接入算法以实现基于数据的全周期运营设计
好的 SDK 不是功能堆砌,而是在正确的时机,以最小的成本,做最精准的事情。






