在 Vue3 中愉快的写 JSX/TSX 😁

4,733 阅读5分钟

Vue3 的 render 函数与 JSX 写法

1. 概述

  • template 写法可以满足 90% 的需求, template 模板经过 vue 的模板编译后会生成渲染函数

  • render 函数 + TSX 可以补充 Vue 模板的灵活性

  • 通过使用 JSX/TSX 可以更加容易的写渲染函数

  • 但同时会导致 vue 的模板编译阶段中静态提升等其他运行时优化手手段失效, 从而产生性能损失, 不过一般情况下无须担心

  • 可以使用 h() / createVNode() 手写渲染函数, 可以理解 h() 是对用户友好版本的 createVNode()

渲染管线

详见: cn.vuejs.org/guide/extra…

  1. 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。

  2. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。

  3. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

render pipeline

关于JSX/TSX 造成的性能损失

参见 template 编译为渲染函数: template-explorer.vuejs.org

模板写法

<div>
  <div>foo</div>
  <div>bar</div>
  <div>{{ dynamic }}</div>
</div>

模板写法编译后

import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "bar", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _hoisted_2,
    _createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
  ]))
}

JSX写法

function myRenderer() {
  return (
    <div>
      <div>foo</div>
      <div>bar</div>
      <div>{dynamic.value}</div>
    </div>
  )
}

JSX写法编译后

function myRenderer() {
  return _createVNode(
    "div",
    null,
    [
      _createVNode("div", null, [_createTextVNode("foo")]),
      _createVNode("div", null, [_createTextVNode("bar")]),
      _createVNode("div", null, [dynamic.value])
    ]);
}

2. render 函数的基本用法

当组合式 API 与模板一起使用时,setup() 钩子的返回值是用于暴露数据给模板

当我们使用渲染函数时,可以直接把渲染函数返回,也可以将渲染函数写在 render 选项中, 如下面代码所示

<script lang="tsx">
import { defineComponent, ref } from 'vue';

export default defineComponent({
  name: 'TestRenderView',
  setup() {
    const text = ref('vue in render')
    return {
      text
    }
  },
  render() {
    // 在 render 中使用 setup 中声明的变量需要使用 this, 此处写法和 vue2 类似
    // 同时 setup 中需要将对应的变量暴露出去
    // 此处可用的所有属性参考: https://cn.vuejs.org/api/component-instance.html
    return <div>{this.text}</div>
  }
})
</script>

3. TSX 的基本应用

TestSetupView.vue

<script lang="tsx">
import MyChildComp from '@/components/MyChildComp.vue';
import { defineComponent, ref } from 'vue';
import { Input as AInput } from 'ant-design-vue'

export default defineComponent({
  name: 'TestSetupView',
  setup() {
    const comp = ref()
    const text = ref('vue in render')
    const inputVal = ref()

    const options = ref([
      { id: 1, value: 1 },
      { id: 2, value: 2 },
      { id: 3, value: 3 },
    ])

    const divRefs = ref<any[]>([])

    function handleMyEvent(data: string) {
      alert('received data: '+ data)
    }

    return () => (
      // <> </> 是空标签写法
      <>
        {/* 此处需要使用 .value 获取 text 的值 */}
        <div>{text.value}</div>

        <div>使用v-model: { inputVal.value }</div>
        <div><AInput v-model:value={inputVal.value} style={{width: '300px'}}></AInput></div>

        <hr></hr>
        {/* v-for, 行内样式, 循环中的 ref 设置  */}
        {options.value.map((item: any) => (
          <div
            key={item.id}
            style={{ fontSize: '20px', color: 'red' }}
            ref={(ref: any) => { divRefs.value.push(ref) }}
          >
            {item.value}
          </div>
        ))}
        <button onClick={() => console.log(divRefs.value)}>show divRefs</button>

        <hr></hr>
        {/* 绑定 ref 不需要写 .value */}
        <MyChildComp class="myComp" ref={comp} text="abc" number={123} onMyEvent={handleMyEvent}>
          {/* 插槽写法 */}
          {{
            default: () => (<div>我是默认插槽</div>),
            someName: () => (<div>我是具名插槽</div>),
            scopeSlot: (data: string) => (<div>作用域插槽: {data}</div>)
          }}
        </MyChildComp>

        <button onClick={() => comp.value.someFn('some data')}>do something</button>
      </>
    )
  },
})
</script>

MyChildComp.vue

<script lang="tsx">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'MyChildComp',
  // props 添加复杂类型参考: https://cn.vuejs.org/guide/typescript/options-api.html#typing-component-props
  props: {
    text: {
      type: String,
      require: true,
    },
    number: {
      type: Number,
    }
  },
  // emits 添加 Ts 标注类型参考: https://cn.vuejs.org/guide/typescript/options-api.html#typing-component-emits
  emits: ['myEvent'],
  setup(props, { slots, expose, attrs, emit }) {
    const { text, number } = { ...props }

    function someFn(data: string) {
      alert('我是传进来的数据: ' + data)
    }

    // 用于暴露方法/属性
    expose({ someFn })

    return () => (
      // 属性穿透写法
      <div {...attrs}>
        <div>组件传参</div>
        <div>text: {text}</div>
        <div>number: {number}</div>
        <hr></hr>

        {/* 默认插槽 */}
        {slots.default && <div>{slots.default()}</div>}
        {/* 具名插槽 */}
        {slots.someName && <div>{slots.someName()}</div>}
        {/* 作用域插槽 */}
        {slots.scopeSlot && <div>{slots.scopeSlot(text)}</div>}

        <button onClick={() => emit('myEvent', 'Hello')}>emit data</button>
      </div>
    )
  },
})
</script>

4. 一些拓展

工厂组件函数写法: 一般常见在组件库源码中

MyTsxComp.tsx

import { defineComponent } from 'vue';

// 解锁更加灵活的组件传参方式
export const buildMyTsxComp = (str: string, configObj: any) => defineComponent({
  name: 'MyTsxComp',
  setup() {
    return () => (
      <>
        <div>{str}</div>
        {/* 条件渲染 v-if, 两种写法均可 */}
        { configObj ? <div>configObj.id: {configObj.id}</div> : null }
        { configObj && <div>configObj.num: {configObj.num}</div> }
      </>
    )
  }
})

TestSetupView2.vue

<script setup lang="tsx">
import { buildMyTsxComp } from '@/components/MyTsxComp';
import { createVNode, h, ref, createApp } from 'vue';

const MyComp = buildMyTsxComp('abc', { id: 'def', num: 123 })

const myString = ref('function comp')

// 此处函数签名与 setup 函数签名相同
// 注意函数式组件不会保存任何状态!!!
// 此外最好是个纯函数, 下面这个就不是个纯函数, 因为依赖了外部的 myString
// 详见: https://cn.vuejs.org/guide/extras/render-function.html#functional-components
function FunctionComp(props: any) {
  return (
    <>
      <div>我是函数式组件</div>
      <div>闭包传入的变量: {myString.value}</div>
      {props.num && <div>{props.num}</div>}
    </>
  )
}

function FunctionComp2() {
  // h 是 createVNode 的简化版, 支持函数重载, 使用起来比 createVNode 更方便
  // 但不能设置相关优化属性, 比如 patchFlag, dynamicProps
  // 详见: https://cn.vuejs.org/guide/extras/rendering-mechanism.html#static-hoisting
  return h(
    'div', // 可写 html 或组件, 详见 vue-core, packages\runtime-core\src\h.ts
    { class: 'colorRed' },
    [
      '使用了 h',
      createVNode( // 详见 vue-core, packages\compiler-core\src\ast.ts 其中的 createVNodeCall
        'div',
        { style: 'font-size: 26px' },
        ['使用了 createVNode'],
        1, // 设置 patchFlag 用于标记节点类型, 详见 vue-core, packages\shared\src\patchFlags.ts
      )
    ]
  )
}

// 此处是抛开实际用处不谈的一些花活...
const compInstance = ref<any[]>([])
compInstance.value.push({
  id: 1,
  comp: FunctionComp
})
compInstance.value.push({
  id: 2,
  comp: FunctionComp2
})


function manualMountComp() {
  const compVNodes = createApp(FunctionComp, { props: 123 })
  // 此处已拿到组件 vnode
  const container = document.createElement('div')
  compVNodes.mount(container)
  // 此处可以已经拿到组件 dom 从而修改已经渲染好的组件
  console.log(container)
  // 将修改后的组件挂载到 自定义挂载点上
  document.querySelector('.my-app')!.appendChild(container)
}

setTimeout(() => {
  manualMountComp()
});
</script>

<template>
  <h2>使用了组件工厂拿的组件</h2>
  <MyComp></MyComp>

  <hr>
  <!-- 调用函数式组件 -->
  <h2>函数式组件</h2>
  <FunctionComp :num="789"></FunctionComp>

  <hr>
  <!-- 使用 h / createVNode -->
  <h2>使用 h / createVNode</h2>
  <FunctionComp2></FunctionComp2>

  <hr>
  <!-- 一些花活 -->
  <h2>以下是一些花活....</h2>
  <template v-for="item of compInstance" :key="item.id">
    <component :is="item.comp" :num="91011"></component>
  </template>

  <hr>
  <h2>手动挂载组件</h2>
  <div class="my-app"></div>
</template>

<style scoped>
.colorRed {
  color: red
}
</style>

如果文章有什么错误欢迎大家指教~

如果文章对你有帮助除了收藏外不妨帮我点个赞~