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

前言

本文简要地探讨了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
footer
`, // 模板的写法很好理解, 渲染函数的写法是这样: // 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版本之前的写法: slotslot-scope这里就不介绍了,读者可以阅读Vue的官方文档, 这里介绍下v-slot的写法:

const child = {
  data() {
    return {
      obj: { name: 'obj' },
    };
  },
  // slot上绑定的属性可以传递给父组件,通过`v-slot:[name]="slotProps"`接收,当然slotProps可以命名为其他名称, 也可以写成下文中的对象解构的写法
  template: `
    
header
footer
`, }; new Vue({ el: '#app', components: { child }, data() { return { loading: false }; }, mounted() { this.loading = true; setTimeout(() => { this.loading = false; }, 1000); }, // #content是v-slot:content的简写 template: ` `, });

需要注意的是跟slot不同,v-slot只能添加在