探索同构服务器端渲染之道

文摘   2024-12-16 07:07   江苏  

同构 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框架都是。

最终,它们都:

  1. 让你构建可服务器端渲染的Web组件,如果你…
  2. 使用它们的自定义API和/或语言,并且…
  3. 将它们作为依赖项包含在你的项目中。

这让我感到不安。

当我选择编写Web组件而不是Svelte组件时,我的主要原因之一是直接与Web平台合作。我不想添加构建步骤或改变我的代码编写方式。

Web组件最酷的事情之一是它们充当了解耦层。

不管一个组件是用Enhance、Lit或其他任何东西构建的;我可以将它放入Svelte应用、Astro站点、Markdown文件或手写的HTML页面中,它都能正常工作,而我对此一无所知。

这就是为什么我对与特定库绑定的服务器端渲染解决方案不太兴奋。

互操作性承诺被打破了——或者至少,被削弱了。

组件是如何构建的不再只是一个实现细节。

我为前端选择的东西现在对后端产生了影响,反之亦然。

因此,目标是采用现有的Web组件并在服务器端渲染它们:

  1. 从一个在浏览器中已经工作的Web组件开始。
  2. 这个组件将经受时间的考验! 没有需要更新的依赖项,没有需要维护的服务器,没有可能莫名其妙停止工作的构建过程。 当然,有JavaScript可能会中断的原因,但总的来说,这个组件将可靠地永远工作。
  3. 将该组件渲染到交付给浏览器的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({ serializableShadowRootstrue });
}

在模块的顶层,我们创建了一个Happy DOMWindow

就像在浏览器中一样,Window的一个实例包含了所有可用的全局变量——HTMLElementcustomElements,等等。

我们将这些全局变量设置在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"serializabletrue });
    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的好处:它就是这么灵活。


编程悟道
自制软件研发、软件商店,全栈,ARTS 、架构,模型,原生系统,后端(Node、React)以及跨平台技术(Flutter、RN).vue.js react.js next.js express koa hapi uniapp Astro
 最新文章