JavaScript 函数:作用域、闭包与高阶函数
在上一篇文章中我们了解了 JavaScript 函数的基本概念——如何定义函数、传递参数、返回值以及箭头函数的用法。但会写函数、会调用函数只是入门的第一步。你有没有想过为什么一个函数可以访问在它外面声明的变量为什么一个函数在创建它的外层函数已经执行完之后还能访问那个变量为什么一个函数可以被当作参数传给另一个函数这些行为背后涉及 JavaScript 三个核心概念作用域、闭包和高阶函数。本文带你一步步理解它们并用实例说明它们之间的联系。如果你在手机上调试网页也可以先了解一下如何让 iPhone Safari 在后台打开新标签页方便同时查看代码和文档。一、理解作用域作用域决定了变量在代码中的哪些位置可以被访问。看这个例子const name Dini; function greet() { console.log(name); } greet();greet()函数可以访问name尽管name并不是在函数内部声明的。这是因为name声明在外部作用域中。再看另一个例子function greet() { const message Hello, Dini!; console.log(message); // Hello, Dini! } greet(); console.log(message); // ReferenceError: message is not defined第一个console.log()正常运行因为message是在greet()内部声明的。但第二个会报错因为message只存在于函数的作用域里。全局作用域 └── greet() └── messagemessage在greet()内部可以访问但从外部访问不到。这就是作用域的核心一个变量只能在它被定义的作用域以及嵌套在它内部的作用域中被访问。JavaScript 有以下几种作用域全局作用域脚本模式下所有代码的默认作用域。模块作用域模块模式下代码的作用域。函数作用域由函数创建的作用域。块级作用域由一对花括号代码块创建的作用域。注意代码块只对let和const声明生效对var不生效。{ var x 10; } console.log(x); // 10{ const x 10; } console.log(x); // ReferenceError: x is not defined二、词法作用域与作用域链JavaScript 使用词法作用域也就是说变量的可访问性由代码书写的位置决定而不是由函数调用的位置决定。const name Dini; function outer() { const greeting Hello; function inner() { console.log(greeting); console.log(name); } inner(); } outer();inner()可以同时访问greeting和name。为什么因为 JavaScript 通过作用域链来查找变量。inner() ↓ outer() ↓ 全局作用域当 JavaScript 执行console.log(greeting)时先在inner()内部找greeting没找到再到外层outer()找找到了查找停止。而执行console.log(name)时inner()和outer()里都没有name于是继续往全局作用域找最终在那里找到。这就是作用域链。要记住这个关系只能从内层往外层走。内层函数可以访问外层作用域的变量反过来不行。这个概念非常重要因为它是理解闭包的基础。三、什么是闭包理解了作用域和作用域链之后来看一个初看可能觉得奇怪的现象function createGreeting() { const message Hello, Dini!; return function () { console.log(message); }; } const greet createGreeting(); greet();输出是Hello, Dini!你可能会疑惑createGreeting()已经执行完了greet()为什么还能访问message按常理外层函数执行完后它内部的变量应该被回收才对。这就是闭包在起作用。函数会记住它诞生时的作用域当 JavaScript 创建createGreeting()返回的那个内部函数时这个函数会保持对其创建时所处作用域中所有变量的访问权。greet ↓ function () { console.log(message); } ↓ 记住了对 ↓ message Hello, Dini! 的访问也就是说即使createGreeting()已经执行完毕返回的函数仍然能访问它创建时的环境。这种行为就叫闭包。简单定义闭包是指函数在其外层函数执行完毕后仍然保持对外层词法作用域中变量的访问能力。关键点在于函数记住的不是变量的值的拷贝而是对变量本身的引用。闭包可以保存状态看一个计数器的例子function createCounter() { let count 0; return function () { count; return count; }; } const counter createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 console.log(counter()); // 3count声明在createCounter()内部。执行完const counter createCounter()后createCounter()已经结束了。但每次调用counter()count的值仍然存在并且在之前的基础上累加。这是因为返回的函数形成了对count的闭包在多次调用之间维持了这个变量的状态。闭包创建独立的状态更有意思的是如果创建两个计数器function createCounter() { let count 0; return function () { count; return count; }; } const counterA createCounter(); const counterB createCounter(); console.log(counterA()); // 1 console.log(counterA()); // 2 console.log(counterB()); // 1 console.log(counterB()); // 2虽然两个计数器来自同一个函数但它们各自维护独立的count互不影响。每次调用createCounter()都会创建一个新的词法环境返回的函数闭包在各自的环境上。这就是闭包的强大之处它可以用来创建独立、持久的状态。闭包有什么用闭包不只是 JavaScript 的一个有趣特性它在实际开发中用途广泛在多次函数调用之间保持状态创建函数工厂封装数据创建需要访问外部变量的回调函数处理异步操作举个函数工厂的例子function createMultiplier(multiplier) { return function (number) { return number * multiplier; }; } const double createMultiplier(2); const triple createMultiplier(3); console.log(double(5)); // 10 console.log(triple(5)); // 15double记住了multiplier 2triple记住了multiplier 3。同一个工厂函数可以生成不同的函数各自保留自己的状态。这就是闭包的核心思想一个函数可以携带它创建时所处环境的访问权。四、函数是值JavaScript 函数还有一个重要特性函数就是值。这意味着函数可以像其他值一样对待——可以赋值给变量、可以传给另一个函数、也可以从函数中返回。把函数赋值给变量const greet function () { console.log(Hello!); }; greet();这里greet存储的不是函数的执行结果而是对函数本身的引用。所以要加括号greet()才会执行。还可以把同一个函数赋值给另一个变量const greet function () { console.log(Hello!); }; const sayHello greet; sayHello();把函数作为参数传递function greet(name) { console.log(Hello, ${name}!); } function processUser(name, callback) { callback(name); } processUser(Dini, greet);注意我们传的是greet不是greet()。传greet是把函数本身传过去传greet()会立刻执行函数传过去的是它的返回值。这种被传给另一个函数、稍后再被执行的函数通常叫做回调函数。从函数中返回另一个函数function createGreeting(name) { return function () { console.log(Hello, ${name}!); }; } const greetDini createGreeting(Dini); greetDini();这个例子和闭包那一节很像。返回的函数不仅是一个函数值它还闭包在了name变量上。const greetDini createGreeting(Dini); const greetNaufal createGreeting(Naufal); greetDini(); // Hello, Dini! greetNaufal(); // Hello, Naufal!每个返回的函数都能访问自己那份name。函数可以被存储、传递和返回——这就是所谓的一等函数。JavaScript 把函数当作一等公民来对待这也是函数如此强大的原因。五、什么是高阶函数既然函数可以接收另一个函数作为参数或者返回一个函数那么操作其他函数的函数叫什么这就是高阶函数。定义高阶函数是满足以下至少一个条件的函数接收另一个函数作为参数返回一个函数作为结果两者都做。看一个简单例子function processNumber(number, operation) { return operation(number); } function double(number) { return number * 2; } const result processNumber(5, double); console.log(result); // 10processNumber()接收double作为参数然后执行它。它不需要知道operation具体做什么只需要接收函数并调用它。这让函数更加灵活和可复用。高阶函数 vs 回调函数这两个概念紧密相关但描述的是不同角色processNumber()是高阶函数它接收另一个函数double()是回调函数它被传给高阶函数稍后被调用。回调函数是被传过去稍后执行的函数高阶函数是接收或返回其他函数的函数。高阶函数也可以返回函数回到之前createMultiplier的例子function createMultiplier(multiplier) { return function (number) { return number * multiplier; }; } const double createMultiplier(2); const triple createMultiplier(3);createMultiplier()返回了一个新函数所以它也是高阶函数。返回的函数同时闭包在了multiplier上。这就是高阶函数和闭包协同工作的典型例子。高阶函数有什么用高阶函数让代码更灵活、可复用、可组合。我们不需要为每种操作写一个独立的函数而是写一个通用函数在调用时传入具体的行为。function calculate(number, operation) { return operation(number); } const double calculate(10, number number * 2); const square calculate(10, number number ** 2); console.log(double); // 20 console.log(square); // 100实际上你大概率已经用过很多次高阶函数了。比如数组的map()const numbers [1, 2, 3, 4, 5]; const doubled numbers.map(number number * 2);map()接收一个函数作为参数所以它是高阶函数。类似的还有filter()、forEach()、reduce()、find()、some()、every()等。六、数组方法中的高阶函数用同一个数据集来演示const users [ { name: Dini, age: 25, active: true }, { name: Naufal, age: 30, active: false }, { name: Nabil, age: 28, active: true }, ];map()map()通过对原数组的每一项进行变换返回一个新数组。const names users.map(user user.name); console.log(names); // [Dini, Naufal, Nabil]用法规则当你想把每一项都变换成另一种形式时用 map()。filter()filter()筛选出满足条件的项返回新数组。const activeUsers users.filter(user user.active); console.log(activeUsers);回调返回true的项被保留返回false的被剔除。用法规则当你想从数组中挑选出符合条件的若干项时用 filter()。forEach()forEach()对每一项执行一次函数但不返回新数组。users.forEach(user { console.log(user.name); }); // Dini // Naufal // Nabil注意forEach()返回undefined。如果你需要得到一个新数组应该用map()。用法规则当你只想对每一项执行某个操作、不需要返回新数组时用 forEach()。reduce()reduce()把数组的多项合并成一个最终结果。const totalAge users.reduce((total, user) { return total user.age; }, 0); console.log(totalAge); // 83第二个参数0是累加器的初始值。回调接收两个参数total当前累加值和user当前项。reduce()的结果不一定是数字也可以是对象const usersByName users.reduce((result, user) { result[user.name] user; return result; }, {}); console.log(usersByName);用法规则当你想把整个数组归约成一个值或一个对象时用 reduce()。总结对比方法作用返回值map()变换每一项新数组filter()筛选符合条件的项新数组forEach()对每一项执行操作undefinedreduce()归约为一个结果单个累加结果这些方法虽然用途不同但模式是一样的数组方法负责怎么遍历回调函数负责对每一项做什么。这就是高阶函数的威力——我们不需要自己写循环逻辑只需要描述想要的行为。比如不使用filter()时我们要这样写const activeUsers []; for (const user of users) { if (user.active) { activeUsers.push(user); } }使用filter()后const activeUsers users.filter(user user.active);第二版更关注我们要什么活跃用户而不是怎么遍历。这就是现代 JavaScript 中高阶函数如此常用的原因。七、总结本文梳理了 JavaScript 函数相关的几个核心概念作用域决定变量在哪里可以被访问。词法作用域与作用域链决定 JavaScript 从哪里开始查找变量。闭包让函数在外部作用域结束后仍能访问其中的变量。函数是值函数可以被存储、传递和返回。高阶函数接收或返回其他函数的函数如map()、filter()、reduce()。理解这些概念之间的联系是写出清晰、优雅 JavaScript 代码的基础。写代码时遇到环境问题可以参考Windows 网络相关设置以及其他实用教程。相关阅读如何手动和自动把 iPhone 备份到 iCloud如何在 Safari 浏览器中允许或拦截弹窗如何关闭查找我的 iPhone