[译]<<Vue.js 设计模式>> (七)Render Functions的力量

271 阅读3分钟

本文的翻译于<<Design Patterns for Vue.js - a Test Driven Ap-proach to Maintainable Applications>>, 特别感谢!! 本书完整的代码请点这

目前为止我们都是通过<template>来写HTML代码。实际上在<template>渲染到浏览器上,Vue做了大量的工作。

<template>中的的代码被编译成了:Render Functions,这个过程有几件事发生:

  1. 类似于v-ifv-for被编译成了js语句
  2. 优化
  3. 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>

image.png

<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。我们可以看后台输出情况:

image.png

就拿第一个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)
    }
}

image.png

我们期待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函数。 他有三种用法:

  1. 最简单的创建HTML元素:
    const el=h('div')
    
    能直接创建一个div元素
  2. 添加props:
    const el=h('div',{class:'tab',foo:'bar'})
    
    会创建下面元素:
    <div class="tab" foo="bar"/>
    
  3. 添加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 }",浏览器看起来这样:

image.png

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

image.png

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)),
        ]
    }
}

浏览器:

image.png

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')
})