同构 Web 组件
Web 组件若能在服务器上渲染,那它们就真的名副其实了。
或者真的可以吗?
一直以来,人们普遍认为Web组件无法在服务器端渲染,许多人基于这一(所谓的)缺陷来评价Web组件。
我要高兴地告诉大家,这些担忧都是多余的:你完全可以在服务器端渲染一个Web组件。
但这有几种不同的实现方式。
当前格局
让我们从头说起。
Web 组件的基石——模板元素、自定义元素和(声明式的)影子DOM——都只是HTML标签。
因此,严格来说,服务器端渲染一个Web组件是小菜一碟:只需在你的标记中放入一个<template>
或<custom-element>
标签。
我这么说可能有点轻描淡写,但这一点已经非常强大了!
自定义元素让你能够在光DOM的特定位置附加逻辑。
你不是在单独的JavaScript文件中声明那个附加点,而是可以直接在你的HTML标记中完成。
这种策略——使用没有模板或影子DOM的自定义元素,来增强已经存在的光DOM元素——被称为HTML Web 组件。
你甚至不需要引入JavaScript就可以使Web组件发挥作用。
Hawk Ticehurst分享了他称之为CSS Web组件的模式,其中自定义元素属性被用作CSS选择器的钩子。
这为我们提供了一个类似props的API,可以在不写任何JavaScript代码的情况下修改组件的外观。
但这些并不是人们通常所说的。
“Web组件”实际上只是这三个API的总称,但实际上人们用它来指代通过继承HTMLElement
向自定义元素添加客户端行为。
当他们谈论Web组件的服务器端渲染时,他们的意思是在服务器上运行那个子类,并让它输出它在客户端生成的标记。
这种方法——在两个不同的环境中运行相同的代码——被称为同构。
这是大多数主要JavaScript框架处理服务器端渲染的方式。
但出于某种原因,用Web组件实现它的资源非常稀少。
同构纯粹是JavaScript框架的事情,还是有更标准的方法?
如果你打开HTML自定义元素规范并搜索“服务器”这个词,你会得到两个结果,它们都与表单处理有关。
你在DOM规范中根本找不到它。
你可能会想,“等一下——声明式影子DOM不是Web组件服务器端渲染的规范吗?”
答案是:并不是。
声明式影子DOM定义了一种在HTML中设置影子根的方法(即不需要JavaScript)。
但许多Web组件根本不使用影子DOM——它们渲染普通的光DOM。
而且,尽管规范详细说明了HTML如何被解析成影子根,但它并不关心HTML是如何生成的。
这很有道理!
冒着显而易见的风险:浏览器规范是为浏览器编写的。
它们关心的是浏览器如何解释它接收到的HTML;HTML是如何被创建的,不在它们的管辖范围内。
好的,没有W3C的官方指导。
现在怎么办?
此时,像Lit、WebC和Enhance这样的库进入了讨论。
这些工具以Web组件作为输出格式:你用它们构建一个组件,最后你得到一个可以在任何网站或Web应用中使用的自定义元素。
它们中的许多还允许你在服务器上将该组件渲染为静态HTML。
问题解决了,对吧?
并不完全。
你在这些库中编写的代码可能看起来并不像“纯”Web组件。
这是一个Enhance元素的示例:
export default function MyElement({ html, state }) {
const { attrs } = state;
const { name } = attrs;
return html`
<p>Hello${name}!</p>
<style>
p {
color: rebeccapurple;
}
</style>
`;
}
你会原谅自己以为是在看一个React组件!
坦白说,我看不出这些和像Svelte或Vue这样的框架之间有什么根本区别,它们也可以使用Web组件作为编译目标。
要清楚,我并不是在贬低这些。
这些都是好工具——Web组件库和JavaScript框架都是。
最终,它们都:
让你构建可服务器端渲染的Web组件,如果你… 使用它们的自定义API和/或语言,并且… 将它们作为依赖项包含在你的项目中。
这让我感到不安。
当我选择编写Web组件而不是Svelte组件时,我的主要原因之一是直接与Web平台合作。我不想添加构建步骤或改变我的代码编写方式。
Web组件最酷的事情之一是它们充当了解耦层。
不管一个组件是用Enhance、Lit或其他任何东西构建的;我可以将它放入Svelte应用、Astro站点、Markdown文件或手写的HTML页面中,它都能正常工作,而我对此一无所知。
这就是为什么我对与特定库绑定的服务器端渲染解决方案不太兴奋。
互操作性承诺被打破了——或者至少,被削弱了。
组件是如何构建的不再只是一个实现细节。
我为前端选择的东西现在对后端产生了影响,反之亦然。
因此,目标是采用现有的Web组件并在服务器端渲染它们:
从一个在浏览器中已经工作的Web组件开始。 这个组件将经受时间的考验! 没有需要更新的依赖项,没有需要维护的服务器,没有可能莫名其妙停止工作的构建过程。 当然,有JavaScript可能会中断的原因,但总的来说,这个组件将可靠地永远工作。 将该组件渲染到交付给浏览器的HTML中。 这引入了一个依赖项和构建过程可能会中断,但没关系——如果最坏的情况发生,组件仍然可以仅通过客户端渲染工作。
这有点像是反向渐进增强。
我们不是从HTML开始,用JavaScript增强它,而是从JavaScript开始,用HTML增强它。
请注意,这与传统的渐进增强并不相互排斥!
有了这种策略,即使JavaScript未能加载,组件的服务器端渲染HTML仍然可以提供基本功能。
生成HTML的相同代码后来在浏览器中运行是一个实现细节。
奇怪的是,网上似乎没有太多关于这种方法的讨论。
如果有人在做,他们并没有真正谈论它。
这就是为什么我决定构建一个概念验证。
同构渲染
我们先尝试服务器端渲染这个Web组件:
customElements.define("greet-person", class extends HTMLElement {
connectedCallback() {
const name = this.getAttribute("name");
this.innerHTML = `<p>Hello,${name}!</p>`;
}
});
我们希望能够在我们的HTML中这样写:
<greet-personname="Jake"></greet-person>
…并将其展开为这样:
<greet-personname="Jake">
<p>Hello, Jake!</p>
</greet-person>
如果我们要采用任何在客户端工作的Web组件,这意味着我们需要一种方法来模拟DOM。
有很多库可以做到这一点,但我们将使用一个名为Happy DOM的库。
有了Happy DOM,实际渲染的代码非常短:
import { Window } from"happy-dom";
const globals = newWindow();
global.document = globals.document;
global.customElements = globals.customElements;
global.HTMLElement = globals.HTMLElement;
exportasyncfunctionrender(html: string, imports:Array<() =>Promise<void>> = []) {
awaitPromise.all(imports.map((init) =>init()));
document.documentElement.innerHTML = html;
returndocument.documentElement.getHTML({ serializableShadowRoots: true });
}
在模块的顶层,我们创建了一个Happy DOMWindow
。
就像在浏览器中一样,Window
的一个实例包含了所有可用的全局变量——HTMLElement
、customElements
,等等。
我们将这些全局变量设置在Node的global
对象上,这使得它们对所有模块都可用。
我们只需要定义一个函数就可以使这个工作。
我们将称之为render
,它将接受两个参数:作为字符串渲染的HTML,以及导入Web组件类的函数数组。
在等待这些函数的返回值之后,我们将窗口的documentElement.innerHTML
设置为我们传入render
函数的字符串,然后将模拟的DOM序列化为HTML字符串并返回。
我们这样调用render
函数:
import { render } from "./render.js";
const html = `<!doctype html>
<html lang="en">
<body>
<greet-person name="Jake"></greet-person>
</body>
</html>
`;
const result = await render(html, [
() => import("./greet-person.js")
]);
这里重要的是,我们需要在导入Web组件之前导入render
函数。
这样,当Web组件被声明时,它们依赖的所有浏览器API都已经在Node的全局作用域上可用。
这对光DOM中的Web组件有效。
那么影子DOM呢?
让我们更新我们的组件:
customElements.define("greet-person", class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open", serializable: true });
this.shadowRoot.innerHTML = "<p>Hello, <slot></slot>!</p>";
}
});
如果你之前使用过影子DOM,这可能看起来很熟悉。
一件可能对你来说是新事物——或者至少,对我来说是——是可序列化的影子根:可序列化只读属性 - Web API | MDN可序列化只读属性返回true,如果影子根是可序列化的。developer.mozilla.org/en-US/docs/Web/API/ShadowRoot/serializable property,它指示元素将影子根渲染为HTML。
现在,如果我们在标记中放入这个:
<greet-person><span>Jake</span></greet-person>
…它将展开为这样:
<greet-person>
<templateshadowrootmode="open"shadowrootserializable="">
<p>Hello, <slot></slot></p>
</template>
<span>Jake</span>
</greet-person>
就这样:同构Web组件。
结论
无论如何,你今天可以服务器端渲染Web组件:
使用模板、自定义元素和声明式影子DOM不需要特殊工具 Web组件库如Lit、Enhance和WebC让你可以编写既可以服务器端渲染也可以编译为客户端Web组件的代码 模拟DOM让你可以编写同构Web组件,它们在服务器和浏览器中都能工作
聪明的人可能会对最佳方法有不同的看法,但我倾向于同构渲染。
它适用于所有Web组件,无论它们是如何编写的。
它完全拥抱Web平台API,而不是将它们视为编译目标。
它通过优雅地降级到客户端渲染,使我们的组件对工具链熵变得有韧性。
即使你不同意我的看法,也有适合你的服务器端渲染解决方案。
这就是Web的好处:它就是这么灵活。