立等可取的 Vue + Typescript 函数式组件实战
不同于面向对象编程(OOP)中通过抽象出各种对象并注重其间的解耦问题等,函数式编程(FP) 聚焦最小的单项操作,将复杂任务变成一次次 f(x) = y
式的函数运算叠加。函数是 FP 中的一等公民(First-class object),可以被当成函数参数或被函数返回;同时,这些函数应该不依赖或影响外部状态,这意味着对于给定的输入,将产生相同的输出。
在 Vue 中,一个函数式组件(FC - functional component)就意味着一个没有实例(没有 this
上下文、没有生命周期方法、不监听任何属性、不管理任何状态)的组件。从外部看,它也可以被视作一个只接受一些 prop 并按预期返回某种渲染结果的 fc(props) => VNode
函数。Vue 中的 FC 有时也被称作无状态组件(stateless component)。
❓为何需要函数式(无状态)组件
-
因为函数式组件忽略了生命周期和监听等实现逻辑,所以 渲染开销很低、执行速度快 -
相比于普通组件中的 v-if
等指令,使用 h 函数或结合 jsx 更容易地实现子组件的条件性渲染 -
比普通组件中的 <component>
+v-if 指令
更容易地实现 高阶组件(HOC - higher-order component)模式,即一个封装了某些逻辑并条件性地渲染参数子组件的容器组件
❓函数式组件与真正 FP 有何区别
真正的 FP 函数基于不可变状态(immutable state),但 Vue 中的“函数式”组件没有这么理想化。后者基于可变数据,相比普通组件也只是没有实例概念而已。
同时,与 React Hooks 类似的是,Vue Composition API 也在一定程度上为函数式组件带来了少许响应式特征、onMounted 等生命周期式的概念和管理副作用的方法。
❓TypeScript 对于函数式组件有何意义
无论是 React 还是 Vue,本身都提供了一些验证 props 类型的手段。但这些方法一来配置上都稍显麻烦,二来对于轻巧的函数式组件都有点过“重”了。
TypeScript 作为一种强类型的 JavaScript 超集,可以被用来更精确的定义和检查 props 的类型、使用更简便,在 VSCode 或其他支持 Vetur 的开发工具中的自动提示也更友好。
🐂React 中的 FC + TS
在 React 中,可以 使用 FC<propsType>
来约束一个返回了 jsx 的函数入参:
import React from "react";
type GreetingProps = {
name: string;
}
const Greeting:React.FC<GreetingProps> = ({ name }) => {
return <h1>Hello {name}</h1>
} ;
也可以直接定义函数的参数类型,这样的好处是可以对 props 的类型再使用泛型:
interface IGreeting<T = 'm' | 'f'> {
name: string;
gender: T
}
export const Greeting = ({ name, gender }: IGreeting<0 | 1>): JSX.Element => {
return <h1>Hello { gender === 0 ? 'Ms.' : 'Mr.' } {name}</h1>
} ;
而 Vue 中的做法该如何呢?
本文主要基于 vue 2.x
版本,结合 tsx 语法,尝试探讨一种在大多数现有 vue 项目中马上就能用起来的、具有良好 props 类型约束的函数式组件实践。
Vue 3 风格的 tsx 函数式组件
💡RenderContext
RenderContext 类型被用来约束 render 函数的第二个参数,vue 2.x 项目中对渲染上下文的类型定义如下:
// types/options.d.ts
export interface RenderContext<Props=DefaultProps> {
props: Props;
children: VNode[];
slots(): any;
data: VNodeData;
parent: Vue;
listeners: { [key: string]: Function | Function[] };
scopedSlots: { [key: string]: NormalizedScopedSlot };
injections: any
}
这很清晰地对应了文档中的相应说明段落:
...组件需要的一切都是通过
context
参数传递,它是一个包括如下字段的对象:
-
props
:提供所有 prop 的对象 -
children
:VNode 子节点的数组 -
slots
:一个函数,返回了包含所有插槽的对象 -
scopedSlots
:(2.6.0+) 一个暴露传入的作用域插槽的对象。也以函数形式暴露普通插槽。 -
data
:传递给组件的整个数据对象,作为createElement
的第二个参数传入组件 -
parent
:对父组件的引用 -
listeners
:(2.3.0+) 一个包含了所有父组件为当前组件注册的事件监听器的对象。这是data.on
的一个别名。 -
injections
:(2.3.0+) 如果使用了inject
选项,则该对象包含了应当被注入的 property。
💡interface
正如 interface RenderContext<Props=DefaultProps>
定义的那样,对于函数式组件外部输入的 props,可以使用一个自定义的 TypeScript 接口声明其结构,如:
interface IProps {
year: string;
quarters: Array<'Q1' | 'Q2' | 'Q3' | 'Q4'>;
note: {
content: string;
auther: stiring;
}
}
而后指定该接口为 RenderContext 的首个泛型:
import Vue, { CreateElement, RenderContext } from 'vue';
...
export default Vue.extend({
functional: true,
render: (h: CreateElement, context: RenderContext<IProps>) => {
console.log(context.props.year);
//...
}
});
💡emit
在函数式组件中是没有实例上的 this.$emit
可以用的,要达到同样的效果,可以采用下面的写法:
render: (h: CreateElement, context: RenderContext<IProps>) => {
const emit = (evtName: string, value: any) => (context.listeners[evtName] as Function)(value);
//...
}
配合上 model: { prop, event }
组件选项,对外依然可以达到 v-model
的效果。
💡filter
在 jsx 返回结构中,传统模板中的 <label>{ title | withColon }</label>
过滤器语法不再奏效。
等效的写法比如:
import filters from '@/filters';
//...
const { withColon } = filters;
//...
// render 返回的 jsx 中
<label>{ withColon(title) }</label>
💡子组件的 v-model
jsx 中 v-model
指令是无法正确的工作的,替代写法为:
<input
model={{
value: formdata.iptValue,
callback: (v: string) => (formdata.iptValue = v)
}}
placeholder="请填写"
/>
💡作用域插槽
传统模板中对于作用域插槽的用法如下:
<dynamic-lines :list="attrs">
<template v-slot="scope">
<el-input v-model="attrs[scope.scopeIndex].attr" />
</template>
</dynamic-lines>
jsx 中相应写法则是:
<DynamicLines
list={attrs}
scopedSlots={{
default: (scope: any) => (
<el-input
model={{
value: attrs[scope.scopeIndex].attr,
callback: (v: string) => {
//...
}
}}
/>
)
}}
/>
同时,正如例子中所示,element-ui 等全局注册的组件仍需要使用 kebab-case 形式才能正确被编译。
💡与 Composition API 结合
虽说目的是简单渲染的函数式组件中不用太多响应式特性,但也并非不可以一起工作,比如:
import {
h, inject, Ref, ref
} from '@vue/composition-api';
//...
const pageType = inject<MyPageType>('pageType', 'create');
const dictBrands = inject<Ref<any[]>>('dictBrands', ref([]));
🐂综合实例
了解过以上这些要点,编写一个类型良好的 tsx 函数式组件就没有什么障碍了。
一个实例如:
<script lang="tsx">
import Vue, { CreateElement, RenderContext } from 'vue';
import {
h, inject, Ref, ref
} from '@vue/composition-api';
import DynamicLines from '@/components/DynamicLines';
interface IProps {
list: Spec['model'];
}
export const getEmptyModelRow = (): Spec['model'][0] => ({
brand_id: '',
data: [
{
model: '',
}
]
});
export default Vue.extend({
functional: true,
render: (h: CreateElement, context: RenderContext<IProps>) => {
const emit = (evtName: string, value: any) => (context.listeners[evtName] as Function)(value);
const pageType = inject<CompSpecType>('pageType', '');
const dictBrands = inject<Ref<any[]>>('dictBrands', ref([]));
const isModelRequired = pageType !== 'other';
const list: Spec['model'] = context.props.list || [getEmptyModelRow()];
return [
<table class="dialog-subtable">
<thead>
<tr>
<th class="required">品牌</th>
<th class={isModelRequired ? 'required' : ''}>Model</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{list.map((am, aidx) => (
<tr>
<td>
<el-select
value={am.brand_id}
onChange={(v: string) => {
emit('error', '');
if (list.some(m => String(m.brand_id) === String(v))) {
list[aidx].brand_id = '';
emit('error', '品牌已存在');
emit('change', list);
return;
}
list[aidx].brand_id = v;
emit('change', list);
}}
>
{dictBrands.value.map((dictBrand: { id: string | number; brand: string }) => (
<el-option key={dictBrand.id} label={dictBrand.brand} value={dictBrand.id} />
))}
</el-select>
</td>
<td>
<DynamicLines
list={am.data}
onAdd={() => {
list[aidx].data.push(getEmptyModelRow().data[0]);
emit('change', list);
}}
onDelete={(delIdx: number) => {
list[aidx].data.splice(delIdx, 1);
emit('change', list);
}}
scopedSlots={{
default: (scope: any) => (
<el-input
placeholder="Model"
model={{
value: am.data[scope.scopeIndex].model,
callback: (v: string) => {
const brandItem = list[aidx];
const models = brandItem.data;
emit('error', '');
if (models.some(m => String(m) === String(v))) {
emit('error', '该品牌下相同model已存在');
}
models[scope.scopeIndex].model = v;
emit('change', list);
}
}}
/>
)
}}
/>
</td>
<td>
<el-link
type="danger"
disabled={list.length <= 1}
onClick={() => {
emit('error', '');
list.splice(aidx, 1);
emit('change', list);
}}
>
删除行
</el-link>
</td>
</tr>
))}
</tbody>
</table>,
<el-link
type="primary"
onClick={() => {
emit('error', '');
list.push(getEmptyModelRow());
emit('change', list);
}}
domPropsInnerHTML="+添加行"
/>
];
}
});
</script>
<style lang="scss" scoped>
@import "@/styles/create";
::v-deep {
.dynamic-lines > .line {
white-space: no-wrap;
}
}
</style>
函数式组件的单元测试
有了 TypeScript 的强类型加持,组件内外的参数类型有了较好的保障。
而对于组件逻辑上,仍需要通过单元测试完成安全脚手架的搭建。同时,由于函数式组件一般相对简单,测试编写起来也不麻烦。
关于 Vue 组件的单元测试,可以参阅以下文章:
在实践中,由于 FC 与普通组件的区别,还是有些小问题需要注意:
🐂re-render
由于函数式组件只依赖其传入 props 的变化才会触发一次渲染,所以在测试用例中只靠 nextTick()
是无法获得更新后的状态的,需要设法手动触发其重新渲染:
it("批量全选", async () => {
let result = mockData;
// 此处实际上模拟了每次靠外部传入的 props 更新组件的过程
// wrapper.setProps() cannot be called on a functional component
const update = async () => {
makeWrapper(
{
value: result
},
{
listeners: {
change: m => (result = m)
}
}
);
await localVue.nextTick();
};
await update();
expect(wrapper.findAll("input")).toHaveLength(6);
wrapper.find("tr.whole label").trigger("click");
await update();
expect(wrapper.findAll("input:checked")).toHaveLength(6);
wrapper.find("tr.whole label").trigger("click");
await update();
expect(wrapper.findAll("input:checked")).toHaveLength(0);
wrapper.find("tr.whole label").trigger("click");
await update();
wrapper.find("tbody>tr:nth-child(3)>td:nth-child(2)>ul>li:nth-child(4)>label").trigger("click");
await update();
expect(wrapper.find("tr.whole label input:checked").exists()).toBeFalsy();
});
🐂多个根节点
函数式组件的一个好处是可以返回一个元素数组,相当于在 render() 中返回了多个根节点(multiple root nodes)。
这时候如果直接用 shallowMount 等方式在测试中加载组件,会出现报错:
[Vue warn]: Multiple root nodes returned from render function. Render function should return a single root node.
解决方式是封装一个包装组件:
import { mount } from '@vue/test-utils'
import Cell from '@/components/Cell'
const WrappedCell = {
components: { Cell },
template: `
<div>
<Cell v-bind="$attrs" v-on="$listeners" />
</div>
`
}
const wrapper = mount(WrappedCell, {
propsData: {
cellData: {
category: 'foo',
description: 'bar'
}
}
});
describe('Cell.vue', () => {
it('should output two tds with category and description', () => {
expect(wrapper.findAll('td')).toHaveLength(2);
expect(wrapper.findAll('td').at(0).text()).toBe('foo');
expect(wrapper.findAll('td').at(1).text()).toBe('bar');
});
});
🐂辅助 fragment 组件的测试
另一个可用到 FC 的小技巧是,对于一些引用了 vue-fragment (一般也是用来解决多节点问题)的普通组件,在其中可以封装一个函数式组件 stub 掉 fragment 组件,从而减少依赖、方便测试:
let wrapper = null;
const makeWrapper = (props = null, opts = null) => {
wrapper = mount(Comp, {
localVue,
propsData: {
...props
},
stubs: {
Fragment: {
functional: true,
render(h, { slots }) {
return h("div", slots().default);
}
}
},
attachedToDocument: true,
sync: false,
...opts
});
};
总结
-
一个 Vue 函数式组件就是一个没有实例的组件,也称“无状态组件” -
函数式组件渲染速度快,更易于实现条件性渲染和高阶特性 -
Vue 中的“函数式”组件基于可变数据,并非纯粹的函数式编程 -
TypeScript 可以更精确的定义和检查 props 类型,自动提示也更友好 -
可使用自定义的 TS 接口声明 Vue FC 的 props 结构 -
Vue 函数式组件可以与 Composition API 结合使用 -
对 Vue 函数式组件进行单元测试时需要注意渲染触发问题 -
在测试中可以通过封装包装组件方式解决多节点问题
参考资料
-
https://stevenklambert.com/writing/unit-testing-vuejs-functional-component-multiple-root-nodes/ -
https://devinduct.com/blogpost/47/understanding-stateless-components-in-vue -
https://cn.vuejs.org/v2/guide/render-function.html#%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BB%84%E4%BB%B6 -
https://juejin.im/post/6844904205669367822 -
https://zh.wikipedia.org/wiki/%E5%87%BD%E6%95%B0%E5%BC%8F%E7%BC%96%E7%A8%8B -
https://stevenklambert.com/writing/unit-testing-vuejs-functional-component-multiple-root-nodes/ -
https://zhuanlan.zhihu.com/p/71879386 -
https://fettblog.eu/typescript-react-why-i-dont-use-react-fc/ -
https://juejin.im/post/6844904175831089165 -
https://medium.com/@ethan_ikt/react-stateless-functional-component-with-typescript-ce5043466011
--End--