万字解析《JS语言精粹》之第四章:函数15大核心精髓(JS灵魂核心)

开篇引子

兄弟们,你们有没有这种经历:写了两年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个部分:

  1. function 关键字(逃不掉)
  2. 函数名(可省略,省略就成了匿名函数)
  3. 圆括号里的参数列表(a, b
  4. 花括号里的函数体

重点来了:函数可以定义在另一个函数内部,这种"内部函数"会记住它外面的变量。这就是闭包的雏形,我当年死活看不懂,直到我理解了这句话:

内部函数像个小孩子,它能看到家里(外部函数)的所有东西,即使以后离家出走了(外部函数结束),它还记着家里有啥。

这个"记忆能力"就是闭包的根源,后面第10点会详细锤。

javascript 复制代码
function outer() {
  let name = '石林';
  function inner() {
    console.log(name); // 能访问到 outer 里的 name
  }
  inner();
}

⚠️ 踩坑提醒

有的新手在循环里定义函数,以为每次能保存当前循环变量的值,结果全是最后一个值------这就是闭包"引用"而非"复制"导致的。先记着,后面有图有真相。


3. 调用模式:this的"变脸"艺术

这是面试五星重点。函数一调用,除了你传的参数,还会白送你两个隐藏参数:thisarguments。其中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会:

  1. 创建一个新对象
  2. 把这个新对象链接到函数的prototype
  3. this绑定到新对象
  4. 执行函数体
  5. 如果函数没返回其他对象,就自动返回这个新对象
javascript 复制代码
function Person(name) {
  this.name = name;
}
const p = new Person('张三');
console.log(p.name); // 张三

道格拉斯建议尽量不要用这种模式 ,因为忘了写new会污染全局(this变成window),还没有任何警告。大写函数名只是约定,防不住粗心。

现代开发中,更推荐用class或者工厂函数。

3.4 Apply调用模式 ------ 手动指定this

每个函数都有applycall方法。apply接收两个参数:要绑定的this,以及参数数组。

javascript 复制代码
function introduce(greeting) {
  console.log(greeting + ', 我是' + this.name);
}
const user = { name: '李雷' };
introduce.apply(user, ['你好']); // 你好, 我是李雷

这个模式在借用方法 时极其有用,比如把一个数组的方法借给类数组对象用(后面讲arguments时会用到)。

🎯 面试高频考题

写出下面代码的打印结果,并解释原因

javascript 复制代码
const 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属性和索引),但没有pushpopforEach等方法。

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);
}

抛出的对象最好有namemessage属性,方便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声明都提到函数顶部 ,这样看起来像"手动实现块级作用域"。现在有了letconst,老老实实用它们替代var,能避免99%的作用域问题。

但旧代码维护时你还会遇到,所以得懂这个特性。

面试常考题:

javascript 复制代码
var a = 1;
function fn() {
  console.log(a);
  var a = 2;
}
fn(); // 输出什么?

答案:undefined,因为变量提升,var a被提到函数顶部但还没赋值,相当于:

javascript 复制代码
var 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=1b=2,一不小心就冲突。道格拉斯老爷子给出一个方案:用闭包造模块

模块模式的核心公式:

  1. 创建一个函数(提供私有作用域)
  2. 在函数里定义私有变量和方法
  3. 返回一个对象或函数(暴露公共接口)
  4. 立即执行这个函数(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('&lt;div&gt;')); // <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进阶的分水岭,掌握好了,你就能:

  1. 搞懂this------四种调用模式牢记于心,面试再问直接画图解释。
  2. 玩转闭包------模块模式、柯里化、记忆函数,底层全是闭包那点事。
  3. 写出优雅代码------级联链式调用、模块化封装,告别全局污染。
  4. 性能优化------记忆函数缓存计算结果,避免无效递归。

下一步建议:把第五章"继承"和第六章"数组"一起刷了,原型链和闭包结合,才能真正理解JS的对象系统。

推荐配合练习:

  • 手写一个bind(考察this和闭包)
  • 手写防抖节流(闭包经典应用)
  • 用模块模式改造你项目里的工具函数

如果本文对你有帮助,点赞、收藏、评论"催更下一章" ,我动力更足!

相关推荐
程序员黑豆1 小时前
AI全栈开发之Java:什么是JDK
前端·后端·ai编程
宋拾壹1 小时前
同时添加多个类目
android·开发语言·javascript
IT知识分享1 小时前
从零开发在线简繁转换工具:OpenCC 实战、避坑经验与方案选型
javascript·python
mqcode1 小时前
Vue3 + Element Plus + Vite 企业级后台框架搭建全流程
前端
SL-staff1 小时前
Web 白板技术架构深度解析:从渲染到协作的选型哲学
前端·架构
川冰ICE1 小时前
JavaScript实战④|天气查询应用,调用API与异步处理
javascript·css·css3
微扬嘴角1 小时前
react篇4--setState、LazyLoad和Hooks
前端·javascript·react.js
杨梦馨1 小时前
万级数据表格卡死?Web Worker 一招搞定
前端·javascript·vue.js
阿明在折腾1 小时前
从Canvas到AI模型:我在线工具站里的图片处理实战
前端·后端