文档教程前端【免费下载链接】zh.javascript.info现代 JavaScript 教程The Modern JavaScript Tutorial以最新的 ECMAScript 规范为基准通过简单但足够详细的内容为你讲解从基础到高阶的 JavaScript 相关知识。项目地址https://gitcode.com/gh_mirrors/zh/zh.javascript.info点击查看免费下载导读在 JavaScript 中函数是一等公民可以被传递、保存为对象属性甚至被另一个函数包装从而改变其行为。本文以《现代 JavaScript 教程》zh.javascript.info的 装饰器模式和转发call/apply 一章为骨架系统讲解装饰器decorator与转发forwarding两大核心机制从透明缓存包装器出发逐一剖析func.call与func.apply的上下文传递、多参数哈希缓存、方法借用method borrowing等底层原理并延伸讲解仓库中 spy、delay、debounce、throttle 四个实战任务的实现与测试证据。读完本文你将掌握不修改原函数即可为其叠加功能的通用能力并能在真实项目中正确运用 call/apply 进行调用转发。装饰器的本质包装函数改变行为JavaScript 处理函数时提供了非凡的灵活性。函数可以被传递、用作对象属性甚至可以在函数之间转发调用并装饰它们。装饰器decorator是一个特殊的函数它接受另一个函数并改变其行为返回一个包装器wrapper而主要工作仍由原函数完成。装饰器可以被看作是可以添加到函数上的特性features或方面aspects。我们可以添加一个也可以组合添加多个而这一切都无需更改原函数的代码。这正是装饰器模式的核心价值横切关注点缓存、计时、日志、节流等与业务逻辑彻底解耦。透明缓存第一个装饰器假设我们有一个 CPU 重负载的函数slow(x)但它的结果是稳定的——对于相同的x它总是返回相同的结果。如果该函数被频繁调用我们希望能缓存记住结果避免重复计算。与其把缓存逻辑塞进slow()本身不如创建一个包装器函数来增加缓存功能function slow(x) { // 这里可能会有重负载的 CPU 密集型工作 alert(Called with ${x}); return x; } function cachingDecorator(func) { let cache new Map(); return function(x) { if (cache.has(x)) { // 如果缓存中有对应的结果 return cache.get(x); // 从缓存中读取结果 } let result func(x); // 否则就调用 func cache.set(x, result); // 然后将结果缓存记住下来 return result; }; } slow cachingDecorator(slow); alert( slow(1) ); // slow(1) 被缓存下来了并返回结果 alert( Again: slow(1) ); // 返回缓存中的 slow(1) 的结果 alert( slow(2) ); // slow(2) 被缓存下来了并返回结果 alert( Again: slow(2) ); // 返回缓存中的 slow(2) 的结果cachingDecorator(func)的结果是一个包装器function(x)将func(x)的调用包装到缓存逻辑中其工作方式如下图所示从外部代码来看包装后的slow执行的仍然是与之前相同的操作只是在行为上添加了缓存功能。使用分离的cachingDecorator而不是修改slow本身有以下几个好处可复用cachingDecorator可以应用于任意其他函数逻辑独立缓存逻辑没有增加slow本身的复杂度可组合如果需要可以组合多个装饰器其他装饰器遵循同样的逻辑。使用 func.call 设定上下文上面提到的缓存装饰器不适用于对象方法。例如下面的worker.slow()在装饰后就会停止工作// 我们将对 worker.slow 的结果进行缓存 let worker { someMethod() { return 1; }, slow(x) { // 可怕的 CPU 过载任务 alert(Called with x); return x * this.someMethod(); // (*) } }; // 和之前例子中的代码相同 function cachingDecorator(func) { let cache new Map(); return function(x) { if (cache.has(x)) { return cache.get(x); } let result func(x); // (**) 问题所在 cache.set(x, result); return result; }; } alert( worker.slow(1) ); // 原始方法有效 worker.slow cachingDecorator(worker.slow); // 现在对其进行缓存 alert( worker.slow(2) ); // 蛤Error: Cannot read property someMethod of undefined错误发生在试图访问this.someMethod并失败的(*)行。原因是包装器在(**)行将原始函数调用为func(x)——当函数以这种裸调用方式执行时它得到的this undefined。如果尝试直接运行下面这段代码也会观察到类似的问题let func worker.slow; func(2);也就是说包装器把调用传递给了原始方法但丢失了上下文this因此报错。func.call 的语法与语义解决这个问题的钥匙是内建方法func.call(context, ...args)它允许调用一个显式设置this的函数func.call(context, arg1, arg2, ...)它运行func提供的第一个参数作为this后面的作为参数arguments。简单地说这两个调用几乎相同func(1, 2, 3); func.call(obj, 1, 2, 3);它们调用的都是func参数都是1、2和3唯一的区别是func.call还会将this设置为obj。例如在下面代码中我们在不同对象的上下文中调用sayHifunction sayHi() { alert(this.name); } let user { name: John }; let admin { name: Admin }; // 使用 call 将不同的对象传递为 this sayHi.call( user ); // John sayHi.call( admin ); // Admin带参数调用时第一个参数成为this其余参数成为实参function say(phrase) { alert(this.name : phrase); } let user { name: John }; // user 成为 thisHello 成为第一个参数 say.call( user, Hello ); // John: Hello在包装器中使用 call 传递上下文现在我们在缓存包装器中用call把上下文交给原始函数let worker { someMethod() { return 1; }, slow(x) { alert(Called with x); return x * this.someMethod(); // (*) } }; function cachingDecorator(func) { let cache new Map(); return function(x) { if (cache.has(x)) { return cache.get(x); } let result func.call(this, x); // 现在 this 被正确地传递了 cache.set(x, result); return result; }; } worker.slow cachingDecorator(worker.slow); // 现在对其进行缓存 alert( worker.slow(2) ); // 工作正常 alert( worker.slow(2) ); // 工作正常没有调用原始函数使用的缓存为了更清晰地理解this的传递过程可以分三步走经过装饰之后worker.slow现在是包装器function (x) { ... }当worker.slow(2)执行时包装器将2作为参数并且this worker它是点符号.之前的对象在包装器内部假设结果尚未缓存func.call(this, x)将当前的thisworker和当前的参数2传递给原始方法。传递多个参数哈希键与展开转发现在把cachingDecorator写得更通用一些。到目前为止它只能用于单参数函数。如果要缓存多参数的方法呢let worker { slow(min, max) { return min max; // 假设是可怕的 CPU 重负载任务 } }; // 应该记住相同参数的调用 worker.slow cachingDecorator(worker.slow);之前对于单个参数x可以直接用cache.set(x, result)保存、cache.get(x)检索。但现在需要记住参数组合(min,max)的结果而原生的Map仅将单个值作为键key。这里有几种解决方案实现一个新的或使用第三方的类似 map、更通用且允许多个键的数据结构使用嵌套 mapcache.set(min)存一个(max, result)的Map然后用cache.get(min).get(max)获取result将多个值合并为一个为了灵活性允许为装饰器提供一个哈希函数该函数知道如何把多个值合并为一个值。对于许多实际应用第三种方式就足够了。当然我们需要传入的不只是x而是func.call的所有参数。在function()中可以通过伪数组pseudo-arrayarguments拿到全部参数因此func.call(this, x)应替换为func.call(this, ...arguments)let worker { slow(min, max) { alert(Called with ${min},${max}); return min max; } }; function cachingDecorator(func, hash) { let cache new Map(); return function() { let key hash(arguments); // (*) 从参数组合生成唯一键 if (cache.has(key)) { return cache.get(key); } let result func.call(this, ...arguments); // (**) 转发上下文与全部参数 cache.set(key, result); return result; }; } function hash(args) { return args[0] , args[1]; } worker.slow cachingDecorator(worker.slow, hash); alert( worker.slow(3, 5) ); // works alert( Again worker.slow(3, 5) ); // same (cached)这里有两个关键变化在(*)行调用hash从arguments创建一个单独的键。这里使用简单的连接函数将参数(3, 5)转换为键3,5更复杂的情况可能需要其他哈希函数(**)行使用func.call(this, ...arguments)将包装器获得的上下文和所有参数不仅仅是第一个参数传递给原始函数。现在这个包装器可以处理任意数量的参数了尽管哈希函数还需要调整以支持任意数量的参数——一种有趣的处理方法见下文方法借用。func.apply类数组参数转发我们可以用func.apply(this, arguments)代替func.call(this, ...arguments)。内建方法func.apply的语法是func.apply(context, args)它运行func设置this context并使用类数组对象args作为参数列表arguments。call和apply之间唯一的语法区别是call期望一个参数列表而apply期望一个包含这些参数的类数组对象。因此这两个调用几乎是等效的func.call(context, ...args); func.apply(context, args);它们使用给定的上下文和参数执行相同的func调用。唯一的细微差别在于args的类型Spread 语法...允许将可迭代对象args作为列表传递给callapply只接受类数组args。对于既可迭代又是类数组的对象例如真正的数组使用call或apply均可但apply可能会更快因为大多数 JavaScript 引擎在内部对其进行了优化。调用转发call forwarding将所有参数连同上下文一起传递给另一个函数被称为呼叫转移 / 调用转发call forwarding。这是它的最简形式let wrapper function() { return func.apply(this, arguments); };当外部代码调用这种包装器wrapper时它与原始函数func的调用无法区分——这是通用转发包装器的黄金标准后续的 spy、delay、debounce、throttle 装饰器全都建立在这一模式之上。方法借用method borrowing现在对哈希函数做一个改进。目前的实现只适用于两个参数function hash(args) { return args[0] , args[1]; }如果它能适用于任意数量的args就更好了。自然的解决方案是使用arr.join方法function hash(args) { return args.join(); }……不幸的是这行不通。因为我们调用的是hash(arguments)而arguments对象既是可迭代对象又是类数组对象但它并不是真正的数组所以在它上面调用join会失败function hash() { alert( arguments.join() ); // Error: arguments.join is not a function } hash(1, 2);不过有一种简单的方法可以使用数组的 join 方法function hash() { alert( [].join.call(arguments) ); // 1,2 } hash(1, 2);这个技巧被称为方法借用method borrowing我们从常规数组[].join中获取借用join 方法并使用[].join.call在arguments的上下文中运行它。它为什么有效原生方法arr.join(glue)的内部算法非常简单从规范中几乎按原样解释如下让glue成为第一个参数如果没有参数则使用逗号,让result为空字符串将this[0]附加到result附加glue和this[1]附加glue和this[2]……以此类推直到this.length个项目被粘在一起返回result。因此从技术上讲join需要this并将this[0]、this[1]……等 join 在一起。它的编写方式故意允许任何类数组的this很多原生方法都遵循这种做法这不是巧合。这就是为什么它也可以与this arguments一起使用。从源码结构看这正是 JavaScript 标准库鸭子类型设计哲学的典型体现方法只要求this具备索引访问与length属性而不要求它是 Array 实例。装饰器与函数属性通常用装饰后的函数替换一个函数或方法是安全的除了一件小东西如果原始函数有属性例如func.calledCount装饰后的函数将不再提供这些属性——因为包装器是一个全新的函数。因此如果有人依赖这些属性就需要小心。一些包装器可能会提供自己的属性。例如装饰器可以计算一个函数被调用了多少次以及花费了多少时间并通过包装器属性公开expose这些信息。此外还存在一种可保留对函数属性访问的装饰器创建方式它需要使用特殊的Proxy对象来包装函数详见 代理Proxy 章节的proxy.apply部分。实战任务一间谍装饰器 spy任务要求见 01-spy-decorator/task.md创建一个装饰器spy(func)返回一个包装器该包装器将所有对函数的调用保存在其calls属性中每个调用保存为一个参数数组function work(a, b) { alert( a b ); // work 是一个任意的函数或方法 } work spy(work); work(1, 2); // 3 work(4, 5); // 9 for (let args of work.calls) { alert( call: args.join() ); // call:1,2, call:4,5 }仓库给出的参考实现01-spy-decorator/_js.view/solution.jsfunction spy(func) { function wrapper(...args) { // 使用 ...args 而非 arguments以便在 wrapper.calls 中保存真正的数组 wrapper.calls.push(args); return func.apply(this, args); } wrapper.calls []; return wrapper; }实现要点用 Rest 参数...args收集调用参数存入真正的数组而不是伪数组arguments这正是测试assert.deepEqual(work.calls, [[1, 2]])能通过的前提用func.apply(this, args)完成透明转发既保留返回值又保留this上下文wrapper.calls []让属性挂在包装器函数自身之上。对应的测试用例01-spy-decorator/_js.view/test.js验证了三件事调用被完整记录到calls属性、包装器对普通函数透明返回值与sinon.spy一致、包装器对方法透明calledOn(calc)断言this正确。该装饰器对单元测试非常有用其高级形式是 Sinon.JS 库中的sinon.spy。实战任务二延时装饰器 delay任务要求见 02-delay/task.md创建装饰器delay(f, ms)将f的每次调用延时ms毫秒并且传递所有参数和上下文thisfunction f(x) { alert(x); } // create wrappers let f1000 delay(f, 1000); let f1500 delay(f, 1500); f1000(test); // 在 1000ms 后显示 test f1500(test); // 在 1500ms 后显示 test仓库给出的参考实现02-delay/_js.view/solution.jsfunction delay(f, ms) { return function() { setTimeout(() f.apply(this, arguments), ms); }; }注意这里是如何使用箭头函数的箭头函数没有自己的this和arguments所以f.apply(this, arguments)从包装器中获取this和arguments。如果我们传入一个常规函数setTimeout将不带参数地调用它并且this window假设浏览器环境。如果不想依赖箭头函数也可以通过中间变量保存thisfunction delay(f, ms) { return function(...args) { let savedThis this; // 将 this 存储到中间变量 setTimeout(function() { f.apply(savedThis, args); // 在这儿使用它 }, ms); }; }对应的测试02-delay/_js.view/test.js使用sinon.useFakeTimers()模拟时钟既验证了延时毫秒数Date.now() - start恰好为 1000/1500也验证了this与全部参数都被正确传递assert.equal(this, user)、assert.equal(phrase, Hello)。实战任务三防抖装饰器 debounce任务要求见 03-debounce/task.mddebounce(f, ms)装饰器的结果是一个包装器该包装器将暂停对f的调用直到经过ms毫秒的非活动状态没有函数调用冷却期然后使用最新的参数调用f一次。debounce就像一个接听电话的秘书一直等到ms毫秒的安静时间之后才将最新的呼叫信息传达给老板调用实际的f。例如将f替换为f debounce(f, 1000)后如果包装函数分别在 0ms、200ms、500ms 时被调用之后没有其他调用那么实际的f只会在 1500ms 时被调用一次——即从最后一次调用开始经过 1000ms 的冷却期之后真实场景用户输入内容我们希望在用户输入完成时向服务器发送请求而不是为每一个字符都发一次请求。对输入事件的处理程序做 1000ms 的debounce处理后它仅会在最后一次输入后的 1000ms 被调用一次可运行演示见 03-debounce/debounce.view/index.html。仓库给出的参考实现03-debounce/_js.view/solution.jsfunction debounce(func, ms) { let timeout; return function() { clearTimeout(timeout); timeout setTimeout(() func.apply(this, arguments), ms); }; }原理每次调用包装器先clearTimeout取消之前的定时器再安排一个新的setTimeout。这样只要调用间隔小于ms定时器就不断被重置原函数永远不会执行直到安静ms毫秒后定时器才真正触发。由于每次重置都用最新的arguments和this最终执行的是最后一次调用的参数。实战任务四节流装饰器 throttle任务要求见 04-throttle/task.md创建节流装饰器throttle(f, ms)当被多次调用时它在每ms毫秒最多将调用传递给f一次。与防抖debounce相比两者行为完全不同debounce会在冷却期后运行函数一次适用于处理最终结果throttle运行函数的频率不会大于给定的ms毫秒适用于不应该过于频繁的定期更新。典型场景是跟踪鼠标移动鼠标移动时事件处理函数每秒可能触发约 100 次每 10ms 一次而更新函数update()太重了无法在每个微小移动上都执行高于每 100ms 一次的更新频次也没有意义。于是用throttle(update, 100)作为每次鼠标移动时运行的函数装饰器会被频繁调用但最多每 100ms 将调用转发给update()一次。其行为在视觉上表现为第一个鼠标移动装饰的变体立即将调用传递给update用户会立刻看到反应随后 100ms 内装饰的变体忽略所有调用100ms 结束时——用最后一个坐标又发生一次update鼠标停下后等到 100ms 到期再用最后一个坐标运行一次update确保处理最终的鼠标坐标。代码示例function f(a) { console.log(a); } // f1000 最多每 1000ms 将调用传递给 f 一次 let f1000 throttle(f, 1000); f1000(1); // 显示 1 f1000(2); // (节流尚未到 1000ms) f1000(3); // (节流尚未到 1000ms) // 当 1000ms 时间到... // ...输出 3中间值 2 被忽略仓库给出的参考实现04-throttle/_js.view/solution.jsfunction throttle(func, ms) { let isThrottled false, savedArgs, savedThis; function wrapper() { if (isThrottled) { // (2) savedArgs arguments; savedThis this; return; } isThrottled true; func.apply(this, arguments); // (1) setTimeout(function() { isThrottled false; // (3) if (savedArgs) { wrapper.apply(savedThis, savedArgs); savedArgs savedThis null; } }, ms); } return wrapper; }调用throttle(func, ms)返回wrapper其工作流程第一次调用wrapper直接运行func并进入冷却状态isThrottled true冷却期间所有调用都被保存在savedArgs / savedThis中。注意上下文this和参数arguments都很重要必须一并保存因为需要它们来重现调用ms毫秒后setTimeout中的函数被触发冷却状态被移除isThrottled false如果存在被忽略的调用则使用最后一次调用保存的参数和上下文重新运行wrapper。第 3 步运行的是wrapper而不是func因为我们不仅需要执行func还需要再次进入冷却状态并设置setTimeout以重置节流——这正是节流区别于防抖的精髓保证调用频率上限同时不丢失最后一次调用的数据。总结装饰器是一个围绕改变函数行为的包装器主要工作仍由原函数完成。它可以被看作是可以添加到函数上的特性或方面可以添加一个或多个而这一切都无需更改原函数代码。本文的核心 API 与模式可以归纳如下API / 模式作用func.call(context, arg1, arg2...)用给定的上下文和参数调用funcfunc.apply(context, args)调用func将context作为this、类数组args作为参数列表调用转发call forwardinglet wrapper function() { return original.apply(this, arguments); }通用转发通常用apply完成方法借用method borrowing从一个对象获取方法在另一个对象的上下文中调用它如[].join.call(arguments)Rest 参数...args收集参数为真正数组的替代方案相比arguments伪数组四个实战装饰器可以对照记忆spy 记录调用、delay 延后调用、debounce 合并连发取最后一次、throttle 限制频率保最后一次。在 JavaScript 生态中装饰器应用极其广泛缓存、日志、计时、防抖节流等掌握call/apply的转发语义与arguments/ Rest 参数的区别是写出可复用、可组合装饰器的前提。如果想进一步巩固可以继续完成本章 各任务目录 下的练习并用对应的_js.view/test.js测试文件验证自己的实现。赞分享文档教程前端【免费下载链接】zh.javascript.info现代 JavaScript 教程The Modern JavaScript Tutorial以最新的 ECMAScript 规范为基准通过简单但足够详细的内容为你讲解从基础到高阶的 JavaScript 相关知识。项目地址https://gitcode.com/gh_mirrors/zh/zh.javascript.info点击查看免费下载相关推荐Modern JavaScript Tutorial 实战手写 debounce 防抖装饰器与 call/apply 调用转发Modern JavaScript Tutorial 实战手写 debounce 防抖装饰器与 call/apply 调用转发 本篇技术指南以 JavaScr文档/教程前端JavaScript 装饰器与转发call/apply 实战指南en.javascript.info 高级函数篇JavaScript 装饰器与转发call/apply 实战指南en.javascript.info 高级函数篇 本篇技术指南以 en.javascrip文档/教程前端JavaScript 节流装饰器Throttle Decorator实现与原理解析以 call/apply 转发控制调用频率JavaScript 节流装饰器Throttle Decorator实现与原理解析以 call/apply 转发控制调用频率 导读 本文围绕现代 Java文档/教程前端上一篇JetBrains IDE试用期重置终极指南2026年最完整的30天评估延长方案下一篇音乐解锁神器Unlock Music Electron桌面版完全使用指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?