Skip to content

五.render

前言

多选框组件

1.Render 函数

template 和 render 写法的对照:

vue
<template>
  <div id="main" class="container" style="color: red">
    <p v-if="show">内容 1</p>
    <p v-else>内容 2</p>
  </div>
</template>
<script>
export default {
  data() {
    return {
      show: false,
    }
  },
}
</script>
js
export default {
  data() {
    return {
      show: false,
    }
  },
  render: (h) => {
    let childNode
    if (this.show) {
      childNode = h("p", "内容 1")
    } else {
      childNode = h("p", "内容 2")
    }

    return h(
      "div",
      {
        attrs: {
          id: "main",
        },
        class: {
          container: true,
        },
        style: {
          color: "red",
        },
      },
      [childNode]
    )
  },
}

这里h,即createElement,是 Render 函数的核心。可以看到,template 中v-if/v-else等指令,都被 JS 的if/else替代了,那v-for自然也会被for语句替换。

h 有 3 个参数,分别是:

1.要渲染的元素或组件,可以是一个 html 标签、组件选项或一个函数(不常用),该参数为必填项。

js
// 1.html 标签
h("div")
// 2.组件选项
import DatePicker from "../component/data-picker.vue"
h(DatePicker)

2.对应属性的数据对象,比如组件的 props、元素的 class、绑定的事件、slot、自定义指令等,该参数是可选的。

3.子节点,可选,String 或 Array,它同样是一个 h。

2.约束

所有的组件树种,如果 vNode 是组件或含有组件的 slot,那么 vNode 必须是唯一。以下两个示例是错误的。

js
// 局部声明组件
const Child = {
  render: (h) => {
    return h("p", "text")
  },
}

export default {
  render: (h) => {
    // 创建一个子节点,使用组件 Child
    const ChildNode = h(Child)

    return h("div", [ChildNode, ChildNode])
  },
}
js
{
  render: (h) => {
    return h("div", [this.$slots.default, this.$slots.default])
  }
}

重复渲染多个组件或元素,可以疼痛感一个循环和工厂函数来解决:

js
const Child = {
  render: (h) => {
    return h("p", "text")
  },
}

export default {
  render: (h) => {
    const children = Array.apply(null, {
      length: 5,
    }).map(() => {
      return h(Child)
    })
    return h("div", children)
  },
}

对于函数有组件的 slot,复用比较复杂,需要将 slot 的每个子节点克隆一份,例如:

js
{
  render: (h) => {
    function cloneVNode(vnode) {
      // 递归遍历所有子节点,并克隆
      const clonedChildren =
        vnode.children && vnode.children.map((vnode) => cloneVNode(vnode))
      const cloned = h(vnode.tag, vnode.data, clonedChildren)
      cloned.text = vnode.text
      cloned.isComment = vnode.isComment
      cloned.componentOptions = vnode.componentOptions
      cloned.elm = vnode.elm
      cloned.context = vnode.context
      cloned.ns = vnode.ns
      cloned.isStatic = vnode.isStatic
      cloned.key = vnode.key

      return cloned
    }

    const vNodes = this.$slots.default === undefined ? [] : this.$slots.default
    const clonedVNodes =
      this.$slots.default === undefined
        ? []
        : vNodes.map((vnode) => cloneVNode(vnode))

    return h("div", [vNodes, clonedVNodes])
  }
}

在 Render 函数里创建一个 cloneVNode 的工厂函数,通过递归将 slot 所有子节点都克隆一份,并对 VNode 的关键属性也进行了复制。

深度克隆 slot 并非 Vue.js 内置方法,也没有得到推荐,属于黑科技,在一些特殊的场景才会使用到,正常业务几乎是用不到的。比如 iView 组件库的穿梭框组件 Transfer,就用到这种方法。

它的使用方法是:

vue
<template>
  <Transfer
    :data="data"
    :target-keys="targetKeys"
    :render-format="renderFormat"
  >
    <div :style="{ float: 'right', margin: '5px' }">
      <Button  @click="reloadMockData">Refresh</Button>
    </div>
  </Transfer>
</template>

示例中的默认 slot 是一个 Refresh 按钮,使用者只写了一遍,但在 Transfer 组件中,是通过克隆 VNode 的方法,显示了两遍。如果不这样做,就要声明两个具名 slot,但是左右两个的逻辑可能是完全一样的,使用者就要写两个一模一样的 slot,非常不友好。

3.Render 函数使用场景

一般情况下是不推荐直接使用 Render 函数的,使用 template 足以,在 Vue.js 中,使用 Render 函数的场景,主要有以下 4 点:

1.使用两个相同 slot。在 template 中,Vue.js 不允许使用两个相同的 slot,比如下面的示例是错误的:

vue
<template>
  <div>
    <slot></slot>
    <slot></slot>
  </div>
</template>

解决方案就是上文中讲到的约束,使用一个深度克隆 VNode 节点的方法。

2.在 SSR 环境(服务端渲染),如果不是常规的 template 写法,比如通过 Vue.extend 和 new Vue 构造来生成的组件实例,是编译不过的。如 Alert 组件

js
// 正确写法
import Alert from "./alert.vue"
import Vue from "vue"

Alert.newInstance = (properties) => {
  const props = properties || {}

  const Instance = new Vue({
    data: props,
    render(h) {
      return h(Alert, {
        props: props,
      })
    },
  })

  const component = Instance.$mount()
  document.body.appendChild(component.$el)

  const alert = Instance.$children[0]

  return {
    add(noticeProps) {
      alert.add(noticeProps)
    },
    remove(name) {
      alert.remove(name)
    },
  }
}

export default Alert
js
// 在 SSR 下报错的写法
import Alert from "./alert.vue"
import Vue from "vue"

Alert.newInstance = (properties) => {
  const props = properties || {}

  const div = document.createElement("div")
  div.innerHTML = `<Alert ${props}></Alert>`
  document.body.appendChild(div)

  const Instance = new Vue({
    el: div,
    data: props,
    components: { Alert },
  })

  const alert = Instance.$children[0]

  return {
    add(noticeProps) {
      alert.add(noticeProps)
    },
    remove(name) {
      alert.remove(name)
    },
  }
}

export default Alert

3.在 runtime 版本的 Vue.js 中,如果使用 Vue.extend 手动构造一个实例,使用 template 选项是会报错的,把 template 改成 Render 就可以了。需要注意的是,在开发独立组件时,可以通过配置 Vue.js 版本来使 template 选项可用,但这是在自己的环境,无法保证使用者的 Vue.js 版本,所以对于听过给他人用的组件,是需要考虑兼容 runtime 版本和 SSR 环境的。

4.一个 Vue.js 组件,有一部分内容需要从父级传递来显示,如果是文本之类的,直接通过props就可以了,如果这个内容带有样式或复杂一点的 html 结构,可以使用v-html指令来渲染,父级传递的仍然是一个 HTML Element 字符串,不过它仅仅是能解析正常的 html 节点且有 XSS 风险。当需要最大化程度自定义显示内容是,就需要Render函数,它可以渲染一个完整的 Vue.js 组件。你可能会说,用 slot 不就好了。的确,slot 作用就是做内容分发的,但在一些特殊的组件中,可能 slot 也不行。比如一个表格组件Table,它接收两个 props:列配置 columns 和行数据 data,不过某一列的单元格,不是只将数据显示出来那边简单,可能带有一些复杂的操作,这种场景只用 slot 是不行的,没办法确定是那一列的 slot。这种场景有两种解决方案,其一就是 Render 函数,另一种就是作用域 slot。

4.Functional Render

Vue.js 提供了一个function的布尔值选项,设置为 true 可以使组件无状态和无实例,也就是没有 data 和 this 上下文。这样用 Render 函数返回虚拟节点可以更容易渲染,因为函数化组件只是一个函数,渲染开销要小很多。

使用函数化组件,Render 函数提供了第二参数 context 来提供临时上下文。组件需要的 data、props、slots、children、parent 都是通过这个上下文来传递的,比如 this.level 要改写为 context.props.level,this.$slots.default 改写为 context.children

函数化组件在业务中并不是很常见,而且也有类似的方法实现,比如某些场景可以用 is 特性来动态挂载组件。函数化组件主要适用于以下两个场景:

  • 程序化地在多个组件中选一个
  • 在将 children、props、data 传递给子组件之前操作它们

比如某个组件需要使用 Render 函数来自定义,而不是通过传递普通文本或 v-html 指令,这时就可以用 Function Render

  1. 首先创建一个函数化组件render.js
js
// render.js
export default {
  functional: true,
  props: {
    render: Function,
  },
  render: (h, ctx) => {
    return ctx.props.render(h)
  },
}

它只定义了一个 props:render,格式为 Function,因为 functional,所以在 render 里使用了第二个参数ctx来获取 props。这时一个中间文件,并且可以复用,其他组件需要这个功能时,都可以引入它。

2.创建组件:

vue
<!-- my-component.vue -->
<template>
  <div>
    <Render :render="render"></Render>
  </div>
</template>
<script>
import Render form './render.js';

export default {
  components: { Render },
  props: {
    render: Function
  }
}
</script>

3.使用上面的 my-component 组件

vue
<!-- demo.vue -->
<template>
  <div>
    <my-component :render="render"></my-component>
  </div>
</template>
<script>
import myComponent from "../components/my-component.vue"

export default {
  components: { myComponent },
  data() {
    return {
      render: (h) => {
        return h(
          "div",
          {
            style: {
              color: "red",
            },
          },
          "自定义内容"
        )
      },
    }
  },
}
</script>

这里的 render.js 因为只是把 demo.vue 中 Render 内容过继,并无其他用处,所以用了 Functional Render

就此来说,完全可以用 slot 取代 Function Render,那是因为只有render这一个 prop。如果示例中的<Render>是用v-for生成的,也就是多个时,用一个 slot 是实现不了的,那时用 Render 函数就很方便了。

总结

通过对前端组件的分析,需要重点关注组件中易变性对组件封装的影响,它会对组件的可复用性、可扩展性产生很大影响