前言
本文简要地探讨了React和Vue两个主流视图库的逻辑组合与复用模式历史: 从最初的Mixins到HOC, 再到Render Props,最后是最新推出的Hooks。
*注:本文中JS脚本文件均为全局引入,因此您会看到:const { createElement: h } = React;
之类对象解构写法,而非ES Modules导入的写法。另外,请注意阅读注释里的内容!
全文共22560字,阅读完成大约需要45分钟。
Mixins
面向对象中的mixin
mixins是传统面向对象编程中十分流行的一种逻辑复用模式,其本质就是属性/方法的拷贝,比如下面这个例子:
const eventMixin = {
on(type, handler) {
this.eventMap[type] = handler;
},
emit(type) {
const evt = this.eventMap[type];
if (typeof evt === 'function') {
evt();
}
},
};
class Event {
constructor() {
this.eventMap = {};
}
}
// 将mixin中的属性方法拷贝到Event原型对象上
Object.assign(Event.prototype, eventMixin);
const evt = new Event();
evt.on('click', () => { console.warn('a'); });
// 1秒后触发click事件
setTimeout(() => {
evt.emit('click');
}, 1000);
Vue中的mixin
在Vue中mixin可以包含所有组件实例可以传入的选项,如data, computed, 以及mounted等生命周期钩子函数。其同名冲突合并策略为: 值为对象的选项以组件数据优先, 同名生命周期钩子函数都会被调用,且mixin中的生命周期钩子函数在组件之前被调用。
const mixin = {
data() {
return { message: 'a' };
},
computed: {
msg() { return `msg-${this.message}`; }
},
mounted() {
// 你觉得这两个属性值的打印结果会是什么?
console.warn(this.message, this.msg);
},
};
new Vue({
// 为什么要加非空的el选项呢? 因为根实例没有el选项的话,是不会触发mounted生命周期钩子函数的, 你可以试试把它置为空值, 或者把mounted改成created试试
el: '#app',
mixins: [mixin],
data() {
return { message: 'b' };
},
computed: {
msg() { return `msg_${this.message}`; }
},
mounted() {
// data中的message属性已被merge, 所以打印的是b; msg属性也是一样,打印的是msg_b
console.warn(this.message, this.msg);
},
});
从mixin的同名冲突合并策略也不难看出,在组件中添加mixin, 组件是需要做一些特殊处理的, 添加众多mixins难免会有性能损耗。
React中的mixin
在React中mixin已经随着createClass方法在16版本被移除了, 不过我们也可以找个15的版本来看看:
// 如果把注释去掉是会报错的,React对值为对象的选项不会自动进行合并,而是提醒开发者不要声明同名属性
const mixin = {
// getInitialState() {
// return { message: 'a' };
// },
componentWillMount() {
console.warn(this.state.message);
this.setData();
},
// setData() {
// this.setState({ message: 'c' });
// },
};
const { createElement: h } = React;
const App = React.createClass({
mixins: [mixin],
getInitialState() {
return { message: 'b' };
},
componentWillMount() {
// 对于生命周期钩子函数合并策略Vue和React是一样的: 同名生命周期钩子函数都会被调用,且mixin中的生命周期钩子函数在组件之前被调用。
console.warn(this.state.message);
this.setData();
},
setData() {
this.setState({ message: 'd' });
},
render() { return null; },
});
ReactDOM.render(h(App), document.getElementById('app'));
Mixins的缺陷
- 首先Mixins引入了隐式的依赖关系, 尤其是引入了多个mixin甚至是嵌套mixin的时候,组件中属性/方法来源非常不清晰。
- 其次Mixins可能会导致命名空间冲突, 所有引入的mixin都位于同一个命名空间,前一个mixin引入的属性/方法会被后一个mixin的同名属性/方法覆盖,这对引用了第三方包的项目尤其不友好
- 嵌套Mixins相互依赖相互耦合,会导致滚雪球式的复杂性,不利于代码维护
好了,以上就是本文关于mixin的所有内容,如果你有些累了不妨先休息一下, 后面还有很多内容:)
HOC
高阶函数
我们先来了解下高阶函数, 看下维基百科的概念:
在数学和计算机科学中,高阶函数是至少满足下列一个条件的函数: 接受一个或多个函数作为输入, 输出一个函数在很多函数式编程语言中能找到的map函数是高阶函数的一个例子。它接受一个函数f作为参数,并返回接受一个列表并应用f到它的每个元素的一个函数。在函数式编程中,返回另一个函数的高阶函数被称为Curry化的函数。
举个例子(请忽略我没有进行类型检查):
function sum(...args) {
return args.reduce((a, c) => a + c);
}
const withAbs = fn => (...args) => fn.apply(null, args.map(Math.abs));
// 全用箭头函数的写法可能会对不熟悉的人带来理解上的负担,不过这种写法还是很常见的,其实就相当于下面的写法
// function withAbs(fn) {
// return (...args) => {
// return fn.apply(null, args.map(Math.abs));
// };
// }
const sumAbs = withAbs(sum);
console.warn(sumAbs(1, 2, 3));
console.warn(sumAbs(1, -2));
React中的HOC
根据上面的概念,高阶组件就是一个接受组件函数,输出一个组件函数的Curry化的函数, HOC最为经典的例子便是为组件包裹一层加载状态, 例如:
对于一些加载比较慢的资源,组件最初展示标准的Loading效果,但在一定时间(比如2秒)后,变为“资源较大,正在积极加载,请稍候”这样的友好提示,资源加载完毕后再展示具体内容。
const { createElement: h, Component: C } = React;
// HOC的输入可以这样简单的表示
function Display({ loading, delayed, data }) {
if (delayed) {
return h('div', null, '资源较大,正在积极加载,请稍候');
}
if (loading) {
return h('div', null, '正在加载');
}
return h('div', null, data);
}
// 高阶组件就是一个接受组件函数,输出一个组件函数的Curry化的函数
const A = withDelay()(Display);
const B = withDelay()(Display);
class App extends C {
constructor(props) {
super(props);
this.state = {
aLoading: true,
bLoading: true,
aData: null,
bData: null,
};
this.handleFetchA = this.handleFetchA.bind(this);
this.handleFetchB = this.handleFetchB.bind(this);
}
componentDidMount() {
this.handleFetchA();
this.handleFetchB();
}
handleFetchA() {
this.setState({ aLoading: true });
// 资源1秒加载完成,不会触发加载提示文字切换
setTimeout(() => {
this.setState({ aLoading: false, aData: 'a' });
}, 1000);
}
handleFetchB() {
this.setState({ bLoading: true });
// 资源需要7秒加载完成,请求开始5秒后加载提示文字切换
setTimeout(() => {
this.setState({ bLoading: false, bData: 'b' });
}, 7000);
}
render() {
const {
aLoading, bLoading, aData, bData,
} = this.state;
return h('article', null, [
h(A, { loading: aLoading, data: aData }),
h(B, { loading: bLoading, data: bData }),
// 重新加载后,加载提示文字的逻辑不能改变
h('button', { onClick: this.handleFetchB, disabled: bLoading }, 'click me'),
]);
}
}
// 默认5秒后切换加载提示文字
function withDelay(delay = 5000) {
// 那么这个高阶函数要怎么实现呢? 读者可以自己先写一写
}
ReactDOM.render(h(App), document.getElementById('app'));
写出来大致是这样的:
function withDelay(delay = 5000) {
return (ComponentIn) => {
class ComponentOut extends C {
constructor(props) {
super(props);
this.state = {
timeoutId: null,
delayed: false,
};
this.setDelayTimeout = this.setDelayTimeout.bind(this);
}
componentDidMount() {
this.setDelayTimeout();
}
componentDidUpdate(prevProps) {
// 加载完成/重新加载时,清理旧的定时器,设置新的定时器
if (this.props.loading !== prevProps.loading) {
clearTimeout(this.state.timeoutId);
this.setDelayTimeout();
}
}
componentWillUnmount() {
clearTimeout(this.state.timeoutId);
}
setDelayTimeout() {
// 加载完成后/重新加载需要重置delayed
if (this.state.delayed) {
this.setState({ delayed: false });
}
// 处于加载状态才设置定时器
if (this.props.loading) {
const timeoutId = setTimeout(() => {
this.setState({ delayed: true });
}, delay);
this.setState({ timeoutId });
}
}
render() {
const { delayed } = this.state;
// 透传props
return h(ComponentIn, { ...this.props, delayed });
}
}
return ComponentOut;
};
}
Vue中的HOC
Vue中实现HOC思路也是一样的,不过Vue中的输入/输出的组件不是一个函数或是类, 而是一个包含template/render选项的JavaScript对象:
const A = {
template: 'a',
};
const B = {
render(h) {
return h('div', null, 'b');
},
};
new Vue({
el: '#app',
render(h) {
// 渲染函数的第一个传参不为字符串类型时,需要是包含template/render选项的JavaScript对象
return h('article', null, [h(A), h(B)]);
},
// 用模板的写法的话,需要在实例里注册组件
// components: { A, B },
// template: `
//
//
//
//
// `,
});
因此在Vue中HOC的输入需要这样表示:
const Display = {
// 为了行文的简洁,这里就不加类型检测和默认值设置了
props: ['loading', 'data', 'delayed'],
render(h) {
if (this.delayed) {
return h('div', null, '资源过大,正在努力加载');
}
if (this.loading) {
return h('div', null, '正在加载');
}
return h('div', null, this.data);
},
};
// 使用的方式几乎完全一样
const A = withDelay()(Display);
const B = withDelay()(Display);
new Vue({
el: '#app',
data() {
return {
aLoading: true,
bLoading: true,
aData: null,
bData: null,
};
},
mounted() {
this.handleFetchA();
this.handleFetchB();
},
methods: {
handleFetchA() {
this.aLoading = true;
// 资源1秒加载完成,不会触发加载提示文字切换
setTimeout(() => {
this.aLoading = false;
this.aData = 'a';
}, 1000);
},
handleFetchB() {
this.bLoading = true;
// 资源需要7秒加载完成,请求开始5秒后加载提示文字切换
setTimeout(() => {
this.bLoading = false;
this.bData = 'b';
}, 7000);
},
},
render(h) {
return h('article', null, [
h(A, { props: { loading: this.aLoading, data: this.aData } }),
h(B, { props: { loading: this.bLoading, data: this.bData } }),
// 重新加载后,加载提示文字的逻辑不能改变
h('button', {
attrs: {
disabled: this.bLoading,
},
on: {
click: this.handleFetchB,
},
}, 'click me'),
]);
},
});
withDelay
函数也不难写出:
function withDelay(delay = 5000) {
return (ComponentIn) => {
return {
// 如果ComponentIn和ComponentOut的props完全一致的话可以用`props: ComponentIn.props`的写法
props: ['loading', 'data'],
data() {
return {
delayed: false,
timeoutId: null,
};
},
watch: {
// 用watch代替componentDidUpdate
loading(val, oldVal) {
// 加载完成/重新加载时,清理旧的定时器,设置新的定时器
if (oldVal !== undefined) {
clearTimeout(this.timeoutId);
this.setDelayTimeout();
}
},
},
mounted() {
this.setDelayTimeout();
},
beforeDestroy() {
clearTimeout(this.timeoutId);
},
methods: {
setDelayTimeout() {
// 加载完成后/重新加载需要重置delayed
if (this.delayed) {
this.delayed = false;
}
// 处于加载状态才设置定时器
if (this.loading) {
this.timeoutId = setTimeout(() => {
this.delayed = true;
}, delay);
}
},
},
render(h) {
const { delayed } = this;
// 透传props
return h(ComponentIn, {
props: { ...this.$props, delayed },
});
},
};
};
}
嵌套的HOC
这里就用React的写法来举例:
const { createElement: h, Component: C } = React;
const withA = (ComponentIn) => {
class ComponentOut extends C {
renderA() {
return h('p', { key: 'a' }, 'a');
}
render() {
const { renderA } = this;
return h(ComponentIn, { ...this.props, renderA });
}
}
return ComponentOut;
};
const withB = (ComponentIn) => {
class ComponentOut extends C {
renderB() {
return h('p', { key: 'b' }, 'b');
}
// 在HOC存在同名函数
renderA() {
return h('p', { key: 'c' }, 'c');
}
render() {
const { renderB, renderA } = this;
return h(ComponentIn, { ...this.props, renderB, renderA });
}
}
return ComponentOut;
};
class App extends C {
render() {
const { renderA, renderB } = this.props;
return h('article', null, [
typeof renderA === 'function' && renderA(),
'app',
typeof renderB === 'function' && renderB(),
]);
}
}
// 你觉得renderA返回的是什么? withA(withB(App))呢?
const container = withB(withA(App));
ReactDOM.render(h(container), document.getElementById('app'));
所以不难看出,对于HOC而言,props也是存在命名冲突问题的。同样的引入了多个HOC甚至是嵌套HOC的时候,组件中prop的属性/方法来源非常不清晰
HOC的优势与缺陷
先说缺陷:
- 首先和Mixins一样,HOC的props也会引入隐式的依赖关系, 引入了多个HOC甚至是嵌套HOC的时候,组件中prop的属性/方法来源非常不清晰
- 其次HOC的props可能会导致命名空间冲突, prop的同名属性/方法会被之后执行的HOC覆盖。
- HOC需要额外的组件实例嵌套来封装逻辑,会导致无谓的性能开销
再说优势:
- HOC是没有副作用的纯函数,嵌套HOC不会相互依赖相互耦合
- 输出组件不和输入组件共享状态,也不能使用自身的
setState
直接修改输出组件的状态,保证了状态修改来源单一。
你可能想知道HOC并没有解决太多Mixins带来的问题,为什么不继续使用Mixins呢?
一个非常重要的原因是: 基于类/函数语法定义的组件,需要实例化后才能将mixins中的属性/方法拷贝到组件中,开发者可以在构造函数中自行拷贝,但是类库要提供这样一个mixins选项比较困难。
好了,以上就是本文关于HOC的全部内容。本文没有介绍使用HOC的注意事项/compose函数之类的知识点,不熟悉的读者可以阅读React的官方文档, (逃
Render Props
React中的Render Props
其实你在上文的嵌套的HOC一节中已经看到过Render Props的用法了,其本质就是把渲染函数传递给子组件:
const { createElement: h, Component: C } = React;
class Child extends C {
render() {
const { render } = this.props;
return h('article', null, [
h('header', null, 'header'),
typeof render === 'function' && render(),
h('footer', null, 'footer'),
]);
}
}
class App extends C {
constructor(props) {
super(props);
this.state = { loading: false };
}
componentDidMount() {
this.setState({ loading: true });
setTimeout(() => {
this.setState({ loading: false });
}, 1000);
}
renderA() { return h('p', null, 'a'); }
renderB() { return h('p', null, 'b'); }
render() {
const render = this.state.loading ? this.renderA : this.renderB;
// 当然你也可以不叫render,只要把这个渲染函数准确地传给子组件就可以了
return h(Child, { render });
}
}
ReactDOM.render(h(App), document.getElementById('app'));
Vue中的slot
在Vue中Render Props对应的概念是插槽(slots)或是笼统地称为Renderless Components。
const child = {
template: `
header
`,
// 模板的写法很好理解, 渲染函数的写法是这样:
// render(h) {
// return h('article', null, [
// h('header', null, 'header'),
// // 因为没有用到具名slot, 所以这里就直接用default取到所有的Vnode
// this.$slots.default,
// h('footer', null, 'footer'),
// ]);
// },
};
new Vue({
el: '#app',
components: { child },
data() {
return { loading: false };
},
mounted() {
this.loading = true;
setTimeout(() => {
this.loading = false;
}, 1000);
},
template: `
a
b
`,
});
不难看出在Vue中,我们不需要显式地去传递渲染函数,库会通过$slots
自动传递。
限于篇幅,Vue2.6版本之前的写法: slot
和slot-scope
这里就不介绍了,读者可以阅读Vue的官方文档, 这里介绍下v-slot
的写法:
const child = {
data() {
return {
obj: { name: 'obj' },
};
},
// slot上绑定的属性可以传递给父组件,通过`v-slot:[name]="slotProps"`接收,当然slotProps可以命名为其他名称, 也可以写成下文中的对象解构的写法
template: `
header
`,
};
new Vue({
el: '#app',
components: { child },
data() {
return { loading: false };
},
mounted() {
this.loading = true;
setTimeout(() => {
this.loading = false;
}, 1000);
},
// #content是v-slot:content的简写
template: `
a
b
{{ obj.name }}
`,
});
需要注意的是跟slot
不同,v-slot
只能添加在上,而非任意标签。
Render Props的优势和缺陷
就跟这个模式的名称一样,Render Props只是组件prop的一种用法,为了逻辑复用,需要将状态/视图的操作都封装到prop的这个渲染函数中,因此和HOC一样也会造成性能上的损耗。但是由于prop的属性只有一个,不会导致HOC prop名称冲突的问题。
好了,以上就是本文关于Render Props的全部内容, 最后我们将介绍目前最优秀的组件逻辑组合与复用模式Hooks。
Hooks
React中的Hooks
Hooks在React中在16.8版本正式引入,我们先看下操作状态的钩子useState
:
const { createElement: h, useState } = React;
function App() {
// 没有super(props), 没有this.onClick = this.onClick.bind(this)
const [count, setCount] = useState(0);
function onClick() {
// 没有this.state.count, 没有this.setState
setCount(count + 1);
}
return h('article', null, [
h('p', null, count),
h('button', { onClick }, 'click me'),
]);
}
ReactDOM.render(h(App), document.getElementById('app'));
函数中没有生命周期函数钩子,因此React Hooks提供了一个操作副作用的钩子useEffect
, 钩子中的callback会在渲染完成后被调用
const { createElement: h, useState, useEffect } = React;
function App() {
const [message, setMessage] = useState('a');
const [count, setCount] = useState(0);
useEffect(() => {
// 未指定`useEffect`的第二个参数,每次渲染完成后都会调用callback, 因此点击按钮会一直打印use effect
console.warn('use effect', count);
setTimeout(() => {
setMessage('b');
}, 1000);
// useEffect中返回的函数会在渲染完成后,下一个effect开始前被调用
return () => {
console.warn('clean up', count);
};
});
useEffect(() => {
// 告诉 React 你的 effect 不依赖于 props 或 state 中的任何值,所以它永远都不需要重复执行, 相当于componentDidMount
}, []);
// 空数组可以替换成state不会改变的变量组成的数组
// const [fake] = useState(0);
// useEffect(() => {}, [fake]);
useEffect(() => {
return () => {
// 相当于componentWillUnmount
};
}, []);
console.warn('render', count);
return h('article', null, [
h('p', null, count),
h('button', { onClick }, 'click me'),
h('p', null, message),
]);
}
ReactDOM.render(h(App), document.getElementById('app'));
除了这两个最常用的钩子,React Hooks还提供了许多内置的钩子函数,这里举个useCallback
的例子:
const { createElement: h, useCallback } = React;
function useBinding(initialValue) {
const [value, setValue] = useState(initialValue);
// 利用useCallback可以轻松地实现双向绑定的功能
const onChange = useCallback((evt) => {
setValue(evt.target.value);
}, [value]);
return [value, onChange];
}
function App() {
const [value, onChange] = useBinding('text');
return h('article', null, [
h('p', null, value),
h('input', { value, onChange }),
]);
}
好了,我们知道了Hooks的基本用法。那么上文中HOC的例子用Hooks要怎么改写呢?
对于一些加载比较慢的资源,组件最初展示标准的Loading效果,但在一定时间(比如2秒)后,变为“资源较大,正在积极加载,请稍候”这样的友好提示,资源加载完毕后再展示具体内容。
仔细观察上文中的withDelay
函数,不难发现就组件层面而言, 我们只是给输入组件传递了一个名为delayed的prop。
那么对于Hooks而言也是一样, 我们可以保证视图组件Display
和根组件App
不变, 仅仅修改withDelay
这一HOC为自定义HookuseDelay
, 这个Hook只返回delayed
变量。
function useDelay({ loading, delay = 5000 }) {
// 自定义Hook, 需要返回一个delayed变量
}
function HookDisplay(props) {
const delayed = useDelay({ loading: props.loading });
// Display组件函数请看上文中的React中的HOC章节
return h(Display, { ...props, delayed });
}
// 由于例子中的两个组件除了props其余部分都是一致的,因此共用一个组件函数(你仔细观察HOC的例子会发现其实也是一样的)
const A = HookDisplay;
const B = HookDisplay;
// 你还能用更简洁的函数完成这个函数完成的事情吗?
function useDelay({ loading, delay = 5000 }) {
const [delayed, setDelayed] = useState(false);
useEffect(() => {
// 加载完成后/重新加载需要重置delayed
if (delayed) {
setDelayed(false);
}
// 处于加载状态才设置定时器
const timeoutId = loading ? setTimeout(() => {
setDelayed(true);
}, delay) : null;
return () => {
clearTimeout(timeoutId);
};
}, [loading]);
return delayed;
}
Vue中的Composition API
Vue中Hooks被称为Composition API提案,目前API还不太稳定,因此下面的内容有可能还会更改.
同样的我们先来看下操作状态的钩子, Vue提供了两个操作状态的Hook, 分别是ref
和reactive
(在之前的RFC中分别叫做value
和state
):
{{ count }}
值得注意的是Vue的ref
钩子和React的useRef
钩子还是有一些差别的,useRef
本质上并不是一个操作状态的钩子(或者说操作的状态不会影响到视图)。
const { createElement: h, useRef } = React;
function App() {
const count = useRef(0);
function onClick() {
// 虽然每次渲染都会返回同一个ref对象,但是变更current属性并不会引发组件重新渲染
console.warn(count.current);
count.current += 1;
}
return h('article', null, [
h('p', null, count.current),
h('button', { onClick }, 'click me'),
]);
}
ReactDOM.render(h(App), document.getElementById('app'));
{{ state.count }}
{{ state.double }}
React Hooks 在每次组件渲染时都会调用,通过隐式地将状态挂载在当前的内部组件节点上,在下一次渲染时根据调用顺序取出。而 Vue 的 setup() 每个组件实例只会在初始化时调用一次 ,状态通过引用储存在 setup() 的闭包内。
因此Vue没有直接提供操作副作用的钩子,提供的依旧是生命周期函数的钩子,除了加了on前缀和之前没太多的差别, 以onMounted为例:
- {{ item }}
那么上文中HOC的例子迁移到Composition API几乎不需要修改, 保持Display
组件对象和根Vue实例选项不变:
function useDelay(props, delay = 5000) {
// 自定义Hook, 需要返回一个delayed变量
}
const HookDisplay = {
props: ['loading', 'data'],
setup(props) {
const delayed = useDelay(props);
return { delayed };
},
render(h) {
// Display组件对象请看上文中的Vue中的HOC章节
return h(Display, {
props: {
...this.$props, delayed: this.delayed,
},
});
},
};
const A = HookDisplay;
const B = HookDisplay;
const {
default: VueCompositionApi, ref, watch, onMounted, onUnmounted,
} = vueCompositionApi;
Vue.use(VueCompositionApi);
function useDelay(props, delay = 5000) {
const delayed = ref(false);
let timeoutId = null;
// 你可以试试把传参props换成loading
// 由于loading是基础类型, 在传参的时候会丢失响应式的能力(不再是对象的getter/setter)
watch(() => props.loading, (val, oldVal) => {
if (oldVal !== undefined) {
clearTimeout(timeoutId);
setDelayTimeout();
}
});
onMounted(() => {
setDelayTimeout();
});
onUnmounted(() => {
clearTimeout(timeoutId);
});
function setDelayTimeout() {
if (delayed.value) {
delayed.value = false;
}
if (props.loading) {
timeoutId = setTimeout(() => {
delayed.value = true;
}, delay);
}
}
return delayed;
}
Hooks的优势
不难看出Hooks和Render Props的思想有些许的相似,只不过Render Props返回的是组件,Hooks返回的是一些状态(需要你自己传递给组件)。得益于Hooks这种细粒度的封装能力,渲染函数不再需要通过组件传递,修正了Render Props需要额外的组件实例嵌套来封装逻辑的缺陷。
好了,以上就是本文关于逻辑组合与复用模式的全部内容。行文难免有疏漏和错误,还望读者批评指正。