# 1. 前言

这段时间重新看了下vue的文档,发现还有很多使用使用频率不是那么高,或者简单使用过但不那么清晰的知识点。今天我们就来看一下其中的渲染函数render,jsx语法和插槽slot的用法。

# 2. 模板语法的弊端

熟悉vue单文件组件写法的同学们都知道,vue文件的html部分是由<template></template>组成,这种方法使用起来比较简单,配合vue指令可以实现大多数情况下的需求。不过还是存在模板语法不方便的时候,比如需要开发一个组件,这个组件要根据父组件传过来的值来选择渲染的html标签,来看一个示例:

// hLabel.vue
<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:{
      type: Number
    }
  }
}
</script>

上面这个组件虽然能够实现根据level值来渲染对应的<h1>,<h2>...标签,但是冗余代码也很多,而且在每个级别的标题标签中都有一个<slot>标签。

为了解决这个问题,我们需要用到vue中的渲染函数render

# 3. 渲染函数render

先来看下如何使用render函数来实现上面要求的组件:

// hLabel.vue
<script>
export default {
  props: {
    level: {
      type: Number,
    }
  },
  render: function(createElement){
    return createElement(
      'h' + this.level, // 标签名称,根据父组件传入的level值确定
      this.$slots.default // 子节点数组
    )
  }
}
</script>

上面的代码十分精简,通过render函数就可以渲染一个标签模板。同时如果此时需要向组件中传递原来<slot>接收的内容,这时候要使用$slots.default,关于slot的用法我们后面会专门提及。

vue给render函数提供了一个参数createElement,这个参数也是一个函数方法,接受一定的参数,返回的是虚拟DOM(Virtual Dom) VNode,而且在vue中我们一般约定可以把createElement简写为h。下面来看下createElement的用法:

render: function(createElement){
  // @returns {VNode}
  createElement(
    // {String | Object | Function}
    // 一个 HTML 标签名、组件选项对象,或者
    // resolve 了上述任何一种的一个 async 函数。必填项。
    'div',

    // {Object}
    // 一个与模板中属性对应的数据对象。可选。
    {
      // 主要是html模板标签中的属性值的写法,下面单独介绍
    },

    // {String | Array}
    // 子级虚拟节点 (VNodes),由 `createElement()` 构建而成,
    // 也可以使用字符串来生成“文本虚拟节点”。可选。
    [
      '先写一些文字', // 如果是字符串,表示是生成标签中的内容
      createElement('h1', '一则头条'), // createElement生成的新VNode
      createElement(MyComponent, {
        props: {
          someProp: 'foobar'
        }
      })
    ]
  )
}

来单独看下createElement函数中,模板中属性的写法:

{
  // 与 `v-bind:class` 的 API 相同,
  // 接受一个字符串、对象或字符串和对象组成的数组
  'class': {
    foo: true,
    bar: false
  },
  // 与 `v-bind:style` 的 API 相同,
  // 接受一个字符串、对象,或对象组成的数组
  style: {
    color: 'red',
    fontSize: '14px'
  },
  // 普通的 HTML 特性
  attrs: {
    id: 'foo'
  },
  // 组件 prop,这个属性是当createElement渲染的是一个组件时使用
  props: {
    myProp: 'bar'
  },
  // DOM 属性
  domProps: {
    innerHTML: 'baz'
  },
  // 事件监听器在 `on` 属性内,
  // 但不再支持如 `v-on:keyup.enter` 这样的修饰器。
  // 需要在处理函数中手动检查 keyCode。
  on: {
    click: this.clickHandler
  },
  // 仅用于组件,用于监听原生事件,而不是组件内部使用
  // 组件内的原生事件触发时,使用`vm.$emit` 触发的事件。
  nativeOn: {
    click: this.nativeClickHandler
  },
  // 自定义指令。注意,你无法对 `binding` 中的 `oldValue`
  // 赋值,因为 Vue 已经自动为你进行了同步。
  directives: [
    {
      name: 'my-custom-directive',
      value: '2',
      expression: '1 + 1',
      arg: 'foo',
      modifiers: {
        bar: true
      }
    }
  ],
  // 作用域插槽的格式为
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  // 如果组件是其它组件的子组件,需为插槽指定名称
  slot: 'name-of-slot',
  // 其它特殊顶层属性
  key: 'myKey',
  ref: 'myRef',
  // 如果你在渲染函数中给多个元素都应用了相同的 ref 名,
  // 那么 `$refs.myRef` 会变成一个数组。
  refInFor: true
}

通过上面的示例,我们可以看到正如 v-bind:class 和 v-bind:style 在模板语法中会被特别对待一样,它们在 VNode 数据对象中也有对应的顶层字段。该对象也允许你绑定普通的 HTML 特性,也允许绑定如 innerHTML 这样的 DOM 属性 (这会覆盖 v-html 指令)。

相信说到这里大家肯定会觉得render的使用方法太麻烦了,如果需要写一个稍微复杂点的html模版,那我的render函数要写到死了,所以自然就引出了jsx的使用。

# 4. jsx语法

相信写过react的人对这种语法肯定不会陌生。通过babel插件的支持,在vue的render函数中也可以直接使用jsx语法。如果你使用的是vue-cli 3.x创建的项目,那么不需要任何配置,直接就把jsx用起来吧。

// hLabel.vue
<script>
export default{
  props: {
    level: {
      type: Number,
    }
  },
  methods: {
    clickHandler(){

    },
    nativeClickHandler(){

    }
  },
  render:function(h) { // createElement约定可简写为h
    let tag = `h${this.level}`
    return (
      <tag
        key="key"
        ref="ref"
        id='title'
        class={{'foo':true,, 'bar':false}}
        style={{margin: '10px', color:'red'}}
        onClick={this.clickHandler}
        nativeOnClick={this.nativeClickHandler} // 监听组件内的原生事件
      >{this.$slots.default}
      </tag>
    )
  }
}
</script>

上面的例子给出了jsx语法和在标签上添加属性的一个简单示例。不过如果我们使用了render函数之后vue中自带的一些指令就不在生效了,包括v-if,v-forv-model,需要我们自己实现。

# 5. render函数中vue指令的实现

v-if和v-for:

<ul v-if="items.length">
  <li v-for="item in items">{{ item.name }}</li>
</ul>
<p v-else>No items found.</p>
// 在渲染函数中需要使用 if/else 和 map 来重写
props: ['items'],
render: function (h) {
  if (this.items.length) {
    return h('ul', this.items.map(function (item) {
      return h('li', item.name)
    }))
  } else {
    return h('p', 'No items found.')
  }
}

v-model:

props: ['value'],
render: function (createElement) {
  var self = this
  return createElement('input', {
    domProps: {
      value: self.value
    },
    on: {
      input: function (event) {
        self.$emit('input', event.target.value)
      }
    }
  })
}

其实上面的代码就是vue中v-model指令双向绑定的原理,只是v-model对不同的绑定元素做了兼容处理。同时v-model也是可以绑定在组件上的,具体用法可以点击这里查看 (opens new window)

同时在vue中绑定事件时,事件和按键修饰符也不能使用了,因为这些事件修饰符都是vue替我们做了处理的语法糖。关于如何在render函数中使用事件/按键修饰符比较简单,可以去官方文档 (opens new window)查看。

# 6. 函数式组件

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

一个函数式组件就像这样:

<script>
export default{
  functional: true, // 添加属性functional: true,表示该组件为函数式组件
  // Props 是可选的
  props: {
    // ...
  },
  // 为了弥补缺少的实例
  // 提供第二个参数作为上下文
  render: function (createElement, context) {
    // ...
  },
}
</script>

在2.5.0及以上版本的单文件组件,那么基于模板的函数式组件可以这样声明:

<template functional>
</template>

因为函数式组件是无状态的,也没有this上下文,没有data等属性,所以如果所需要的数据都是由render函数的第二个参数context获得的:

  • props:提供所有 prop 的对象
  • data:传递给组件的整个数据对象,作为 createElement 的第二个参数传入组件
  • children: VNode 子节点的数组
  • parent:对父组件的引用
  • slots: 一个函数,返回了包含所有插槽的对象
  • scopedSlots: (2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。
  • listeners: (2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是 data.on 的一个别名。
  • injections: (2.3.0+) 如果使用了 inject 选项,则该对象包含了应当被注入的属性。

在改成函数式组件之后,需要修改一下我们组件的渲染函数,为其增加context参数,并且如果有this.$slots.default 要改为context.children,然后将this.level 要改为context.props.level等。

# 7. 插槽, $slots 和 $scopedSlots

上面我们在render函数中,反复看到插槽slot的使用。所以这次也来顺便看下slot到底是什么的东西。

插槽内容:

Vue 实现了一套内容分发的 API,将 <slot> 元素作为承载分发内容的出口,即可以将在组件内填写的内容渲染在子组件的slot之间。

<!-- 父组件 -->
<navigation-link url="/profile">
  Your Profile
</navigation-link>
<!-- navigation-link组件 -->
<a
  v-bind:href="url"
  class="nav-link"
>
  <slot></slot>
</a>

当组件渲染的时候,<slot></slot> 将会被替换为“Your Profile”。

插槽内可以包含任何模板代码,包括 HTML或者其他组件:

<navigation-link url="/profile">
  <!-- 添加一个 Font Awesome 图标 -->
  <span class="fa fa-user"></span>
  Your Profile
</navigation-link>

<!-- 插槽内为组件 -->
<navigation-link url="/profile">
  <!-- 添加一个图标的组件 -->
  <font-awesome-icon name="user"></font-awesome-icon>
  Your Profile
</navigation-link>

编译作用域:

编译作用域是指在引用组件内部写的内容和子组件内部的内容,所能获取的都只能是其当前作用域下的值。有一个原则是**父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。**结合代码来看:

<!-- 如果想在插槽中使用数据user -->
<!-- user必须是navigation-link组件坐在的作用域可以访问到的值 -->
<navigation-link url="/profile">
  Logged in as {{ user.name }}
</navigation-link>

下面是个访问不到错误例子:

<!-- 这里是访问不到url的 -->
<!-- 因为当前的url值"/profile"是在navigation-link组件内部定义的 -->
<!-- 在navigation-link组件所在的作用域,是访问不到url的 -->
<navigation-link url="/profile">
  Clicking here will send you to: {{ url }}
</navigation-link>

后备内容和具名插槽:

直接看后备内容的示例代码:

<!-- 父组件 -->
<submit-button>
  Save
</submit-button>

<!-- submit-button组件 -->
<button type="submit">
  <slot>Submit</slot>
</button>

后备内容就是说我在slot之间也写入内容作为后备内容,当如果在父组件内使用submit-button且之间有内容时,会优先显示这个值。如果submit-button之间没有内容时,则会显示slot之间的后备内容。

<!-- 父组件 -->
<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <p>A paragraph for the main content.</p>
  <p>And another one.</p>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>
<!-- base-layout 组件 -->
<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

上面是具名插槽的用法示例。如果需要指定多个插槽的渲染内容,可以给slot添加name属性,同时在向插槽提供内容的时候,可以使用template包裹住内容,而且在template之上写入v-slot指定,并且以参数的形式在v-slot上提供要渲染的插槽的名称。这样template的内容就可以渲染到指定name的slot之内。template上的v-slot: name可以简写为#name。如果template没有指定名称的话,默认name为default。

作用域插槽:

上面我们说到,插槽是有作用域的,父级模板里的内容只能访问到父级模板的作用域,子级组件内的内容只能在子级的作用域内渲染。假如我想在父级模板内使用子级组件内的值如何实现呢,这个时候就需要用到作用于插槽,:

<!-- 错误示范 -->
<!-- 父组件想要使用current-user组件内的值user -->
<current-user>
  {{ user.firstName }}
</current-user>

<!-- current-user组件 -->
<span>
  <slot>{{ user.lastName }}</slot>
</span>

<!-- 正确示范 -->
<current-user>
  <template v-slot:default="slotProps">
    {{ slotProps.user.firstName }}
  </template>
</current-user>
<span>
  <slot v-bind:user="user">
    {{ user.lastName }}
  </slot>
</span>

$slots:

vue中用来访问被插槽分发的内容的api,相当于模板中的<slot></slot>。每个具名插槽有其相应的属性 (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default 属性包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。

$scopedSlots:

用来访问作用域插槽,相当可以给<slot>提供值的插槽作用域。对于包括默认 slot 在内的每一个插槽,该对象都包含一个返回相应 VNode 的函数。

# 8. 总结

以上就是这次我要介绍的内容了,虽然比较基础,但是基本用法都有涉及到,希望对大家之后的开发或者面试都能有所帮助。

# 9. 参考文章

vue官方文档:渲染函数&jsx (opens new window)

vue官方文档:插槽 (opens new window)

vue官方api (opens new window)

在vue中使用jsx语法 (opens new window)

vue jsx 不完全指北 (opens new window)


作者简介: 宫晨光,人和未来 (opens new window)大数据前端工程师。