宿主页面在运行时加载一个自己不参与构建的 UI 叠加层,常见做法是一段 <script src>:bundle 拉进来,自己找个容器渲染,宿主只负责给它一个位置和一份 token。社交分享按钮、地图嵌入、客服浮窗都是这个路子。micro frontend 里组合可以发生在三处:网关按路径把流量分给各自的 SPA、宿主和远端模块共享同一批依赖(Module Federation 走的是这条,共享项在构建期声明、装载时才协商版本)、运行时直接往页面里插一段 bundle。本篇是第三种。前两种在这里都用不上,因为宿主不参与 bundle 的构建,而且 N 个叠加层要同时活在同一个页面上。

要定的就是名字:入口叫什么,这个名字归谁定。

讨论有几个前提,下面每一节的结论都只在这个范围内成立:

  • 宿主和叠加层是两套代码,两个仓库,各自发版。宿主不参与 bundle 的构建,运行时只做一件事:用 <script src> 把它拉进来。
  • bundle 跑在宿主页面的同一个 JS 上下文里,不是 iframe,它摸到的 window 就是宿主自己的。选同上下文是为了让叠加层直接操作宿主 DOM,省掉 postMessage 那层协议,代价是 token 和宿主的 JS 待在一起,隔离强度比 iframe 低,第三方嵌入选 iframe 通常就是为了这层隔离。本篇所有冲突都从这个前提来;它跑在什么浏览器上,也由宿主说了算。
  • 支持范围按「两年前发布的版本」算下限:Chrome / Edge 129(2024-09-17)、Safari 18(2024-09-16)、Firefox 130(2024-09-03),iOS 跟着 Safari 18。下面每处「能不能用」都按这几个版本判,不按最新版判。构建产物的语法目标比它们还宽松:Vite 8 的 build.target 默认值 'baseline-widely-available' 在这个 major 固定成 ['chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4'],默认不动就够,自己改过的要回去对一遍。
  • 叠加层的 DOM 和样式渲染在 shadow root 里。
  • 叠加层的定位和层级不在本篇范围。宿主祖先上的 transformfiltercontain 会换掉 position: fixed 的包含块,z-index 也要跟宿主争,这一类问题交给 top layer,但按上面那条下限得挑着用:dialog.showModal() 是稳的(Safari 15.4、Firefox 98 就有),popover 属性在桌面上从 Chrome 114、Safari 17 起有,iOS 要到 Safari 18.3(2025-01)才补齐,支持范围里的 iOS 18.0 到 18.2 还得留退路。这跟下面讲的名字冲突是两件事。
  • 要挂哪些叠加层,由一个接口下发(每项给 url 和 token),宿主事先不知道清单。
  • 需求是同一页面上同时挂 N 个叠加层。下面三处单例问题都由这条触发。
  • 叠加层的静态资源由我们自己发,缓存策略可以自己定。

现状长这样,bundle 侧的入口挂在 window 上:

window.Overlay = { mount, unmount }

宿主那边对应地写:

script.onload = function () {
  const container = document.createElement('div')
  container.id = 'overlay'
  document.body.appendChild(container)
  window.Overlay.mount(container, { token: data.overlayToken, cssUrl: base + 'overlay.css' })
}

只有一个叠加层时这套写法是够用的。要加第二个的时候,不够用的地方不止 window 这一处。文中的实测输出都是在 Chrome 152(macOS)上跑 probe 页面打出来的,代码片段贴进 HTML 文件就能复现。

window 上的挂载点只能有一个#

两个 bundle 先后加载,各自给 window.Overlay 赋值:

window.Overlay = { mount: () => 'A' }   // 先加载的
window.Overlay = { mount: () => 'B' }   // 后加载的
console.log('[probe] 1 window.Overlay.mount() =>', window.Overlay.mount())
[probe] 1 window.Overlay.mount() => B

后赋值的赢,先加载的那个没有任何提示。宿主拿到的是最后一次赋值的结果,第一个叠加层的 unmount 也跟着没了,它渲染在页面上,但已经没有句柄可以卸载它。

这一处容易看见。同一个假设在另外两处也写着,只是不在 JS 里:

位置 单例写法 换成 custom element 之后
bundle 的入口 window.Overlay 一个 tag name
宿主建的容器 container.id = 'overlay' document.createElement(tag),要几个建几个
接口下发的字段 overlayUrl / overlayToken 一个数组,每项带自己的 tag 和 token

三处是同一个假设的三份副本,只改其中一处不解决问题。

命名空间、dynamic import 和 custom element 三条路#

三处单例都来自同一件事:名字定在 JS 全局对象上。把名字挪走有三条路。

最省事的一条是给全局变量加命名空间,bundle 侧改一行:

(window.AcmeOverlays ||= {}).chat = { mount, unmount }

冲突从 Overlay 挪到了 AcmeOverlays.chat,挪走的只是概率:两个叠加层的 key 撞了仍然是后赋值的赢,仍然没有任何提示。上面表格后两行一处都没解开,容器、卸载句柄和反向回调还是宿主自己记账。

dynamic import() 连全局名字都不要:

const mod = await import(spec.url)
mod.mount(container, { token: spec.token })

每个 URL 一个 module namespace,注册表这件事从根上没有了,版本也能直接 export const contractVersion = 2。它输在另一头:MDN 对 module script 的说法是「Unlike classic scripts, module scripts require the use of the CORS protocol for cross-origin fetching」,跨域取 bundle 要服务端配 Access-Control-Allow-Origin<script src> 不用;生命周期、容器、卸载、事件也仍然是一套自定义约定,和现状比只换掉了入口那一处。

第三条是 custom element,把名字挪进 DOM,下一节展开。三条路按前面那三处对一遍:

全局加命名空间 dynamic import() custom element
入口重名 仍靠约定,撞了无提示 没有全局名字 重复 define 当场抛错
容器与卸载 宿主自己记账 宿主自己记账 元素本身就是句柄
参数传递 任意 JS 值 任意 JS 值 attribute 只收字符串
版本声明 自己定 export const 自己定
跨域取 bundle <script src> 即可 Access-Control-Allow-Origin <script src> 即可

最后落在 custom element,理由是头两行:重名当场抛错,容器和生命周期交给平台之后「每实例一份」由 DOM 语义保证,不用宿主再守这条纪律。代价是参数从对象降级成字符串,加上后面三节各自要补的约定。

这些代价里只有参数降级是 custom element 独有的;前缀和版本声明,命名空间那条路一样要自己定,dynamic import() 两样都省了(没有全局名字,版本有 export const)。@property@keyframes 的拆法跟着 shadow root 那条前提走,三条路都要付。

把挂载点从 window 换成一个 tag name#

custom element 的注册键是 tag name,它住在 CustomElementRegistry 里。同一个名字注册两次的行为和 window 那边相反:

customElements.define('probe-widget', class extends HTMLElement {})
customElements.define('probe-widget', class extends HTMLElement {})
[probe] 2 second define threw: NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name "probe-widget" has already been used with this registry

它出声:window.Overlay 被覆盖时什么都不报,重复 define 当场抛出。抛错不会让页面停掉,未捕获的异常只中断抛出它的那段脚本,但 bundle 在 define 之后的顶层代码都不再执行,控制台里还多一条报错。所以 bundle 侧要自己挡一道,同一个 bundle 被加载两次(宿主重复注入、或两个叠加层依赖同一个内部元素)时跳过 define,直接用已经注册的那份:

const TAG = 'acme-overlay-chat'
if (!customElements.get(TAG)) customElements.define(TAG, ChatOverlay)

WICG 提案提醒这个写法挡得住报错,判断不了版本:

To solve the problem of double-defining compatible versions of the same tag name, one could use a registration pattern with error handling… However, there is no way to determine if the definitions are compatible.

换过来之后,上面表格里那三处各自有了对应的东西。mount / unmountconnectedCallback / disconnectedCallback 接管,options 对象换成元素上的 attribute,宿主那段装配代码从一次调用变成一个循环:

specs.forEach(function (spec) {
  const script = document.createElement('script')
  script.src = spec.url + 'overlay.js'
  script.onload = function () {
    const el = document.createElement(spec.tag)
    el.setAttribute('token', spec.token)
    document.body.appendChild(el)
  }
  document.head.appendChild(script)
})

connectedCallback / disconnectedCallback 和原来的 mount / unmount 不是一对一的。MDN 的说法是 connectedCallback「Called each time the element is added to the document」,宿主用 appendChild / insertBefore 给元素换个位置,等于先摘下来再插回去,叠加层会先走一遍 disconnectedCallback 再走一遍 connectedCallback。专门处理移动的 connectedMoveCallback 只在 Element.moveBefore() 下触发,而支持下限里的浏览器都还没有 moveBefore()。所以 bundle 里的初始化和清理要经得起反复进出,定时器、observer 在 disconnect 时收干净,重新 connect 时再建。

attribute 的值只能是字符串,这是换过来之后实打实的损失。原来 mount(container, { token, cssUrl }) 能传对象和回调,现在非字符串的参数要么 JSON 序列化塞进 attribute,要么让宿主拿到元素引用之后走 property(el.config = {...}),后者又绕开了 attribute 声明式的那点好处。token 这种本来就是字符串的不受影响。

反向通信也挪到 DOM 上:叠加层在自己的元素上 dispatchEvent(new CustomEvent('acme-overlay:win', { detail })),宿主 addEventListener 收。元素从 DOM 上摘掉、宿主也不再引用它之后,监听跟着元素一起被回收,宿主不用再持有全局回调。

前缀是自己约的,scoped registry 还替不掉它#

acme-overlay-chat 里的前缀只是约定,运行时不保护它。WICG 提案把这件事说得比较直接:

Third-party, CDN-hosted libraries: Libraries like social-media share buttons and embeds, maps and documentation viewers, etc., can vend complex UI widgets that may need to register many elements, and they should not be using up a global namespace that they don’t fully control.

规范给的答案是 scoped registry:注册表从全局独一份变成可以 new 出来的对象,每张表各记各的定义,同一个 tag name 在不同表里指向不同的类。表挂到哪里有三个入口:

const registry = new CustomElementRegistry()
registry.define('probe-scoped', class extends HTMLElement {})

// 建 shadow root 的时候挂
host.attachShadow({ mode: 'open', customElementRegistry: registry })

// 事后挂:declarative shadow DOM 先在 <template> 上标 shadowrootcustomelementregistry 占位,再调 initialize
registry.initialize(shadowRoot)

// 挂在单个元素上,它和它的子树跟着这张表走
document.createElement('probe-scoped', { customElementRegistry: registry })

元素查哪张表,看它所在的根上挂了哪张,没挂就落回全局的 window.customElements。WICG 提案里这个选项还叫 registry,发出来的 API 名字是 customElementRegistry。Chrome 152 上把边界过一遍:

[probe] UA Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36
[probe] 3 new CustomElementRegistry(): yes
[probe] 3 同名 tag 在两个 root 里: A root = "A" | B root = "B"
[probe] 3 customElements.get("probe-scoped") => undefined
[probe] 3 全局 define 同名: ok | document 里的实例 = "GLOBAL"
[probe] 3 只在全局注册的元素: document 里 = "GLOBAL-ONLY" | scoped root 里 = "" | constructor = HTMLElement
[probe] 3 createElement 带 registry 的元素放进 document = "B"

隔离是双向的。两张表各定义一个 probe-scoped,两个 shadow root 各挂一张,同一个名字在 A 里渲染出 A、在 B 里渲染出 B;这两处定义都没漏到 document 上,customElements.get() 取到 undefined,全局再拿同一个名字 define 一次也照样成功。反过来也一样:只在全局注册的 probe-global-only 放进挂了 scoped registry 的 root 里就不升级了,constructor 停在 HTMLElement,内容是空的,没有任何报错。提案里那句「The registry must contain definitions for all elements used」说的就是这件事,一张 scoped 表不会回落到全局表,叠加层内部用到的每个元素都要往自己这张表里放一份。

对本篇这个场景,scoped registry 能解的是叠加层自己 shadow root 里的那些元素,也正是上面 WICG 动机里「register many elements」说的情况。宿主创建的入口元素在 document 树上,走的还是全局注册表。probe 最后一行是唯一的绕法:document.createElement() 的同名选项能让单个元素带着自己那张表进 document,probe 里这样建出来的元素放进 document 之后渲染出 B,用的是第二张表的定义。但要用它,宿主手上得先有一个 registry 对象,而 <script src> 进来的 bundle 只能往全局表里 define,把 registry 交接给它还得再开一个全局名字,绕回 window.Overlay 那种写法。入口 tag 的前缀因此去不掉。

发得最早的是 Safari 26(2025-09-15),Chrome 和 Edge 146(2026-03)跟上,Chrome 的博客(2026-03-09)是这么写的:

Scoped custom element registries are now available by default from Edge and Chrome 146, as well as other Chromium-based browsers.

剩下的是 Firefox,到写这篇时还在 preview 里。MDN 给这个构造函数标 limited availability 就是因为它:

This feature is not Baseline because it does not work in some of the most widely-used browsers.

Firefox 发没发,对这次的判断其实不起决定作用。按前提里的两年下限,Safari 26 是 2025-09 发的,要到 2027-09 才进得来,Chrome / Edge 146 要到 2028-03,Firefox 连正式版都还没发,计时都没开始。也就是说支持范围里一个引擎都没有 scoped registry,而且这个状态还要再持续将近一年。

polyfill 这条路也不走。@webcomponents/scoped-custom-element-registry(webcomponents/polyfills 仓库里的一个包,最新 0.0.10,2025-02-26)的做法是 patch 全局的 customElements.defineattachShadow,第三方 bundle 往宿主页面里装一个全局补丁,风险比自己带前缀大:同一个页面上还有谁在注册元素,我们并不知道。

所以前缀这条约定还是自己维持:tag name 里带上组织名或产品名,把冲突概率压到自己能控制的范围里。第三方元素的通行写法也是这样,Google Maps JS API 的元素是 gmp-map,Web Awesome(原来的 Shoelace)是 wa-,Adobe 的 Spectrum Web Components 是 sp-。等支持下限整体推到有 scoped registry 的版本,可以给每个叠加层的 shadow root 配一张自己的表,管住它内部那些元素的重名;宿主认的那个入口 tag,前缀仍归自己维持。

版本放在 URL 路径上,tag name 保持不变#

tag name 是宿主创建元素时用的那个名字。版本不进 tag name,前提是一个页面会话里只拉一次清单,每个叠加层在页面上只会有一个版本。版本放在 URL 路径上:

/widgets/chat/a1b3f7/overlay.js     Cache-Control: public, max-age=31536000, immutable
/widgets/chat/a1b3f7/overlay.css    同上
下发上面这个路径的那个接口            Cache-Control: no-store

版本在目录上,入口文件名带不带 hash 就不重要了。Vite 的 library 模式默认也不给入口加 content hash:build.lib.fileName 默认取 package.jsonname,产物名是 <name>.<ext>,umd 和 iife 这两种格式是 <name>.<format>.<ext><script src> 用的就是它们;hash 只留在 code splitting 出来的 chunk 上(resolveLibFilename())。路径上再没有版本,长缓存就会把新版本挡在外面。回滚也只是接口改回旧路径,不用等缓存过期。

tag name 不带版本的代价,是宿主没法从名字上看出接口变没变,所以另给一个可读的声明:

class ChatOverlay extends HTMLElement {
  static contractVersion = 2
}

宿主在 createElement 之前核一遍 customElements.get(spec.tag)?.contractVersion,对不上就走降级。这比 mount(container, { token, cssUrl }) 那种隐式签名好查:签名改了不报错,只是某个参数从此读到 undefinedget() 取到 undefined 也按对不上处理:<script>load 事件在脚本执行完就触发,中途抛了错也一样,所以 bundle 执行出错、或者接口下发的 tag 和 bundle 里 define 的不是同一个,onload 照样会进来。

contractVersion 这个名字是自己起的,规范里没有。离它最近的现成东西是 Custom Elements Manifest(custom-elements.json),但那份清单描述的是构建期给 IDE 和文档工具用的信息,运行时核不核版本、对不上怎么办,仍然要自己定。

shadow root 里的两条 at-rule 方向相反#

shadow root 把宿主页面的全局 CSS 挡在外面,这层隔离正是叠加层要的。样式送进去,<link rel="stylesheet"> 塞进 shadow root 最直接。三个 shadow root 各挂一个 <link>、都指向同一个 URL(前两个同时挂,第三个等它们 load 完再挂),CSS 由本机一个 HTTP server 按前面那套 immutable 头发出来:

const addLink = (root) => new Promise((res) => {
  const l = document.createElement('link')
  l.rel = 'stylesheet'
  l.href = URL_                 // 三个 root 用的是同一个 URL
  l.onload = res
  root.appendChild(l)
})
const hits = () => performance.getEntriesByType('resource')
  .filter(e => e.name.includes('overlay-probe.css'))
[probe] 4 两个 link 刚 append,还没 load:a = rgb(0, 0, 0) | b = rgb(0, 0, 0)
[probe] 4 两个都 load 之后:a = rgb(5, 5, 5) | b = rgb(5, 5, 5)
[probe] 4 晚一步挂的第三个 link → resource 条目数 = 1 | 最后一条 transferSize = 326
[probe] 4 三个 root 各有一张表,互不相同 => true
[probe] 4 但规则内容一样 => true
[probe] 4 换成 adoptedStyleSheets:赋值后立刻取色 a = rgb(7, 7, 7) | b = rgb(7, 7, 7) | 同一个对象 => true

三个 <link> 指向同一个 URL,resource timing 里从头到尾只有一条,晚一步挂的那个也没再发,网络上就是一次请求。代价在另外两处。每个 shadow root 各留一份 CSSOM 副本,规则一模一样;样式还要等表回来才生效,append 那一刻取到的仍是继承来的黑色。构造式样式表这两处都没有,一个 CSSStyleSheet 对象赋给几个 root,赋值那一刻就生效,之后改一次全体跟着变:

const shared = new CSSStyleSheet()
shared.replaceSync('p { color: rgb(9, 9, 9) }')
rootC.adoptedStyleSheets = [shared]
rootA.adoptedStyleSheets = [inShadow, shared]
shared.replaceSync('p { color: rgb(7, 7, 7) }')
[probe] 5 共享前 c = rgb(9, 9, 9) | a = rgb(9, 9, 9)
[probe] 5 replaceSync 一次后 c = rgb(7, 7, 7) | a = rgb(7, 7, 7)

MDN 对这个行为的说法是「Changing an adopted stylesheet will affect all the objects that adopt it」。各浏览器支持齐是 2023 年 3 月的事(Safari 16.4 收尾),Baseline 升到 widely available 则要到 2025-09。

隔离到这里都是顺的。接下来有两条 at-rule 不跟着 shadow root 走,方向还相反。

@property 要留在 document。同一份声明,写在 shadow root 里和写在 document 里,取到的值不一样:

[probe2] A  <style> in shadow root          = rgb(0, 0, 0)
[probe2] B  adopted sheet in shadow root    = rgb(0, 0, 0)
[probe2] C  @property in document <style>   = rgb(33, 33, 33)
[probe2] D  --pa seen from document         = rgb(0, 0, 0)

A 和 B 把 @property --pa { syntax: "<color>"; inherits: false; initial-value: rgb(11, 11, 11) } 和用到它的规则放在同一张表里:--pa 没注册上,var(--pa) 取不到值,color 落回继承来的黑色,<style> 和 adopted sheet 两种写法结果一样。C 把 @property 挪到 document、规则留在 shadow root 里,值就出来了。D 查 A 里那条注册有没有漏到 document 上:也没有,它哪儿都没注册上。

CSS Houdini 的草案里写的是另一回事:

All registrations, whether they appear in the outermost document or within a shadow tree, interact in a single global registration map for the Document.

按这句话,A 和 B 应该被拍平到 document 作用域然后生效。Chrome 团队那篇讲 author-defined CSS names 的文章(2024-08-02 更新)把这个差写出来了:

As defined in the specification, any declaration of @property will be flattened to the document scope. Today however, in all browsers you can only declare @property in the document scope and @property declarations within shadow roots are ignored.

那篇文章的结论到现在没变。Adobe 维护的 shadow DOM CSS 支持跟踪页 上这条用例,Chrome 153、Edge 152、Firefox 155、Safari 26.6 四家全部 fail(跟踪页自己标的最后更新是 2026-08-07),对应的规范 issue w3c/csswg-drafts#10541 写这篇时也还开着。时间上前后都对得上:那篇文章和这个 issue 都早于支持下限,下限以内的老版本上这个差异同样存在;写在 document 里倒是四家都支持,最后一家是 Firefox 128(2024-07),比下限早两个月。这条对用 Tailwind v4 的叠加层是直接相关的:v4 拿 @property 定自定义属性的初始值,Tailwind 仓库里 issue #15005(标题 @property isn't supported in shadow roots,2024-11-14 开,截至 2026-09-22 仍 open)记的就是这件事,给的绕法和 C 测到的一样,把 @property 声明提到宿主文档里去。

@keyframes 的方向相反,它要留在 shadow root 里:

[probe2] E  @keyframes 在 document,shadow root 引用 → getAnimations().length = 0,animationName = doc-spin
[probe2] F  @keyframes 在同一个 shadow root 里    → getAnimations().length = 1,animationName = own-spin

E 这条比较难查:animation-name 算出来就是 doc-spin,看 computed style 一切正常,但没有动画在跑,名字没解析到定义上,也没有任何报错。Chrome 那篇文章把这条记在 w3c/csswg-drafts#10540,issue 标题就是「@keyframes referencing from shadow roots does not match spec in any browser」,结论是 keyframes 只能在自己的作用域里被引用。issue 在 2024-09-26 关掉,底下 2025-10-26 又有人补充说 Safari 26、Firefox 144、Chrome 141 三家的行为仍不一致;E 在 Chrome 152 上测到的和 issue 里记的是同一个样子。同一篇还列了 @font-face@counter-style 在 shadow root 里的各家差异,结论是「None of the features we examined here behaves consistently across browsers and according to spec」。

两条 at-rule 的方向画出来是这样:

flowchart TB subgraph doc["宿主 document"] prop["@property 声明
:root 上的变量"] end subgraph r1["叠加层 A 的 shadow root"] kf1["@keyframes"] use1["用到变量和 animation 的规则"] end subgraph r2["叠加层 B 的 shadow root"] kf2["@keyframes"] use2["用到变量和 animation 的规则"] end prop -->|"注册是 document 全局的"| use1 prop -->|"一份声明多个叠加层共用"| use2 kf1 -->|"只在本作用域解析得到"| use1 kf2 -->|"只在本作用域解析得到"| use2

所以叠加层的构建产物要拆成两份:@property 声明和 :root 上的变量由宿主注入 document,动画和其余规则留在 shadow root 的那张 sheet 里。两份都能长缓存,前一份多个叠加层还能共用,而且最好就共用同一份:@property 的注册整个 document 只有一套(前面引的 single global registration map),同名的两条按草案 §3 是「the last one in stylesheet order “wins”」,CSS.registerProperty() 又压过所有 @property。两个叠加层各带一份不同的定义,样式表顺序靠后的那份会把前一个叠加层的定义一起换掉。

shadow root 那份的 CSS 文本怎么到 replaceSync 手里,也要一起定:原来那个 cssUrl 参数换成 custom element 之后就没有了。单独发一个 CSS 文件再 fetch 回来,长缓存挡得住第二次,首屏那次仍要等;CSS module scripts(import sheet from './overlay.css' with { type: 'css' })写法最干净,但支持下限这里过不去:Chrome 123(2024-03)赶上了,Firefox 要到 147(2026-01),Safari 到写这篇时还没有;剩下一条是把 CSS 串进 JS bundle,多一点体积换掉一次请求和一次等待,按前面几条前提这条最省事。

哪些归规范管,哪些是自己约的#

把上面用到的东西按出处和归属分一下,平台提供的和自己定的就分开了:

接口 出处 谁定、谁用
customElements.define 和重复注册的 NotSupportedErrorconnectedCallback、attribute、CustomEventadoptedStyleSheets HTML / DOM / CSS 规范,给的是机制 规范定,两边都只是用
@property@keyframes、tag name 必须含连字符 同一批规范;前两条在 shadow root 里,各家实现的作用范围和规范对不上(前面那两个 issue),连字符是规范定的命名约束 规范定,两边都只是用
tag name 和前缀 acme-overlay- 自定义命名 bundle 里 define,接口下发给宿主,宿主照着 createElement
token 属性名 自定义命名 bundle 声明要读哪个 attribute,宿主照着 setAttribute
acme-overlay:win 事件名 自定义命名 bundle 派发,宿主监听
contractVersion 自定义命名 bundle 声明,宿主核对、决定降级
版本目录 /widgets/chat/a1b3f7/ 部署约定 bundle 发布时定,接口下发,宿主不解析路径
构建产物拆两份 部署约定 bundle 构建出来,@property 那份由宿主注入 document

宿主和 bundle 之间真正的契约面是自定义命名和部署约定那六行:规范一处都管不到,全靠两边自己对齐。这六行的第三列都是 bundle 定、宿主照着用,名字的定义权在 bundle 一侧,宿主只负责从接口拿到它、原样用上。

小结#

三处单例假设只有一个来源:宿主和 bundle 之间的名字定在了 JS 全局对象上。命名空间和 dynamic import() 都能把名字挪走,但容器和卸载仍旧要宿主记账;名字挪进 DOM 之后这两件事由平台接管,这是最后落在 custom element 的理由。名字由 bundle 定,宿主从接口拿到后原样用上。参数传递反过来降了一级,attribute 只收字符串。

代价有三件事要自己管:tag name 的前缀靠约定(scoped registry 在两年下限内还没有一个引擎支持,补它要往宿主页面装全局 polyfill,而且它管得住的是 shadow root 内部的元素,入口那个 tag 仍在全局注册表里),接口版本要另给一个 static contractVersion 之类的声明,样式里的 @property@keyframes 要按相反方向拆开放。

还没验证的部分:@property 拆出来那一小段由宿主在什么时机注入;宿主页面本身也用 Tailwind v4 时,两边注册的名字会不会重合、定义有没有差别。按草案,重名的注册只有样式表顺序靠后的那条生效,影响到底落在哪些样式上,等真接上第二个叠加层再测。

参考资料#

规范与浏览器文档:

第三方元素的 tag name:

跟到的 issue:

  • w3c/csswg-drafts#10541Property registration cannot be scoped 与各家实现的一致性差异,2024-07-08 开,写这篇时仍 open
  • w3c/csswg-drafts#10540@keyframes 的跨作用域引用在任何浏览器上都不符合规范,2024-07-08 开,2024-09-26 关闭
  • tailwindlabs/tailwindcss#15005:Tailwind v4 在 shadow root 下的 @property,2024-11-14 开,写这篇时仍 open