- 文档
- 教程
- 前端
【免费下载链接】zh.javascript.info
现代 JavaScript 教程(The Modern JavaScript Tutorial),以最新的 ECMAScript 规范为基准,通过简单但足够详细的内容,为你讲解从基础到高阶的 JavaScript 相关知识。
导读:在 JavaScript 中,把对象方法作为回调传递给setTimeout、事件监听器或其他调度器时,最常见的坑就是this意外丢失。本文以现代 JavaScript 教程(zh.javascript.info)中《函数绑定》一章为骨架,系统讲解this丢失的成因、包装器方案、func.bind(context, ...args)的完整语法与底层原理,并延伸讲解偏函数(partial application)与不带上下文的部分应用实现,最后结合本仓库的配套练习与示例代码给出可验证的实战结论。
问题起点:把方法传出去,"this" 就丢了
在对象方法内部,this指向调用该方法的对象。但一旦方法被"与对象分离开"地传递,例如传递给setTimeout,this就会丢失。下面是最经典的演示:
let user = { firstName: "John", sayHi() { alert(`Hello, ${this.firstName}!`); } }; setTimeout(user.sayHi, 1000); // Hello, undefined!输出不是Hello, John!,而是Hello, undefined!。原因在于setTimeout拿到的是函数user.sayHi本身,它与user对象已经脱钩。上面最后一行等价于:
let f = user.sayHi; setTimeout(f, 1000); // 丢失了 user 上下文在浏览器中,setTimeout对回调的调用会设定this=window(Node.js 中则是计时器对象),所以this.firstName实际读取的是window.firstName,自然不存在;在其他类似场景下,this通常会变为undefined。这类需求非常典型:我们想把一个对象方法传递到别处(比如调度程序)并在那里调用,同时保证它在正确的上下文中执行。
解决方案 1:包装器函数
最直接的修复是"包一层"——用一个普通函数把方法调用包起来,使其从外部词法环境捕获user:
let user = { firstName: "John", sayHi() { alert(`Hello, ${this.firstName}!`); } }; setTimeout(function() { user.sayHi(); // Hello, John! }, 1000);用箭头函数可以写得更简洁:
setTimeout(() => user.sayHi(), 1000); // Hello, John!但包装器方案存在一个隐患:它捕获的是变量user本身,而不是当时的对象。如果在setTimeout触发之前user被重新赋值,就会调用到错误的对象:
let user = { firstName: "John", sayHi() { alert(`Hello, ${this.firstName}!`); } }; setTimeout(() => user.sayHi(), 1000); // ……user 的值在不到 1 秒的时间内发生了改变 user = { sayHi() { alert("Another user in setTimeout!"); } }; // Another user in setTimeout!这正是原生bind所要解决的问题。
解决方案 2:func.bind(context) 绑定 this
函数对象有一个内建方法bind,它能把this固定下来。基本语法:
// 稍后将会有更复杂的语法 let boundFunc = func.bind(context);func.bind(context)的返回值是一个特殊的、类似函数的"外来对象(exotic object)"——在 ECMAScript 规范中称为Bound Function Exotic Object。它可以像函数一样被调用,并把调用透明地转发给func,同时强制设定this=context。换句话说,boundFunc调用起来就像绑定了this的func。
看一个最简例子:
let user = { firstName: "John" }; function func() { alert(this.firstName); } let funcUser = func.bind(user); funcUser(); // Johnfunc.bind(user)生成的绑定函数是func的一个"绑定变体",this被固定为user。调用时传入的所有参数都会原样转发给原始函数:
let user = { firstName: "John" }; function func(phrase) { alert(phrase + ', ' + this.firstName); } // 将 this 绑定到 user let funcUser = func.bind(user); funcUser("Hello"); // Hello, John(参数 "Hello" 被传递,并且 this=user)回到最初的setTimeout场景,绑定对象方法后一切正常:
let user = { firstName: "John", sayHi() { alert(`Hello, ${this.firstName}!`); } }; let sayHi = user.sayHi.bind(user); // (*) // 可以在没有对象(与对象分离)的情况下运行它 sayHi(); // Hello, John! setTimeout(sayHi, 1000); // Hello, John! // 即使 user 的值在不到 1 秒内发生了改变 // sayHi 还是会使用预先绑定(pre-bound)的值,该值是对旧的 user 对象的引用 user = { sayHi() { alert("Another user in setTimeout!"); } };在(*)行,我们把方法user.sayHi绑定到了user上。此后sayHi就是一个"绑定后(bound)"的方法,无论单独调用还是传给setTimeout,上下文都保持正确——因为它绑定的是一开始那个user对象的引用,而不是可变的user变量。参数同样原样传递:
let user = { firstName: "John", say(phrase) { alert(`${phrase}, ${this.firstName}!`); } }; let say = user.say.bind(user); say("Hello"); // Hello, John!(参数 "Hello" 被传递给了 say) say("Bye"); // Bye, John!(参数 "Bye" 被传递给了 say)便捷方法:bindAll如果一个对象有很多方法都要传递出去,可以用一个循环批量绑定:
for (let key in user) { if (typeof user[key] == 'function') { user[key] = user[key].bind(user); } }JavaScript 生态中也有现成的批量绑定工具,例如 lodash 的
_.bindAll(object, methodNames)。
深入原理:bind 到底做了什么
本仓库在 head.html 中提供了一个手写bind的参考实现,可以直观看到绑定函数的内部工作机制:
function bind(func, context /*, args*/) { let bindArgs = [].slice.call(arguments, 2); // (1) function wrapper() { // (2) let args = [].slice.call(arguments); let unshiftArgs = bindArgs.concat(args); // (3) return func.apply(context, unshiftArgs); // (4) } return wrapper; }这段实现揭示了绑定的四个关键步骤:
- 收集预绑定参数:
(1)把调用bind时除func和context之外的参数收集为bindArgs; - 生成包装函数:
(2)返回一个普通wrapper; - 合并参数:
(3)把预绑定参数与调用时传入的参数拼接(bindArgs.concat(args)); - 用 apply 固定上下文:
(4)通过func.apply(context, ...)强制以context作为this调用原始函数。
原生bind的行为正是如此:上下文被硬绑定、参数被预先拼接,调用被转发给原始函数。理解了这一点,就不难解释下文几个"反直觉"的练习结论。
bind 的三个边界行为(配套练习实证)
本仓库为本章配了多道习题,它们分别验证了绑定函数的三个重要特性:
1. 绑定函数的 this 无法被覆盖
题目 作为方法的绑定函数:如果把f.bind(null)作为对象方法user.g调用,this是什么?
function f() { alert( this ); // ? } let user = { g: f.bind(null) }; user.g();答案:null。绑定函数的上下文是硬绑定的,无法再修改。即使通过user.g()调用,源方法仍以this=null执行。
2. 函数不能被重复绑定(re-bound)
题目 二次 bind:
function f() { alert(this.name); } f = f.bind( {name: "John"} ).bind( {name: "Ann" } ); f();答案:John。f.bind(...)返回的外来绑定函数对象只在创建时记忆上下文与参数。对绑定函数再次bind只是在它之上又包了一层,内层绑定的this不会改变——一个函数不能被重绑定。
3. 绑定结果是新对象,不继承原函数属性
题目 bind 后的函数属性:
function sayHi() { alert( this.name ); } sayHi.test = 5; let bound = sayHi.bind({ name: "John" }); alert( bound.test ); // 输出将会是什么?为什么?答案:undefined。bind的结果是另一个对象,它没有test属性。绑定函数与原始函数是两个独立的对象,原函数的自定义属性不会带过去。
部分(应用)函数:Partial functions
bind不仅能绑定this,还能绑定参数。完整语法:
let bound = func.bind(context, [arg1], [arg2], ...);它允许同时绑定上下文this和函数的部分参数。以乘法函数为例:
function mul(a, b) { return a * b; }基于mul创建double(翻倍)函数:
function mul(a, b) { return a * b; } let double = mul.bind(null, 2); alert( double(3) ); // = mul(2, 3) = 6 alert( double(4) ); // = mul(2, 4) = 8 alert( double(5) ); // = mul(2, 5) = 10mul.bind(null, 2)创建的新函数double会把调用转发给mul,以null为上下文、2为第一个参数,其余参数原样传递。这种"通过预先绑定已有函数的部分参数来创建新函数"的技术称为函数的部分应用(partial application)。
注意:这里实际上没有用到this,但bind的第一个参数必须是上下文,所以只能传null(或undefined)占位。同理可以做出triple:
function mul(a, b) { return a * b; } let triple = mul.bind(null, 3); alert( triple(3) ); // = mul(3, 3) = 9 alert( triple(4) ); // = mul(3, 4) = 12 alert( triple(5) ); // = mul(3, 5) = 15部分应用的价值在于:其一,我们能得到一个可读性强、语义明确的独立函数(double、triple),调用时不必每次都重复那个固定参数;其二,当我们有一个非常灵活、参数很多的函数,却需要一个"不那么灵活"的特定变体时,偏函数尤为有用。例如有个send(from, to, text)函数,在user对象内部可以借助偏函数得到"从当前用户发送"的sendTo(to, text)变体。
在没有上下文的情况下做 partial
原生bind不允许"跳过上下文只绑参数"——你不能省略this直接跳到arguments。但如果想为一个对象方法绑定部分参数、同时保留调用方提供的this,就需要手写一个partial。好消息是它非常简单:
function partial(func, ...argsBound) { return function(...args) { // (*) return func.call(this, ...argsBound, ...args); } } // 用法: let user = { firstName: "John", say(time, phrase) { alert(`[${time}] ${this.firstName}: ${phrase}!`); } }; // 添加一个带有绑定时间的 partial 方法 user.sayNow = partial(user.say, new Date().getHours() + ':' + new Date().getMinutes()); user.sayNow("Hello"); // 类似于这样的一些内容: // [10:00] John: Hello!partial(func[, arg1, arg2...])返回的包装器(*)调用func时:
this与调用者一致:对user.sayNow("Hello")而言,this就是user(通过func.call(this, ...)透传);- 先给
...argsBound:来自partial调用时绑定的参数(如"10:00"); - 再给
...args:调用包装器时传入的参数(如"Hello")。
利用 spread 语法,这个实现非常简洁。lodash 也提供了现成的_.partial实现,功能与此一致。
实战案例:回调中的方法绑定与偏函数
本章的两道综合练习完整展现了bind在真实代码中的两种用法。
案例 1:修复丢失 this 的回调
题目 修复丢失了 "this" 的函数:askPassword()校验密码后调用user.loginOk/loginFail,但直接传入对象方法会导致this丢失:
function askPassword(ok, fail) { let password = prompt("Password?", ''); if (password == "rockstar") ok(); else fail(); } let user = { name: 'John', loginOk() { alert(`${this.name} logged in`); }, loginFail() { alert(`${this.name} failed to log in`); }, }; askPassword(user.loginOk, user.loginFail); // this 丢失,报错错误根源:askPassword拿到的是未绑定对象的loginOk/loginFail,调用时this=undefined。修复方式是用bind绑定上下文:
askPassword(user.loginOk.bind(user), user.loginFail.bind(user));另一种替代方案是用箭头函数包装:
askPassword(() => user.loginOk(), () => user.loginFail());它通常也能工作,但在更复杂的场景下可能失效——例如askPassword被调用之后、访问者应答触发回调之前,变量user被重新赋值,箭头函数就会调用错误的对象。这正是"包装器捕获变量、bind 捕获引用"差异的实战体现。
案例 2:偏函数在登录中的应用
题目 偏函数在登录中的应用 是上一个任务的变体:对象只有一个方法user.login(true/false),需要向askPassword传入参数,使user.login(true)表示成功、user.login(false)表示失败。
方案一(包装器 + 箭头函数):
askPassword(() => user.login(true), () => user.login(false));方案二(bind 绑定上下文 + 预绑定参数——即偏函数):
askPassword(user.login.bind(user, true), user.login.bind(user, false));第二种方案正是"绑定上下文 + 绑定参数"的典型应用:user.login.bind(user, true)生成的函数被调用时等价于user.login(true)。
总结
func.bind(context, ...args)返回func的"绑定变体",同时固定this与...args参数;- 最常见的用途是把对象方法的
this绑定好,再安全地传递到setTimeout、事件监听等外部位置; - 预先绑定部分参数得到的、通用性较低的绑定函数称为partially applied(部分应用)函数或partial;
- 绑定函数的
this是硬绑定的,不可重绑定;绑定结果是新的外来对象,不继承原函数属性(可参见 作为方法的绑定函数、二次 bind、bind 后的函数属性 三道配套练习); - 当只需要绑定参数、不想绑定上下文时,原生
bind无法胜任,可参考文中partial的手写实现(对应源码思路见 head.html),或使用 lodash 的_.partial; - 当不想反复重复相同参数时,偏函数非常有用——就像
send(from, to)中from固定时,只需创建并使用它的一个部分应用变体。
与之相关的概念可以继续阅读本教程的 闭包、箭头函数 与 函数对象(Function 对象的属性与方法) 等章节,它们共同构成对 JavaScript 函数机制的完整认知。
- 文档
- 教程
- 前端
【免费下载链接】zh.javascript.info
现代 JavaScript 教程(The Modern JavaScript Tutorial),以最新的 ECMAScript 规范为基准,通过简单但足够详细的内容,为你讲解从基础到高阶的 JavaScript 相关知识。
相关推荐
JavaScript 教程:修复丢失 `this` 的 `askPassword` 函数 —— 深入理解 `bind` 绑定机制
JavaScript 教程:修复丢失 this 的 askPassword 函数 —— 深入理解 bind 绑定机制 导读 在 Modern JavaScrip
文档/教程前端MXNet ndarray.linalg 线性代数算子 API 全解析:从 GEMM 到特征分解的完整指南
MXNet ndarray.linalg 线性代数算子 API 全解析:从 GEMM 到特征分解的完整指南 本文是 MXNet 旧式(legacy)线性代数算子
文档教程前端0元起步做开源:dotnet官方免费工具与云服务清单全解析(IDE、CI、性能分析器全覆盖)
0元起步做开源:dotnet官方免费工具与云服务清单全解析(IDE、CI、性能分析器全覆盖) 想做开源却担心工具费钱?这篇指南带你完整解析 dotnet 官方免
文档教程前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考