前一段时间在一个内部项目里,我和团队决定用 Lit 重写一个偏展示型的中后台模块。这个模块不算大——几张表格、几个表单、一些动态筛选逻辑——但我们当时实在不想为了这些东西再拖拽一整棵 React 依赖树进来,于是 Lit 进入了视野。这个选择听起来有点"逆潮流",2024 年了,前端圈到处是 React 生态、Vue 生态、编译时加响应式的组合拳,为什么还会有人回头去找一个诞生于 Web Components 时代的库?但正是这种看似"反方向"的选择,让我对"响应式渲染"这件事有了些不一样的体会。
这篇文章我想从一个相对完整的角度来拆解 Lit 的响应式渲染机制,会结合实际的组件代码、更新调度细节、性能特征来展开。适合三类人阅读:第一类是厌倦了框架重依赖、想回归标准 Web 技术的开发者;第二类是已经在 Web Components 边缘试探、想知道 Lit 到底解决了什么问题的朋友;第三类纯粹是想理解"不依赖虚拟 DOM 的响应式渲染到底怎么做"的好奇者。不管你是哪一种,希望这篇内容能给你带来一些新的思考角度。
1. 当整个前端圈都在卷框架时,Lit 为什么敢说"回归 Web 本质"
1.1 Web Components 才是浏览器亲儿子
先理一个被很多人忽略的事实:React/Vue 的组件模型从第一天开始就没有在浏览器层面得到任何原生支持。React 有 JSX,Vue 有 SFC,这些都需要经过编译才能在浏览器里跑;如果不编译,写起来就是一团字符串拼 HTML,维护性接近零。而 Web Components 不一样,它是浏览器原生的组件标准,由 Custom Elements、Shadow DOM、HTML Templates 三个规范组成,任何现代浏览器都内置支持,不需要框架运行时。
这意味着你用 Lit 写出来的组件,本质上就是一个原生 HTML 标签。你的<my-counter>组件,在浏览器看来和<div>、<span>没有本质区别,其他框架也能直接使用它。这一点是 React/Vue 组件永远做不到的——你不能把一个 React 组件直接塞进 Vue 项目里用(除非通过官方适配器做桥接),但一个 Web Component 可以被任何框架通过原生 DOM 操作方式无缝消费。这种"底层标准"的优势,在需要跨团队、跨技术栈共享组件的场景里体现得淋漓尽致。
1.2 Lit 从 Polymer 到今天的演进逻辑
Lit 的前身是 Polymer,也就是 Google 推出的第一代 Web Components 库。Polymer 一度是 Web Components 阵营最响亮的旗号,但它有一些历史和设计上的包袱——比如依赖 HTML Imports 这种后来被废弃的规范,导致整个项目差点折在标准迭代的半路上。Lit 可以看作是 Google 在 Web Components 标准尘埃落定之后的一次"重做",它吸取了 Polymer 时代的教训,把体积压缩到极致,用现代 JavaScript 的 class 字段和装饰器语法重新设计了开发者体验。
我查过lit和lit-html两个包的 gzip 体积,加在一起大概 5KB 左右,这是 React 加 ReactDOM 的十分之一都不到的体量。对于需要首屏极速加载、离线包体积敏感或有低端移动设备要求的项目,这一条就是天壤之别。更重要的是,Lit 把 Polymer 时代的复杂 API 精简成了几个核心概念:LitElement基类、html模板标签、css样式标标签、@property装饰器,学习曲线一下子平缓了很多。
1.3 "回归 Web 本质"的三种具体表现
"回归 Web 本质"不是一句营销口号,它落地为三个具体表现。
第一,产物不需要框架运行时。Lit 组件被编译后(其实不编译也能用,原生 ES 模块方式直接跑)就是一个标准 JS 类注册的自定义元素,浏览器加载后可以独立使用。早年写 Polymer 组件时需要引入 webcomponents polyfill,如今现代浏览器全量支持 Web Components,真正做到了"写出来就能跑"。
第二,模板就是 HTML,不是一种新语言。lit-html 用的模板语法基于 JavaScript 的模板字符串(Template Literals),而不是引进 JSX 或者 Vue 那种自定义模板编译器。你在<script>标签里写html`<div>...</div>`,解析的就是字符串模板,配合 tagged template 机制做静态分析。这意味着你已有的 HTML/CSS 知识可以直接迁移,不用学习新的编译规则,也没有 JSX 里className这种别扭的替换。对后端转前端的同事来说,Lit 的学习成本尤其低。
第三,样式隔离是浏览器的能力,不是 CSS Module 的魔法。Lit 约定组件样式写在带css标签的模板字符串里,这套样式会被挂到 Shadow DOM 上,天然与其他组件和全局样式隔离。不管你是用 Tailwind 还是手写 BEM,都不用再去纠结类名冲突这种古老的问题了。
这三个点合在一起,就是"回归 Web 本质"最朴素的解释:用浏览器理解的方式写组件,用标准提供的能力做隔离,让组件变成可跨框架、可长期存活的一等公民。
2. 响应式渲染的核心引擎:reactive properties 与更新调度器
这一部分是最重要的,也是我觉得 Lit 设计得最精巧的地方。
2.1 声明一个属性,后续发生了什么
Lit 组件的响应式渲染围绕着"响应式属性"(reactive property)这一概念展开。你可以通过两种方式声明:一是静态properties字段,二是使用@property装饰器。下面这个例子是典型的计数器组件:
import { LitElement, html, css } from 'lit'; import { customElement, property } from 'lit/decorators.js'; @customElement('my-counter') export class MyCounter extends LitElement { @property({ type: Number }) count = 0; @property({ type: String, attribute: 'data-label' }) label = '计数'; render() { return html` <button @click=${this._increment}> ${this.label}: ${this.count} </button> `; } private _increment() { this.count += 1; } }当你给count赋值时,Lit 内部先做一次hasChanged判断(默认是严格不等比较),如果值真的变了,就会触发requestUpdate()。这个requestUpdate()是整个响应式系统的入口,后续所有动作都由它开启。
这里有个很关键的细节:Lit 的响应式属性与浏览器原生的 attribute 之间有对应关系。默认情况下,count这个 property 会映射到 HTML attributecount,但 attribute 的取值都是字符串,所以需要type: Number这个配置告诉 Lit 在反射(reflection)时怎么解析和序列化。没有这个类型标注,attribute 解析可能出错,这也是新手最常踩的坑之一——尤其是 boolean 类型的属性。
2.2 更新调度:单微任务里的批量合并机制
如果每次this.count = xxx都立刻触发一次 DOM 更新,那性能会非常差。Lit 的解法是异步批量更新,大致流程是:
- 首次赋值触发
requestUpdate(); - Lit 会在内部用一个微任务作为合并窗口;
- 在这个微任务触发前,如果又改了其他属性,它们都会进入同一个更新批次;
- 微任务触发后,
performUpdate()执行真正的更新流程。
用伪代码理解大概是这样:
requestUpdate(name, oldValue) { if (this._updateRequested === false) { this._updateRequested = true; this._updatePromise = new Promise((res) => this._resolveUpdate = res); queueMicrotask(() => this.performUpdate()); } }queueMicrotask的时机早于setTimeout、也早于下一帧,这使得批量更新的延迟极低,同时又能保证一帧里多次修改只触发一次渲染。我们写 React 时习惯用useEffect来观察副作用,而在 Lit 里你其实是在"属性赋值"这个源头就已经在进行状态管理了,副作用由框架统一调度。这种设计极大地简化了状态同步的心智负担——开发者只需要关心"值变没变",不需要关心"什么时候同步到 DOM"。
2.3 一次完整更新流程的链路
一次完整更新流程大致如下:
hasChanged判断属性值是否变化;- 若变化,缓存旧值,存储新值;
- 进入更新队列,等待微任务;
- 微任务触发
performUpdate; - 依序执行
willUpdate(),然后通过update()方法将新值传入 render 流程,render()生成模板结果并更新 DOM; - 最后执行
updated()回调,并把内部更新 promise resolve。
这个设计里最值得玩味的是willUpdate()和updated()两个钩子的分工:willUpdate在 DOM 更新前调用,适合做基于多属性新值计算派生数据;updated在 DOM 更新后调用,适合做 DOM 相关的一步操作(比如聚焦某个元素)。这个生命周期模型比 React 函数组件配合useEffect的心智负担小很多,因为触发顺序是确定且同步的,不需要担心依赖数组写漏了导致副作用没有触发。
2.4 响应式控制器:Lit 版的 Composition API
Lit 2.0 加入了响应式控制器(Reactive Controllers),这是我非常喜欢的一个设计。它允许你把一组与宿主组件生命周期绑定的逻辑封装成一个独立对象,挂载到任意 Lit 组件上。举个例子,我要封装一个监听ResizeObserver的逻辑:
import type { ReactiveController, ReactiveControllerHost } from 'lit'; export class ResizeController implements ReactiveController { private _observer: ResizeObserver; private _entry?: ResizeObserverEntry; constructor(private host: ReactiveControllerHost) { this.host.addController(this); this._observer = new ResizeObserver((entries) => { this._entry = entries[0]; this.host.requestUpdate(); }); } hostConnected() { this._observer.observe(this.host as Element); } hostDisconnected() { this._observer.disconnect(); } }控制器可以访问宿主的requestUpdate方法,因此任何异步数据(网络请求、定时器、观察器)的返回值都能通过控制器触发宿主组件的响应式更新。这比 React 的 Hook 设计更加显式:依赖关系是声明式的,生命周期是明确的,不需要 hook 规则,也没有闭包陷阱。我在实际项目里用控制器封装了"获取用户信息""监听鼠标选择""防抖输入"等逻辑,复用性相当好。
3. 从零写一个可用的 Lit 组件:模板、事件、样式、生命周期
这一节我们动手实践一下。假设要做一个带搜索建议输入框的组件——这个场景能覆盖模板表达式、事件绑定、Shadow DOM 样式、生命周期等多个核心点。
3.1 项目初始化与工程配置总览
用官方脚手架创建项目是最省心的:
npm init @open-wc这个工具会问你组件名称、是否用 TypeScript、是否生成演示页面等,按需选择即可。生成的工程里会有src目录、stories目录(Storybook 配置)、demo页面。我一般会更精简一些,手动搭一个只包含 Vite 加@vitejs/plugin-legacy的最小工程,因为 Lit 原生 ES 模块在 Vite 开发模式下的热更新体验很好,不需要额外配置。
如果想快速在浏览器里跑起来(不借助打包工具),也可以直接用 CDN 上的 ES module 版本:
<!DOCTYPE html> <html> <head> <script type="module"> import { LitElement, html } from 'https://esm.run/lit'; customElements.define('hello-lit', class extends LitElement { render() { return html`<p>Hello, Lit!</p>`; } }); </script> </head> <body> <hello-lit></hello-lit> </body> </html>这个例子说明了 Lit 的"回归 Web 本质"并非一句空话:没有 build 步骤,四个标签直接跑。当然实际项目中我们还是需要 TypeScript、打包和测试环境,但这不是 Lit 的要求,而是现代前端工程化的自然需求。
3.2 模板表达式:lit-html 如何定位变化点
Lit 的模板语法看似只是插值,但它在底层利用了 JavaScript 的 tagged template 机制。当我们写:
html` <div class=${this.cls}>${this.content}</div> `浏览器会调用html这个标签函数,参数是"被插值分割开的字符串数组"和"每次插值对应的值数组"。第一次渲染时,lit-html 会为这个模板构建一棵可复用的"模板实例",静态部分(比如<div>、</div>)只在首次被创建,动态部分(比如class属性的值、文本内容)会被标记为 "part",后续更新时只需要精确地修改这些 part 的对应值。
这就是为什么 Lit 不需要虚拟 DOM 也能做到局部更新——模板结构是静态且确定的,插值点是稀疏的,无需在更新时再次 diff 整个 DOM 树。拿生活化场景比喻:虚拟 DOM diff 相当于每次修改表单都先打印一份草稿再和上一份草稿对比找差异;而 lit-html 是直接在任何插值位置动态替换文本,成本低得多。这种机制让 Lit 在"模板结构不经常变化但数据频繁更新"的场景中天然高效。
3.3 事件绑定与自定义事件
Lit 的事件绑定语法是@event=${handler},非常接近原生 DOM 的事件模型——实际上它就是在内部调用了addEventListener。比如点击按钮累加数据,并把新值通过自定义事件抛给父组件:
@customElement('search-box') export class SearchBox extends LitElement { @property({ type: String }) keyword = ''; private _onInput(e: Event) { const val = (e.target as HTMLInputElement).value; this.keyword = val; this.dispatchEvent(new CustomEvent('search-change', { detail: { keyword: val }, bubbles: true, composed: true, })); } render() { return html` <input class="search-input" .value=${this.keyword} @input=${this._onInput} placeholder="输入关键词搜索..." /> `; } }注意我用了.value而不是value,这个.前缀的意思是"设置 DOM property 而不是 attribute"。Lit 对 attribute 和 property 的区分非常严格:value=${...}会设置 HTML attribute(字符串),而.value=${...}会设置 DOM property(可以是任意 JavaScript 值)。这是一个高频易错点——如果你用value去设置 input 的内容,在某些情况下行为可能与预期不符,因为 attribute 设置和 property 设置会走不同的路径。我在早期项目里就因为这个细节在表单双向绑定上栽过跟头。
自定义事件的composed: true也值得留一下:Shadow DOM 默认会阻挡事件冒泡到组件外部,如果你不设置 composed,父组件用普通的addEventListener就接收不到search-change事件。这个属性是 Web Components 标准里专门为跨 Shadow DOM 边界事件通信设计的。
3.4 Shadow DOM 样式隔离与 CSS 自定义属性
Lit 组件的样式写在静态的styles属性中:
@customElement('search-box') export class SearchBox extends LitElement { static styles = css` :host { display: inline-block; position: relative; } .search-input { width: 240px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; } .search-input:focus { outline: none; border-color: #4a90d9; box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.2); } `; }这套样式会通过<style>元素注入到组件的 Shadow DOM 中,天然不会泄漏到全局,也不受全局样式影响。这个隔离能力是浏览器标准内置的,不是 CSS Modules 通过构建工具模拟出来的,所以即使在没有任何框架的环境中它也能生效。
但 Shadow DOM 样式隔离也带来一个限制:你没法直接在组件外面轻易覆盖组件内部的样式。解法是使用 CSS 自定义属性(CSS Variables)作为"样式接口":
static styles = css` .search-input { border-color: var(--search-box-border, #ccc); } `;这样外部使用方可以在组件外部作用域设置--search-box-border来定制主题颜色。这是 Lit 生态中最主流的样式定制方案,我建议把所有需要外部可控的视觉变量都提升为 CSS 自定义属性,这样既保持了隔离性,又保留了可定制性。
3.5 生命周期钩子全梳理
Web Components 标准自带的生命周期有connectedCallback、disconnectedCallback、adoptedCallback、attributeChangedCallback四个。Lit 在此基础上增加了一层更贴合渲染需求的钩子:
| 钩子 | 触发时机 | 适合用来做什么 |
|---|---|---|
connectedCallback | 组件被插入文档时 | 启动外部资源监听、定时器 |
disconnectedCallback | 组件被移出文档时 | 清理监听器、取消请求 |
firstUpdated | 首次渲染完成后 | 初始化第三方图形库、聚焦首元素 |
updated | 每次渲染完成后 | 读取更新后的 DOM |
willUpdate | 渲染前,属性已更新 | 基于最新值计算派生数据 |
我用firstUpdated配合ResizeObserver做一个自适应宽度且顶部吸附的容器时就特别方便:首渲染时先用一个静态黑盒渲染出来,等 DOM 落地后再测量尺寸并切换为绝对定位模式,彻底避免了一次额外的布局抖动。这种"渲染后再测量"的模式在图表库、富文本编辑器等需要操作真实 DOM 的集成场景中也很常见。
4. 没有虚拟 DOM,性能到底是更好还是更差
"Lit 没有虚拟 DOM"这句话让很多初次接触的人产生一个疑问:那它如何保证渲染性能?实际上"没有虚拟 DOM"并不等同于"每次全量渲染",Lit 采用的模板细粒度更新策略在多数场景下性能表现相当好。
4.1 模板缓存机制决定了静态结构只建一次
lit-html 的模板缓存机制是性能的第一层保障。同一份模板函数(比如组件里的render方法返回的模板)首次执行后,lit-html 会将模板的骨架缓存起来。之后的每次更新,模板结构字符串没有变化,因此不会重新创建任何静态节点,只对插值位置做值更新。
这套机制的一个推论是:如果render()方法中通过动态拼接字符串产生新模板(每次都产生不同的字符串),缓存机制就会失效,性能会明显下降。所以我建议代码风格上尽量避免在模板里做复杂的字符串拼接或"整个模板结构根据条件切换"的操作,而是用 Lit 提供的when、choose等内置指令来处理条件渲染,它们会把不同分支建模为模板中可追踪的节点,而不是重新生成整个模板。
Lit 2.x / 3.x 提供了一些内置指令来应对"无法静态化"的场景:
ifDefined:属性值为undefined时不输出该属性;repeat:带 key 的列表渲染,配合 DOM 重用;classMap/styleMap:动态控制 class 和 style;until:异步数据到达前展示 fallback 内容;cache:在多个模板分支间切换时保留旧分支的 DOM 状态。
这些指令的使用方式都写成函数调用形式,直接在模板插值位置调用即可,无需额外配置,也不会破坏模板的静态分析能力。
4.2 列表渲染的 keyed 与 unkeyed 差别
列表渲染是我实际项目里最需要关心的性能点。Lit 的默认map方式(直接用Array.map在模板中产出节点)不做 keyed 追踪,重新渲染时,列表项如果顺序变了,DOM 会被重建而不是复用。如果你需要稳定的元素身份(比如列表项内部保持了滚动位置、持有了某个焦点),就要用repeat指令指定唯一的 key:
import { repeat } from 'lit/directives/repeat.js'; render() { return html` <ul> ${repeat(this.items, (item) => item.id, (item, index) => html` <li>type Listener = () => void; class Store { private state: Record<string, unknown> = {}; private listeners = new Set<Listener>(); setState(patch: Record<string, unknown>) { Object.assign(this.state, patch); this.listeners.forEach((fn) => fn()); } getState() { return this.state; } subscribe(fn: Listener) { this.listeners.add(fn); return () => this.listeners.delete(fn); } } export const store = new Store();然后在 Lit 组件中通过connectedCallback订阅 store 变化,并在回调里调用this.requestUpdate():
connectedCallback() { super.connectedCallback(); this._unsubscribe = store.subscribe(() => this.requestUpdate()); } disconnectedCallback() { super.disconnectedCallback(); this._unsubscribe?.(); }这比在 Lit 中强行塞入 Redux 之类的方案要轻量得多。当然如果真的需要引入第三方,Lit 也有@lit/context这个官方包,类似 React Context,用于祖先向子孙组件传递不可变数据的场景,适合主题、用户信息等注入场景。
5.2 Lit 如何与 React/Vue 共存
Lit 组件的最大卖点之一就是它是标准自定义元素,所以与 React/Vue 共存有天然优势。React 里你可以直接写:
import '@myscope/my-counter'; function App() { return ( <div> <my-counter count={3} /> </div> ); }但这里有一个实际坑:React 对自定义元素的事件监听支持经历了一个不算顺畅的过程。在 React 19 之前,React 并不能直接通过 JSX 的onEvent语法绑定CustomEvent,正确做法是通过ref拿到 DOM 节点后手动addEventListener。这一点和 Vue 对 Web Components 的天然支持形成了对比——Vue 对自定义元素的集成更顺畅,在非编译环境下使用自定义元素的体验很好。团队里如果有 React 和 Vue 并存的情况,Lit 组件的横跨能力就能派上大用场。
5.3 TypeScript 装饰器版本问题的坑
Lit 的装饰器@property、@customElement依赖 TypeScript 的 legacy decorator 配置。在tsconfig.json中需要设置如下项:
{ "compilerOptions": { "experimentalDecorators": true, "useDefineForClassFields": false } }其中useDefineForClassFields: false特别容易被忽略。如果把这个选项打开,class 字段会被Object.defineProperty定义,可能会覆盖掉装饰器注入的 getter/setter 逻辑,导致响应式属性失效。这是一个容易踩而且报错信息不明朗的坑——你可能会发现属性赋值后 DOM 不更新,排查半天最后发现是 tsconfig 的问题。
5.4 Shadow DOM 样式穿透限制与绕过方案
关于 Shadow DOM,前面说它能隔离样式和 DOM 查询,但这也意味着外部的全局 CSS 无法进入组件。比如你用了 Bootstrap,希望在 Lit 组件里继承它的栅格样式,但 Shadow DOM 隔离后全局 Bootstrap 样式不会生效。解决办法有几种:
- 在组件外部调用
adoptedStyleSheets将共享的 CSSStyleSheet 注入到组件的 shadowRoot; - 干脆不用 Shadow DOM,用
createRenderRoot()返回this来绕过; - 在组件内部显式导入外部 CSS 的内容,但这会破坏样式隔离的初衷。
我自己的建议是:如果要做"无论如何都要完全独立、可复用"的通用组件,自带样式是基本要求,外部 CSS 不该依赖;但如果是在某一个应用内部做一些临时模块,可以酌情牺牲样式隔离换取更灵活的样式覆盖。使用createRenderRoot()返回this会损失 Lit 的样式隔离保障,一般不太推荐长期这么做。
5.5 实际踩过的几个坑,逐个记录
- attribute 反射导致类型变成字符串。前文提过
property与attribute的区别,一旦你给组件传入my-prop="3"这种方式,无论type: Number怎么声明,在 attribute 上下文里拿到的仍是字符串"3"。Lit 严格区分属性与属性的反射行为,推荐在模板里始终使用.property绑定。 - 在
firstUpdated里才能安全地测量 DOM。不要在connectedCallback里访问this.shadowRoot.querySelector的尺寸,此时浏览器还没有完成首帧布局,读到的多半是 0 或未初始化的值。 @lit-labs/ssr的服务端渲染还远不够成熟。如果项目强依赖 SEO 且必须服务端渲染整棵组件树,目前 Lit 的 SSR 支持处于 labs 阶段,比 Next.js/Nuxt 之类的成熟框架差不少。此时不建议用 Lit 做全站框架,可以只在局部模块中使用。- 和
@media查询的互动问题。Shadow DOM 里的@media仍然是基于视口生效的(与容器查询不同),如果你期望"根据组件父容器的宽度来改变内部布局",应该使用 CSS Container Queries,Lit 并不会为你额外处理容器查询。
5.6 生态里真正值得关注的周边工具
除了 lit 核心,我实际用过且觉得顺手的周边有:
@lit/context:跨组件层级传数据的上下文机制,API 设计得像 Observable 加 Context 的融合体;@lit/task:用来包装"依赖响应式属性异步取数"逻辑的控制器,官方出品,写法非常紧凑,替代了以前手动管理 loading、error、data 三个状态的老套路;@lit/localize:官方轻量国际化方案,与模板的表达式机制结合紧密,性能优于运行时替换字符串的方案;@open-wc系列工具:提供 lint、testing、demo 等工程化配套。
另外,Lit 的一个隐藏优点:如果你在写一个开放的 Widget 或可嵌入的 Web 组件,Lit 产物体积小、无运行时依赖、不受宿主框架影响,几乎是这类场景下目前最好的选择。我曾经把一个图表组件从 React 中抽出来,改成 Lit 后载入到公司三个不同技术栈的后台工程中,体感和集成成本远小于维护三套实现。
最后再分享一个我自己的使用体会:Lit 让我重新有了一种"写组件就是在写标准 Web"的感觉。你不必每天都在转译器和框架源码之间转圈,也不必担心某天某个框架的大版本升级会把你绑架。它的响应式渲染机制并不神秘,却在"保留浏览器原生能力"和"提供声明式体验"之间找到了一个相当漂亮的平衡点。如果你也已经对越来越重的框架栈感到疲惫,不妨花一个周末,拿 Lit 重写一个你曾经用 React 或 Vue 写过的小组件,从模板的第一次渲染开始,感受一下那种轻装上阵的爽快感。