乐趣区

论如何复用一个组件的逻辑

前言

本文简要地探讨了 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: '<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},
        });
      },
    };
  };
}

嵌套的 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: `
    <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 版本之前的写法: slotslot-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 的优势和缺陷

就跟这个模式的名称一样,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, 分别是 refreactive(在之前的 RFC 中分别叫做 valuestate):

<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 的优势

不难看出 Hooks 和 Render Props 的思想有些许的相似,只不过 Render Props 返回的是组件,Hooks 返回的是一些状态(需要你自己传递给组件)。得益于 Hooks 这种细粒度的封装能力,渲染函数不再需要通过组件传递,修正了 Render Props 需要额外的组件实例嵌套来封装逻辑的缺陷。

好了,以上就是本文关于逻辑组合与复用模式的全部内容。行文难免有疏漏和错误,还望读者批评指正。

退出移动版