Web Components

这两个库功能高度相似, 差异很小.
据hyperHTML的作者所说, lit-html是Google抄袭hyperHTML灵感的结果.
  1. 1.
    它的作者总共创建了hyperHTML, ligherhtml, uhtml这三个功能相似的库,
    尽管作者给出了说明, 但仍然很难理解这三个库的差异.
  2. 2.
    由JavaScript编写.
  3. 3.
    它的源代码品味不怎么样.
出于问题2和3, 我认为Google出于可维护性自行创建lit-html是完全可以理解的.
根据作者自己撰写的文章, hyperHTML可以根据模板插值的类型自动完成相应的绑定,
而lit-html则必须在模板里手动指定绑定的类型.
这种说法并不是很有说服力, 因为DOM存在着attribute和property的差异,
hyperHTML在简单性上的这种"优势"可能会成为不良的使用习惯.
  • 由TypeScript编写.
  • 有更大的用户社区.
    通过npm的下载量可知, 即使将hyperHTML, lighterhtmll, uhtml的下载量加起来,
    下载量也仅仅是lit-html的1/50.
从各种角度都更推荐使用lit-html.
不能因为hyperHTML是先发明的, 就否认lit-html比hyperHTML在各种层面上都更健康的事实.
CustomElement v1规范之后, 更容易在不使用框架的情况下创建Web组件.
如需直接编写Web Components, 请参考:
https://zh.javascript.info/web-components
https://developers.google.com/web/fundamentals/web-components/customelements
有两种对待Web Components的观点:
  • 将其视为Widget的替代品, 用户使用Widget时只需导入一段打包好的JavaScript代码(或者ESM),
    然后在对应的HTML位置插入组件即可.
  • Web Components具备在组件之间相互嵌套和通信的能力,
    因此可将其视为React, Angular, Vue等MVVM框架的替代品.
在用WC模仿Angular或Vue这类基于模板引擎的框架时, 可能会按照以下方式编写HTML, 可读性不强:
<my-app>
<my-nav></my-nav>
<logic-if condition="$some_condition">
<template slot="then">
<my-article></my-article>
</template>
<template slot="else">
<my-article></my-article>
</template>
</logic-if>
</my-app>
MVVM框架里的组件可以分为两种类型:
  • 开发者在项目内自己封装的组件, 其具体实现对开发者而言是透明的, 可以随时修改组件代码.
  • 开发者在项目内引入的第三方组件, 它的具体实现对开发者来说是黑箱, 代码不可修改.
结论:
在React大行其道的今天, WC只在第一种观点下有意义,
它提供了一种与框架无关的组件封装能力, 这是现存的MVVM框架无法做到的.
同时, MVVM框架们丰富的功能和持续演进的范式对WC来说也是不可能得到的东西.
WC没有解决传统组件的样式问题, 无法让组件与不同的设计系统兼容.
对于网站内部开发的组件来说, 除非网站开发人员经常需要变更MVVM框架, 或同时使用了多种MVVM框架,
否则缺乏使用WC而不是MVVM框架进行组件封装的理由(由于WC与DOM距离更近, 组件的开发难度总是高于MVVM框架).
Shadow DOM是一项 可选的功能, Shadow DOM的存在使Web Components与现有的其他技术有了决定性不同.
Shadow DOM真的如同技术营销所说的那样有意义吗?
人们难道不是已经在一个只有Light DOM的世界里生存了这么久吗?
Shadow DOM似乎是在满足两个并不存在的需求:
  • 阻断脚本对组件内部DOM的访问.
    这一点明显缺乏实用性, 以至于根本想不出除避免受用户脚本操作以外的存在意义.
  • 阻断外部样式对组件内部DOM的影响.
    这一点无论对于原子化CSS还是语义化CSS来说都不重要, 因为中型以上的项目根本不会使用类名以外的方式为元素设置样式.
    基于选择器的方案是难以维护的, 注定会陷入仅追加样式的窘境.
    既然样式只受类名影响, 又有什么必要隔离外部样式呢?
attachShadow({ mode: 'open' })
此模式可以让宿主环境的代码通过根元素的shadowRoot属性访问到Shadow DOM,
例如从外部修改Shadow DOM里的元素属性.
attachShadow({ mode: 'closed' }) 此模式下,
根元素的shadowRoot属性为null,
因此无法从外部访问(除非组件在创建时将attachShadow的返回值存储并暴露在外).
const originalAttachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function () {
return originalAttachShadow.call(this, { mode: 'open' })
}
Chrome提供了一个API chrome.dom.openOrClosedShadowRoot 用于解决此问题, 但该API没有文档记录.
Slot插槽将Light DOM的元素填充(实际上像是映射或者链接)到自定义元素的Shadow DOM里
(插槽位于Shadow DOM内, 填充的元素则来自Light DOM), 这使得填充元素在Light DOM里的样式仍然能生效.
由于Shadow DOM隔离了宿主, 所以无法在外部为内部元素设置样式.
因此, Web Components的样式通常通过CSS变量(或组件本身的attribute)来间接设置.
指望组件的制作者将所有的样式都做成CSS变量是荒谬的,
因此为Shadow DOM内的元素设置样式仍然存在很大障碍.
与Shadow DOM相对的概念是来自文档的Light DOM.
由于Shadow DOM的宿主(自定义元素本身)处于Light DOM,
所以可以通过CSS选择器给自定义元素的容器本身设置样式.
来自Light DOM的样式会优先于Shadow DOM里通过 :host 伪元素设置的样式.
在一些情况下, 我们可能只是希望用Web Components引入一种行为(即JavaScript代码),
而我们又希望像Light DOM那样完全控制组件的样式.
这时, 实际上可以将Web Components设置为Light DOM, 如此一来其内部元素就会完全暴露于Light DOM.
// LitElement LightDOM
createRenderRoot() {
return this
}
// 对于一般CustomElement用例, 只需要用this替代this.shadowRoot
这种场景暗示了使用Light DOM的Web Components的存在意义,
如果我们使用Shadow DOM的第三方的Web Components,
则它通常只能是以下项目之一:
  • 一个经过良好设计的组件库(提供丰富的基于变量的定制选项, 例如Ant Design和Bootstrap)
  • 一个足够原子的组件库(因此绝大多数样式都直接设置在根元素和slot上)
  • 一个Widget(例如Twitter的Widget, 拥有自己的设计语言, 注定打破网站的整体设计)
如果第三方的Web Components只是提供行为封装, 则在使用Light DOM的情况下,
为组件添加样式会比Shadow DOM自由得多.
这也意味着使用组件的人需要知道样式里有哪些类型的元素, 及其对应的class, 这 仍然会成为问题.
最初于Elix组件库发现此方案, 其具体做法是用自定义的组件替换掉组件里的内部组件.
(Elix还有一些其他的组件自定义方式, 例如模板修补, 通常耦合性更高, 可以预料到会产生较高的维护成本)
基于名称的内部组件替换:
<!-- 提供elix-carousel -->
<script type="module" src="node_modules/elix/define/Carousel.js"></script>
<!-- 提供my-arrow-button -->
<script type="module" src="MyArrowButton.js"></script>
<!-- 提供my-page-dot -->
<script type="module" src="MyPageDot.js"></script>
<body>
<elix-carousel
arrow-button-part-type="my-arrow-button"
proxy-part-type="my-page-dot"
>
<img src="image1.jpg" />
<img src="image2.jpg" />
<img src="image3.jpg" />
</elix-carousel>
</body>
基于继承的内部组件替换:
import * as internal from 'elix/src/internal.js'
import Carousel from 'elix/define/Carousel.js'
import MyArrowButton from 'MyArrowButton.js'
import MyPageDot from 'MyPageDot.js'
class MyCarousel extends Carousel {
get [internal.defaultState]() {
return Object.assign(super[internal.defaultState], {
arrowButtonPartType: MyArrowButton // 替换为MyArrowButton
, proxyPartType: MyPageDot // 替换为MyPageDot
})
}
}
该方案的缺点在于, 用户需要较多的知识才能创建可被替换的自定义组件.
在不使用TypeScript这样支持接口的语言时, 这种模式很难使用.
Elix不适合此方案, 因为学习成本很高:
  • 由JavaScript开发, 所有的接口都是由文档定义的
  • 组件之间的继承关系复杂
该方案当前不如LightDOM:
同为使用CSS选择器自上而下定义样式的方案, 该方案的CSS选择器却受到Shadow DOM的严重限制.
该草案在Shadow Host里定义了一种 ::part() 伪元素, 允许将Shadow Tree里的特定元素公开.
<style>
component::part(textspan) {
color: red;
}
</style>
<!-- Shadow Tree in component -->
<span part="textspan">This text will be red</span>
注意: 尽管part可将特定元素公开, 但无法通过伪元素继续匹配该元素的后代, 也不能穿透多层Shadow Tree.
因此, 当Web组件内部嵌套其他Web组件时, 将无法设置深层的Web组件的样式.
以上问题进而衍生出exportparts属性, 允许导出深层的Web组件的part.
带有重命名的导出:
<style>
component::part(textspan) {
color: red;
}
</style>
<!-- Shadow Tree in component -->
<!-- 用exportparts公开更深层的part, 多个导出用逗号隔开 -->
<inner-component exportparts="innerspan: textspan"></inner-component>
<!-- Shadow Tree in inner-component -->
<span part="innerspan">红色</span>
<span part="textspan">不是红色</span>
示例2: 直接导出
<style>
component::part(innerspan) {
color: red;
}
</style>
<!-- 用exportparts公开更深层的part, 多个导出用逗号隔开 -->
<inner-component exportparts="innerspan"><</inner-component>
<!-- Shadow Tree in inner-component -->
<span part="innerspan">红色</span>
该方案仍然存在问题, 即 导出深层元素的全部属性, 因此有提案建议引入通配符.
::part() 基本一样, 但对Shadow Tree具有穿透效果, 因此可以避免定义大量exportparts.
Web Components基本上无法使用像React一样的高阶组件:
  • Custom Elements必须在注册之后才能被重用.
    渲染时, 需要提供被包装组件的名称, 而被包装组件此时可能只是一个临时组件, 既没有注册也不应该注册.
  • DOM里的HTMLElement是不能通过new运算符实例化的, Custom Elements规范也缺乏对渲染方法的定义.
    这两点使得通用的高阶组件在Web Components里难以实现, 因为至少需要约定一个用于渲染的render方法.
Elix项目使用mixins进行代码重用, mixins的代码重用是基于继承类实现的,
相比React的高阶组件仍有一些缺陷(参考react.org),
因此需要开发人员约定一些代码规范.
Web Components之间的嵌套很可能会把问题复杂化.
只有在组件是不可修改, 且代码无法提取的情况下, 组件嵌套才有必要性.
我们有一个IPFS图片组件, 我们明白这实际上是IPFS资源载入组件 + 图片展示组件(img),
所以我们可能想将IPFS资源载入组件单独拆分出来, 最后得到这样的代码:
<ipfs-fetch address="Qmxxxxx" .onload=${() => {
const img = this.querySelector('img')
img.src = this.blob
}}>
<template>
<img>
</template>
</ipfs-fetch>
在IPFS资源载入组件和图片展示组件之间, 势必有一个具备接口知识的中间层将二者连接起来.
然而, 我们可能根本就不需要ipfs-fetch, 只是需要ipfs-image, fetch功能完全可以只用JavaScript模块进行替代.
如果我们需要对ipfs-image里的img进行自定义, 也可以通过组件替换的方式实现.