装饰器模式

在不改变原对象的前提下,动态地给它添加新的职责。

装饰器是继承的替代方案。用继承扩展功能会导致类爆炸:要给按钮加日志、加埋点、加防抖,用继承就得写 LoggedButtonTrackedButtonLoggedTrackedDebouncedButton……而装饰器可以任意组合、运行时叠加

一、JS 里的装饰器:高阶函数

JS 有头等函数,装饰器退化成"接收函数、返回增强后函数"的高阶函数,这是它最常见的形态。

// 日志装饰
const withLog = fn => function (...args) {
    console.log(`调用 ${fn.name},参数:`, args);
    const result = fn.apply(this, args);
    console.log(`返回:`, result);
    return result;
};

// 计时装饰
const withTiming = fn => function (...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    console.log(`${fn.name} 耗时 ${performance.now() - start}ms`);
    return result;
};

// 自由组合
const add = (a, b) => a + b;
const enhanced = withLog(withTiming(add));

写装饰器必须守住的三条

  1. function 而不是箭头函数包装,否则 this 会丢失(箭头函数没有自己的 this,无法通过 apply 转发调用者的 this)。
  2. 必须 return 原函数的返回值,否则装饰后返回 undefined——这是最常见的低级 bug。
  3. 保留函数元信息:包装后 fn.name 变成 ''fn.length 变成 0。依赖这些信息的框架(如某些依赖注入库)会失效,必要时手动复制:
    Object.defineProperty(wrapped, 'name', { value: fn.name });
    

异步函数要单独处理,否则计时和异常捕获都会失效:

const withAsyncTiming = fn => async function (...args) {
    const start = performance.now();
    try {
        return await fn.apply(this, args);       // 必须 await,否则计的是"发起"的时间
    } finally {
        console.log(`耗时 ${performance.now() - start}ms`);
    }
};

二、AOP:给原型方法织入行为

面向切面编程(AOP)把日志、埋点、鉴权这些横切关注点从业务逻辑里剥离出来。

Function.prototype.before = function (beforeFn) {
    const self = this;
    return function (...args) {
        beforeFn.apply(this, args);          // 先执行前置逻辑
        return self.apply(this, args);
    };
};

Function.prototype.after = function (afterFn) {
    const self = this;
    return function (...args) {
        const result = self.apply(this, args);
        afterFn.apply(this, args);
        return result;                        // 注意返回的是原函数的结果
    };
};

let submit = function () { console.log('提交表单'); };
submit = submit.before(() => console.log('校验参数')).after(() => console.log('埋点上报'));
submit();
// 校验参数 → 提交表单 → 埋点上报

不要在生产代码里污染原型

修改 Function.prototype 是典型的猴子补丁:多个库同时这么做会互相覆盖,且 for...in 遍历、类型定义都可能受影响。上面写法适合理解原理,工程中请写成独立的工具函数:

const before = (fn, beforeFn) => function (...args) { beforeFn.apply(this, args); return fn.apply(this, args); };

一个真实用途:给已有方法打补丁而不改源码(比如统计所有路由跳转)。

const originalPush = router.push;
router.push = function (...args) {
    track('route_change', args[0]);
    return originalPush.apply(this, args);
};

三、TypeScript / ES 装饰器语法

装饰器提案已进入 ES Stage 3,TypeScript 5.0+ 支持新版标准语法(与旧的 experimentalDecorators 实现不兼容,注意区分)。

// 方法装饰器(TS 5.0+ 标准语法)
function logged<This, Args extends unknown[], Return>(
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext
) {
    return function (this: This, ...args: Args): Return {
        console.log(`调用 ${String(context.name)}`);
        return target.call(this, ...args);
    };
}

class Service {
    @logged
    fetchData(id: number) { return `data-${id}`; }
}

装饰器在框架里随处可见:Angular 的 @Component / @Injectable、NestJS 的 @Controller / @Get、MobX 的 @observable、TypeORM 的 @Entity。它们本质上都是在类定义时注册元数据,框架再据此完成依赖注入或路由绑定。

装饰器执行顺序

多个装饰器叠加时,求值自上而下,应用自下而上(像洋葱):

@A @B method() {}   // 等价于 A(B(method)),B 先生效

四、React 里的对应形态

React 早期用高阶组件(HOC)做装饰,现在更多用 Hooks 和组合:

// HOC:一个接收组件、返回增强组件的函数,就是装饰器
const withLoading = Component => props =>
    props.loading ? <Spinner /> : <Component {...props} />;

// 现代写法:自定义 Hook 复用逻辑,避免 HOC 的嵌套地狱和 props 冲突
function useFetch(url) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    useEffect(() => {
        let cancelled = false;                       // 防止组件卸载后 setState
        fetch(url).then(r => r.json()).then(d => {
            if (!cancelled) { setData(d); setLoading(false); }
        });
        return () => { cancelled = true; };
    }, [url]);
    return { data, loading };
}

五、装饰器 vs 代理

两者代码结构几乎一样,区别只在意图

  • 装饰器:我要添加功能,原功能一定会被执行;
  • 代理:我要控制访问,原功能可能根本不执行(无权限、命中缓存、还没到时候)。

更多对比见代理模式最后一节


下一步 👉 策略模式

上次更新:
贡献者: Joe