本文的翻译于<<Design Patterns for Vue.js - a Test Driven Ap-proach to Maintainable Applications>>, 特别感谢!! 本书完整的代码请点这
目前为止我们都是通过<template>来写HTML代码。实际上在<template>渲染到浏览器上,Vue做了大量的工作。
<template>中的的代码被编译成了:Render Functions,这个过程有几件事发生:
- 类似于
v-if和v-for被编译成了js语句 - 优化
- css 被局部化处理。 虽然<template>写法更符合人类直觉。但是某些情况下,使用Render Functions好处更多。例如当你写一些通用的UI组件库。
这章我们准备构造一个<Tab>组件,使用方法如下:
<template>
<tab-container v-model:activeTabId="activeTabId">
<tab tabId="1">Tab #1</tab>
<tab tabId="2">Tab #2</tab>
<tab tabId="3">Tab #3</tab>
<tab-content tabId="1">Content #1</tab-content>
<tab-content tabId="2">Content #2</tab-content>
<tab-content tabId="3">Content #3</tab-content>
</tab-container>
</template>
<tab-container>由带有tabId的<tab>组成。相同的tabId的<tab>和<tab-content>绑定。只有tabId和activeId相同的<tab-content>才能显示。我们可以通过点击切换activeId。
7.1 为什么选择Render Functions
没有Render Functions,你只能这么写:
<template>
<tab-container
v-model:activeTabId="activeTabId"
>
<tab @click="activeTabId ='1'">Tab #1</tab>
<tab @click="activeTabId ='2'">Tab #2</tab>
<tab @click="activeTabId ='3'">Tab #3</tab>
<tab-content v-if="activeTabId ==='1'">Content #1</tab-content>
<tab-content v-if="activeTabId ==='2'">Content #2</tab-content>
<tab-content v-if="activeTabId ==='3'">Content #3</tab-content>
</tab-container>
</template>
随着开发进行,上面的写法不够简洁。
Render Functions的另外一个用处:当你需要写重复使用的组件时(例如这里的tabs),Render Functions有很多好处
7.2 创建组件
使用Render Functions另一个好处是:你可以在同一个文件写多个组件。通常一个文件写一个组件。
这里我将<tab-container> ,<tab-content>,<tab>写在一个文件内的原因:<tab-content>和<tab>不会脱离<tab-container>使用。
import {h} from 'vue'
export const TabContent = {
props: {tabId: {type: String, required: true}}, render() {
return h(this.$slots.default)
}
}
export const Tab = {
props: {tabId: {type: String, required: true}}, render() {
return h('div', h(this.$slots.default))
}
}
这里的Tab,Tabcontent使用的是Render Functions而不是<template>。
h函数我们之后再讲
我们做一下重构,减少代码重复
import {h} from 'vue'
const withTabId = (content) => ({
props: {
tabId: {
type: String,
required: true
}
},
...content
})
export const TabContent = withTabId({
render() {
return h(this.$slots.default)
}
})
export const Tab = withTabId({
render() {
return h('div', h(this.$slots.default))
}
})
7.3 筛选slots根据components
来到令人激动的环节,我们<tab-container>的Render Functions。
export const TabContainer = {
props: {activeTabId: String},
render() {
console.log(this.$slots.default())
}
}
如果你喜欢Composition API,那就是这个样子:
export const TabContainer = {
props: {activeTabId: String},
setup(props,{ slots }) {
console.log(slots.default())
}
}
我们使用接下来的UI代码:
<template>
<tab-container v-model:activeTabId="activeTabId">
<tab tabId="1" data-test="1">Tab #1</tab>
<tab tabId="2" data-test="2">Tab #2</tab>
<tab-content tabId="1">Content #1</tab-content>
<tab-content tabId="2">Content #2</tab-content>
</tab-container>
</template>
<script>
import {ref} from 'vue'
import {
Tab,
TabContent,
TabContainer
} from './tab-container.js'
export default {
components: {
Tab,
TabContainer,
TabContent
},
setup() {
return {
activeTabId: ref('1')
}
}
}
</script>
这个UI代码中的this.$slots.default()包含四个部分,两个<tab>的VNode,两个<tab-content>的VNode。我们可以看后台输出情况:
就拿第一个VNode来举例,它代表这个:
<tab tabId="1">Tab #1</tab>
它的child为 Tab #1,是一个text VNode
它的props为{tabId:"1"}
它的type为 tab,<div> 的type为 div
7.5 Adding Attributes to Render Functions
让我用h函数在页面上渲染点东西:
export const TabContainer = {
props: {activeTabId: String},
render() {
const $slots = this.$slots.default()
const tabs = $slots.filter(slot => slot.type === Tab).map(tab => {
return h(tab)
})
const contents = $slots.filter(slot => slot.type === TabContent)
return h(() => tabs)
}
}
我们期待h(() => tabs) 而不是return tabs,h函数期待一个回调函数。
7.6 什么是h函数?
来看一个更复杂的例子,同时返回三个元素,两个是HTML元素,一个是自定义的元素:
const Comp = {
render() {
const e1 = h('div')
const e2 = h('span')
const e3 = h({
render() {
return h('p', {}, ['Some Content'])
}
})
return [h(() => e1), h(() => e2), h(() => e3)]
}
}
h,是hyper的缩写,h函数其实是一个用来创建HTML结构体的js函数。 他有三种用法:
- 最简单的创建HTML元素:
能直接创建一个div元素const el=h('div') - 添加props:
会创建下面元素:const el=h('div',{class:'tab',foo:'bar'})<div class="tab" foo="bar"/> - 添加children,第三个参数是children,通常是array:
会创建下面的元素:const el=h('div',{class:'tab',foo:'bar'},['Content'])`<div class="tab" foo="bar">Content</div>
你并不局限于用h函数生成标准的HTML元素,我们可以生成定制的组件:
const Tab = {
render() {
return h('span')
}
}
const el = h('div', {}, [h(Tab), {}, ['Tab #1']])
7.7 添加动态class
我们可以给active状态下的tab,添加active的class。
export const TabContainer = {
props: {activeTabId: String}, render() {
const $slots = this.$slots.default()
const tabs = $slots.filter(slot => slot.type === Tab).map(tab => {
return h(tab, {class: {tab: true, active: tab.props.tabId === this.activeTabId}})
})
const contents = $slots.filter(slot => slot.type === TabContent)
return h(() => h('div', {class: 'tabs'}, tabs))
}
}
是否看起来很熟悉?
{
class: {
tab: true,
active:
tab.props.tabId === this.activeTabId
}
}
没错,就是v-bind:class语法格式,上面的写法等同于你写v-bind:class="{ tab:true, active: tabId === activeTabId }",浏览器看起来这样:
7.8 Render Functions 中的事件监听
当用户点击tab,要将tab的状态改为active。Render Functions的事件监听和class写法类似:
{
class: {
tab: true,
active: tab.props.tabId === this.activeTabId
},
onClick: () => {
this.$emit('update:activeTabId', tab.props.tabId)
}
}
上面的写法等价于\<tab v on:click="update:activeTabId(tabId)"/> 。on:click变成了onClick
7.9 筛选content
最后我们要做的功能是筛选content,只有id等于activeId的content才会显示。我们用find来筛选,而不是filter。因为find始终会返回一个元素。
const content = $slots.find(slot =>
slot.type === TabContent &&
slot.props.tabId === this.activeTabId
)
这是完整的Render函数
export const TabContainer = {
props: {
activeTabId: String
},
emits: ['update:activeTabId'],
render() {
const $slots = this.$slots.default()
const tabs = $slots
.filter(slot => slot.type === Tab)
.map(tab => {
return h(
tab,
{
class: {
tab: true,
active: tab.props.tabId === this.activeTabId
},
onClick: () => {
this.$emit('update:activeTabId', tab.props.tabId)
}
}
)
})
const content = $slots.find(slot =>
slot.type === TabContent &&
slot.props.tabId === this.activeTabId
)
return [
h(() => h('div', {class: 'tabs'}, tabs)),
h(() => h('div', {class: 'content'}, content)),
]
}
}
浏览器:
7.10 测试渲染函数组件
由于我们将Render函数分离出来,我们测试非常方便:
import { render, screen, fireEvent } from '@testing-library/vue'
import App from './app.vue'
test('tabs', async () => {
render(App)
expect(screen.queryByText('Content #2')).toBeFalsy()
fireEvent.click(screen.getByText('Tab #2'))
await screen.findByText('Content #2')
})