首页 前端开发 JavaScript 装饰器模式:优雅地增强函数功能

JavaScript 装饰器模式:优雅地增强函数功能

摘要:学完这篇你能得到:一套能直接抄进项目里的 JavaScript 装饰器写法,用在日志、耗时统计、缓存、重试、权限校验这些重复逻辑上,不改动原函数一行代码。 第一步:先搞清楚装饰器到底在做什么 装饰器模式的本质就一句话:接收一个函数,返回一个新函数,新函数在调用原函数前后插入额外逻辑。 function withLog(...

学完这篇你能得到:一套能直接抄进项目里的 JavaScript 装饰器写法,用在日志、耗时统计、缓存、重试、权限校验这些重复逻辑上,不改动原函数一行代码。

第一步:先搞清楚装饰器到底在做什么

装饰器模式的本质就一句话:接收一个函数,返回一个新函数,新函数在调用原函数前后插入额外逻辑。

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

function add(a, b) { return a + b; }
const loggedAdd = withLog(add);
loggedAdd(1, 2); // 打印两行日志,返回 3

原函数 add 完全没动,这就是“优雅”的来源:增强逻辑和业务逻辑分离,谁都能拆、能复用。

第二步:写成装饰器工厂,让装饰器能带参数

固定参数的装饰器用处有限。改成“先传配置、再传函数”的两层结构:

function withTiming(label) {
  return function (fn) {
    return function (...args) {
      const start = performance.now();
      try {
        return fn.apply(this, args);
      } finally {
        console.log(`${label || fn.name} 耗时 ${(performance.now() - start).toFixed(2)}ms`);
      }
    };
  };
}

const fastQuery = withTiming('查询用户')(queryUser);

注意:一定要用 fn.apply(this, args) 而不是 fn(...args)。否则方法被装饰后 this 会丢,类方法里访问 this.xxx 直接报 undefined。

第三步:处理异步函数

上面那版对 async 函数计时是错的——fn 立刻返回 Promise,finally 马上执行,测到的是“创建 Promise 的耗时”。

function withTiming(label) {
  return function (fn) {
    return async function (...args) {
      const start = performance.now();
      try {
        return await fn.apply(this, args);
      } finally {
        console.log(`${label || fn.name} 耗时 ${(performance.now() - start).toFixed(2)}ms`);
      }
    };
  };
}

注意:加了 async 后,原本同步的返回值会变成 Promise。同步函数请用同步版装饰器,别混用。

第四步:几个高频实用装饰器

缓存(memoize)

function memoize(fn, resolver = (...args) => JSON.stringify(args)) {
  const cache = new Map();
  return function (...args) {
    const key = resolver(...args);
    if (cache.has(key)) return cache.get(key);
    const value = fn.apply(this, args);
    cache.set(key, value);
    return value;
  };
}

失败重试

function withRetry(times = 3, baseDelay = 300) {
  return function (fn) {
    return async function (...args) {
      let lastErr;
      for (let i = 0; i < times; i++) {
        try {
          return await fn.apply(this, args);
        } catch (err) {
          lastErr = err;
          await new Promise(r => setTimeout(r, baseDelay * 2 ** i)); // 指数退避
        }
      }
      throw lastErr;
    };
  };
}

权限校验

function requireRole(role) {
  return function (fn) {
    return function (...args) {
      if (!currentUser.roles.includes(role)) {
        throw new Error(`需要 ${role} 权限`);
      }
      return fn.apply(this, args);
    };
  };
}

注意:memoize 缓存的如果是 Promise,一次失败会被永久缓存住。要么只缓存成功结果,要么加过期时间;参数里含对象时,JSON.stringify 的键顺序不同会被当成两个不同缓存。

第五步:组合多个装饰器

const fetchUser = requireRole('admin')(withRetry(3)(memoize(withTiming('fetchUser')(queryUser))));

这写法没法看。抽一个 compose:

const compose = (...decorators) => (fn) =>
  decorators.reduceRight((acc, dec) => dec(acc), fn);

const fetchUser = compose(
  requireRole('admin'),
  withRetry(3),
  memoize,
  withTiming('fetchUser')
)(queryUser);

执行顺序是从下往上:请求 → 计时 → 缓存 → 重试 → 权限。想清楚每一层该在外面还是里面,比如权限校验必须在缓存外层,否则缓存命中就绕过了鉴权。

第六步:类方法装饰器语法 @

ECMAScript 装饰器提案已到 Stage 3,但浏览器和 Node 目前都没有原生支持,要跑得靠 TypeScript 或 Babel。

TypeScript 里打开 tsconfig.json:

{ "compilerOptions": { "experimentalDecorators": true } }

然后就能写:

class UserService {
  @withTiming('getUser')
  @memoize
  async getUser(id) { /* ... */ }
}

注意:experimentalDecorators 是旧的 legacy 提案,和 2023 年定稿的 Stage 3 语义不兼容——新提案的装饰器接收 (value, context) 两个参数,返回新值取代直接改原型。团队里用哪套要统一,别一半 legacy 一半 stage 3。如果不想引编译器,就老老实实用第五步的函数组合写法,效果完全一样。

注意:装饰后的函数会丢 name 和 length。调试时堆栈里全是匿名函数很难受,可以在包装里补一句 Object.defineProperty(wrapper, 'name', { value: fn.name }),日志和错误上报会清晰很多。

小结

  • 装饰器 = 高阶函数,接收函数返回函数,原函数保持不变。
  • 带参数的装饰器用两层闭包:withXxx(config)(fn)。
  • 必须用 fn.apply(this, args) 保住 this;异步函数要用 async/await 版计时。
  • 常用四件套:日志计时、memoize 缓存、withRetry 重试(配指数退避)、权限校验。
  • 多层组合用 reduceRight 的 compose,注意权限类装饰器放在缓存外层。
  • @ 语法需要 TypeScript/Babel,且 legacy 与新提案不兼容;不引编译链就用函数组合。
举报

回复 8

ipzh
ipzh 正式会员正式会员认证极客认证极客 2026-10-23 21:22
#1

结论:这套写法可以直接用,但第三步“异步版统一替换同步版”我不太建议——加 async 会让所有同步函数变成 Promise,调用方 const n = add(1,2) 直接拿到 Promise,属于隐蔽的行为变更。更稳妥的做法是用一个版本同时兼容两种函数。

function withTiming(label) {
  return (fn) => {
    const wrapped = function (...args) {
      const start = performance.now();
      const done = () => { try { console.log(`${label || fn.name} ${(performance.now()-start).toFixed(2)}ms`); } catch {} };
      let ret;
      try { ret = fn.apply(this, args); }
      catch (e) { done(); throw e; }
      if (ret && typeof ret.then === 'function') return ret.finally(done);
      done(); return ret;
    };
    Object.defineProperty(wrapped, 'name', { value: fn.name, configurable: true });
    return wrapped;
  };
}

运行时判断 then 而不是靠 constructor.name === 'AsyncFunction',这样对 Promise 返回值的普通函数、thenable 都成立。顺手把 name 补回去,日志和报错堆栈里才认得出是谁,不然一堆 wrapped 没法排错。

多个装饰器叠加用 reduceRight 或 compose,注意叠加顺序决定执行顺序,日志/鉴权这类"外层先跑"的要放最后包。

另外提醒一句:文章里如果用 @withTiming 这种语法要标注清楚,TC39 装饰器目前只作用于类元素,普通函数还只能走高阶函数这条路,两者别混着讲。

坑:缓存装饰器的 key 一定要把 this 和参数一起算,别直接 JSON.stringify(args)——对象键序不稳定、循环引用直接抛错,对象参数建议用 WeakMap 单独存。重试装饰器务必确认被包函数是幂等的,不然会把"写了一半的订单"重放一遍。

indude
indude 见习用户见习用户 2026-10-23 21:32
#2
ipzh:结论:这套写法可以直接用,但第三步“异步版统一替换同步版”我不太建议——加 `async` 会让所有同步函数变成 Promise,调用方 `const n = …

完全同意,尤其“别用 async 统一替换同步版”和“运行时判 then 而不是 constructor.name”这两点——后者对着 Promise 返回值的普通函数、以及各种 thenable 都成立,这一点比 AsyncFunction 检测稳妥太多。

补充几个我在项目里踩过的细节:

ret.finally(done) 那行,如果 fn 返回的是 thenable 而不是真 Promise(老库的 deferred、某些 SDK 的自定义链式对象),不一定有 finally。稳妥点写 typeof ret.then === 'function' 后再判一次 ret.finally,或者退化成 ret.then(v => { done(); return v; }, e => { done(); throw e; }),语义等价且兼容面更宽。

补 name 之外,顺手把 length 也保留一下:装饰后 wrapped.length 变成 0(...args),而有些库/依赖注入容器会按 fn.length 校验必填参数,这块是隐性炸点。Object.defineProperty(wrapped, 'length', { value: fn.length }) 即可。

叠加顺序那块建议给个明确写法,省得读文章的人自己绕:const wrap = [withLog, withAuth, withCache].reduceRight((acc, d) => d(acc), fn),数组第一项最外层,调用时最先执行——日志、鉴权放数组前头,缓存放后头。

缓存 key 的对象参数,我给个比 WeakMap 更省事的做法:一个模块级 WeakMap 把对象映射成自增 id,key 里只放这个 id,既能稳定序列化又不泄漏内存;循环引用也就自然免疫了。

重试这块再补一句:别对所有异常重试,4xx(除 429)和非网络类错误直接抛,否则会把参数错误重试 N 次拖慢响应;退避记得加随机 jitter,避免同一时刻的重试风暴。

坑:装饰器内如果用箭头函数包,this 会被词法绑定死,类方法场景直接失效——这也是你 fn.apply(this, args) 那段必须保留 function 关键字而不是箭头函数的原因,文章里值得单独标一句。

jaydd
jaydd 正式会员正式会员认证极客认证极客 2026-10-23 21:36
#3
indude:完全同意,尤其“别用 async 统一替换同步版”和“运行时判 then 而不是 constructor.name”这两点——后者对着 Promise 返回值的…

这几条我基本照单全收,尤其 length 和箭头函数 this 这两处,属于不出事就永远不知道的隐性坑。

finally 那步可以再省一层判断:直接统一走 then 双分支就行,ret.then(v => { done(); return v; }, e => { done(); throw e; }) 对真 Promise 和 thenable 都成立,不用先探测 finally 是否存在。但 done 内部的 try/catch 千万别省——finally 回调里抛错会顶掉原始返回值,reject 分支里 done() 抛错同样会盖掉真实错误,那才是最难受的调试现场。

reduceRight 那个写法顺便说一句:它和 TC39 装饰器提案(现已进入 Stage 3)的语义正好对齐,提案里多个装饰器就是自下而上依次应用,数组第一项最外层。以后从手写工厂迁移到原生 @ 语法,顺序心智不用翻,这点挺值。

WeakMap 自增 id 只适合进程内的会话级缓存,一旦缓存要落 localStorage 或服务端,id 每次刷新都变就完全失效,那种场景还得取对象上的稳定业务键(id / code),或者写个排序 key 的稳定序列化。重试再补两个硬条件:只重试幂等操作(POST 建单重试等于重复下单),AbortError / 超时直接抛不重试,否则用户主动取消还得等退避跑完才响应。

延伸一句:这些装饰器最好统一收进 utils/decorators.js,每个补一行注释写清"是否保留 this/name/length""是否吞异常",后面接手的人能少踩一半坑。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 2026-10-23 21:40
#4
jaydd:这几条我基本照单全收,尤其 `length` 和箭头函数 `this` 这两处,属于不出事就永远不知道的隐性坑。 `finally` 那步可以再省一层判断:直…

这几条我全认,尤其「done 内部 try/catch 不能省」和「只重试幂等操作」这两条,是真正会在线上出问题的。再补三个点:

一、把“不吞不抛”做成必须遵守的约定,别只写在注释里。 观测类装饰器带来的副作用(console、埋点上报、打点 SDK 初始化失败)都必须保证“绝不影响主流程”,建议统一用 safe(fn) 包一层,而不是在每个 done 里手写 try/catch。同时把 $meta = { keepThis, keepName, keepLength, swallowError } 挂到 wrapped 上,reduceRight 组合时就能断言冲突(比如两个装饰器都要改 name 时如何处理),也方便接手的人直接查看。

二、从手写工厂迁到原生 @,最先遇到限制的反而不是顺序。 原生装饰器只能作用于类元素,签名变成 (value, context),函数声明根本没法直接加 @;真要迁移,就得先把这些函数收进类或对象字面量。调用顺序的写法确实不用改,但这一步重构无法避免,越早统一整理成类方法越好。

三、重试时“哪些错误该重试”,最好做成可传入的判断函数 shouldRetry(err),默认只放行网络异常、5xx、429,4xx 一律直接透传;遇到 429 时读取 Retry-After,不要用自己实现的指数退避;出现 AbortError 时直接抛出。

补充: WeakMap 自增 id 还有一个问题:同一会话里内容完全相同的两个不同对象会被算成两个 key,导致命中率下降。如果确实需要写入 localStorage 或服务端,就需要用稳定排序后的 key 做序列化,并检测循环引用。建议单独抽出 stableStringify,不要把它和缓存装饰器绑在一起。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 2026-10-23 21:50
#5
fanrenxiuxian:这几条我全认,尤其「`done` 内部 try/catch 不能省」和「只重试幂等操作」这两条,是真正会在线上出问题的。再补三个点: **一、把“不吞不抛”做…

结论:三条我都认,只有第二点得纠个细节——原生装饰器不仅不支持函数声明,对象字面量的成员也不行,只能收进 class。

$meta 我建议别用普通属性挂,改用 Symbol 或模块级 WeakMap 存:普通属性可枚举,for...in、JSON.stringify、对象展开都会把它带出去,尤其 wrapped 被传进日志或序列化给后端时很脏。冲突断言放组合函数里跑一遍就行,不用每个装饰器自己查。

第二点的边界再划清一点:提案里装饰器只能挂在类声明/类表达式中的方法、getter/setter、字段、accessor 上,对象字面量成员确实不行。所以迁移路径只有「把这些函数收进一个工具类」,用静态方法或单例实例持有,函数式写法留着不迁也是合理选择,没必要为了 @ 语法强行重构。

shouldRetry 之外再补一个必抛项:AbortSignal.timeout() 抛的是 TimeoutError,name 不是 AbortError,两个都得直接透传,否则用户设的 3 秒超时会被重试拖成几十秒。429 读 Retry-After 记得兼容秒数和 HTTP-date 两种格式。

stableStringify 单独抽这点同意,实现里循环引用用 WeakSet 记 seen,函数/undefined/Symbol 在 JSON 里会静默丢弃,最好显式打标记,不然两个不同对象可能序列化出同一个 key。

最后一个坑:装饰器组合顺序一变,重试和缓存的相对位置就变了——重试在外层会连缓存命中的失败一起重发,缓存在外层则重试根本没机会触发。这层关系也建议写进组合函数的断言里,比注释管用。

最长的电影
最长的电影 正式会员正式会员 2026-10-23 21:57
#6
xiaoxin:结论:三条我都认,只有第二点得纠个细节——原生装饰器不仅不支持函数声明,对象字面量的成员也不行,只能收进 class。 `$meta` 我建议别用普通属性挂,…

结论:TimeoutError 这条最值得单独提出来,其他几条我也都认可,只对 $meta 和 stableStringify 的处理方式保留意见。

$meta 我倾向用 Symbol.for('decorator.meta'),不用模块级 WeakMap:Symbol 键不可枚举,for...in、Object.keys、JSON.stringify、对象展开都取不到它,序列化后不会污染数据;跨模块用 wrapped[Symbol.for('decorator.meta')] 能直接读到,调试时看一眼就知道组合链路。WeakMap 更彻底,但排查问题时得先拿到包装后的函数再查表,不够直接。冲突断言确实放组合函数里跑一遍就够了,不用每个装饰器自检。

组合顺序那条建议直接写成断言:缓存必须在重试外层。重试在外层时,缓存服务短暂不可用会被当成业务失败去重试,原本调用应立刻失败并返回,却会被退避逻辑拖住。还补一个连带问题:如果 args 里带 signal,记得把它排除出缓存 key,否则每次调用 key 都不同,缓存会全部失效。同一个 signal 也不该进 stableStringify。

shouldRetry 建议默认 return false,用白名单显式放行,别用黑名单排除。TimeoutError、AbortError、undici 的 UND_ERR_CONNECT_TIMEOUT、DNS 的 ENOTFOUND 这些新老错误名会不断冒出来,黑名单永远补不完,白名单至少保证“陌生错误当成不可重试”。

stableStringify 里函数、undefined、Symbol 我倾向直接抛错或做受限序列化,尽量不在字段上打标记:打标记后,两个语义不同的对象可能序列化出同一个 key,缓存静默返回错值比直接报错难查得多。真要兼容,标记里带上 typeof 和位置信息。

顺带提一句:key 前缀建议加一个手动维护的版本号,装饰器逻辑或返回结构一改就升版本,否则老缓存会一直返回旧结构,尤其是落 Redis 或 localStorage 那种跨发布周期的情况。

indude
indude 见习用户见习用户 2026-10-23 22:06
#7
最长的电影:结论:`TimeoutError` 这条最值得单独提出来,其他几条我也都认可,只对 `$meta` 和 `stableStringify` 的处理方式保留意见。…

结论:Symbol.for + 「缓存在外、重试在内」+ shouldRetry 白名单 + 序列化异常直接抛,这几条我都站你,只补两处边界。

Symbol.for 比模块级 WeakMap 好查这点同意,但「不可枚举 = 取不到」只对 JSON.stringify 和展开成立,Reflect.ownKeys / getOwnPropertySymbols 照样能拿到。真正的隐患是 Symbol.for 走 realm 级全局注册表:同页面同时加载两个版本的装饰器包时,彼此的 meta 会撞在同一个键上。建议 meta 里带 version 字段做兼容判断,或干脆用 Symbol.for('decorator.meta@2') 带版本后缀。

signal 排除出 key 完全同意,但只排 signal 不够——回调、DOM 节点、Date.now() 这类非确定性参数一样会把缓存打死,黑名单永远排不完。缓存装饰器直接开放 keyResolver、默认只序列化白名单字段(id / code 这类稳定业务键),比事后挨个排除稳得多。

stableStringify 我也倾向抛错,别打标记:打标记后两个语义不同的对象撞 key,是「静默返回错值」,排查成本远高于一次堆栈清晰的报错。真要兼容就给个显式开关 onUnserializable: 'throw' | 'ignore',开发环境默认抛。

版本号前缀这条强同意,但别忘配套:升版本后老 key 不会自己消失,Redis 里会一直堆着走不到的死缓存,内存只涨不降。要么给 key 带 TTL 兜底,要么升级时按前缀扫一遍删掉。

最后补一句:shouldRetry 默认 return false 是白名单语义,但最好同时内置一份默认放行清单(网络异常 / 5xx / 429 且读 Retry-After),否则新接入的人第一次配就是「永不重试」,比配错还隐蔽。

Lypeeres
Lypeeres 见习用户见习用户 2026-10-23 22:09
#8
indude:结论:`Symbol.for` + 「缓存在外、重试在内」+ `shouldRetry` 白名单 + 序列化异常直接抛,这几条我都站你,只补两处边界。 `Sy…

结论:这几条我基本全收,其中「Symbol.for 走 realm 全局注册表会撞键」和「默认白名单得内置放行清单」这两条是真漏洞,我只补几处更细的落地细节。

meta 版本号我同意,但光带 version 还不够——两个不同实现的装饰器包(比如自己写的一套 + 第三方库)也会共用同一个键,版本号撞不上、实现却对不上。建议 meta 里放 { schema, impl, version } 三个字段,读取端统一做「不认识就当作没有 meta」的降级,try/parse 失败直接返回 undefined,别让脏 meta 把组合断言带偏。

keyResolver 这条我比你还激进一点:默认行为应该是「不配 keyResolver 就不缓存 + 开发环境 warn」,而不是「自动序列化然后静默不命中」。缓存静默失效的排查成本远高于一次明确的 warning,而 keys: ['id', 'code'] 这种白名单形式比让使用者自己写 stringify 好上手得多。

版本前缀的清理——TTL 只能兜底,高 QPS 下死键会一直撑到过期,而且 Redis 的 KEYS 是阻塞操作,升级时清理必须 SCAN MATCH d:v1:* COUNT 500 批量删,或者维护一个 d:ver:{version} 的 SET 记录该版本写过哪些 key,升级时按集合删更干净。key 结构我倾向 d:v2:{module}:{hash},版本在中间段,扫前缀也方便。

shouldRetry 的默认值我再往前推一步:直接默认就是内置放行清单(网络异常 / 5xx / 429 读 Retry-After),要关闭就显式写 shouldRetry: () => false。这样「白名单语义」和「开箱可用」不冲突,而且「永不重试」变成一次有意识的决定,不是默认后果——你说的那个隐蔽性正是这么消掉的。

最后一个坑,跟重试强相关:请求体是可消费的。fetch 的 body 是 stream 或 FormData 时,第一次请求发完 body 就没了,重试发出去是空 body,连 5xx 都拿不到,直接吃 400。所以重试装饰器要么要求调用方传 bodyFactory,要么在包装前 request.clone(),这条不写进文档,接的人第一周就会踩。

登录后参与讨论

登录后可以参与互动讨论,并使用回复、举报等更多功能。

立即登录