同一页面挂多个 UI 叠加层时,接口该定在哪里?
目录
宿主页面在运行时加载一个自己不参与构建的 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 里。
- 叠加层的定位和层级不在本篇范围。宿主祖先上的
transform、filter、contain会换掉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 / unmount 由 connectedCallback / 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.define 和 attachShadow,第三方 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.json 的 name,产物名是 <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 }) 那种隐式签名好查:签名改了不报错,只是某个参数从此读到 undefined。get() 取到 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
@propertywill be flattened to the document scope. Today however, in all browsers you can only declare@propertyin the document scope and@propertydeclarations 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 的方向画出来是这样:
: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 和重复注册的 NotSupportedError、connectedCallback、attribute、CustomEvent、adoptedStyleSheets |
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 时,两边注册的名字会不会重合、定义有没有差别。按草案,重名的注册只有样式表顺序靠后的那条生效,影响到底落在哪些样式上,等真接上第二个叠加层再测。
参考资料#
规范与浏览器文档:
- Scoped Custom Element Registries(WICG 提案):全局注册表的冲突场景,以及第三方 CDN widget 那段动机
- Make custom elements behave with scoped registries,Chrome for Developers,2026-03-09:Edge 和 Chrome 146 起默认可用,
new CustomElementRegistry()与attachShadow()的customElementRegistry选项 - MDN: CustomElementRegistry() 构造函数:当前标的是 limited availability,Chrome 146 / Edge 146 / Safari 26 已发,Firefox 在 preview
- MDN: 使用 custom elements 的 Scoped custom element registries 一节与
Document.createElement()的customElementRegistry选项、Element.attachShadow()的选项:三种挂法,以及给单个元素指定注册表 - MDN: 使用 custom elements 的 lifecycle callbacks 一节:
connectedCallback每次插入都触发,connectedMoveCallback只管moveBefore() @webcomponents/scoped-custom-element-registry:scoped registry 的 polyfill,靠 patch 全局customElements实现- MDN:
<script>的type="module":module script 跨域取要走 CORS,classic script 不用 - HTML 规范:execute the script element:外部脚本执行完之后派发
load的那一步 - MDN: Document.adoptedStyleSheets:构造式样式表的定义、共享语义、2023 年 3 月起的 Baseline 状态
- CSS Properties and Values API Level 1(草案):§2.8 写明注册是 document 全局的,§3 写同名
@property哪条生效 - Author-defined CSS names and shadow DOM: In specification and in practice,Chrome for Developers,2024-08-02 更新:
@property、@keyframes、@font-face、@counter-style在 shadow root 里的实现现状 - Modern CSS Feature Support For Shadow DOM:Adobe 维护的跟踪页,
@property、@keyframes在 shadow root 里的当前测试结果和测试时间 - MDN: Import attributes:CSS module scripts(
with { type: 'css' })的各家支持版本
第三方元素的 tag name:
- Google Maps JavaScript API 的 MapElement 参考:
<gmp-map>的用法示例 - Web Awesome: Button:
wa-前缀的元素命名;Shoelace 的组件页上写着「Shoelace is now Web Awesome」 - Spectrum Web Components: Button:
sp-前缀的元素命名
跟到的 issue:
- w3c/csswg-drafts#10541:
Property 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