Shadow DOM实战指南从基础概念到项目应用的完整总结

设计师玉飞 前端 阅读 2,916
赞 10 收藏
二维码
手机扫码查看
反馈

为什么要做这次对比?

最近做组件库重构,被Shadow DOM的各种方案搞得有点懵。以前只是零散地用过Web Components,这次深入对比一下不同的实现方案。说实话,这个技术真的是一言难尽,浏览器兼容性、样式隔离、事件穿透,每个地方都有坑。

Shadow DOM实战指南从基础概念到项目应用的完整总结

我主要对比了原生Web Components、Lit、Stencil这三个方案。其实还有其他的,但大部分都是基于这三个的封装,或者就是React的CSS-in-JS那种玩法。我比较关注实际开发体验,而不是理论上的完美。

原生Web Components:纯粹但痛苦

先说原生的,这个是最纯粹的Shadow DOM实现。我比较喜欢原生的东西,因为没有额外依赖,但这次真让我体会到了什么叫”理想很丰满,现实很骨感”。

class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    
    this.shadowRoot.innerHTML = 
      <style>
        .card {
          border: 1px solid #ddd;
          padding: 16px;
          border-radius: 8px;
        }
      </style>
      <div class="card">
        <slot name="title"></slot>
        <slot></slot>
      </div>
    ;
  }
}

customElements.define('my-card', MyCard);

这个写法看起来简单,但实际项目中你会发现很多问题。比如样式作用域确实隔离了,但调试起来特别麻烦,Chrome DevTools里看到的DOM结构乱七八糟的。还有就是事件处理,shadowRoot外面捕获不到shadowRoot里面的事件,除非显式设置composed: true。

最大的问题是浏览器兼容性。虽然现在主流浏览器都支持了,但在一些老版本或者移动端上还是有问题。我之前在Safari上就遇到过Shadow DOM样式不生效的问题,折腾了半天才发现需要加一些前缀。

Lit:平衡之选

然后是Lit,这个是我现在项目里主要用的方案。相比原生确实省事不少,特别是模板语法和响应式更新这块。

import { LitElement, html, css } from 'lit';

class MyCard extends LitElement {
  static styles = css
    .card {
      border: 1px solid #ddd;
      padding: 16px;
      border-radius: 8px;
    }
  ;

  render() {
    return html
      <div class="card">
        <slot name="title"></slot>
        <slot></slot>
      </div>
    ;
  }
}

customElements.define('my-card', MyCard);

Lit的优势在于它处理了很多原生Web Components的痛点。比如自动处理事件冒泡、更好的属性绑定、内置的响应式系统。我最喜欢的是它的@event语法,写事件处理器特别方便。

不过Lit也不是完美的。包体积相对原生会大一些,而且有时候它的响应式机制会让你摸不着头脑。我记得有一次状态更新后UI没变化,查了半天发现是对象引用没变导致的。

Stencil:复杂项目的解法

Stencil这个比较特殊,它是编译工具,最终生成标准的Web Components。说实话,第一次接触的时候觉得挺神奇的,用TypeScript写组件,最后生成各种格式的代码。

import { Component, Prop, h } from '@stencil/core';

@Component({
  tag: 'my-card',
  shadow: true,
})
export class MyCard {
  @Prop() title: string;

  render() {
    return (
      <div class="card">
        <slot name="title">{this.title}</slot>
        <slot></slot>
      </div>
    );
  }

  private getCardStyle() {
    return 
      .card {
        border: 1px solid #ddd;
        padding: 16px;
        border-radius: 8px;
      }
    ;
  }
}

Stencil的卖点是跨框架兼容,生成的组件可以在React、Vue、Angular等框架中使用。如果你的产品需要被各种技术栈集成,这个确实有优势。但我个人觉得对于单个项目来说,可能有点重了。

Stencil的学习成本也不低,因为它有自己的生命周期、装饰器等等。而且编译后代码量会比较大,这对于对包大小敏感的项目是个问题。

谁更灵活?谁更省事?

从灵活性角度,原生方案肯定是最灵活的,你想怎么玩都行。但灵活性往往意味着更多的工作量和更多bug的可能性。Lit在灵活性和便利性之间找到了不错的平衡点,基本需求都能满足,特殊需求也能通过扩展实现。

Stencil灵活性也不错,但限制在它的编译规则内。比如你不能随便用一些实验性的API,因为要考虑编译后的兼容性。

从开发效率来看,Lit是最快的。模板语法清晰,调试也相对容易。原生需要手写innerHTML,模板复杂的话维护起来很痛苦。Stencil需要编译,开发时的热更新时间也比Lit长一些。

性能对比:差距比我想象的小

我一直以为原生会比封装过的快很多,实际上测试下来差距并不明显。主要是Shadow DOM本身的开销在那里,框架层面的差异影响不大。

但有个细节需要注意,Lit的响应式更新确实比原生手动操作要慢一点点,特别是在高频更新的场景下。不过对于大多数UI组件来说,这种差异几乎感受不到。

Stencil生成的代码在某些情况下会有额外的性能开销,特别是当组件树层级比较深的时候。这是因为它的代理机制会增加一层调用。

我的选型逻辑

如果是在现有React/Vue项目中,我一般选择Lit。它和现代框架配合比较好,学习成本也不高。特别是团队里前端人员技能参差不齐的情况下,Lit的API更容易理解和掌握。

如果是纯原生项目,且对包大小有严格要求,我会考虑原生Web Components。但会自己封装一些基础工具函数来减少样板代码。

Stencil主要用于需要跨框架复用的场景,比如公司级组件库。这种情况下它的优势很明显,虽然开发复杂度高一些,但收益也更大。

React的CSS-in-JS那种方案我没在这次对比范围内,因为它是完全不同的思路。如果你的团队全是React开发者,那个方案可能更合适。

总的来说,没有银弹。每个方案都有自己的适用场景,关键是根据项目实际情况来选择。我个人更倾向于Lit,因为它够用,而且不会让你掉进各种坑里。

踩坑提醒

这里有三个一定要注意的地方:

  • Shadow DOM的样式隔离真的很严格,连全局样式都不会生效,记得预留出一些自定义类名的接口
  • 事件处理要特别小心,很多事件默认不会冒泡到shadowRoot外面
  • 调试的时候记得在DevTools里勾选”Show user agent shadow DOM”

这些坑我都踩过,特别是事件处理那块,花了好几天才搞明白为什么要设置composed: true。

以上是我对这几个Shadow DOM方案的完整对比,有不同看法欢迎评论区交流。这个领域变化很快,说不定过两年又有新的解决方案出现。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论