JavaScript 的声明式 Shadow DOM(DSD)与 SSR 集成:实现 Web Components 在服务端渲染的流式水合协议

各位同仁,下午好!

今天,我们将深入探讨一个在现代Web开发中日益重要的话题:如何将JavaScript的声明式Shadow DOM(Declarative Shadow DOM, DSD)与服务端渲染(Server-Side Rendering, SSR)技术无缝集成,从而实现Web Components的流式水合(Streaming Hydration)协议。这不仅仅是技术栈的叠加,更是对用户体验、性能优化以及开发效率的全面提升。

1. 现代Web开发的挑战:性能与交互的平衡

在Web发展的早期,页面以静态HTML为主,交互性通过简单的JavaScript片段实现。随着富客户端应用的兴起,JavaScript框架(如React, Vue, Angular)占据主导,它们通过客户端渲染(CSR)提供了极致的交互体验。然而,CSR也带来了显著的性能问题:

  • 首次内容绘制(FCP)延迟: 用户需要等待JavaScript下载、解析、执行,才能看到页面内容。
  • 首次输入延迟(FID): 即使内容可见,页面也可能因为JavaScript的忙碌而无法响应用户输入。
  • 搜索引擎优化(SEO)挑战: 搜索引擎爬虫对纯JavaScript渲染的内容索引能力有限。

为了解决这些问题,服务端渲染(SSR)应运而生。SSR允许服务器预先生成HTML,然后发送给客户端,从而显著改善FCP和SEO。但SSR并非万能药,它引入了“水合”(Hydration)的挑战。

1.1 传统SSR与水合的困境

当我们谈论SSR时,通常指的是将客户端框架的代码在服务器上运行,生成静态HTML。这个HTML被发送到浏览器后,客户端的JavaScript会接管,重新构建组件树,并附加事件监听器,使页面变得可交互。这个过程就是“水合”。

传统水合的常见问题:

  • 双重工作: 客户端需要重新执行与服务器相同的工作来构建DOM树。
  • 阻塞主线程: 水合过程可能耗时,阻塞主线程,导致页面在一段时间内无法响应用户输入。
  • “水合不匹配”: 如果服务器渲染的DOM与客户端期望的DOM不一致,可能导致页面闪烁或错误。
  • Web Components的特殊挑战: 传统的Shadow DOM是“命令式”的,它需要JavaScript来创建和附加。这意味着,即使Web Component的骨架HTML通过SSR发送到客户端,其封装的Shadow DOM内容也必须等待JavaScript加载并执行后才能呈现。这导致了“无样式内容闪烁”(Flash of Unstyled Content, FOUC)的变体——“无封装内容闪烁”(Flash of Unencapsulated Content, FOUC-EC)或“内容布局偏移”(Content Layout Shift, CLS)。

想象一下,一个使用Shadow DOM封装的日期选择器,在SSR后,用户可能首先看到的是未封装的原始 <template> 内容,甚至是没有样式的元素,直到对应的JavaScript加载并将其转换为真正的Shadow DOM。这无疑损害了用户体验。

2. Web Components与Shadow DOM的价值

Web Components是一套W3C标准,旨在让开发者创建可复用、封装性强的自定义元素。它包含四个主要技术:

  • Custom Elements: 允许定义新的HTML标签。
  • Shadow DOM: 提供DOM和CSS的封装,将组件的内部结构与外部文档隔离。
  • HTML Templates: <template><slot> 标签,用于定义可复用的标记结构。
  • ES Modules: 用于模块化JavaScript。

Shadow DOM是Web Components实现强大封装性的核心。它创建了一个独立的DOM子树,与主文档DOM完全隔离。这意味着:

  • CSS隔离: Shadow DOM内部的CSS不会泄漏到外部,外部的CSS也不会影响到内部,除非明确通过CSS变量等机制暴露。
  • DOM隔离: 内部DOM结构不会与外部DOM冲突,也不会被外部JavaScript轻易访问和修改。

然而,正如前面提到的,传统Shadow DOM的命令式特性是其在SSR场景下的主要障碍。

3. 声明式Shadow DOM (Declarative Shadow DOM, DSD) 的诞生

为了解决传统Shadow DOM与SSR的集成问题,W3C孵化了声明式Shadow DOM提案。DSD的核心思想是允许开发者在HTML中直接声明Shadow DOM,而不是通过JavaScript动态创建。

3.1 DSD的语法与机制

DSD通过一个特殊的 <template> 标签来实现:

<my-custom-element>
  <template shadowroot="open">
    <style>
      :host {
        display: block;
        border: 1px solid blue;
        padding: 10px;
        margin: 10px;
      }
      h2 { color: navy; }
      p { font-size: 0.9em; }
    </style>
    <h2>这是我的自定义组件标题</h2>
    <p>这是Shadow DOM内部的内容。</p>
    <slot name="footer"></slot>
  </template>
  <p slot="footer">这是外部传入的页脚内容。</p>
</my-custom-element>

当浏览器解析这段HTML时,如果支持DSD,它会在 <my-custom-element> 元素被创建时,自动将其内部带有 shadowroot="open"(或 shadowroot="closed")属性的 <template> 元素作为Shadow Root附加到该自定义元素上。

  • shadowroot="open":表示这个Shadow Root是开放的,可以通过JavaScript的 element.shadowRoot 属性访问。这是最常用的模式。
  • shadowroot="closed":表示这个Shadow Root是封闭的,不能通过 element.shadowRoot 访问,增强了封装性(尽管并非绝对安全)。

关键机制:

  1. 浏览器原生解析: 最重要的区别在于,DSD <template> 是由浏览器HTML解析器在DOM构建阶段直接处理的。这意味着,在任何JavaScript代码执行之前,Shadow DOM的内容(包括其CSS)就已经作为HTML文档的一部分被解析并呈现在屏幕上。
  2. 即时视觉呈现: 用户无需等待JavaScript加载,即可看到带有正确封装和样式的Web Component内容。这彻底消除了FOUC-EC。
  3. 水合准备: 当自定义元素的JavaScript定义加载并注册后,它会发现一个已经存在的Shadow Root。此时,元素只需要执行其构造函数和 connectedCallback 等生命周期方法,并附加事件监听器即可。

3.2 DSD带来的核心优势

  • 零JS FOUC-EC: 在JavaScript加载之前,Shadow DOM的内容和样式就已经可见并正确应用。
  • 更好的性能: 减少了客户端JavaScript创建DOM和附加Shadow Root的工作量,加快了FCP和LCP(Largest Contentful Paint)。
  • 渐进增强: 即使JavaScript加载失败或被禁用,用户也能看到部分内容和结构。
  • 简化SSR逻辑: 服务器只需要生成包含DSD <template> 的HTML,而无需担心客户端的Shadow DOM创建逻辑。
  • 搜索引擎友好: Shadow DOM内部的内容现在是HTML文档的一部分,更容易被搜索引擎抓取和索引。

4. DSD与SSR的集成:实现流式水合协议

现在,我们来深入探讨DSD如何与SSR协同工作,实现Web Components的流式水合协议。

流式水合(Streaming Hydration) 在这里意味着:

  1. 即时视觉流: 服务器发送的HTML包含DSD,浏览器在接收到HTML片段时,能够立即解析并渲染出Web Component的封装内容和样式。用户无需等待整个页面HTML接收完毕或JS加载,就能看到组件的骨架和样式。
  2. 渐进交互流: 随着JavaScript的按需加载和执行,组件逐步变得可交互。由于DSD已经处理了DOM结构和样式,JavaScript只需关注事件绑定、状态管理和动态行为,而不需要重建DOM。

4.1 服务端渲染Web Components与DSD

在服务器端,我们需要一个能够渲染Web Components并生成DSD <template> 结构的机制。这通常涉及到:

  1. 自定义元素定义: 服务器需要访问与客户端相同的自定义元素定义。
  2. 虚拟DOM或字符串渲染: 使用一个库或框架,能够在服务器环境中模拟Web Component的生命周期,并将其渲染为包含DSD的HTML字符串。

示例:使用Node.js和Lit(或纯JS)进行SSR

假设我们有一个简单的计数器Web Component。

my-counter.js (客户端和服务器共享)

// my-counter.js
class MyCounter extends HTMLElement {
  static observedAttributes = ['initial-count'];

  constructor() {
    super();
    // 在DSD场景下,如果Shadow Root已经存在,我们不需要再次创建
    // 而是直接使用它。
    // 如果没有DSD,或者在客户端动态创建,这里才会执行attachShadow
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' });
      this.render(); // 第一次渲染,或在客户端动态创建时
    } else {
      // Shadow Root已由DSD创建,只需初始化组件状态和事件
      console.log('Shadow Root already exists via DSD.');
    }

    this.count = parseInt(this.getAttribute('initial-count') || '0', 10);
  }

  connectedCallback() {
    console.log('MyCounter connectedCallback');
    // 确保在连接到DOM时,Shadow DOM内容是最新的
    if (this.shadowRoot) {
      this.updateContent();
      this.addEventListeners();
    }
  }

  disconnectedCallback() {
    console.log('MyCounter disconnectedCallback');
    this.removeEventListeners();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'initial-count' && oldValue !== newValue) {
      this.count = parseInt(newValue, 10);
      if (this.shadowRoot) {
        this.updateContent();
      }
    }
  }

  render() {
    // 只有在没有DSD,或者第一次在客户端动态创建时才需要完整渲染
    // DSD会提供初始HTML,客户端JS只需要更新和绑定事件
    if (!this.shadowRoot) { // 仅在没有DSD的情况下执行此逻辑
      this.shadowRoot.innerHTML = `
        <style>
          :host { display: inline-block; padding: 5px; border: 1px solid #ccc; border-radius: 4px; }
          button { margin: 0 5px; padding: 5px 10px; cursor: pointer; }
          span { font-weight: bold; }
        </style>
        <button id="decrement">-</button>
        <span id="count">${this.count}</span>
        <button id="increment">+</button>
      `;
    }
  }

  updateContent() {
    const countSpan = this.shadowRoot.getElementById('count');
    if (countSpan) {
      countSpan.textContent = this.count;
    }
  }

  addEventListeners() {
    const decrementButton = this.shadowRoot.getElementById('decrement');
    const incrementButton = this.shadowRoot.getElementById('increment');

    if (decrementButton && incrementButton) {
      this.boundDecrement = this.decrement.bind(this);
      this.boundIncrement = this.increment.bind(this);
      decrementButton.addEventListener('click', this.boundDecrement);
      incrementButton.addEventListener('click', this.boundIncrement);
    }
  }

  removeEventListeners() {
    const decrementButton = this.shadowRoot.getElementById('decrement');
    const incrementButton = this.shadowRoot.getElementById('increment');
    if (decrementButton && incrementButton) {
      decrementButton.removeEventListener('click', this.boundDecrement);
      incrementButton.removeEventListener('click', this.boundIncrement);
    }
  }

  decrement() {
    this.count--;
    this.updateContent();
  }

  increment() {
    this.count++;
    this.updateContent();
  }
}

// 客户端会注册这个自定义元素
// customElements.define('my-counter', MyCounter); // 客户端JS会做这个

server.js (Node.js Express 示例)

// server.js
import express from 'express';
import { fileURLToPath } from 'url';
import path from 'path';
import { JSDOM } from 'jsdom'; // 用于在Node.js中模拟DOM环境
// 导入自定义元素定义,确保在Node.js环境中也能访问
import { MyCounter } from './my-counter.js'; 

const app = express();
const port = 3000;

// 获取当前模块的目录名
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 静态文件服务,用于客户端JS
app.use(express.static(path.join(__dirname, 'public')));

// 定义一个函数来渲染自定义元素为DSD HTML
function renderMyCounterDSD(initialCount) {
  // 模拟一个临时的DOM环境来创建和渲染Web Component
  const dom = new JSDOM(`<!DOCTYPE html><body></body>`);
  const { window } = dom;
  global.window = window;
  global.document = window.document;
  global.HTMLElement = window.HTMLElement; // 确保HTMLElement可用
  global.customElements = window.customElements; // 确保customElements可用

  // 在模拟的DOM环境中注册自定义元素
  // 确保在每次渲染前,如果有同名元素,先移除旧的注册
  if (customElements.get('my-counter')) {
    // JSDOM 不支持直接 unregister custom elements
    // 这里的策略是每次都创建一个新的 JSDOM 实例,确保环境干净
  } else {
    customElements.define('my-counter', MyCounter);
  }

  // 创建一个自定义元素实例
  const counterElement = document.createElement('my-counter');
  counterElement.setAttribute('initial-count', initialCount);

  // 模拟连接到DOM,触发connectedCallback
  document.body.appendChild(counterElement);

  // 在这里,我们需要手动生成DSD的template结构
  // 这是因为JSDOM本身不会自动创建 <template shadowroot>
  // 实际的框架(如Lit SSR插件)会自动化这个过程
  const shadowRootContent = `
    <style>
      :host { display: inline-block; padding: 5px; border: 1px solid #ccc; border-radius: 4px; }
      button { margin: 0 5px; padding: 5px 10px; cursor: pointer; }
      span { font-weight: bold; }
    </style>
    <button id="decrement">-</button>
    <span id="count">${counterElement.count}</span>
    <button id="increment">+</button>
  `;

  // 生成包含DSD的HTML字符串
  const DSD_HTML = `
    <my-counter initial-count="${initialCount}">
      <template shadowroot="open">
        ${shadowRootContent}
      </template>
    </my-counter>
  `;

  // 清理模拟环境
  delete global.window;
  delete global.document;
  delete global.HTMLElement;
  delete global.customElements;

  return DSD_HTML;
}

app.get('/', (req, res) => {
  const initialCount = 5;
  const counterComponentHTML = renderMyCounterDSD(initialCount);

  const html = `
    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>DSD + SSR 流式水合示例</title>
      <style>
        body { font-family: sans-serif; margin: 20px; }
        .container { border: 2px dashed green; padding: 20px; margin-top: 20px; }
      </style>
    </head>
    <body>
      <h1>DSD + SSR Web Components 示例</h1>
      <p>下面的计数器组件内容由服务器渲染,并使用声明式Shadow DOM。</p>

      <div class="container">
        ${counterComponentHTML}
      </div>

      <p>客户端 JavaScript 将在加载后使其可交互。</p>

      <!-- 客户端 JavaScript -->
      <script type="module" src="/my-counter-client.js"></script>
    </body>
    </html>
  `;
  res.send(html);
});

app.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`);
});

public/my-counter-client.js (客户端 JavaScript)

// public/my-counter-client.js
// 导入自定义元素定义
import { MyCounter } from '../my-counter.js';

// 注册自定义元素
// 当浏览器解析到 <my-counter> 标签时,如果它已经由DSD创建了Shadow Root,
// 那么在 customElements.define 之后,MyCounter 的 constructor 将会被调用,
// 然后是 connectedCallback。
// DSD确保在 constructor 之前 Shadow Root 已经存在。
customElements.define('my-counter', MyCounter);

console.log('my-counter-client.js loaded and custom element defined.');

解释:

  1. 服务器端渲染 (server.js):
    • 我们使用 JSDOM 在Node.js环境中模拟一个浏览器DOM环境。这是为了能够像浏览器一样“实例化” MyCounter 并获取其内部状态,以便生成正确的DSD内容。
    • renderMyCounterDSD 函数负责创建 my-counter 元素,并根据其 initial-count 属性生成包含 DSD <template shadowroot="open"> 的HTML字符串。注意,这里我们是手动构造了 shadowRootContent,在实际生产中,会使用专门的库(如 @lit-labs/ssr)来自动化这个过程。
    • 生成的HTML字符串被嵌入到整个页面的HTML中。
  2. 客户端水合 (public/my-counter-client.js):
    • 浏览器接收到完整的HTML后,会立即解析 <my-counter> 元素及其内部的 DSD <template shadowroot="open">
    • 在 JavaScript 文件 (my-counter-client.js) 加载并执行之前,用户就已经看到了带有正确计数(initial-count)和样式的计数器组件。
    • 一旦 my-counter-client.js 加载,customElements.define('my-counter', MyCounter) 会被调用。
    • 浏览器会找到所有已经存在的 <my-counter> 元素,并将其升级为 MyCounter 类的实例。
    • MyCounterconstructor 会被调用,它会检测到 this.shadowRoot 已经存在(由DSD创建),所以它不会再次调用 attachShadow 或重新渲染HTML。
    • 接着 connectedCallback 会被调用,此时组件会附加事件监听器(click 事件到按钮)。
    • 至此,Web Component 完全水合,变得可交互。

4.2 DSD与流式水合协议的协同工作流程

阶段 传统SSR + 命令式Shadow DOM DSD + SSR 流式水合 优势
服务器 渲染Web Component的骨架HTML(如 <my-component></my-component>)。 渲染Web Component的骨架HTML,并包含DSD <template shadowroot="open"> 结构。 DSD生成完整的封装内容。
浏览器接收HTML 浏览器解析HTML。 浏览器解析HTML。 相同。
首次内容绘制 (FCP) 仅显示Web Component的骨架(无样式,无内部内容)。用户看到FOUC-EC。 立即显示完整的Web Component,包含封装的DOM和样式。 消除FOUC-EC,用户体验显著提升。
JavaScript加载 浏览器下载并解析Web Component的JS。 浏览器下载并解析Web Component的JS。 相同。
JavaScript执行 JS执行 customElements.define()。对于每个实例,执行 attachShadow(),动态创建Shadow DOM,注入内容和样式,然后附加事件监听器。 JS执行 customElements.define()。对于每个实例,检测到DSD已创建Shadow Root。直接使用现有Shadow Root,附加事件监听器。 减少JS工作量: 无需创建DOM和注入内容,只需附加事件。更快的交互时间。
可交互时间 (TTI) 较晚。需要等待JS创建DOM并水合。 显著提前。 JS只需绑定事件,无需DOM操作。 更快的交互性,页面更快响应用户输入。
渐进增强 如果JS失败,用户看不到组件的内部内容和样式。 即使JS失败,用户仍能看到组件的结构和内容。 更好的健壮性和可访问性。

总结: DSD通过将Shadow DOM的创建从JavaScript的运行时推到HTML的解析时,有效地将Web Components的视觉呈现与交互逻辑分离。这使得服务器能够提供一个“即时可见”的Web Component,而客户端JavaScript只需负责将其“激活”为可交互状态。这正是“流式水合”的核心思想:内容按流呈现,交互性按流增强。

5. 高级议题与最佳实践

5.1 数据传递与初始化状态

SSR时,如何将初始数据传递给Web Component是一个关键问题。常见方法包括:

  • HTML Attributes: 如上面的 initial-count。简单且适用于基本类型。
  • *`data-` Attributes:** 适用于更复杂的数据,但需要JS解析。
  • <script type="application/json"> 将JSON数据内联到HTML中,放在自定义元素旁边,然后Web Component在 connectedCallback 中读取。这是传递复杂对象和数组的推荐方式,避免了属性序列化的限制。

示例:使用 <script type="application/json">

<my-complex-component id="my-comp-1">
  <template shadowroot="open">
    <!-- Shadow DOM content here -->
    <p>Component Data: <span id="data-display"></span></p>
  </template>
  <script type="application/json" data-for="my-comp-1">
    {
      "title": "Complex Component",
      "items": ["Item A", "Item B"],
      "isActive": true
    }
  </script>
</my-complex-component>

<script type="module">
  class MyComplexComponent extends HTMLElement {
    constructor() {
      super();
      if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); }
      // ... render initial DSD content or update existing ...
      this.data = {}; // Initialize data
    }

    connectedCallback() {
      // 查找紧随其后的 JSON script 标签
      const dataScript = this.querySelector(`script[type="application/json"][data-for="${this.id}"]`);
      if (dataScript) {
        try {
          this.data = JSON.parse(dataScript.textContent);
          console.log('Component data loaded:', this.data);
          // 更新Shadow DOM中的显示
          if (this.shadowRoot) {
            this.shadowRoot.getElementById('data-display').textContent = JSON.stringify(this.data);
          }
        } catch (e) {
          console.error('Failed to parse component data:', e);
        }
      }
      // ... 其他水合逻辑,如事件监听 ...
    }
  }
  customElements.define('my-complex-component', MyComplexComponent);
</script>

5.2 Scoped CSS与DSD

DSD的 <template shadowroot> 内部的 <style> 标签在浏览器解析时就会被应用,确保了组件的样式在JavaScript加载前就已经就位,完全符合Shadow DOM的样式封装规则。

  • :host 选择器: 针对自定义元素本身(即Shadow Host)的样式。
  • CSS变量: 允许外部通过CSS变量影响内部样式,实现主题化。
  • ::part()::theme() 允许组件暴露内部部分,供外部样式定制。

5.3 Slots与DSD

DSD完全支持HTML <slot> 元素。服务器在渲染DSD时,会将 <slot> 元素作为Shadow DOM的一部分输出。客户端在自定义元素升级后,connectedCallback 中可以正常处理槽点分配。

示例:

<my-card>
  <template shadowroot="open">
    <style>
      .card { border: 1px solid #ddd; padding: 10px; }
      header { font-weight: bold; margin-bottom: 5px; }
      footer { font-size: 0.8em; color: #666; margin-top: 10px; }
    </style>
    <div class="card">
      <header><slot name="header">Default Header</slot></header>
      <main><slot>Default Content</slot></main>
      <footer><slot name="footer">Default Footer</slot></footer>
    </div>
  </template>
  <h3 slot="header">我的卡片标题</h3>
  <p>这是卡片的主体内容。</p>
  <small slot="footer">版权所有 © 2023</small>
</my-card>

当浏览器解析这段HTML时,Shadow DOM会立即被创建,并将外部的 <h3><p><small> 元素正确地分配到对应的槽位中。

5.4 性能优化与懒加载

虽然DSD提升了FCP和LCP,但仍需注意JavaScript的总体大小和加载策略。

  • 关键组件优先水合: 对于首屏可见的重要组件,优先加载其JavaScript进行水合。
  • 非关键组件懒加载: 使用动态 import() 或 Intersection Observer 等技术,按需加载不影响首屏的组件的JavaScript。
  • Hydration Hints: 一些框架(如Next.js / React Server Components)提供了“水合提示”,指示浏览器何时或如何水合特定组件,DSD与这种模式是兼容的。

5.5 错误处理与回退

如果客户端JavaScript加载失败或执行出错,DSD的优势在于,用户至少能看到组件的静态内容。对于关键交互,需要考虑优雅降级策略。

5.6 框架与工具支持

  • Lit: 作为构建Web Components的流行库,Lit Labs SSR 提供了对DSD的良好支持,可以方便地在Node.js环境中将Lit组件渲染为DSD HTML。
  • Enhance.dev: 一个专注于Web Components和SSR的框架,原生支持DSD。
  • Astro: Astro 是一个“岛屿架构”框架,它将页面分解为静态HTML“岛屿”和独立的、水合的JavaScript“岛屿”。DSD非常适合作为其Web Components岛屿的渲染机制。
  • Next.js/Remix: 这些全栈框架虽然主要围绕React构建,但可以通过集成Web Components和DSD来增强其功能,特别是对于需要在不同技术栈之间共享UI组件的场景。

6. DSD的局限性与考量

尽管DSD带来了诸多益处,但也有一些需要考虑的方面:

  • 浏览器兼容性: DSD是一个较新的提案,虽然主流浏览器(Chrome, Edge, Firefox, Safari)已广泛支持,但在一些旧版浏览器中可能需要Polyfill。然而,DSD的Polyfill通常是轻量级的,因为它只需将 <template shadowroot> 转换为命令式 attachShadow
  • 构建工具复杂性: 如果没有像Lit Labs SSR这样的专用工具,手动在服务器端生成DSD的HTML字符串可能会有些复杂。
  • 动态内容: 对于需要频繁更新的组件,DSD提供了初始状态,但后续的动态更新仍然依赖于客户端JavaScript。

7. 总结与展望

声明式Shadow DOM是Web Components发展的一个里程碑,它完美弥补了传统Shadow DOM在SSR场景下的短板。通过将Shadow DOM的创建转移到浏览器解析阶段,DSD实现了Web Components的“零JS FOUC-EC”,显著提升了首次内容绘制和首次输入延迟,并为服务器端渲染的Web Components提供了真正的流式水合能力。

DSD与SSR的结合,为构建高性能、高可用、可维护的现代Web应用开辟了新的道路。它使得Web Components能够更好地融入全栈渲染策略,成为统一前后端UI开发的强大基石。随着DSD的进一步普及和工具生态的成熟,我们可以预见Web Components将会在更广泛的场景中发挥其独特的价值。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注