ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入拆解 Lit 响应式渲染:不依赖虚拟 DOM 的组件技术解析

深入拆解 Lit 响应式渲染:不依赖虚拟 DOM 的组件技术解析 前一段时间在一个内部项目里我和团队决定用 Lit 重写一个偏展示型的中后台模块。这个模块不算大——几张表格、几个表单、一些动态筛选逻辑——但我们当时实在不想为了这些东西再拖拽一整棵 React 依赖树进来于是 Lit 进入了视野。这个选择听起来有点逆潮流2024 年了前端圈到处是 React 生态、Vue 生态、编译时加响应式的组合拳为什么还会有人回头去找一个诞生于 Web Components 时代的库但正是这种看似反方向的选择让我对响应式渲染这件事有了些不一样的体会。这篇文章我想从一个相对完整的角度来拆解 Lit 的响应式渲染机制会结合实际的组件代码、更新调度细节、性能特征来展开。适合三类人阅读第一类是厌倦了框架重依赖、想回归标准 Web 技术的开发者第二类是已经在 Web Components 边缘试探、想知道 Lit 到底解决了什么问题的朋友第三类纯粹是想理解不依赖虚拟 DOM 的响应式渲染到底怎么做的好奇者。不管你是哪一种希望这篇内容能给你带来一些新的思考角度。1. 当整个前端圈都在卷框架时Lit 为什么敢说回归 Web 本质1.1 Web Components 才是浏览器亲儿子先理一个被很多人忽略的事实React/Vue 的组件模型从第一天开始就没有在浏览器层面得到任何原生支持。React 有 JSXVue 有 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标签里写htmldiv.../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 APILit 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 typemodule import { LitElement, html } from https://esm.run/lit; customElements.define(hello-lit, class extends LitElement { render() { return htmlpHello, 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 classsearch-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每次渲染完成后读取更新后的 DOMwillUpdate渲染前属性已更新基于最新值计算派生数据我用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 和 styleuntil异步数据到达前展示 fallback 内容cache在多个模板分支间切换时保留旧分支的 DOM 状态。这些指令的使用方式都写成函数调用形式直接在模板插值位置调用即可无需额外配置也不会破坏模板的静态分析能力。4.2 列表渲染的 keyed 与 unkeyed 差别列表渲染是我实际项目里最需要关心的性能点。Lit 的默认map方式直接用Array.map在模板中产出节点不做 keyed 追踪重新渲染时列表项如果顺序变了DOM 会被重建而不是复用。如果你需要稳定的元素身份比如列表项内部保持了滚动位置、持有了某个焦点就要用repeat指令指定唯一的 keyimport { 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: Recordstring, unknown {}; private listeners new SetListener(); setState(patch: Recordstring, 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-prop3这种方式无论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 QueriesLit 并不会为你额外处理容器查询。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 写过的小组件从模板的第一次渲染开始感受一下那种轻装上阵的爽快感。
返回列表