开篇引子
兄弟们,你们有没有这种经历:写了两年JS,面试官一问"说一下函数里面的this指向",心里立马咯噔一下,嘴上开始打太极?或者明明照着教程写了个闭包,结果一运行全是"意料之外"的值?
我最近二刷《JavaScript语言精粹》第四章,读得后背发凉------原来好多之前自以为懂的东西,其实都是"半瓶子醋"。特别是函数那一章,Douglas Crockford老爷子(江湖人称"道格拉斯")讲得贼犀利,各种踩坑点、设计缺陷直接给你扒干净。
这篇文章就是我边啃书边写代码验证出来的实战笔记 。我不会给你照搬教科书定义,而是用我踩过的坑 + 面试常考题 + 真实业务场景,把函数的核心知识点拆成10个"良心总结"。
读完你会收获:
- 搞懂4种调用模式对应的
this指向(面试必问) - 掌握闭包的底层原理,再也不怕写循环绑定事件
- 学会用"模块模式"干掉全局变量(老项目重构利器)
- 理解为什么函数作用域要"把所有var写在顶部"
废话不多说,直接开整。
正文
1. 函数也是对象?没错,它能当"变量"使
以前我一直以为函数就是"一段可以执行的代码",后来才恍然大悟:在JS里,函数本质上是个对象。
怎么理解呢?你就把函数想象成一个"特殊的人":
- 普通人(普通对象)有姓名、年龄,也能干活(方法)
- 但函数这个人自带一个括号技能------你一给他加括号,他立马开始执行代码
因为函数是对象,所以它可以:
- 赋值给变量(
const fn = function(){}) - 放进数组里(
arr.push(function(){})) - 当参数传给另一个函数(回调函数就是这么来的)
- 从一个函数里返回另一个函数(闭包的基础)
每个函数创建的时候,JS偷偷给它挂了两个隐藏属性:
- 函数上下文(也就是它"在哪出生"的环境)
- 实现行为的代码(你写的那些逻辑)
还有一个特别迷惑的属性叫prototype,它跟__proto__不是一码事。这个后面讲原型链的时候会细说,但记住一句话:函数既是对象,又能被调用。
💡 面试小贴士 :
问"函数和普通对象的区别"------答:函数有
[[Call]]内部方法,能被执行;普通对象不行。函数还默认有prototype属性。
2. 函数字面量:匿名函数、闭包伏笔
函数最常见的写法就是"函数字面量",长这样:
javascript
const add = function(a, b) {
return a + b;
};
拆开看4个部分:
function关键字(逃不掉)- 函数名(可省略,省略就成了匿名函数)
- 圆括号里的参数列表(
a, b) - 花括号里的函数体
重点来了:函数可以定义在另一个函数内部,这种"内部函数"会记住它外面的变量。这就是闭包的雏形,我当年死活看不懂,直到我理解了这句话:
内部函数像个小孩子,它能看到家里(外部函数)的所有东西,即使以后离家出走了(外部函数结束),它还记着家里有啥。
这个"记忆能力"就是闭包的根源,后面第10点会详细锤。
javascript
function outer() {
let name = '石林';
function inner() {
console.log(name); // 能访问到 outer 里的 name
}
inner();
}
⚠️ 踩坑提醒 :
有的新手在循环里定义函数,以为每次能保存当前循环变量的值,结果全是最后一个值------这就是闭包"引用"而非"复制"导致的。先记着,后面有图有真相。
3. 调用模式:this的"变脸"艺术
这是面试五星重点。函数一调用,除了你传的参数,还会白送你两个隐藏参数:this 和 arguments。其中this的值完全取决于你怎么调用,而不是你在哪定义的。
总共4种调用模式,我一个一个说。
3.1 方法调用模式 ------ this 指向"点"前面的对象
当你把一个函数存为对象的属性,然后通过对象.方法()调用,这就是方法调用。this乖乖指向那个对象。
javascript
const counter = {
count: 0,
increment: function() {
this.count++; // this 就是 counter
}
};
counter.increment();
console.log(counter.count); // 1
这个模式最符合直觉,适合给对象定义行为。
3.2 函数调用模式 ------ this 指向全局对象(设计缺陷!)
直接写add(3,4)这样调用,就是函数调用模式。这时this指向全局对象 (浏览器里是window,Node里是global)。
道格拉斯老爷子骂这是个设计错误。为啥?看个例子:
javascript
const obj = {
value: 100,
double: function() {
function helper() {
this.value = this.value * 2; // 这里 this 是 window!
}
helper();
}
};
obj.double();
console.log(obj.value); // 100,没变!因为 window.value 被改了(如果有的话)
在浏览器非严格模式下:
普通函数裸调用 时,
this= window(全局对象)
你看,内部函数helper本意是想操作obj.value,结果this跑偏了。
解决方案 :用that = this存一下。
javascript
double: function() {
const that = this; // that 永远指向外层的 obj
function helper() {
that.value *= 2; // 用 that,没问题了
}
helper();
}
这个模式面试必考,记住:普通函数(非箭头)调用时,this = 全局/undefined(严格模式) 。
3.3 构造器调用模式 ------ new 出来一个新对象
如果你在一个函数前面加new调用,JS会:
- 创建一个新对象
- 把这个新对象链接到函数的
prototype - 把
this绑定到新对象 - 执行函数体
- 如果函数没返回其他对象,就自动返回这个新对象
javascript
function Person(name) {
this.name = name;
}
const p = new Person('张三');
console.log(p.name); // 张三
道格拉斯建议尽量不要用这种模式 ,因为忘了写new会污染全局(this变成window),还没有任何警告。大写函数名只是约定,防不住粗心。
现代开发中,更推荐用
class或者工厂函数。
3.4 Apply调用模式 ------ 手动指定this
每个函数都有apply和call方法。apply接收两个参数:要绑定的this,以及参数数组。
javascript
function introduce(greeting) {
console.log(greeting + ', 我是' + this.name);
}
const user = { name: '李雷' };
introduce.apply(user, ['你好']); // 你好, 我是李雷
这个模式在借用方法 时极其有用,比如把一个数组的方法借给类数组对象用(后面讲arguments时会用到)。
🎯 面试高频考题 :
写出下面代码的打印结果,并解释原因
javascriptconst obj = { fn: function(){ console.log(this); } }; const fn = obj.fn; fn(); // this是什么?答案:
window(非严格模式)。因为fn()是函数调用模式,不是方法调用。
4. arguments:像数组但不是数组的"骗子"
每个函数内部都能访问arguments对象,它包含了调用时传入的所有参数,不管你有没有定义形参。
javascript
function sum() {
let total = 0;
for (let i = 0; i < arguments.length; i++) {
total += arguments[i];
}
return total;
}
console.log(sum(1,2,3,4)); // 10
这玩意儿写工具函数很方便,比如实现一个任意数量数字求和的函数。
但是 ,它有个大坑:arguments不是真正的数组 !它只是长得像数组(有length属性和索引),但没有push、pop、forEach等方法。
javascript
function test() {
arguments.push(4); // TypeError: arguments.push is not a function
}
怎么转成真数组?老办法:
javascript
const args = Array.prototype.slice.call(arguments);
// 或者 ES6 写法
const args = Array.from(arguments);
// 或者用展开运算符(但得在函数参数里用...)
另一个坑 :在非严格模式下,arguments和形参是"联动"的(改一个另一个也跟着变),容易出玄学bug。严格模式下就分家了。
⚠️ 现在更推荐 :使用剩余参数
...args,它就是个真数组,干净利落。
5. return:你不写,JS帮你补
每个函数都有return。你不写,JS会在最后帮你return undefined。
- 普通返回:
return value; - 提前返回:执行到
return就立马跳出函数 - 没写
return:默认undefined
特殊场景:用new调用构造函数时,如果返回的不是一个对象,那么this(新创建的对象)会被返回。
javascript
function Foo() {
this.a = 1;
return 123; // 返回的不是对象,所以最终还是返回 {a:1}
}
const f = new Foo();
console.log(f); // Foo { a: 1 }
如果显式返回一个对象,那就会覆盖默认返回:
javascript
function Bar() {
return { b: 2 };
}
const b = new Bar();
console.log(b); // { b: 2 },不是 Bar 的实例了
平时写函数最好明确return,别偷懒,让代码意图清晰。
6. 异常处理:throw + try/catch
写代码难免遇到"意外",比如传进来的参数根本不是数字。这时候别让程序直接崩,抛个异常让上层处理。
javascript
function divide(a, b) {
if (b === 0) {
throw {
name: 'DivideByZeroError',
message: '除数不能为0,兄弟你清醒点'
};
}
return a / b;
}
try {
console.log(divide(10, 0));
} catch (e) {
console.error(e.name + ':' + e.message);
}
抛出的对象最好有name和message属性,方便catch里根据类型做不同处理。
实际开发中,可以继承
Error类,或者直接用new Error('xxx'),但throw后面可以跟任何值(字符串、数字、对象),不过为了规范还是建议用Error对象。
7. 给内置类型"开外挂":慎用但真香
JavaScript允许你给内置类型的原型加方法,比如给所有字符串加一个trim方法(老环境没有)。这挺爽,但也有风险------可能会跟其他库冲突。
道格拉斯给了一个安全添加方法的模板:
javascript
javascript
Function.prototype.method = function(name, func) {
if (!this.prototype[name]) { // 只有不存在时才加,避免覆盖
this.prototype[name] = func;
}
return this;
};
// 给数字加一个整数取整方法
Number.method('integer', function() {
return Math[this < 0 ? 'ceil' : 'floor'](this);
});
console.log((-10 / 3).integer()); // -3(注意是ceil,不是向下取整)
也可以给字符串加个真正的trim(老IE没有):
javascript
String.method('trim', function() {
return this.replace(/^\s+|\s+$/g, '');
});
💡 个人建议 :
在团队项目或开源库中,修改原型要非常谨慎,最好用ES6的
Symbol或者工具函数替代。但在你自己的脚手架、学习项目里,玩玩无伤大雅,还能加深理解。
8. 递归:函数自己调自己,但小心爆栈
递归就是函数调用自身,适合解决那些可以分解成相同子问题的问题,比如树形结构遍历、汉诺塔、斐波那契。
一个经典的遍历DOM树的递归函数:
javascript
function walkDOM(node, callback) {
callback(node);
node = node.firstChild;
while (node) {
walkDOM(node, callback);
node = node.nextSibling;
}
}
// 查找所有带有 data-id 属性的元素
const results = [];
walkDOM(document.body, function(node) {
if (node.nodeType === 1 && node.hasAttribute('data-id')) {
results.push(node);
}
});
递归的风险:调用深度过大时,JS引擎会抛出"Maximum call stack size exceeded"。因为每次调用都会在内存中压栈,没及时释放。
比如计算斐波那契数列的朴素递归,效率低到令人发指(重复计算大量子问题)。
javascript
function fib(n) {
if (n < 2) return n;
return fib(n-1) + fib(n-2); // 指数级时间复杂度
}
fib(40) // 浏览器卡死警告
面试经常问"递归优化",答案有两个:记忆化(缓存) 和 改成循环(尾递归) 。但JS引擎对尾递归优化支持不好,所以最常用的是加缓存,这个我们第15点再细讲(等写后5点的时候补)。
9. 作用域:JS是函数级,不是块级
很多C语言背景的同学刚写JS会蒙圈:明明在for循环里用var定义的变量,循环外面还能访问?对,因为JS只有函数作用域,没有块级作用域 (let/const是ES6之后的事了)。
看个例子:
javascript
function test() {
for (var i = 0; i < 5; i++) {
var message = 'hello';
}
console.log(i); // 5,能访问到
console.log(message); // 'hello',也能访问到
}
这导致一个经典坑:你本来以为变量只在循环里有效,结果它"泄露"到整个函数了。
最佳实践 :把所有var声明都提到函数顶部 ,这样看起来像"手动实现块级作用域"。现在有了let和const,老老实实用它们替代var,能避免99%的作用域问题。
但旧代码维护时你还会遇到,所以得懂这个特性。
面试常考题:
javascriptvar a = 1; function fn() { console.log(a); var a = 2; } fn(); // 输出什么?答案:
undefined,因为变量提升,var a被提到函数顶部但还没赋值,相当于:
javascriptvar a = 1; function fn() { var a; console.log(a); a = 2; }
10. 闭包:JS最强大也是最绕的"黑魔法"
终于到了闭包,这个让无数前端头疼的概念。别怕,我用大白话解释。
闭包的定义:一个内部函数,在外部函数结束后,仍然能访问外部函数的变量。
你可能会问:外部函数都执行完了,变量应该被垃圾回收了呀?不,因为内部函数还被某个地方引用着,JS引擎发现这个变量"还有用",就把它保留下来了。
10.1 最简单的闭包例子
javascript
function createCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
// createCounter 早就执行完了,但 count 一直活着,被内部函数"咬住"了
// count 只在第一次创建时被初始化 0,之后就一直活在内存里,再也不会被重置
这个特性可以用来实现私有变量 ,外部没法直接修改count,只能通过返回的函数去操作。
10.2 闭包的经典坑:循环绑定事件
我当年面试被这道题卡过:
javascript
// 假设有3个按钮,想点击哪个就弹出它的索引
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = function() {
alert(i); // 不管点哪个,都弹出 3
};
}
原因:onclick函数里访问的i是同一个变量,循环结束后i变成了3,所有回调都共享这个3。
解决方案:用立即执行函数(IIFE)为每一轮循环创建一个新的作用域。
javascript
for (var i = 0; i < btns.length; i++) {
btns[i].onclick = (function(j) {
return function() {
alert(j);
};
})(i);
}
或者直接用let(ES6完美解决):
javascript
for (let i = 0; i < btns.length; i++) {
btns[i].onclick = function() {
alert(i);
};
}
10.3 闭包的内存泄露风险
因为闭包会让外部函数的变量一直留在内存里,如果用得太多、不注意释放,可能导致内存占用过高。比如:
javascript
function leak() {
const bigData = new Array(1000000).fill('x');
return function() {
console.log(bigData.length);
};
}
const hold = leak(); // bigData 永远不会被释放,直到 hold 被销毁
所以用完闭包后,把引用置为null让垃圾回收可以工作。
11. 回调函数:异步编程的"传话小弟"
刚学JS时我同步思维根深蒂固,以为代码就该一行行按顺序跑。直到碰到网络请求,发现页面直接卡死,才明白同步请求有多坑爹。
javascript
// 同步请求(假代码,别真跑)
let response = sendRequestSync('https://api.example.com/data');
console.log(response); // 等好几秒才打印,期间页面点不了
正确的姿势是异步+回调:你发起请求,然后甩给后端一个函数------"你好了就调这个函数,我先去干别的"。
javascript
function fetchData(callback) {
setTimeout(() => {
callback('数据回来了');
}, 1000);
}
console.log('开始请求');
fetchData((data) => {
console.log(data);
});
console.log('我去干别的事了');
// 输出顺序:开始请求 -> 我去干别的事了 -> 数据回来了
回调函数在事件处理、定时器、AJAX、文件读写里无处不在。但回调也有坑------"回调地狱",就是多个异步层层嵌套,代码写成三角形。
javascript
// 回调地狱示例(别学)
getUser(function(user) {
getOrders(user.id, function(orders) {
orders.forEach(function(order) {
processOrder(order.id, function(result) {
// 套到怀疑人生
});
});
});
});
现在的解决方案:Promise + async/await,但底层本质还是回调。理解回调是理解异步的第一步。
面试经常问"回调函数和普通函数有什么区别"------答:回调函数是作为参数传递、在将来某个时刻被调用的函数,它不立即执行。
12. 模块模式:干掉全局变量的"独门暗器"
早期JS项目经常是全局变量满天飞,a=1、b=2,一不小心就冲突。道格拉斯老爷子给出一个方案:用闭包造模块。
模块模式的核心公式:
- 创建一个函数(提供私有作用域)
- 在函数里定义私有变量和方法
- 返回一个对象或函数(暴露公共接口)
- 立即执行这个函数(IIFE)
来个实战例子:封装一个计数器,外部只能通过方法修改,不能直接碰内部变量。
javascript
const counterModule = (function() {
let count = 0; // 私有变量
function add() {
count++;
}
function get() {
return count;
}
// 暴露公共API
return {
increment: add,
value: get
};
})();
counterModule.increment();
counterModule.increment();
console.log(counterModule.value()); // 2
console.log(counterModule.count); // undefined,访问不到
再看一个更实用的:HTML实体解码器。书中例子用闭包缓存字符映射表,避免每次调用都重新创建。
javascript
const deentityify = (function() {
const entity = {
quot: '"',
lt: '<',
gt: '>',
amp: '&'
};
return function(str) {
return str.replace(/&([^&;]+);/g, function(match, key) {
return entity[key] || match;
});
};
})();
console.log(deentityify('<div>')); // <div>
这个模式的优点:
- 隐藏内部实现细节
- 避免全局污染
- 可以创建单例对象
踩坑记录 :有次我在模块里用了
this,发现指向不对,后来意识到模块返回的是普通对象,方法里的this指向调用者。所以模块内部尽量不用this,直接用闭包变量。
面试进阶题:"模块模式和构造函数的区别?"------模块模式返回的是一个对象字面量(单例),不需要new;构造函数需要new来创建多个实例。
13. 级联:链式调用的"花式技巧"
你肯定用过jQuery的$('#id').addClass('a').removeClass('b').show(),这就是级联。实现原理贼简单:每个方法都return this。
写个简单的链式操作类:
javascript
class Calculator {
constructor(value = 0) {
this.value = value;
}
add(n) {
this.value += n;
return this;
}
subtract(n) {
this.value -= n;
return this;
}
multiply(n) {
this.value *= n;
return this;
}
get() {
return this.value;
}
}
const result = new Calculator(5)
.add(3)
.multiply(2)
.subtract(1)
.get();
console.log(result); // (5+3)*2-1 = 15
每个方法都返回this,所以可以一直点下去。这种风格在构建器模式 、查询构建器(比如Knex.js)里很常见。
但注意别滥用。如果一个方法本身就是用来获取值(比如get()),通常就不返回this了,而是返回实际值,这样链条自然终止。
个人心得:我以前写API时纠结要不要全用级联,后来发现得看场景。配置类对象适合级联,数据操作类就别硬套,保持语义清晰最重要。
14. 套用(Curry):固定参数的"函数预制菜"
套用,也叫柯里化。听起来高大上,其实意思就是:预先给函数一部分参数,生成一个等着接收剩余参数的新函数。
比如有个加法函数,我想造一个"加1专用函数":
javascript
function add(a, b) {
return a + b;
}
// 手动柯里化
function add1(b) {
return add(1, b);
}
console.log(add1(5)); // 6
通用柯里化函数怎么实现?书中给了一个方法,核心是利用闭包收集参数。
javascript
Function.prototype.curry = function() {
const args = Array.from(arguments);
const that = this;
return function() {
const moreArgs = args.concat(Array.from(arguments));
return that.apply(null, moreArgs);
};
};
const add = (a, b) => a + b;
const add1 = add.curry(1);
console.log(add1(5)); // 6
注意坑 :原书中提到arguments不是真数组,没有concat,所以需要先转成数组。上面我用Array.from解决。
ES6写法更简洁:
javascript
const curry = (fn, ...args) =>
args.length >= fn.length ? fn(...args) : (...more) => curry(fn, ...args, ...more);
const add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
柯里化的实际用途:
- 参数复用(比如上面的
add1) - 延迟计算(先传一部分参数,留到后面再传)
- 配合函数组合(compose)使用,写出函数式风格代码
面试可能问"柯里化和偏函数的区别"------柯里化是把多参数函数转成一串单参数函数;偏函数是固定一部分参数,生成参数更少的函数。两者类似但不等价。
15. 记忆(Memoization):用空间换时间的"性能外挂"
最后一个大招:记忆函数。说白了就是"缓存计算结果",避免重复递归。
最经典的例子:斐波那契数列。普通递归写法效率巨低,计算fib(40)浏览器都要卡几秒。
javascript
function fib(n) {
if (n < 2) return n;
return fib(n-1) + fib(n-2);
}
// fib(40) 调用次数爆炸
加上记忆缓存后,每个n只算一次。
javascript
function memoizedFib() {
const cache = [0, 1];
function fib(n) {
if (cache[n] !== undefined) return cache[n];
cache[n] = fib(n-1) + fib(n-2);
return cache[n];
}
return fib;
}
const fib = memoizedFib();
console.log(fib(40)); // 102334155,秒出
道格拉斯写了一个通用的memoizer,可以给任何递归函数加缓存:
javascript
function memoizer(memo, formula) {
function recur(n) {
let result = memo[n];
if (typeof result !== 'number') {
result = formula(recur, n);
memo[n] = result;
}
return result;
}
return recur;
}
// 斐波那契
const fibonacci = memoizer([0, 1], (recur, n) => recur(n-1) + recur(n-2));
// 阶乘
const factorial = memoizer([1, 1], (recur, n) => n * recur(n-1));
console.log(fibonacci(10)); // 55
console.log(factorial(5)); // 120
记忆函数不仅用于数学递归,还可以用于:
- 缓存昂贵的DOM查询结果
- 避免重复计算复杂数据转换
- 前端状态管理中的selector(比如Reselect库)
踩坑提醒:缓存会占用内存,如果参数范围无限大(比如缓存所有用户输入),可能导致内存泄露。所以要给缓存加容量限制(LRU缓存)或者只在必要时使用。
结尾复盘与学习总结
呼,终于把《JavaScript语言精粹》第四章的15个点啃完了。回头看,函数这一章确实是JS进阶的分水岭,掌握好了,你就能:
- 搞懂this------四种调用模式牢记于心,面试再问直接画图解释。
- 玩转闭包------模块模式、柯里化、记忆函数,底层全是闭包那点事。
- 写出优雅代码------级联链式调用、模块化封装,告别全局污染。
- 性能优化------记忆函数缓存计算结果,避免无效递归。
下一步建议:把第五章"继承"和第六章"数组"一起刷了,原型链和闭包结合,才能真正理解JS的对象系统。
推荐配合练习:
- 手写一个
bind(考察this和闭包) - 手写防抖节流(闭包经典应用)
- 用模块模式改造你项目里的工具函数
如果本文对你有帮助,点赞、收藏、评论"催更下一章" ,我动力更足!