likes
comments
collection
share

vue3学习小札之(五):渲染函数 & JSX

作者站长头像
站长
· 阅读数 20

引子

可以前往专栏阅读该系列其他文章:传送门

vue 作为一个前端框架,定义了一套模板语法,本篇文章将学习 vue 中涉及到模板渲染的相关知识。

渲染机制

vue 中的模板语法,现代浏览器并不能识别,所以需要将模板转换为真实的 DOM 节点,而这一切的基础,就是虚拟 DOM

虚拟 DOM

虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。

下面就是一个简单的虚拟 DOM:

const vnode = {
  type: 'div',
  props: {
    id: 'hello'
  },
  children: [
    /* 更多 vnode */
  ]
}

这里所说的 vnode 即一个纯 JavaScript 的对象 (一个“虚拟节点”),它代表着一个 <div> 元素。 它包含我们创建实际元素所需的所有信息。 它还包含更多的子节点,这使它成为虚拟 DOM 树的根节点

vue 的渲染机制中,涉及到如下几个过程: 编译:Vue 模板被编译为了渲染函数:即用来返回虚拟 DOM 树的函数;

挂载 (mount):遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树; 这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。vue 的数据响应式,就是在这一过程中初始化的。

更新 (patch):当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。又被称为“比对”(diffing),最核心的就是 diff 算法

模板 vs. 渲染函数

上面提到了一个概念叫做:渲染函数。Vue 模板会被预编译成虚拟 DOM 渲染函数。

vue 模板的优势: 模板更贴近实际的 HTML。这使得我们能够更方便地重用一些已有的 HTML 代码片段,能够带来更好的可访问性体验、能更方便地使用 CSS 应用样式,并且更容易使设计师理解和修改。 由于其确定的语法,更容易对模板做静态分析。这使得 Vue 的模板编译器能够应用许多编译时优化来提升虚拟 DOM 的性能表现。

渲染函数的优势: 在处理高度动态的逻辑时,渲染函数相比于模板更加灵活,因为你可以完全地使用 JavaScript 来构造你想要的 vnode(虚拟 DOM 节点)。

渲染函数

接下来,学习如何编写渲染函数:

基本用法

Vue 提供了一个 h() 函数用于创建 vnodes:

创建 Vnodes

import { h } from 'vue'

const vnode = h(
  'div', // type
  { id: 'foo', class: 'bar' }, // props
  [
    /* children */
  ]
)

h() 函数的使用方式非常的灵活:

除了类型必填以外,其他的参数都是可选的:

h('div') 
h('div', { id: 'foo' })

attribute 和 property 都能在 prop 中书写,Vue 会自动将它们分配到正确的位置:

h('div', { class: 'bar', innerHTML: 'hello' })

props 修饰符和属性传递(例如.prop和.attr)可以分别使用'.'和'^'前缀:

h('div', { '.name': 'some-name', '^width': '100' })

类与样式可以像在模板中一样用数组或对象的形式书写:

h('div', { class: [foo, { bar }], style: { color: 'red' } })

事件监听器应以 onXxx 的形式书写:

h('div', { onClick: () => {} })

children 可以是一个字符串:

// 此时子元素就是一个文本元素
h('div', { id: 'foo' }, 'hello')

没有 props 时可以省略不写:

// 因为 props 必须是一个对象,所以当第二个参数传入的类型不是对象时
// vue 会自动将其归为第三个参数:children
h('div', 'hello') h('div', [h('span', 'hello')])

children 数组可以同时包含 vnodes 与字符串:

h('div', ['hello', h('span', 'hello')])

声明渲染函数

当组合式 API 与模板一起使用时,setup() 钩子的返回值是用于暴露数据给模板。 然而当我们使用渲染函数时,可以直接把渲染函数返回。 在 setup() 内部声明的渲染函数天生能够访问在同一范围内声明的 props 和许多响应式状态。

import { ref, h } from 'vue'

export default {
  props: {
    /* ... */
  },
  setup(props) {
    const count = ref(1)

    // 返回渲染函数
    return () => h('div', props.msg + count.value)
  }
}

export default {
  setup() {
    // 使用数组返回多个根节点
    return () => [
      h('div'),
      h('div'),
      h('div')
    ]
  }
}

注意: 请确保返回的是一个函数而不是一个值!setup() 函数在每个组件中只会被调用一次,而返回的渲染函数将会被调用多次,因为渲染是存在更新的。

Vnodes 必须唯一

组件树中的 vnodes 必须是唯一的。

错误的写法:

function render() {
  const p = h('p', 'hi')
  return h('div', [
    // 啊哦,重复的 vnodes 是无效的
    p,
    p
  ])
}

正确的写法: 使用一个工厂函数

function render() {
  return h(
    'div',
    Array.from({ length: 20 }).map(() => {
      return h('p', 'hi')
    })
  )
}

JSX / TSX

JSX 是 JavaScript 的一个类似 XML 的扩展。

vue 中除了可以通过渲染函数来定义一个虚拟 DOM 节点,还可以使用 JSX:

// 使用大括号来嵌入动态值
const vnode = <div id={dynamicId}>hello, {userName}</div>

Vue 的类型定义也提供了 TSX 语法的类型推导支持。当使用 TSX 语法时,确保在 tsconfig.json 中配置了 "jsx": "preserve",这样的 TypeScript 就能保证 Vue JSX 语法编译过程中的完整性。

渲染函数案例

可以前往官网查看如何用渲染函数 / JSX 语法,实现等价的模板功能。

这里只做简单的总结

ref 响应式变量在模板语法中存在解包,但是在渲染函数和 JSX 是不存在解包的,所以需要 .value

以 on 开头,并跟着大写字母的 props 会被当作事件监听器。比如,onClick 与模板中的 @click 等价。

对于 .passive.capture 和 .once 事件修饰符,可以使用驼峰写法将他们拼接在事件名后面。 对于其他的事件和按键修饰符,可以使用 withModifiers 函数。

如果一个组件是用名字注册的,不能直接导入 (例如,由一个库全局注册),可以使用 resolveComponent() 来解决这个问题。

诸如 <KeepAlive><Transition><TransitionGroup><Teleport> 和 <Suspense> 等内置组件在渲染函数中必须导入才能使用

插槽相关

组件内渲染插槽

在渲染函数中,插槽可以通过 setup() 的上下文来访问。 每个 slots 对象中的插槽都是一个返回 vnodes 数组的函数

export default {
  props: ['message'],
  setup(props, { slots }) {
    return () => [
      // 因为插槽本身就是一个返回虚拟 dom 节点数组的函数
      // 所以可以直接调用 作为参数传入
      // 默认插槽:
      // <div><slot /></div>
      h('div', slots.default()),

      // 具名插槽:
      // <div><slot name="footer" :text="message" /></div>
      h(
        'div',
        slots.footer({
          text: props.message
        })
      )
    ]
  }
}

等价 JSX 语法:

// 默认插槽
<div>{slots.default()}</div>

// 具名插槽
<div>{slots.footer({ text: props.message })}</div>

消费组件传递插槽

我们需要传递一个插槽函数或者是一个包含插槽函数的对象而非是数组,插槽函数的返回值同一个正常的渲染函数的返回值一样,都是一个虚拟 dom 节点。 并且某个插槽函数在子组件中被访问时返回值总是会被转化为一个 vnodes 数组

// 单个默认插槽
h(MyComponent, () => 'hello')

// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
    default: () => 'default slot',
    foo: () => h('div', 'foo'),
    bar: () => [h('span', 'one'), h('span', 'two')]
})

等价 JSX 语法:

// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>

// 具名插槽
<MyComponent>{{
  default: () => 'default slot',
  foo: () => <div>foo</div>,
  bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>

v-model

v-model 指令扩展为 modelValue 和 onUpdate:modelValue 在模板编译过程中,我们必须自己提供这些 props:

export default {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  setup(props, { emit }) {
    return () =>
      h(SomeComponent, {
        modelValue: props.modelValue,
        'onUpdate:modelValue': (value) => emit('update:modelValue', value)
      })
  }
}

自定义指令

可以使用 withDirectives 将自定义指令应用于 vnode:

import { h, withDirectives } from 'vue'

// 自定义指令
const pin = {
  mounted() { /* ... */ },
  updated() { /* ... */ }
}

// <div v-pin:top.animate="200"></div>
const vnode = withDirectives(h('div'), [
  [pin, 200, 'top', { animate: true }]
])

当一个指令是以名称注册并且不能被直接导入时,可以使用 resolveDirective 函数来解决这个问题。

函数式组件

函数式组件是一种定义自身没有任何状态的组件的方式。 它们很像纯函数:接收 props,返回 vnodes。 函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。

用一个普通的函数而不是一个选项对象来创建函数式组件。 该函数实际上就是该组件的渲染函数

函数式组件可以像普通组件一样被注册和使用。 如果将一个函数作为第一个参数传入 h,它将会被当作一个函数式组件来对待。

函数式组件的签名与 setup() 钩子相同:

function MyComponent(props, { slots, emit, attrs }) {
  // ...
}

大多数常规组件的配置选项在函数式组件中都不可用,除了 props 和 emits。 我们可以给函数式组件添加对应的属性来声明它们:

MyComponent.props = ['value']
MyComponent.emits = ['click']

总结

本篇文章主要介绍了 vue 中跟渲染相关的知识。 首先是虚拟 DOM,基于虚拟 DOM 的概念,我们可以编写灵活的渲染函数。 除了渲染函数,我们还可以通过 vue 定义的 JSX / TSX 语法来定义虚拟 DOM。