Shadow DOM 和封装问题


Web components 目前正处于风口浪尖。在这个过程中,Shadow DOM 也备受关注。不过,很多讨论似乎集中在为何不应该使用 Shadow DOM 上。

例如,“HTML web components”是基于这样一种理念的:你应该利用Web components的大部分优点(自定义元素、生命周期钩子等),但应当像摆脱坏习惯一样舍弃 Shadow DOM。(这种组件的另一个名称是“light DOM components”。)

对于某些情况来说,这是一种完全可以接受的模式。但我也认为有些人对于使用 Shadow DOM 的权衡感到困惑,因为他们并不理解 Shadow DOM 首先应该达到的目的是什么。在这篇文章中,我想通过解释 Shadow DOM 应该完成的任务,同时评估其实际实现这些任务的成功程度来澄清一些误解。

Shadow DOM 究竟是什么?

Shadow DOM 的主要目标是封装。封装是一个难以解释的概念,因为其好处并不立即显而易见。

假设你有一个第三方组件,你决定将其包含在你的网站或Web应用程序中。也许你在npm上找到了它,并且它非常好地解决了某个使用案例。比如说,它可能是一个简单的下拉组件。

但你知道吗?你真的不喜欢那个插入符号——你宁愿用一个 👇 表情符号。你也更喜欢圆角。而且主题颜色应该是红色而不是蓝色。所以你将一些 CSS 拼凑了起来:

1
2
3
4
5
6
7
.dropdown {
background: red;
border-radius: 8px;
}
.dropdown .caret::before {
content: '<img draggable="false" role="img" class="emoji" alt="👇" src="https://s0.wp.com/wp-content/mu-plugins/wpcom-smileys/twemoji/2/svg/1f447.svg">';
}

太棒了!你得到了想要的样式。发布吧。

但是 6 个月后,组件更新了。而且是为了修复一个安全漏洞!你的老板催促你尽快更新组件,因为否则网站将无法通过安全审计。于是你去更新,然后……

一切都坏了。

原来组件已经将其内部类名从 dropdown 改为 picklist。他们不再使用 CSS content 来创建插入符号了。而且他们添加了一个包装器 <div>,因此现在需要将边框半径应用到其他地方。突然间,你需要付出很大的代价,才能将组件恢复到之前的样子。

全局控制非常好,直到它不好为止。

CSS 给了你一种了不起的超能力,就是只要你能想到正确的选择器,你就可以定位到页面上的任何元素。今天在开发工具中做到这一点非常容易——很多人都被训练成右键单击,“检查元素”,然后搜索任何类或属性来开始定位元素。这在短期内效果很好,但对于你不拥有的组件,它影响了代码的长期可维护性。

这不仅仅是 CSS 的问题——JavaScript 也有同样的缺陷,因为 DOM 的原因。使用 document.querySelector(或等效的 API),你可以在 DOM 中任意遍历,找到一个元素,并对其应用一些自定义行为,比如添加事件监听器或更改其内部结构。我可以用 JavaScript 而不是 CSS 来讲述上面的同样故事。

这种开放性既会给组件作者带来头痛,也会给组件消费者带来头痛。在一个组件作者需要发布新版本的系统中(比如单体存储库、平台,甚至只是一个大型代码库),组件作者实际上会被困在时间中,无法发布任何内部重构,因为他们害怕破坏他们的下游消费者。

Shadow DOM试图通过提供封装来解决这些问题。如果第三方下拉组件使用 Shadow DOM,那么你将无法定位其中的任意内容(除非使用我不想讨论的复杂的解决方法)。

当然,通过关闭对全局样式和 DOM 遍历的访问,Shadow DOM 也极大地限制了组件的可定制性。消费者不能仅仅决定要将背景设为红色,或者将边框设置为圆角——组件作者必须提供一个明确的样式 API,使用诸如 CSS 自定义属性或部件之类的工具。例如:

1
2
3
4
5
6
7
snazzy-dropdown {
--dropdown-bg: red;
}

snazzy-dropdown::part(caret)::before {
content: '<img draggable="false" role="img" class="emoji" alt="👇" src="https://s0.wp.com/wp-content/mu-plugins/wpcom-smileys/twemoji/2/svg/1f447.svg">';
}

通过暴露明确的样式 API,跨组件升级时的破坏风险大大降低了。组件作者实际上正在声明一个他们打算支持的 API 表面,这限制了随着时间的推移需要保持稳定的内容。(这个API仍然可能会破坏,比如主要版本升级,但这是另一个故事。)

权衡

当人们抱怨Shadow DOM时,他们似乎主要在抱怨样式封装。他们想要进入并在某个组件上添加一个圆角,并冒险组件将来不会改变。根据你正在构建的网站类型,这可能是一个完全可以接受的权衡。例如:

  • 一个作品集网站
  • 一篇带有交互式图表的新闻文章
  • 一个超级碗活动的营销网站
  • 一个两年后将被重写的着陆页

在所有这些情况下,长期维护实际上并不是一个很大的问题。页面的寿命要么有限,要么保持其依赖关系最新并不重要。因此,如果下拉组件在一两年后出现问题,也没人在乎。

当然,也有相反的情况,长期维护非常重要:

  • 一个交互式生产力应用
  • 一个设计系统
  • 一个拥有自己的 UI 组件应用商店的平台
  • 一个在线多人游戏

我可以继续列举,但要点是:第二组对长期可维护性的关注要远远超过第一组。如果你的整个职业生涯都在第一组上工作,那么你可能确实会觉得 Shadow DOM 很难理解。你根本无法理解为什么你应该被阻止全局地样式化任何你想要的东西。

相反,如果你的整个职业生涯都在第二组中,那么你可能同样对那些想要全局访问一切的人感到困惑。(“他们是在自找麻烦吗?”)这就是我认为人们在这些问题上经常互相错过的原因。

但这真的有效吗?

现在我们已经确定了 Shadow DOM 尝试解决的问题,必然会有一个问题:它真的解决了吗?

这是一个重要的问题,因为我认为这是与 Shadow DOM 的另一个主要紧张关系的根源。即使了解问题的人也不一定同意 Shadow DOM 是否真的解决了问题。
我并不确信其中任何一个是能够解决人们对 Shadow DOM 挫败感的灵丹妙药。原因在于,这里的核心问题是一个协调问题,而不是一个技术问题。

例如,以“可打开样式的 Shadow Root”为例。其思想是,阴影根可以继承其父上下文的样式(就像轻量DOM一样)。但接下来,我们就会遇到协调问题:

  • npm 上的每个 web 组件都需要启用可打开样式的 Shadow Root 吗?
  • 还是页面作者需要一个全局机制来强制将每个组件置于此模式下?
  • 如果组件作者不想参与怎么办?如果他们更喜欢较小的 API 表面以降低维护成本呢?

这里没有正确的答案。这是因为组件作者的需求与页面作者的需求之间存在固有的冲突。组件作者希望维护成本最低,并避免每次更新都会破坏他们的下游消费者,而页面作者希望能够精确地样式化页面上的每个组件,同时又不会造成破坏。

以这种方式陈述,听起来像是一个无法解决的问题。实际上,我认为问题得到的解决方法是更偏向于其中一组,这在很大程度上取决于上文提到的你的网站属于第一组还是第二组。

一个潜在的解决方案?

如果有一个解决方案让我觉得有希望:

构建能够封装逻辑和UI元素的构建块,通过使用现有机制(CSS 属性、部件、插槽等)“完全”可定制化。一切都必须可以从阴影之外定制。

如果一个构建块在其shadow中使用另一个构建块,则必须作为定义良好的插槽的默认内容之一。

简而言之,组件不应该像这样提供一个下拉组件供使用:

1
<snazzy-dropdown></snazzy-dropdown>

……而应该像这样提供一个:

1
2
3
4
5
6
7
8
9
10
<snazzy-dropdown>
<snazzy-trigger>
<button>Click ▼</button>
</snazzy-trigger>
<snazzy-listbox>
<snazzy-option>One</snazzy-option>
<snazzy-option>Two</snazzy-option>
<snazzy-option>Three</snazzy-option>
</snazzy-listbox>
</snazzy-dropdown>

换句话说,组件应该将其“内部”外部化(在此示例中使用 <slot>)以便一切都可以被样式化。这样,消费者可能想要自定义的任何东西都完全暴露在轻量 DOM 中。

这不是一个全新的想法。事实上,在web组件的世界之外,许多组件系统都遇到了类似的问题,并得出了类似的解决方案。例如,所谓的“ headless”组件系统(如 Radix UIHeadless UI Tanstack)就采用了这种设计。

为了对比,这里是 Radix 文档中的下拉菜单的(节选)示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="soft">
Options
<CaretDownIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item>
<DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item>
<!-- ... --->
<DropdownMenu.Content>
<DropdownMenu.Root>

这与上文中的web组件草图非常相似——下拉菜单的“内部”完全暴露给所有人看,UI 中的任何内容都是可以完全定制的。

然而,对我来说,这些解决方案显然是将复杂性的负担从组件作者转移到组件消费者身上。与其从最简单的情况开始并提供最简单的默认情况,不如从复杂的情况开始,迫使消费者(可能)在他们的代码库中复制粘贴大量的样板代码才能开始调整。

现在,也许这是正确的解决方案!也许长期的维护成本是值得的!但我认为应该认清这种权衡。

据我所知,这种“ headless”解决方案仍然有点新颖,所以我们还没有得到很多真实世界的数据来证明长期的好处。但我毫不怀疑,许多组件作者认为这种方法是解决配置失控问题的必要手段——也就是说,组件消费者要求每一件事都可以配置,所有这些配置选项都被塞进一个顶级 API,整体体验开始看起来像是递归式的瑞士军刀。我并不完全相信暴露组件的“内部”是否真的能减少整体的维护成本。这有点像,与其出售预定义的定制套装(颜色、窗户着色、自动还是手动等),不如出售一组松散的零件,客户可以将其混合搭配成任何类型的车辆。

我没有参与过这样的组件系统,但我担心你会遇到类似于“嗯,当我把滑块放在左边时它能工作,但当我把它放在右边时,滚动位置就会乱掉”的 bug。有这么多的潜在定制性,我不确定你如何能编写测试来覆盖所有可能的配置。尽管也许这就是重点——实际上没有 UI,所以如果 UI 出现问题,那么就是组件消费者的工作来解决。

结论

我并没有所有的答案。此时此刻,我只想确保我们正在提出正确的问题。

对我来说,任何针对当前 Shadow DOM 问题的提议解决方案都应该先问:

  • 所涉及的网站或 Web 应用程序是什么样的背景?
  • 谁会从这种变化中受益——组件作者还是页面作者?
  • 谁需要改变他们的行为以使整个事情运转起来?

我也不认为所有这些问题已经成熟到可以开始进行标准讨论。现在在用户领域有很多可以探索的选项(例如,“暴露内部”的提议,或者开放式可样式化的 Shadow 根的 polyfill),因此现在要求标准机构标准化任何东西都为时过早。

我还认为,在标准讨论中,组件作者和组件消费者之间固有的需求冲突并没有得到足够的认识。而 W3C 的优先权并没有为我们带来太多帮助:

  • 用户需求优先于网页作者的需求,而网页作者的需求优先于用户代理实现者的需求,而用户代理实现者的需求优先于规范撰写者的需求,而规范撰写者的需求优先于理论上的纯净性。

在上述的表述中,并没有区分组件作者和组件消费者——他们都只是“网页作者”。我想在概念上,如果我们将整个 Web 平台想象成一个“堆栈”,那么我们会将组件消费者的需求置于组件作者之上。但即使这有时也会变得混乱,因为组件作者和组件消费者可以在同一个团队中工作,甚至是同一个人。

总的来说,我希望看到对涉及 Web 组件生态系统的各个群体进行全面总结,现有解决方案在实践中的运作情况,尝试过的和未尝试过的内容,以及前进所需的变化。