Vue独立组件开发:换一种思路写Vue——Render 函数与 Functional Render

lxf2023-12-08 16:30:01

本文正在参加「」

本文是独立组件开发的第四篇文章。

第一篇文章: Vue独立组件开发: prop event slot。

第二篇文章:Vue独立组件开发:不一样的组件通信方式

第三篇文章: Vue独立组件开发:动态组件 Vue.extend及$mount

虚拟 Virtual DOM

一般来说,写 Vue 组件时,模板都是写在 <template> 内的,但它并不是最终呈现的内容,template 只是一种对开发者友好的语法,能够一眼看到 DOM 节点,容易维护,在 Vue 编译阶段,会解析为 Virtual DOM

正常的 DOM 节点在 HTML 中是这样的:

<div id="app">
  <p>内容</p>
</div>

Virtual DOM 创建的 JavaScript 对象一般会是这样的:

const vNode = { 
    tag: 'div', 
    data: { 
        id: 'app' 
    }, 
    children: [ // p 节点 ] 
}

vNode 对象通过一些特定的选项描述了真实的 DOM 结构。

为什么一定要设计 vnode 这样的数据结构呢?它有什么优势呢?

首先是抽象。引入 vnode,可以把渲染过程抽象化,从而使得组件的抽象能力得到提升。

其次是可跨平台。因为对于 patch vnode 的过程,不同的平台可以有自己的实现,再基于 vnode 做服务端渲染、weex平台渲染 或 小程序平台的渲染就变得容易得多。

不过这里要特别注意,在浏览器端使用vnode 并不意味着不用操作 DOM 了。很多人误以为 vnode 的性能一定比手动操作原生DOM 好,这其实是不一定的。

这种基于vnode实现的 MVVM 框架,每次组件渲染生成 vnode的过程,会有一定的耗时,大组件尤其如此。

举个例子,对于一个1000行 x 10列的 table 组件,组件渲染生成 vnode 的过程会遍历1000行 x 10列去创建内部的 cell vnode,整个耗时会比较长。再加上挂载 vnode 生成 DOM 的过程也会有一定的耗时,所以当我们更新组件的时候,用户会感觉到明显的卡顿。

虽然 diff 算法在减少 DOM 操作方面足够优秀,但最终还是免不了操作DOM,因此性能并不是vnode的优势所在。

Render 函数

对于大部分场景,使用 template 足以应付,但如果想完全发挥 JavaScript 的编程能力,或在一些特定场景下,需要使用 Vue 的 Render 函数。

使用 Render 函数开发 Vue.js 组件是要比 template 困难的,原因在于 Render 函数返回的是一个 JS 对象,没有传统 DOM 的层级关系,配合上 if、else、for 等语句,将节点拆分成不同 JS 对象再组装,如果模板复杂,那一个 Render 函数是难读且难维护的。

所以,绝大部分组件开发和业务开发,直接使用 template 语法就可以了,并不需要特意使用 Render 函数,那样只会增加负担,同时也放弃了 Vue.js 最大的优势。

来看一组 templateRender 写法的对照:

template 写法

<template>
  <div id="" class="container" style="color: yellow">
    <p v-if="show">内容 1</p>
    <p v-else>内容 2</p>
  </div>
</template>
<script>
export default {
  data() {
    return {
      show: false
    }
  }
}
</script>

Render 函数写法


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: 'app'
      },
      class: {
        container: true
      },
      style: {
        color: 'yellow'
      }
    }, [childNode]);
  }
}

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

h 有 3 个参数,分别是:

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

// html 标签
h('div');
// 组件
import Test from './component/Test.vue';
h(Test);

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

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

[
  '内容',
  h('p', '内容'),
  h(Component, {
    props: {
      someProp: 'foo'
    }
  })
]

下面通过一个例子来说明render函数的用处,假设我们要生成一些带锚点的标题:

<h1>  
    <a name="hello-world" href="#hello-world">  
        Hello world!  
    </a>  
</h1>

对于上面的 HTML,你决定这样定义组件接口:

<anchored-heading :level="1">Hello world!</anchored-heading>

当开始写一个只能通过 level prop 动态生成标题 (heading) 的组件时,你可能很快想到这样实现:

<template>
  <h1 v-if="level === 1">
    <slot></slot>
  </h1>
  <h2 v-else-if="level === 2">
    <slot></slot>
  </h2>
  <h3 v-else-if="level === 3">
    <slot></slot>
  </h3>
  <h4 v-else-if="level === 4">
    <slot></slot>
  </h4>
  <h5 v-else-if="level === 5">
    <slot></slot>
  </h5>
  <h6 v-else-if="level === 6">
    <slot></slot>
  </h6>
</template>

<script>
export default {
  props: {
    level: Number
  }
}
</script>

这里用模板并不是最好的选择:不但代码冗长,而且在每一个级别的标题中重复书写了 <slot></slot>

虽然模板在大多数组件中都非常好用,但是显然在这里它就不合适了。那么,我们来尝试使用 render 函数重写上面的例子:

<script>
export default {
  render(createElement) {
    return createElement(
      'h' + this.level, // 标签名称
      this.$slots.default // 子节点数组
    )
  },
  props: {
    level: {
      type: Number,
      required: true
    }
  }
}
</script>

这样代码精简很多。

Functional Render

上面创建的锚点标题组件是比较简单,没有管理任何状态,也没有监听任何传递给它的状态,也没有生命周期方法。实际上,它只是一个接受一些 prop 的函数。在这样的场景下,我们可以将组件标记为 functional,这意味它无状态 (没有响应式数据),也没有实例 (没有 this 上下文)。一个函数式组件就像这样:

Vue.component('my-component', {
  functional: true,
  // Props 是可选的
  props: {
    // ...
  },
  // 为了弥补缺少的实例
  // 提供第二个参数作为上下文
  render: function (createElement, context) {
    // ...
  }
})

如果你使用了单文件组件,那么基于模板的函数式组件可以这样声明:

<template functional>
</template>

再添加 functional: true 之后,需要更新我们的锚点标题组件的渲染函数,为其增加 context 参数,并将 this.$slots.default 更新为 context.children,然后将 this.level 更新为 context.props.level

export default {
  functional: true,
  props: {
    level: {
      type: Number,
      required: true
    }
  },
  render(createElement, context) {
    return createElement('h' + context.props.level, context.children)
  }
}

因为函数式组件只是函数,没有状态,不需要经历数据响应式的初始化过程,所以渲染开销也低很多。

由于函数式组件无状态和无实例(this),我们就可以把它用作高阶组件。所谓高阶,就是可以生成其他组件的组件。

在作为包装组件时它们也同样非常有用。比如,当你需要做这些时:

  • 程序化地在多个组件中选择一个来代为渲染;
  • 在将 childrenpropsdata 传递给子组件之前操作它们。

下面是一个 smart-list 组件的例子,它能根据传入 prop 的值来代为渲染更具体的组件:

var EmptyList = { /* ... */ }
var TableList = { /* ... */ }
var OrderedList = { /* ... */ }
var UnorderedList = { /* ... */ }

Vue.component('smart-list', {
  functional: true,
  props: {
    items: {
      type: Array,
      required: true
    },
    isOrdered: Boolean
  },
  render: function (createElement, context) {
    function appropriateListComponent () {
      var items = context.props.items

      if (items.length === 0)           return EmptyList
      if (typeof items[0] === 'object') return TableList
      if (context.props.isOrdered)      return OrderedList

      return UnorderedList
    }

    return createElement(
      appropriateListComponent(),
      context.data,
      context.children
    )
  }
})

从这个例子中,根据传入的props来选择渲染那个组件,smart-list组件其实扮演了一个中间组件的角色。

下面再看一个函数式组件作为高阶组件的例子,来加深对函数式组件的理解。

当某个组件的内容需要用户自定义的时候,一般想到都是采用slot插槽的方式来实现,其实我们也可以使用Functional Render来实现,而且它能实现slot不能完成的功能。

1、首先创建一个函数化组件 render.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、创建组件:

<template>
  <div>
    <Render :render="render"></Render>
  </div>
</template>
<script>
  import Render from './render.js';
  
  export default {
    components: { Render },
    props: {
      render: Function
    }
  }
</script>

3、使用上面的 my-component 组件:

<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 因为只是把用户自定义的 Render 内容进行了中转,并无其它用处,所以用了 Functional Render

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

总结

本文首先介绍了虚拟DOM这个数据结构,它对渲染过程进行抽象,为后面的diff算法提供了基础。它还有一个重要的作用是可跨平台,因为它本质是对 HTML 标签的JS化,至于要运行在那个平台,直接转化成对应平台特有的标签语法就可以了。

通常我们写业务组件都是采用template的形式来写,但是在某些高度灵活且需要用户自定义的场景下使用 Render 函数更加方便与简洁。尽管在某些场景下写slot也能实现自定义,但是它也有很大的局限性,此时就可以使用 Render 函数了。

对于一些没有数据状态的组件,我们完全可以使用函数式组件来写,函数式组件因为没有状态,可以快速的渲染出内容。通过,可以用函数式组件作为包裹组件,即高阶组件。

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!