不同于面向对象编程(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 更容易地实现子组件的条件性渲染
比普通组件中的
+ v-if 指令
更容易地实现高阶组件(HOC - higher-order component)模式,即一个封装了某些逻辑并条件性地渲染参数子组件的容器组件
真正的 FP 函数基于不可变状态(immutable state),但 Vue 中的“函数式”组件没有这么理想化。后者基于可变数据,相比普通组件也只是没有实例概念而已。
同时,与 React Hooks 类似的是,Vue Composition API 也在一定程度上为函数式组件带来了少许响应式特征、onMounted 等生命周期式的概念和管理副作用的方法。
无论是 React 还是 Vue,本身都提供了一些验证 props 类型的手段。但这些方法一来配置上都稍显麻烦,二来对于轻巧的函数式组件都有点过“重”了。
TypeScript 作为一种强类型的 JavaScript 超集,可以被用来更精确的定义和检查 props 的类型、使用更简便,在 VSCode 或其他支持 Vetur 的开发工具中的自动提示也更友好。
在 React 中,可以 使用 FC
来约束一个返回了 jsx 的函数入参:
import React from "react";
type GreetingProps = {
name: string;
}
const Greeting:React.FC = ({ name }) => {
return Hello {name}
} ;
也可以直接定义函数的参数类型,这样的好处是可以对 props 的类型再使用泛型:
interface IGreeting {
name: string;
gender: T
}
export const Greeting = ({ name, gender }: IGreeting<0 | 1>): JSX.Element => {
return Hello { gender === 0 ? 'Ms.' : 'Mr.' } {name}
} ;
而 Vue 中的做法该如何呢?
本文主要基于 vue 2.x
版本,结合 tsx 语法,尝试探讨一种在大多数现有 vue 项目中马上就能用起来的、具有良好 props 类型约束的函数式组件实践。
RenderContext 类型被用来约束 render 函数的第二个参数,vue 2.x 项目中对渲染上下文的类型定义如下:
// types/options.d.ts
export interface RenderContext {
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 RenderContext
定义的那样,对于函数式组件外部输入的 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) => {
console.log(context.props.year);
//...
}
});
在函数式组件中是没有实例上的 this.$emit
可以用的,要达到同样的效果,可以采用下面的写法:
render: (h: CreateElement, context: RenderContext) => {
const emit = (evtName: string, value: any) => (context.listeners[evtName] as Function)(value);
//...
}
配合上 model: { prop, event }
组件选项,对外依然可以达到 v-model
的效果。
在 jsx 返回结构中,传统模板中的 过滤器语法不再奏效。
等效的写法比如:
import filters from '@/filters';
//...
const { withColon } = filters;
//...
// render 返回的 jsx 中
jsx 中 v-model
指令是无法正确的工作的,替代写法为:
(formdata.iptValue = v)
}}
placeholder="请填写"
/>
传统模板中对于作用域插槽的用法如下:
jsx 中相应写法则是:
(
{
//...
}
}}
/>
)
}}
/>
同时,正如例子中所示,element-ui 等全局注册的组件仍需要使用 kebab-case 形式才能正确被编译。
虽说目的是简单渲染的函数式组件中不用太多响应式特性,但也并非不可以一起工作,比如:
import {
h, inject, Ref, ref
} from '@vue/composition-api';
//...
const pageType = inject('pageType', 'create');
const dictBrands = inject>('dictBrands', ref([]));
了解过以上这些要点,编写一个类型良好的 tsx 函数式组件就没有什么障碍了。
一个实例如:
有了 TypeScript 的强类型加持,组件内外的参数类型有了较好的保障。
而对于组件逻辑上,仍需要通过单元测试完成安全脚手架的搭建。同时,由于函数式组件一般相对简单,测试编写起来也不麻烦。
关于 Vue 组件的单元测试,可以参阅以下文章:
????实例入门 Vue.js 单元测试
????Vue 3 Composition API 之单元测试
在实践中,由于 FC 与普通组件的区别,还是有些小问题需要注意:
由于函数式组件只依赖其传入 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: `
|
`
}
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');
});
});
另一个可用到 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--
查看更多前端好文
请搜索 云前端 或 fewelife 关注公众号
转载请注明出处