前端知库

装饰器模式是什么?和高阶函数、高阶组件有什么关系?

装饰器模式是在不修改原对象的前提下,用一个外层把它包起来,调用时先经过外层,从而叠加日志、校验、缓存等功能,接口保持不变。在 JavaScript 里最常见的写法是高阶函数,接收一个函数、返回增强后的函数;高阶组件是同样的思路用在 React 组件上。语言层面的 @ 装饰器语法还是 TC39 提案,要靠 TypeScript 或 Babel 编译。

参考答案

简答

一个下单接口函数已经写好了,现在想在调用前后打日志、加参数校验,但不想改它本身的代码。做法是写一个函数把它包起来,调用包装后的版本时先校验、再调用原函数、最后打日志,调用方式和原来完全一样。这就是装饰器模式。

它的意义在于功能可以按需叠加和拆掉。日志、校验、缓存、重试各写成一个包装函数,需要哪几个就套哪几个,原函数始终保持单一职责,也不用为每种组合写一个子类。

在 JavaScript 里,函数可以当参数传、当返回值返回,所以装饰器最自然的写法就是高阶函数:接收一个函数,返回一个增强后的新函数。React 的高阶组件是同一个思路,接收一个组件,返回一个注入了数据或行为的新组件。

要注意,包了几层以后执行顺序是从外到内的,顺序不同结果可能不同。@ 开头的装饰器语法目前还是 TC39 提案,浏览器不能直接运行,要靠 TypeScript 或 Babel 编译,而且新旧两套语义不兼容。

用高阶函数实现装饰器

下面给 add 套上两层:一层校验参数类型,一层打印调用日志。重点看包装后的函数和原函数在调用方式和结果上的差别:

JavaScript
function withLog(fn) {
  return function (...args) {
    const result = fn.apply(this, args);
    console.log(`${fn.name}(${args.join(', ')}) = ${result}`);
    return result;
  };
}

function withValidate(fn) {
  return function (...args) {
    if (args.some((n) => typeof n !== 'number')) throw new TypeError('参数必须是数字');
    return fn.apply(this, args);
  };
}

function add(a, b) {
  return a + b;
}

const safeAdd = withValidate(withLog(add));
safeAdd(1, 2); // add(1, 2) = 3
try {
  safeAdd(1, '2');
} catch (err) {
  console.log(err.message); // 参数必须是数字
}
console.log(add(1, '2')); // 12

safeAdd(1, 2) 的用法和 add(1, 2) 一样,可多了一行日志。传入字符串时,最外层的 withValidate 先检查参数,直接抛错,add 根本没有执行,所以也没有日志。最后一行说明原来的 add 没有被改动,它仍然会把 1 + '2' 拼成字符串 '12'。

为什么用 fn.apply(this, args) 而不是 fn(...args)?因为被装饰的可能是对象的方法,要把调用时的 this 原样传下去,否则方法里读 this 会出错。

为什么日志里的函数名是 add?因为 withLog 包的是 add 本身。如果写成 withLog(withValidate(add)),withLog 拿到的是 withValidate 返回的匿名函数,fn.name 就成了空字符串。这也说明包装的顺序会影响结果:写在最外面的装饰器最先执行。

和代理模式有什么区别

装饰器和代理都是“包一层、接口不变”,结构几乎一样。区别在意图:装饰器给对象叠加新功能,原有的调用一般都会发生;代理侧重控制访问,可以决定调不调、什么时候调,比如缓存命中时就不调用原函数。上面的 withValidate 会拦住不合法的调用,更接近代理的用法。实际讨论时说清楚这一层做了什么比纠结名字更重要,代理模式见《策略模式和代理模式分别解决什么问题?前端怎样使用?》。

高阶组件就是组件的装饰器

高阶组件接收一个组件,返回一个新组件,新组件渲染原组件,同时注入额外的 props 或包一层布局:

JSX
function withOnlineStatus(Component) {
  return function WithOnlineStatus(props) {
    const isOnline = useOnlineStatus();
    return <Component {...props} isOnline={isOnline} />;
  };
}

const StatusButton = withOnlineStatus(Button);

Button 本身不知道网络状态,被包装后多拿到一个 isOnline,其他 props 原样透传,这就是装饰器的思路。现在的 React 里,复用有状态的逻辑首选自定义 Hook,高阶组件要注意的问题和三种写法的比较见《高阶组件、Render Props 和自定义 Hook 怎样选择?》。

@ 语法装饰器是什么

很多框架里能看到 @Component、@observable 这样的写法,这是语言层面的装饰器语法,作用在类和类成员上。它目前是 TC39 的提案,浏览器和 Node 还不能直接运行,需要 TypeScript 或 Babel 编译。TypeScript 有新旧两套实现:5.0 开始支持的标准装饰器,和老的 experimentalDecorators,两者参数和行为不同,详见《TypeScript 新装饰器与 experimentalDecorators 有何区别?》。

无论哪种语法,背后的思路都和高阶函数一样,都是拿到原来的东西,返回一个增强后的版本。

常见追问

装饰器和继承相比有什么好处?

继承要为每种功能组合写一个子类,日志加校验、日志加缓存、三个都要,组合一多类就爆炸了。装饰器把每种功能写成独立的一层,运行时按需组合,原类也不用改。

多个装饰器叠加时怎样避免出错?

每一层都要保持接口不变:参数原样传下去、返回值原样返回、this 用 apply 传递。顺序有讲究的要写清楚,比如校验通常放在最外层,让不合法的调用尽早被拦住。

防抖、节流算装饰器吗?

写法上是。debounce(fn, 300) 接收一个函数、返回一个包装后的函数,接口不变,只是改变了调用时机。从意图上说它控制了原函数什么时候执行,也可以看作代理,这两个模式本来就很接近。