我为什么不使用web components (译)

目录#

简介#

本文翻译自作者 Rich Harris 的 Why I don’t use web components

该文章引起了许多讨论,建议阅后前往原文评论区观摩 :)

前言#

作为我来到 dev.to 发表的第一篇文章,我觉得我可以和大家一起来讨论一下一个很nice,没有什么争议的话题: web components;

我写这篇文章主要是为了备忘,以便下次有人问我一些像 为什么对 web components 持怀疑态度 或者 Svelte 为什么不把编译到web components 作为默认选项 这类问题时,可以再拿出来参考一下。(实际上,根据 Custom Elements Everywhere 上svelte所得到的完美分数,足以证明它能够很好的兼容 web components)

以下所讨论的内容都不应该被当作是对 web components 所包含的艰苦工作的批评。我在这篇文章中可能犯了一些错误,欢迎在评论中更正。

同时我也不是在劝诫你不要使用 web components,它有适合它的使用场景,这里我只是在阐述我不使用它的原因。

1.渐进式的增强#

虽然这可能是一种越来越过时的观点,但我还是认为网站应当尽可能地减少对 Javascript 的依赖,而 Web components 在这方面与我的观点相悖,它依赖于Javascript。

这对于存在着内在交互逻辑的组件是没有什么问题的(比如一个定制化的日期选择组件 <cool-datepicker>),但是对于一个导航栏组件来说,会有些问题。举例来说,我们有一个 <twitter-share> 标签,它内部封装了只对本组件有效的css以及构建一个 Twitter web intenet 链接的所有交互逻辑。我可以在 svelte 中构建这个组件,将会生成以下的 HTML 代码片段用于服务器渲染:

1
2
3
<a target="_blank" noreferrer href="..." class="svelte-1jnfxx">
Tweet this
</a>

换句话说,在可访问性上,它等同于一个标准的 <a> 标签。

当 Javascript 启用时,它以渐进式的方式增强用户体验:打开一个小的 popup 形式的小窗口,而不是直接打开一个网页Tab。当然,Javascript 被禁用时,它也能降级到打开 Tab 的方式。

但如果我用 web components 来编写这个组件时,它会长这样;

1
<twitter-share text="..." url="..." via="..."/>

Javascript 被禁用或者出现报错导致代码不能正常运行时,又或者是在不支持 web components 的旧版本浏览器上时,这个标签会变得不能正常工作并且不可访问;

注:svelte 使用 class="svelte-1jnfxx" 来限制 css的作用域,以确保其仅作用于该组件,而无需借助 Shadow DOM 来实现此目的,这也引出了我的下一个观点:

2. CSS in, emmm… JS#

如果你想通过 Shadow DOM 来实现样式隔离,那么你需要把你的 CSS 放进 <style> 标签;这个需求的唯一的替代方案(至少在希望避免FOUC的情况下),则是将 CSS 通过字符串的形式放入 Javascript 模块中。

这与我们前文已经提到的性能优化建议背道而驰,这个建议可以总结为“请尽量减少JavaScript的使用”,CSS-in-js社区常常因为他们没有将CSS放到.css文件中这件事而受到批评,现在我们遇到了同样的情况。

在未来的几年里,我们可能通过使用由 Constructable Stylesheets 构建的 CSS Modules 来解决这个问题,我们也可能通过用 ::theme::part 伪类来定义 Shadow DOM 内元素样式。但是这些方案也不是没有问题的。

3. 宿主环境的疲乏#

image.png

在撰写本文时,Chromium Bug展示页上有61,000个未解决的问题,反映出构建现代Web浏览器的巨大复杂性。

每次我们向平台添加新功能时,都会增加这种复杂性,为bug创造新的土壤,并使得Chromium出现新的竞争对手的可能性越来越小。

鼓励开发人员学习这些新功能会给开发人员带来了极大的挑战(其中一些功能(例如HTML导入或原始的Custom Elements规范,永远不会在Google外部流行并最终被再次删除)。

4. Polyfills#

如果您想要支持所有的浏览器,那么寄希望于polyfills似乎是不太可能的。
Constructable Stylesheets 这个由谷歌职员(嗨,Jason!)所编写的文章里,完全没有提到这是一个仅在 Chrome 有用的 feature (三个 spec editor 都是谷歌人。Webkit似乎对设计的某些方面存有疑问。)

5. Composition#

组件能够控制何时(或是否)呈现其 slot 内容是很有用的。假设我们想要使用 标签来显示从网络中获取的文档,代码应该像下面这样子:

1
2
3
4
<p>Toggle the section for more info:</p>
<toggled-section>
<html-include src="./more-info.html"/>
</toggled-section>

Surprise! 即使你还没有打开这个页面,但浏览器已经请求了 more-info.html 网页,以及它所链接到的任何图像和资源。

这是因为slot的内容在自定义元素中会优先被渲染。事实证明,大多数情况下,你都希望将 slot 的内容延迟呈现。Svelte v2采用了 eager model,以便与web标准保持一致,但结果证明,它是导致我们无法创建一个与React Router等价的Router的主要原因。在Svelte v3中,我们放弃了自定义元素组合模型,并且不会再回头。

不幸的是,这只是DOM的一个基本特性。这就把我们带到了下面这另一个困境……

6. props 和 attributes 令人迷惑#

props 和 attributes 本质上是同一个东西,对吧?

1
2
3
4
5
6
7
8
const button = document.createElement('button');

button.hasAttribute('disabled'); // false
button.disabled = true;
button.hasAttribute('disabled'); // true

button.removeAttribute('disabled');
button.disabled; // false

我是说,大多数情况下:

1
2
3
4
5
typeof button.disabled; // 'boolean'
typeof button.getAttribute('disabled'); // 'object'

button.disabled = true;
typeof button.getAttribute('disabled'); // 'string'

然后我发现还有一些name是不匹配的……

1
2
3
4
5
6
7
div = document.createElement('div');

div.setAttribute('class', 'one');
div.className; // 'one'

div.className = 'two';
div.getAttribute('class'); // 'two'

…而这些似乎根本不匹配:

1
2
3
4
5
6
7
8
input = document.createElement('input');

input.getAttribute('value'); // null
input.value = 'one';
input.getAttribute('value'); // null

input.setAttribute('value', 'two');
input.value; // 'one'

但是我们可以容忍这些奇怪的部分,因为在字符串格式(HTML)和DOM之间的转换过程中肯定会损失一些东西。这些损失的数量是有限的,并且有文档记录,所以只要有足够的时间和耐心,我们至少可以了解它们。

Web components 改变了这一点。不仅不再有任何关于 attributes 和 props 之间关系的确定性,而且作为一个Web components 的作者,你(大概?)应该同时支持这两者。这意味着你将会看到这样的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class MyThing extends HTMLElement {
static get observedAttributes() {
return ['foo', 'bar', 'baz'];
}

get foo() {
return this.getAttribute('foo');
}

set foo(value) {
this.setAttribute('foo', value);
}

get bar() {
return this.getAttribute('bar');
}

set bar(value) {
this.setAttribute('bar', value);
}

get baz() {
return this.hasAttribute('baz');
}

set baz(value) {
if (value) {
this.setAttribute('baz', '');
} else {
this.removeAttribute('baz');
}
}

attributeChangedCallback(name, oldValue, newValue) {
if (name === 'foo') {
// ...
}

if (name === 'bar') {
// ...
}

if (name === 'baz') {
// ...
}
}
}

有时您会看到程序以另一种方式进行,即调用属性访问器的 attributeChangedCallback 。不管怎样,这对于人的现有认知都是极具毁灭性的。

相反,框架有一种简单而明确的方式将数据传递给组件。

7. 设计缺陷#

有一点比较模糊,让我感到奇怪的是,attributeChangedCallback 只是元素实例上的一个方法。你可以这么做:

1
2
const element = document.querySelector('my-thing');
element.attributeChangedCallback('w', 't', 'f');

没有 attribute 发生改变,但是它表现得就像它改变了一样。当然,JavaScript总是提供了很多恶作剧的机会,但是当我看到这样的实现细节时,我总是觉得他们是在试图告诉我们这样的设计不太正确。

8. DOM 这个东西不太好#

我们已经证明了DOM不是好的玩意。但是对于构建交互式应用程序来说我们不得不使用它,这是一个多么尴尬的情况,怎么说都不为过。

回到几个月前,我写了一篇名为 Write less code 的文章,旨在说明Svelte如何使你能比React和Vue等框架更有效地构建组件。但是我没有将它与DOM进行比较。我想我应该去比较一下了。

翻新一下,这里是一个简单的 <Adder a={1} b={2}/> 组件:

1
2
3
4
5
6
7
8
9
<script>
export let a;
export let b;
</script>

<input type="number" bind:value={a}>
<input type="number" bind:value={b}>

<p>{a} + {b} = {a + b}</p>

这就是完整的实现,我们再看看在 web component 中应该怎么做呢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Adder extends HTMLElement {
constructor() {
super();

this.attachShadow({ mode: 'open' });

this.shadowRoot.innerHTML = `
<input type="number">
<input type="number">
<p></p>
`;

this.inputs = this.shadowRoot.querySelectorAll('input');
this.p = this.shadowRoot.querySelector('p');

this.update();

this.inputs[0].addEventListener('input', e => {
this.a = +e.target.value;
});

this.inputs[1].addEventListener('input', e => {
this.b = +e.target.value;
});
}

static get observedAttributes() {
return ['a', 'b'];
}

get a() {
return +this.getAttribute('a');
}

set a(value) {
this.setAttribute('a', value);
}

get b() {
return +this.getAttribute('b');
}

set b(value) {
this.setAttribute('b', value);
}

attributeChangedCallback() {
this.update();
}

update() {
this.inputs[0].value = this.a;
this.inputs[1].value = this.b;

this.p.textContent = `${this.a} + ${this.b} = ${this.a + this.b}`;
}
}

customElements.define('my-adder', Adder);

嗯…

还要注意,如果同时更改 a 和 b,将导致两个独立的更新。而框架通常不会遇到这个问题。

9. Global namespace#

我们不需要过多地考虑这个问题; 可以这样说,使用单一共享的namespace的危险,人们在一段时间以前就很好地认识到了。

10. 这些都是已经被解决了的问题#

最令人沮丧的是,我们已经有了非常好的组件模型。我们仍然在学习,但是通过以面向组件的方式操作DOM来保持视图与某些状态的同步的基本问题已经解决多年了。

然而,我们正在向平台添加新功能,只是为了让web组件与我们在userland中已经实现的功能对等。

在资源有限的情况下,花在一项任务上的时间意味着没有办法花时间在另一项任务上。尽管大多数开发人员对 web component 漠不关心,但 web component 已经消耗了开发人员大量的精力。如果把这些精力花在别的地方,互联网又可能会取得什么样的突破呢?

完。