每个 Web 开发人员都体会过这种感觉:眼睁睁看着自己的应用在受控的测试环境中完美运行。而发布一个嵌入式组件则会彻底打破这种舒适感。你不再是页面的架构师。你成了一个不请自来的客人,将一个 React 应用注入到一个你不拥有的 DOM、一个并非由你编写的 CSS 层叠上下文,以及一个可能与你作对的运行时环境中。在构建和发布 Clanker Support 组件的过程中,我们发现,一旦你的代码在别人的主题中运行,标准的 Web 开发假设就会土崩瓦解。宿主网站可能会重置字体大小、隐藏空的 div,或者强制执行一种会在你读取配置之前就使其失效的脚本生命周期。以下是我们用“生产环境的血泪教训”总结出的防御规则。
One File, One Failure Mode
现代打包工具会用代码分割和动态导入来诱惑你。请抵制它们。嵌入式组件必须以单个文件的立即调用函数表达式 (IIFE) 形式发布。当客户将你的 script 标签复制到他们的模板中时,他们期望的是一次网络请求。如果你的 bundle 尝试延迟加载一个沉重的解析库或语言模型分片,fetch 请求可能会静默失败。宿主可能拥有严格的内容安全策略 (CSP)、激进的广告拦截器,或者一个与你的 publicPath 假设不匹配的 CDN 路径。通过将所有内容强制封装进一个 IIFE,你消除了二次 chunk 加载的不确定性。如果某个依赖项坚持要延迟加载其内部组件,请在构建时将其别名 (alias) 为一个轻量级的存根 (stub)。结果就是一个单一的产物、单一的故障模式,当客户网站管理员给你发来一张聊天气泡损坏的截图时,调试起来也会容易得多。
The Shadow DOM Leaks Too
开发者通常将 Shadow DOM 视为一座坚不可摧的堡垒。它确实将你的选择器与宿主页面的 CSS 隔离开来,但它无法隔离继承。像 font-family、line-height、color 和 text-align 这样的属性会向下流向你的 shadow tree,仿佛边界并不存在一样。一个声明了全局 font-family: "Comic Sans MS" 的 Shopify 店铺会“感染”你精心设计的支持组件,除非你显式地在根元素上固定每一个可继承的属性。直接在宿主层级使用具体数值设置你自己的排版、间距和文本对齐方式。假设父页面是“敌对”的,并重置所有你在意的属性。Shadow DOM 保护的是你的 class,而不是你的美感。
The Empty Div Vanishing Act
这一点让我们措手不及。许多流行的主题(包括 Shopify Dawn)都带有一个看起来无害的 CSS 规则:div:empty { display: none; }。当你的组件挂载时,它通常会指向一个初始为空的宿主 div。在你的 JavaScript 执行并由 React 对节点进行注水 (hydrate) 之前,那个 div 字面上是空的。主题的样式表将其隐藏了。你的脚本运行了,调用了 ReactDOM.createRoot,但什么也没出现。控制台中没有任何错误。该元素只是在布局中“消失”了。解决方法是暴力且显式的:为你的挂载点应用内联样式 display: block !important。不要指望你的 CSS-in-JS 库稍后能处理这个问题。等到你的样式表生效时,宿主主题早已胜券在握。
Abandon rem for px
在正常的应用程序中,像 rem 这样的相对单位是负责任的选择。但在嵌入式场景中,它们是隐患。rem 值是相对于宿主文档的根 html 字体大小进行解析的,而不是相对于你的组件。如果宿主页面设置了 html { font-size: 10px; } 或使用了旧的 62.5% 技巧,你的整个排版和间距比例就会在毫无预警的情况下发生偏移。一个舒适的 1.6rem 行高可能会塌陷到 16px,或者你的内边距可能会缩小到难以辨认的细缝。由于你无法预测或控制宿主的根尺寸,像素 (px) 是嵌入式组件唯一诚实的单位。无论周围页面的假设如何,它们都能以相同的物理尺寸进行渲染。当你生活在另一个网站的层叠样式中时,请放弃 rem 在理论上的无障碍灵活性,转而选择 px 在实践中的可靠性。
Read Your Config Before It Disappears
如果你通过 script 标签上的 data 属性向 widget 传递配置,你必须同步读取它们。浏览器提供了 document.currentScript,以便脚本可以检查自身的标签,但这个引用是瞬时的。如果你等待 DOMContentLoaded 或任何异步边界,document.currentScript 会变为 null。你的配置就会随之消失。请在脚本执行的最顶层立即读取这些属性。就在那时捕获 API key、widget ID 和颜色主题,将它们存储在闭包或模块变量中,然后再继续启动 React。
让脚本 URL 决定 API Origin
将生产环境的 API URL 硬编码到你的 bundle 中是一个错误,这种错误会在不同环境中成倍放大。相反,你应该从 script 元素的 src 属性中推导出你的 API origin。如果 widget 从 https://cdn.staging.example.com/widget.js 加载,其 API 调用应默认为 https://api.staging.example.com。如果开发者将 script 标签放入由 localhost:3000 提供服务的本地 HTML 文件中,本地构建版本应将请求路由到本地服务器。这种惯例消除了对特定环境构建、功能开关(feature flags)或嵌入用户进行手动配置的需求。它能直接运行,因为基础设施的位置是由交付位置隐含的。
将缓存头(Cache Headers)视为热修复的生命线
用户只需将你的 script 标签复制到他们的 footer 模板中一次,然后就会将其忘之脑后。你无法给五千个商家发邮件,要求他们去更新版本查询参数。这意味着你的缓存头也是你应急响应策略的一部分。为你的 widget bundle 设置较短的 max-age,这样当你发布关键修复时,它能在几小时内传播,而不是几周。长效缓存资产带来的便利,并不值得让你在面对成千上万个网站都在运行你无法撤回的损坏版本时感到束手无策。接受 CDN 的流量成本吧。你的理智全靠它了。
为 iframe 嵌入反转你的 CSP
如果你提供基于 iframe 的嵌入选项,你的内容安全策略(CSP)需要从标准的 Web 应用思维中进行反转。通常你可能会禁止 framing 以防止点击劫持(clickjacking)。但对于 widget,你必须允许它。设置 frame-ancestors * 以允许任何网站托管你的 iframe。然后,对其他所有内容都要变得严苛。在该 iframe 策略内部,严格限制 script-src、style-src 和 connect-src。你正通过 framing 向量刻意地向整个 Web 开放,因此你必须确保,如果宿主页面试图对其进行操纵,运行在 iframe 内部的代码也没有任何作乱的空间。
访客心态
构建嵌入式组件(embeds)所需的姿态与构建标准 Web 应用截然不同。在你自己的应用中,你拥有容器、路由、构建流水线和全局样式。但在嵌入式组件中,你一无所有。宿主页面是随机的、往往是陈旧的、偶尔是充满敌意的,并且始终在你的控制之外。每一个假设都必须是防御性的。明确说明你的意图,积极地验证环境,并针对你无法预见的损坏进行设计。Clanker Support widget 如今之所以能正常工作,并不是因为 Web 是可预测的,而是因为我们不再信任它的可预测性。
