本文简要地探讨了React和Vue两个主流视图库的逻辑组合与复用模式历史: 从最初的Mixins到HOC, 再到Render Props,最后是最新推出的Hooks。css
*注:本文中JS脚本文件均为全局引入,所以您会看到:const { createElement: h } = React;
之类对象解构写法,而非ES Modules导入的写法。另外,请注意阅读注释里的内容!html
全文共22560字,阅读完成大约须要45分钟。vue
mixins是传统面向对象编程中十分流行的一种逻辑复用模式,其本质就是属性/方法的拷贝,好比下面这个例子:node
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能够包含全部组件实例能够传入的选项,如data, computed, 以及mounted等生命周期钩子函数。其同名冲突合并策略为: 值为对象的选项以组件数据优先, 同名生命周期钩子函数都会被调用,且mixin中的生命周期钩子函数在组件以前被调用。react
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已经随着createClass方法在16版本被移除了, 不过咱们也能够找个15的版原本看看:api
// 若是把注释去掉是会报错的,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'));
好了,以上就是本文关于mixin的全部内容,若是你有些累了不妨先休息一下, 后面还有不少内容:)数组
咱们先来了解下高阶函数, 看下维基百科的概念:闭包
在数学和计算机科学中,高阶函数是至少知足下列一个条件的函数: 接受一个或多个函数做为输入, 输出一个函数在不少函数式编程语言中能找到的map函数是高阶函数的一个例子。它接受一个函数f做为参数,并返回接受一个列表并应用f到它的每一个元素的一个函数。在函数式编程中,返回另外一个函数的高阶函数被称为Curry化的函数。app
举个例子(请忽略我没有进行类型检查):
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));
根据上面的概念,高阶组件就是一个接受组件函数,输出一个组件函数的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中的输入/输出的组件不是一个函数或是类, 而是一个包含template/render选项的JavaScript对象:
const A = { template: '<div>a</div>', }; 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: ` // <article> // <A /> // <B /> // </artcile> // `, });
所以在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 }, }); }, }; }; }
这里就用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的属性/方法来源很是不清晰
先说缺陷:
再说优点:
setState
直接修改输出组件的状态,保证了状态修改来源单一。你可能想知道HOC并无解决太多Mixins带来的问题,为何不继续使用Mixins呢?
一个很是重要的缘由是: 基于类/函数语法定义的组件,须要实例化后才能将mixins中的属性/方法拷贝到组件中,开发者能够在构造函数中自行拷贝,可是类库要提供这样一个mixins选项比较困难。
好了,以上就是本文关于HOC的所有内容。本文没有介绍使用HOC的注意事项/compose函数之类的知识点,不熟悉的读者能够阅读React的官方文档, (逃
其实你在上文的嵌套的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中Render Props对应的概念是插槽(slots)或是笼统地称为Renderless Components。
const child = { template: ` <article> <header>header</header> <slot></slot> <footer>footer</footer> </article> `, // 模板的写法很好理解, 渲染函数的写法是这样: // 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: ` <child> <p v-if="loading">a</p> <p v-else>b</p> </child> `, });
不难看出在Vue中,咱们不须要显式地去传递渲染函数,库会经过$slots
自动传递。
限于篇幅,Vue2.6版本以前的写法: slot
和slot-scope
这里就不介绍了,读者能够阅读Vue的官方文档, 这里介绍下v-slot
的写法:
const child = { data() { return { obj: { name: 'obj' }, }; }, // slot上绑定的属性能够传递给父组件,经过`v-slot:[name]="slotProps"`接收,固然slotProps能够命名为其余名称, 也能够写成下文中的对象解构的写法 template: ` <article> <header>header</header> <slot name="content"></slot> <slot :obj="obj"></slot> <footer>footer</footer> </article> `, }; new Vue({ el: '#app', components: { child }, data() { return { loading: false }; }, mounted() { this.loading = true; setTimeout(() => { this.loading = false; }, 1000); }, // #content是v-slot:content的简写 template: ` <child> <template #content> <p v-if="loading">a</p> <p v-else>b</p> </template> <template #default="{ obj }"> {{ obj.name }} </template> </child> `, });
须要注意的是跟slot
不一样,v-slot
只能添加在<template>
上,而非任意标签。
就跟这个模式的名称同样,Render Props只是组件prop的一种用法,为了逻辑复用,须要将状态/视图的操做都封装到prop的这个渲染函数中,所以和HOC同样也会形成性能上的损耗。可是因为prop的属性只有一个,不会致使HOC prop名称冲突的问题。
好了,以上就是本文关于Render Props的所有内容, 最后咱们将介绍目前最优秀的组件逻辑组合与复用模式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中Hooks被称为Composition API提案,目前API还不太稳定,所以下面的内容有可能还会更改.
一样的咱们先来看下操做状态的钩子, Vue提供了两个操做状态的Hook, 分别是ref
和reactive
(在以前的RFC中分别叫作value
和state
):
<main id="app"> <p>{{ count }}</p> <button @click="increment">click me</button> </main> <script src="https://cdn.bootcss.com/vue/2.6.10/vue.js"></script> <script src="https://unpkg.com/@vue/composition-api/dist/vue-composition-api.umd.js"></script> <script> // vueCompositionApi.default是一个包含install属性的对象(也就是Vue的插件) const { ref, default: VueCompositionApi } = vueCompositionApi; Vue.use(VueCompositionApi); new Vue({ el: '#app', setup() { // 你会发现count就是一个响应式对象,只含有一个value属性, 指向其内部的值。由ref声明的变量被称为包装对象(value wrapper) // 包装对象在模板中使用会被自动展开,便可以直接使用`count`而不须要写`count.value` const count = ref(0); function increment() { // 这里须要很是微妙地加上一个`.value`, 这也是Vue决定将`value`重命名为`ref`的缘由之一(叫value函数返回的倒是个包含value属性的对象不太符合直觉) count.value += 1; } return { count, increment }; }, }); </script>
值得注意的是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'));
<main id="app"> <p>{{ state.count }}</p> <p>{{ state.double }}</p> <button @click="increment">click me</button> </main> <script src="https://cdn.bootcss.com/vue/2.6.10/vue.js"></script> <script src="https://unpkg.com/@vue/composition-api/dist/vue-composition-api.umd.js"></script> <script> const { default: VueCompositionApi, reactive, computed } = vueCompositionApi; Vue.use(VueCompositionApi); new Vue({ el: '#app', setup() { const state = reactive({ count: 0, double: computed(() => state.count * 2), }); // 对于值属性而言能够直接用Vue.observable代替 // 而对于计算属性,vueCompositionApi.computed返回的是个包装对象须要进行处理, 读者能够去除注释打印state.double看看 // const state = Vue.observable({ // count: 0, // double: computed(() => state.count * 2), // }); function increment() { state.count += 1; } return { state, increment }; }, }); </script>
React Hooks 在每次组件渲染时都会调用,经过隐式地将状态挂载在当前的内部组件节点上,在下一次渲染时根据调用顺序取出。而 Vue 的 setup() 每一个组件实例只会在初始化时调用一次 ,状态经过引用储存在 setup() 的闭包内。
所以Vue没有直接提供操做反作用的钩子,提供的依旧是生命周期函数的钩子,除了加了on前缀和以前没太多的差异, 以onMounted为例:
<main id="app"> <ul> <li v-for="item in list">{{ item }}</li> </ul> </main> <script src="https://cdn.bootcss.com/vue/2.6.10/vue.js"></script> <script src="https://unpkg.com/@vue/composition-api/dist/vue-composition-api.umd.js"></script> <script> const { default: VueCompositionApi, reactive, onMounted } = vueCompositionApi; Vue.use(VueCompositionApi); new Vue({ el: '#app', setup() { const list = reactive([1, 2, 3]); onMounted(() => { setTimeout(() => { list.push(4); }, 1000); }); return { list }; }, }); </script>
那么上文中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和Render Props的思想有些许的类似,只不过Render Props返回的是组件,Hooks返回的是一些状态(须要你本身传递给组件)。得益于Hooks这种细粒度的封装能力,渲染函数再也不须要经过组件传递,修正了Render Props须要额外的组件实例嵌套来封装逻辑的缺陷。
好了,以上就是本文关于逻辑组合与复用模式的所有内容。行文不免有疏漏和错误,还望读者批评指正。