React 开发中的代码复用技术:继承、构成、装饰和混入

(本文略译自《 Reusable Code In React: Inheritance, Composition, Decorators and Mixins》)

React 是我用过的最好用的UI库,如果你能突破传统旧Web开发观念,并熟悉了 React 的观念和开发模式一定会同意我的看法。好用的最主要原因,是 React 支持数种 代码复用 技术。本文将介绍 React 如何支持 四种代码复用技术,我们以一个计数器(counter)UI作为例子讲解。

Inheritance

继承或类继承 是 传统面向对象构造术 的最主要 代码复用技术。对于像 有Java 背景的人来说,继承是非常熟悉的,但是对JS开发者来说,则是相对陌生一点。类继承技术的特征是,对 某既有的类 进行功能扩充,包括数据或方法。

以下是一个简单的JS例子:

class Automobile {
    constructor() {
        this.vehicleName = automobile;
        this.numWheels = null;
    }
    printNumWheels() {
        console.log(`This ${this.vehicleName} has ${this.numWheels} wheels`);
    }
}

class Car extends Automobile {
    constructor() {
        super(this);
        this.vehicleName = 'car';
        this.numWheels = 4;
    }
}

class Bicycle extends Automobile {
    constructor() {
        super(this);
        this.vehicleName = 'bike';
        this.numWheels = 2;
    }
}

const car = new Car();
const bike = new Bicycle();
car.printNumWheels() // This car has 4 wheels
bike.printNumWheels() // This bike has 2 wheels

类继承技术 适用于这样的一种代码复用场景 ,就是你的code base中有大量相似功能的类——它们的公共部分远多于不同的部分(类对象实例很容易替换interchangeable) ,并且公共部分很少改变;另外继承的层次很浅。

反过来说,类继承技术最不适用于当 「有跨多层复用关系的」 场景,还有就是 「类不同部分大于复用部分」 的。在这种复用场景,类继承会造成程序 的“易脆”(brittle structures)的程序结构,易出错不易维护注1

类继承技术最直接的实例就是React库本身。React Components基类 对V对象行为进行抽象,本身作为通用部分很少改变,像生命事件勾子: render, componentDidMount, componentWillUnmount,并且Components基类会被用来派生大量性质相似的行为有少量不同的具体的V类。

以下是一个简单的计数器(counter)V 组件,注意我们并没有定义 setState方法,它是继承自基类的:

//counter.js
class Counter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0,
        }
    }
    render() {
        let {count} = this.state;
        let increment = () => this.setState(state => {
            return {
               count: state.count + 1
            };
        });
        return <div className="fancy-layout">
            <div className="fancy-display">
                Current Count: {count}
            </div>
            <button
                className="incrementCounterBtn fancy-btn"
                onClick={increment}
            >Increment</button>
        </div>
    }
}

除于「Components基类和具体V类之间」这一层使用继承复用,React开发几乎不用(不宜用)类继承。React 其它复用场景需要其它复用技术。

Composition

构成(Composition)是这样的一种代码复用技术注2:一个大对象由多个小对象结合而成。

构成关系是一种整体和局部组成的成分关系注3,而「网页的HTML标签树状结构」天然符合这种结构模式。所以,我们若要创建一个表格,我们会使用表头(th),表行(tr),和表格(td)等「组合」创建,而不是配置一集参数去扩展一个单一的表格对象(像父类派生子类那样)。

<table>
    <thead>
        <tr><th>Web Framework</th><th>Language</th></tr>
    </thead>
    <tbody>
        <tr><th>Rails</th><th>Ruby</th></tr>
        <tr><th>Django</th><th>Python</th></tr>
        <tr><th>Flask</th><th>Python</th></tr>
        <tr><th>Play</th><th>Scala</th></tr>
        <tr><th>Play</th><th>Java</th></tr>
        <tr><th>Express</th><th>JavaScript</th></tr>
    </tbody>
</table>

类继承和对象构成的区别

与类继承复用不同,构成复用是小对象作为大对象的组成部分而被复用的。==构成复用,其实是最普遍的代码重用技术,像传统的函数调用就是构成复用==。构成复用以程序分割和单元封装技术为基础。

React前端编程 大量使用 构成复用

React 顺应了HTML的这个结构特性,使用构成技术开发UI。只是在与HMTL节点的基础上推进了一步,R节点或者R构件除了有UI呈现,还具有状态,和管理子组件(与其互通信)的功能。

例如对于我们上面的计数器 V组件,假设它有一部分UI控件需要复用到其它V组件,我们完成可以将其分拆为多个小UI(FancyLayout, FancyButton and FancyDisplay ),再“构成”到一起:

// UI components defined elsewhere
const FancyLayout = ({children}) => <div className="fancy-layout">{children}</div>;
const FancyDisplay = ({children}) => <div className="fancy-display">{children}</div>;
const FancyButton = ({children, className, onClick}) => <button
    className={`fancy-layout ${className}`}
    onClick={onClick}
    >
        {children}
    </button>;

// counter.js
class Counter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            count: 0,
        }
    }
    render() {
        let {count} = this.state;
         let increment = () => this.setState(state => {
            return {
               count: state.count + 1
            };
        });
        return <FancyLayout>
            <FancyDisplay>
                Current Count: {count}
            </FancyDisplay>
            <FancyButton
                className="incrementCounterBtn"
                onClick={increment}
            >Increment</FancyButton>
        </FancyLayout>;
    }
}

注意,这里我们有四个UI组件,一个(Counter)由其它三个“构成”,而它们都可以作为通用的小组件「复用」到其它大组件中去。

这个构成原理是React 成功重要原因之一,第二原因是JSX的表达力量。

这里特别注意的是,计数器经分解重构成后,「计数逻辑」依然和「UI布局」耦合在一起。下面我们来看看 两种将计算逻辑从显示依赖中提取出来的技术注4,以便能复用到他处。

Decorators

装饰(Decorators)是这样的一种代码复用技术:通过「编辑」既有类的定义,将复用的功能添加到其中。与 继承(复用的父类是子类定义的一部分)不同,通用的装饰块代码(decorators)不是被装饰的类定义的一部分,它是 ==在运行时通过修改类定义==注5,让一部分的类对象实例拥有不同计算行为。

装饰模式类图

装饰结构模式,一种轻便继承复用

装饰模式 是经典23种设计模式中的一种 结构类设计模式,在传统面向对象语言中,装饰复用是以一种 「接口继承」 的方式实现的(看上面这个图):

  • 第一,使用抽象的接口(Component) 声明通用的行为,用来“装饰”别类;
  • 第二,创建一个装饰类(Decorator)实现这个接口,另外,被装饰的类(ConcreteComponent)也要实现这个接口;
  • 第三,具体类(ConcreteDecorator)通过继承装饰类(Decorator)完成功能行为的添加。

可以看出来,传统面向对象语言中的「装饰复用」其实是一种 轻便继承,是在通过 源码声明定义 完成的,而使用JS的 React开发则有不同。

React 装饰功能的高阶组件

React社区一般是通过一个「装饰函数」,将传入的类定义( component class )进行修改,再返回一个新类( component class ),实现类似于行为装饰的效果。React社区称这种「制作新组件」的函数为 高阶组件(Higher Order Components)。

以下是一个叫 withCounter 的高阶组件(装饰函数),它将计数器的计数行为逻辑抽取出来。

const withCounter = WrappedComponent => {
   class ComponentWithCounter extends React.Component {
      constructor(props) {
         super(props);
         this.state = {
            count: 0
         };
         this.increment = () => this.setState(state => {
            return {
               count: state.count + 1
            };
         });
      }

      render() {
         let {count} = this.state;
         return <WrappedComponent count={count} increment={this.increment} />
      }
  }
  return ComponentWithCounter;
};

// counter.js

// this is a display only version of the component with no logic
// very easy to test and simple to change
class CounterDisplay extends React.Component {
    render() {
        let {count, increment} = this.props;
        return <FancyLayout>
            <FancyDisplay>
                Current Count: {count}
            </FancyDisplay>
            <FancyButton
                className="incrementCounterBtn"
                onClick={increment}
            >Increment</FancyButton>
        </FancyLayout>;
    }
}

// this adds the logic back to the display only version
const Counter = withCounter(CounterDisplay);

现在,我们将原计数器Counter分成了两块:CounterDisplay 和 withCounter,其中 withCounter 是通用的。CounterDisplay 只有显示逻辑,没有状态和计算逻辑;CounterDisplay通过两个接口属性:count 和 increment 将计算逻辑从显示逻辑中脱离出来的。计算逻辑被分割到 withCounter 高阶组件注6中去。

高阶组件是个特殊功能的函数,它的功能可描述为:将一个 通用的组件(功能)「装饰」到另一个具体的组件中去。例如这里,计数功能(ComponentWithCounter)作为 通用的组件功能 被「装饰」到 WrappedComponent 中去。由于高阶组件包装了通用的计数功能,我们可将它 装饰复用 到CounterDisplay中去。

高阶组件 使用注意,以及render props

高阶组件 一开始作为替代旧Mixin的代码复用方案受到很多库作者的欢迎,包括Redux 和 React Router ,但是,使用高阶组件需要高级的技巧,高阶组件使用主要有两个问题:

  • 第一,React V组件形式不容易掌握,分割组件是一个技巧,不是一种技术,一般应用开发者容易踩坑;
  • 第二,组件分割后理论上是松耦合的,可以独立开发,但是装饰件(decorator)和 被装饰者(decoratee)对双方其实需某种程度的依赖,是一种白箱复用,例如装饰件需要知道 被装饰者有哪些prop可用,有没有代理的方法传出等。

由于以上原因,社区近来倾向使用 render props 。render props是很值得理解和学习的新技术,但里暂不介绍。

Mixins

React 为组件复用工具箱最新添加的一个工具,也是最早最老的工具,这就是“mixins”。React早期版本支持使用的 “mixins”由于被证明容易被误用,已经被废弃。“Mixins”已死,但“Mixins 模式”复活了!React 推行的 Hooks 已经被社区广泛接受,这其实是一种新的mixins。

那,什么是mixin?mixin 其实也是一种将通用代码“混入或植入”某个具体类的技术,实现代码复用。继承,构成,装饰也算是一种“混入”注7,但 mixin 不同于继承的地方是,通用 mixin 类 不是什么父类;mixin 类 可任意组合和不限数量“混入”到某个具体类中去,为其添加功能;

mixin 也不同于 decorator,因为 mixin 是 具体类的定义的一部分注8,而不是后来装饰上去的。

Mixins 的适用性及自身问题

Mixins 在这样的一种「类间结构设计」时非常有用:

你有一些负责不同功能的类,要共享一些通用功能,但由于亲缘性低而不宜使用继承时。

在过往的实践中证明,Mixins 的使用是非常tricky的(难以捉摸),因为 mixin代码很容易跟被混入的类强耦合而产生各种问题。例如,劣质设计Mixins方案会产生名字冲突、僵化的代码,和调错困难等问题。

React Hooks

React Hooks 在形式上看着并不像mixins,因为它是一种函数调用,而不是混入的一个类。虽然形式不像,但意义和作用却和传统的mixins是一样的,而且React Hooks 没有了传统mixins的一些毛病。让我们来看盾Dan Abramov的一个例子:

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  });

  return width;
}

这个自定义hook 会在每当浏览器窗口缩放(resized)时返回最新的当前窗口的宽度(width)。这个功能最常见用到的地方,我想是用它来 决定页面头部菜单使用何种样式,例如当窗口收缩时或者在小屏幕加载时,常规菜单会自动的替换为“汉堡”式菜单:

const Header = () => {
    let windowWidth = useWindowWidth();
    let showHamburgerMenu = windowWidth < 800;

    return (<div>
        <Logo>
        {showHamburgerMenu ? <HamburgerMenu/> : <MenuItems/>}
    </div>)
}

自定义hook (useWindowWidth)好像普通的helper function注9,但是由于基于一些特殊的API(useEffect useState),它的效果与一般全局helper还是有一点点区别。例如它会像一般R组件那样,自动更新,为勾选它的组件提供信息。

自定义hook与一般hook一样,就是个封装了V行为功能注10的特殊函数。所以自定义hook的效果很像高阶组件,但没有了高阶组件的缺点。

另外, useWindowWidth 和 Header 高度松耦合的。useWindowWidth 对 Header的内部实现一无所知,它只是提供窗口变化后的宽度信息。useWindowWidth的开发是独立的,它可以完全改变内部的算法实现,而不会影响 Header。

这种函数式混入一点都不像传统的mixin模式(更像构成复用),但是它使用了 mixin一样的概念 和 完成一样的任务注11。而且 函数式混入 的封装性更好,因为混入的功能只是一种函数调用,我们再也不用担心名字冲突之类的问题。如果具体类需要定制,可以将参数传给函数就可以了。

使用 Hooks,我们可以将前面计数器的高阶组件版本写得更精简:

// defined in a library file
const useIncrement = (initial = 0) => {
   const [count, increment] = useReducer(val => val + 1, initial);
   return [count, increment];
}

// counter.js
const Counter = () => {
    let [count, increment] = useIncrement();
    return <FancyLayout>
        <FancyDisplay>
            Current Count: {count}
        </FancyDisplay>
        <FancyButton
            className="incrementCounterBtn"
            onClick={increment}
        >Increment</FancyButton>
    </FancyLayout>
}

和前面的装饰复用实现相似,我们将「计数逻辑」做成独立单元——一个自定义hook——定义到一个库文件中去;可以看到「计数逻辑」的代码少了许多注12。另外,我们删除了CounterDisplay这个中间组件,原因是如果这个中间组件没有其它用途(不会再被复用),我们可直接将hook 植入目标组件中去。当然,如果我们需要,也是很容易的将「显示逻辑」独立出去的:

const Counter = () => {
    let [count, increment] = useIncrement();
    return <CounterDisplay count={count} increment={increment}/>;
}

Summary

经过上述多种React 组件复用技术的分析,如果问我有什么心得,我觉得,我学到了要 针对不同复用任务选择不同复用技术,而不是将所有复用问题硬使用一种自己熟悉的技术去解决。我已经写了超过2000字了,但是还没穷尽React社区所有组件复用技术,例如 render props。

综上,如果下一次你遇到了架构设计问题,不妨多考虑下不同的方案,比较下各方面的成本。

TL;DR

React 开发可使用多种组件复用技术:

  • Inheritance Pattern: Used sparingly to share common code across React class components.
  • Composition Pattern: The core pattern for separating concerns while creating complex UIs with React.
  • Decorator Pattern: Used to provide a nice interface for separating out logic shared by multiple components and centralizing it.
  • Mixin Pattern: Hooks use a variation on the Mixin pattern to allow sharing related behavior and data between unrelated function components easily.

我(译者)的小结

1 类继承复用,是一种由语言内置支持二级抽象(extends and class),因为复用只有在抽象基类和类之间;由于类继承是一种白箱复用,父类子类高度耦合,适用性很有限;

2 构成复用( Compostion),其实是一种“对象级” 的一级抽象 ,性质和普通函数调用相同,用符号链接不同程序部分(代码);JS的交互UI编程 非常适用这种代码复用模式;

3 装饰复用(Decorator),也是一种二级抽象,将一些通用的计算功能 “安装”入 子类定义上,只是它不是语言语法上内置支持,而要开发者 ==手动 开发 抽象 逻辑==——所谓装饰高阶函数。

4 Mixin或 hook复用,性质和目的 和 装饰复用类似,只是 装饰复用 是面向对象式(OO)的,hook复用 函数式(FP)的。hook复用 是高度面向 React组件的,组件的什么功能需要 和 可以被 抽象成hook 进行复用 是有挑战性的。


  1. 父类与子类之间是白箱复用 的原因
  2. Composition一般译作组合,但是“组合”是一个动作,不能确切的刻画复用现象中,复用者(大对象)和被复用者(小对象)的关系——整体与部分。所以我认为译构成,或者成分更贴切。
  3. 根据整体和局部的张力关系,构成为依赖、关联、聚合和构成四种。
  4. 我们可说用父类派生子,用子对象构成父对象,反过来 也可说,我们是从子类中(多个)抽象出通用的行为来创建一个通用的父类;我们是从父对象(多个)分解出部分可复用的计算功能成为子对象。
  5. 所谓「编辑」是这个意思,代码执行「都」能完成一些任务,这些任务,除了可以为用户提供计算结果,也可以对程序本身进行功能(形式)修改。
  6. 高阶组件作为一个特殊的函数——对React V组件进行修改——它的任务是特殊的,特殊的对R组件的处理:改什么和能怎么改都是有约束的。
  7. 这里揭示了存在某种「代码复用」的理论。
  8. 以函数调用的方法给类混入新功能,其实更像是 构成 复用技术了。
  9. 这种调用函数的方式,如其说是mixin复用,其实是一种特殊构成复用。
  10. V的什么行为可以被抽象出来,是个有趣的问题,也是个挑战性问题。
  11. 什么概念什么任务?一种有别于类继承的代码复用任务:被复用的代码可跨层和任意组合的添加到具体类中。
  12. 实现里使用了useReducer这个基础的hook,为什么是它,这个需要我们对「交互功能」的性质的认识
裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.