Yihui’s Blog

第三章:lit-html的威力 - 精通声明式模板

本章核心问题: Lit 的模板系统为什么如此高效?如何正确使用各种数据绑定?列表渲染时应该选择 map() 还是 repeat()?

本章你将掌握:

  • 带标签模板字面量的底层工作原理
  • 五种数据绑定的正确使用场景
  • 条件渲染和列表渲染的最佳实践
  • 避免常见的数据绑定错误

3.1 深入理解带标签模板字面量

Lit模板系统的性能核心在于它巧妙地运用了JavaScript的一项原生功能:带标签模板字面量(Tagged Template Literals)。当你编写  

htmlHello ${name} 时,这并不仅仅是简单的字符串插值 。  

实际上,html这个“标签”是一个函数。JavaScript引擎会将模板字面量解析成两部分传递给这个函数:一个由静态字符串组成的数组,和一个由动态表达式的值组成的数组 。  

对于模板 html<h1>Hello ${name}</h1>,html 函数实际接收到的参数是:

  • 静态字符串数组: ['<h1>Hello ', '</h1>']

  • 动态表达式数组: [name]

lit-html利用这个机制,在第一次渲染时,会解析并缓存静态的HTML结构(['<h1>Hello ', '</h1>']),同时记录下动态表达式([name])在DOM中的确切位置。当后续需要更新时,Lit无需重新处理整个HTML字符串,它只需将新的动态值应用到之前记录的位置即可。这个“一次解析,多次更新”的策略是Lit实现高效渲染的关键,也是其区别于传统字符串拼接或VDOM diffing的“秘密武器”。

3.2 使用表达式实现动态数据绑定

表达式是Lit模板的灵魂,它们是连接组件状态与UI呈现的桥梁。通过${...}语法,你可以将动态数据注入到模板的各个位置 。Lit支持多种类型的绑定,每种都有特定的前缀和用途:  

  • 文本内容绑定:<div>${this.message}</div>  

    这是最常见的绑定类型,用于在元素内部渲染动态文本。

  • 特性 (Attribute) 绑定:<div id="${this.id}"></div>  

    用于设置元素的HTML特性。特性值总是字符串类型。

  • 布尔特性绑定:<button?disabled="${this.isDisabled}"></button>  

    前缀?专门用于处理布尔特性,如disabled、checked。当表达式为真值(truthy)时,该特性会被添加;为假值(falsy)时,该特性会被移除。

  • 属性 (Property) 绑定:<input.value="${this.inputValue}">  

    前缀.用于直接设置DOM元素的JavaScript属性。这对于传递复杂数据(如对象、数组)或控制表单元素的当前值至关重要。

  • 事件监听器绑定:<button @click="${this.handleClick}"></button>  

    前缀@用于声明式地附加事件监听器。

区分特性绑定和属性绑定是理解DOM工作方式的关键一步。HTML特性是在HTML源代码中定义的,主要用于初始化DOM属性。而DOM属性是存在于活动DOM对象上的值,它们可以存储任意类型的数据并且是动态变化的 。Lit的绑定语法清晰地反映了这一区别:当你需要设置一个简单的字符串标识时,使用特性绑定;当你需要传递一个对象给子组件或控制一个输入框的实时值时,必须使用属性绑定。这种明确的语法设计,不仅让代码意图更清晰,也引导开发者深入理解DOM的底层机制。  

🚫 常见错误示例对比

错误示例 1:向子组件传递对象时使用特性绑定

// ❌ 错误:使用特性绑定传递对象
html`<user-card user="${this.userData}"></user-card>`
// 结果:子组件收到字符串 "[object Object]",而不是对象本身

// ✅ 正确:使用属性绑定传递对象
html`<user-card .user="${this.userData}"></user-card>`
// 结果:子组件 user 属性接收到完整的对象引用

错误示例 2:控制输入框的值时使用特性绑定

// ❌ 错误:使用特性绑定控制 input 的值
html`<input value="${this.searchQuery}">`
// 结果:只能设置初始值,用户输入后无法通过数据更新视图

// ✅ 正确:使用属性绑定控制 input 的值
html`<input .value="${this.searchQuery}">`
// 结果:数据变化时输入框实时更新,实现真正的受控组件

错误示例 3:传递数组给子组件

// ❌ 错误:特性绑定会将数组转为字符串
html`<todo-list items="${this.todos}"></todo-list>`
// 结果:子组件收到类似 "item1,item2,item3" 的字符串

// ✅ 正确:使用属性绑定传递数组
html`<todo-list .items="${this.todos}"></todo-list>`
// 结果:子组件 items 属性接收到完整的数组对象

记忆技巧:

  • Attribute(特性)= String(字符串):id、class、title 等简单标识
  • Property(属性)= Any Type(任意类型):对象、数组、函数、复杂数据

3.3 条件渲染

在Lit中实现条件渲染非常直观,因为它完全依赖于标准的JavaScript语法 。  

  • 三元运算符:对于简单的“if-else”逻辑,三元运算符是最直接的方式,可以直接内联在模板中。

    html`${this.isLoggedIn? html`<p>Welcome back!</p>` : html`<button>Login</button>`}`
  • 逻辑与 (&&) 运算符:对于“if”逻辑(没有else分支),可以使用逻辑与运算符。

    html`${this.isAdmin && html`<a href="/admin">Admin Panel</a>`}`
  • when() 指令:lit/directives/when.js提供了一个when指令,它是三元运算符的一个语法糖,可以让代码更具可读性,尤其是在没有else分支的情况下 。  

    import { when } from 'lit/directives/when.js';
    html`${when(this.user, () => html`Welcome ${this.user.name}`)}`
  • cache() 指令:当你在两个复杂且状态化的模板之间频繁切换时,Lit默认会销毁旧的DOM并创建新的DOM。cache指令可以缓存未被渲染的模板的DOM实例,当再次切换回来时,直接重用并更新缓存的DOM,从而提升性能 。  

3.4 列表渲染:map() vs. repeat()

渲染数据列表是前端开发的常见任务。Lit同样利用标准JavaScript方法来处理。

  • Array.prototype.map():这是最常用和最直接的方法。通过对数据数组调用map方法,可以将其中的每一项转换为一个html模板,最终生成一个模板数组,Lit会自动渲染这个数组中的所有项 。  

    html`<ul>
        ${this.items.map(item => html`<li>${item.name}</li>`)}
      </ul>`
  • repeat() 指令:对于大多数情况,map已经足够高效。但当列表很长,或者需要进行频繁的添加、删除、重排操作时,repeat指令能提供更优的性能 。  

    repeat指令接受一个“键函数”(key function),用于为列表中的每一项生成一个唯一的标识。当列表数据变化时,Lit会使用这些键来识别每个DOM节点对应的原始数据项。如果只是顺序改变,Lit会智能地移动现有的DOM节点到新位置,而不是销毁并重建它们。这对于保持DOM状态(如输入框的焦点、CSS动画)和提升性能至关重要。

    import { repeat } from 'lit/directives/repeat.js';
    html`<ul>
      ${repeat(this.items, (item) => item.id, (item) => html`<li>${item.name}</li>`)}
    </ul>`

通过在浏览器开发者工具中观察,可以直观地看到map和repeat在列表重排时的不同行为:map会导致节点内容的更新,而repeat则会直接对节点进行物理位置的移动。


📝 本章小结

通过本章学习,你应该掌握了:

  1. 模板系统原理:带标签模板字面量通过”一次解析,多次更新”机制,让 Lit 能够精准定位和更新动态部分,无需虚拟 DOM 对比
  2. 五种数据绑定:
    • 文本绑定:${value} - 最常用
    • 特性绑定:attr="${value}" - 设置 HTML 特性(字符串)
    • 属性绑定:.prop="${value}" - 传递复杂数据(对象/数组)
    • 布尔特性绑定:?disabled="${value}" - 控制布尔特性
    • 事件绑定:@event="${handler}" - 声明式事件监听
  3. 条件渲染:三元运算符、&&、when()、cache() 各有适用场景
  4. 列表渲染:map() 适用于大多数场景,repeat() 用于需要保持 DOM 稳定性的复杂列表

在下一章中,我们将学习如何让组件”动”起来——响应式状态管理和生命周期。

🤔 思考题

  1. 为什么特性绑定只能传递字符串,而属性绑定可以传递对象?它们的本质区别是什么?
  2. 在一个频繁排序的长列表中,使用 map() 和 repeat() 的性能差异会有多大?
  3. cache() 指令在什么场景下最有价值?它的内存开销需要考虑吗?
维护与整理 · Yihui在 GitHub 上编辑