写 JavaScript 写到一定阶段,很多人会卡在同一个位置:函数和对象。这两个概念单独拎出来都不难,但一旦组合起来,什么 this、闭包、原型链、回调函数,全都跑出来了。我见过不少同学,能写出页面、能调接口,但一问“箭头函数的 this 为什么和普通函数不一样”就蒙,一写“对象的深拷贝”就只会 JSON.parse(JSON.stringify())。这篇内容就是把函数和对象这块硬骨头拆开揉碎,梳理我实际开发里踩过的坑和验证过的写法,给正在进阶的朋友一条能直接照着走的路线。
先说清楚这篇内容能帮你解决什么:第一,真正理解函数在 JS 里的特殊地位,搞懂函数声明、函数表达式和箭头函数的本质区别;第二,把对象的各种创建方式、原型关系、属性遍历理清楚,不再面对对象就只会打点调用;第三,把 this、闭包、回调这些“函数与对象结合后产生的怪现象”一次性讲透;第四,给出一批能直接抄的代码,比如字符串包含判断、深拷贝、数组对象转换;最后附上常见的报错排查技巧。适合已经写过一段时间 JS、想从“会用”走向“理解”的前端开发者,也适合准备面试时系统过一遍基础知识的朋友。
1. 先把“函数是一等公民”这句话吃透
1.1 函数本质上是对象,还能拥有属性
很多教材喜欢说“JavaScript 中的函数是一等公民”,这句话听着玄乎,说白了就是:函数在 JS 里和普通对象一样,可以赋值给变量、可以塞进数组、可以作为参数传给另一个函数、也可以被另一个函数返回。甚至,函数自己还能挂属性。
function sayHi() { console.log('hi'); } sayHi.customProp = '我是挂在函数上的属性'; console.log(sayHi.customProp); // 我是挂在函数上的属性 console.log(sayHi.length); // 0,length 是形参个数 console.log(sayHi.name); // sayHi,函数的 name 属性第一次发现函数可以挂属性的时候,我愣了一下,但这就是 JS 函数最底层的真相:每个函数都是 Function 类型的实例,而 Function 本身是对象,所以函数天然拥有对象的特性。这意味着你写高阶函数、写装饰器、写带缓存的计算函数时,逻辑上完全走得通。
1.2 函数声明与函数表达式,差别全在提升阶段
函数有两种最常见的写法:函数声明和函数表达式。表面上就差一个等号,行为上却有明显区别。
// 函数声明 function foo() { return 'foo'; } // 函数表达式 const bar = function() { return 'bar'; }; // 箭头函数(本质是函数表达式的简写变体) const baz = () => 'baz';函数声明会被整体提升到作用域顶部,所以你可以在声明之前调用它。函数表达式只提升变量的声明,赋值留在原地,如果在赋值之前调用,就会报TypeError: bar is not a function。
console.log(foo()); // 'foo',函数声明整体提升 console.log(bar()); // TypeError: bar is not a function const bar = function() { return 'bar'; };这里有个实际开发中的坑:团队规范里如果混用两种写法,代码调整顺序时很容易出诡异报错。我的习惯是把所有函数表达式统一用 const 声明,并且放在文件靠前的位置,利用“暂时性死区”提前暴露引用错误,反而比 var 那种松散的提升更安全。
1.3 箭头函数不是“简写”这么简单
很多初学者把箭头函数当成普通函数的简写,其实它和普通函数有三个本质差异。第一,箭头函数没有自己的 this,它的 this 继承自外层作用域;第二,箭头函数没有 arguments 对象,要用剩余参数替代;第三,箭头函数不能作为构造函数,也没有 prototype 属性。
const obj = { name: 'tom', greet: function() { setTimeout(function() { // 这里的 this 指向 window/undefined,不是 obj console.log(this.name); }, 100); }, greetArrow: function() { setTimeout(() => { // 箭头函数继承 greetArrow 的 this,也就是 obj console.log(this.name); }, 100); }, };这个例子特别经典。普通函数里嵌套 setTimeout,回调里的 this 会丢;换成箭头函数后,this 从定义位置捕获,问题瞬间解决。这也是为什么现在写定时器、写事件回调、写 Promise 链时,大家默认用箭头函数的原因,不是为了好看,是为了不让 this 乱跑。
我还见过有人刻意用箭头函数去“修复”对象方法里的 this,结果更乱。对象方法本身应该用普通函数写,因为方法被调用时的 this 应该是对象自己;只有方法内部的回调,才适合用箭头函数继承外层 this。这是两个不同的层次,别搞混。
2. 对象创建:不只是 {} 那么简单
2.1 四种创建方式,各有各的适用场景
对象字面量是最常用的方式,适合创建单例对象或配置项。构造函数和 class 适合需要批量创建同类型对象的地方。Object.create 则比较冷门,但它能直接指定新对象的原型,在做原型式继承和对象拷贝的底层实现时很有用。
// 1. 字面量 const user = { name: 'tom', age: 20 }; // 2. 构造函数(普通函数配合 new) function Person(name) { this.name = name; } const person = new Person('jerry'); // 3. Object.create,指定原型 const prototypeObj = { greet() { return 'hi'; } }; const obj = Object.create(prototypeObj); // 4. class 语法糖 class Animal { constructor(name) { this.name = name; } } const dog = new Animal('dog');需要强调一个进阶知识点:new 一个构造函数的时候,到底发生了什么。过程可以拆成四步,第一步创建一个空对象{};第二步把这个空对象的__proto__指向构造函数的 prototype;第三步把构造函数里的 this 绑定到这个空对象上并执行;第四步如果构造函数没有显式返回对象,就把这个空对象作为 new 的结果返回。
function _new(Constructor, ...args) { // 1. 创建空对象并把原型连到构造函数的 prototype const obj = Object.create(Constructor.prototype); // 2. 执行构造函数,绑定 this const result = Constructor.apply(obj, args); // 3. 构造函数如果返回对象则返回它,否则返回 obj return (typeof result === 'object' && result !== null) ? result : obj; }这个手写版本我建议每个前端都敲一遍,它把对象、函数、原型链串在一起,是理解 JS 面向对象的关键入口。
2.2 原型链:对象之间的“继承”其实靠引用
JS 没有真正意义上的“类”,class 只是语法糖,底层还是原型链。每个函数都有一个 prototype 属性,每个对象都有一个__proto__指向构造函数的 prototype。当访问对象上的属性时,如果当前对象没有,就顺着__proto__往上找,一路找到 Object.prototype,再没有就是 undefined。
const arr = [1, 2, 3]; // arr 本身没有 map 方法,但 Array.prototype 上有 // arr.__proto__ === Array.prototype,所以能直接调用 arr.map(x => x * 2); // [2, 4, 6]实际工作中,我几乎不直接操作__proto__,但必须理解原型链的存在。有一次排查一个 bug:页面里到处都能用某个数组的remove方法,但那个方法在代码里找不到定义。后来发现是有人在项目里给 Array.prototype 加了扩展方法,所有数组实例都被“继承”到了。这种做法全局污染太强,我不建议在生产代码里修改内建原型,但理解这个机制,排查这种问题就是秒杀。
2.3 对象的遍历与属性检查,别再只会 for...in
遍历对象属性是新手的重灾区。for...in会连原型链上的可枚举属性一起遍历出来,非常容易造成误判。如果你只想遍历对象自身的属性,用Object.keys配合forEach或for...of更稳。
const person = { name: 'tom', age: 20 }; // 只遍历自身可枚举属性 Object.keys(person).forEach(key => { console.log(key, person[key]); }); // 想要 value 列表 Object.values(person); // ['tom', 20] // 想要键值对数组 Object.entries(person); // [['name', 'tom'], ['age', 20]]判断一个对象是否拥有某个属性,有三种写法,语义完全不同。in操作符会检查原型链,hasOwnProperty只检查自身,obj.prop !== undefined则会把属性存在但值为 undefined 的情况误判为不存在。
const obj = { a: undefined }; 'a' in obj; // true,不管值是什么 obj.hasOwnProperty('a'); // true obj.b !== undefined; // false,但 b 也确实不存在属性顺序也有讲究。整数键按升序排列,字符串键按插入顺序排列,Symbol 键排最后。这个知识点在写序列化、做数据对比时容易坑到你。
3. this 指向问题:函数与对象交汇处最容易翻车的地方
3.1 四条绑定规则,一张表说清楚
this 是函数运行时动态决定的,很多新手以为“this 指向定义函数的对象”,这个理解是错的。this 的值由调用方式决定,一共四类场景。
| 调用场景 | this 指向 |
|---|---|
| 普通函数直接调用 | 非严格模式指向全局对象,严格模式为 undefined |
| 对象方法调用,如 obj.fn() | 指向 obj |
| call / apply / bind 显式指定 | 指向传入的第一个参数 |
| 使用 new 调用 | 指向新创建的对象 |
function show() { console.log(this); } const obj = { show }; show(); // 严格模式下 undefined,否则 window obj.show(); // obj show.call({ name: 'tom' }); // { name: 'tom' } new show(); // show {},新对象优先级也很好记:new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定。遇到问题时,先看有没有 new,再看有没有 call/apply/bind,再看有没有被当作对象方法调用,都不满足就按普通函数处理。
3.2 箭头函数为什么没有自己的 this
箭头函数的 this 不是绑定规则,它是词法作用域下的静态值。简单说,箭头函数定义在哪,它的 this 就跟那个位置的 this 一样,而且这个值在函数创建时就固定了,之后无论怎么调用都不会变。这就产生了两个实际后果:箭头函数不能被 call/apply/bind 改变 this;箭头函数也不能用作构造函数。
实际开发中我踩过这样的坑:类里面的方法用箭头函数定义,结果导致失去通过原型共享方法的能力,每个实例都创造一份新函数。早期前端项目里为了在事件回调里保住 this,很多人无脑把方法写成箭头函数,后来维护成本暴露了。正确做法是方法用普通函数写,需要绑定 this 时在构造器里 bind 一次,或者调用处显式传箭头函数。
3.3 call、apply、bind 的真实使用场景
这三个方法本质都一样:显式指定函数的 this。区别只在参数传递和执行时机上。call 和 apply 会立即执行函数,bind 只返回一个新函数,不立即执行。
function log(prefix, suffix) { console.log(prefix + this.name + suffix); } const user = { name: 'tom' }; log.call(user, '[', ']'); // [tom] log.apply(user, ['[', ']']); // [tom] const bound = log.bind(user, '['); bound(']'); // [tom]工作里的实用场景有几种:借用数组方法处理类数组对象,比如Array.prototype.slice.call(arguments);在事件处理器里提前绑定 this;给函数预设部分参数,实现函数柯里化。尤其 bind 预设参数这个能力,配合回调函数特别好用。
4. 回调、高阶函数与闭包,函数和对象的化学反应
4.1 回调函数到底怎么理解
回调函数,说白了就是“把函数当成参数传给另一个函数,让它在合适的时机被调用”。它既可以是同步的,比如数组的 map、forEach 里的回调,也可以是异步的,比如 setTimeout、Promise 里的回调。
function processData(data, callback) { const result = data.toUpperCase(); callback(result); } processData('hello', function(res) { console.log(res); // HELLO });写回调函数的时候,最容易踩的坑是回调里的 this 丢失。比如把对象方法直接传给某个库的回调,执行时方法内部的 this 已经不再是原来的对象了。解决办法是绑定 this,或者用箭头函数包一层。
4.2 高阶函数:以函数为参数,以函数为返回值
高阶函数就是指操作函数的函数,map、filter、reduce、sort 全是典型代表。理解高阶函数的意义在于,它可以帮你把重复逻辑抽成通用能力。
const users = [ { name: 'tom', age: 20 }, { name: 'jerry', age: 30 }, { name: 'marry', age: 18 }, ]; // 筛选成年人 const adults = users.filter(user => user.age >= 18); // 只要名字 const names = users.map(user => user.name); // 计算年龄总和 const totalAge = users.reduce((sum, user) => sum + user.age, 0);高阶函数还有一个常见的发力点:函数工厂。比如创建一个生成截流函数的工具、创建带日志的包裹函数,都属于函数式编程的思路。我之前写过一个简单的缓存增强函数,利用函数对象挂属性的特性,把计算结果存到函数自身上,同样的入参第二次直接命中缓存。
function withCache(fn) { const cache = {}; return function(...args) { const key = JSON.stringify(args); if (cache[key]) return cache[key]; const result = fn.apply(this, args); cache[key] = result; return result; }; }4.3 闭包不是“内存泄漏”,是“私有变量”
闭包指的是一个函数能访问其外部作用域变量的能力。它的核心是:函数定义时记住了自己所在词法作用域,之后在外面执行,依然能访问那些变量。
function createCounter() { let count = 0; return function() { count += 1; return count; }; } const counter = createCounter(); counter(); // 1 counter(); // 2闭包最常见的应用就是搞“私有状态”。Vue 和 React 内部大量使用闭包保存组件状态和回调上下文。防抖、节流函数也是闭包的经典实践,每次调用都要记住上一次的定时器 id,这 id 就是闭包里保存的变量。
function debounce(fn, delay = 300) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }想理解防抖为什么能生效,关键就在timer变量:它被返回的函数捕获了,不会因为外层函数执行完就消失。这也是很多人面试被问“闭包”时最常举的例子。
5. 实操环节:几个高频需求的完整代码
5.1 判断字符串是否包含指定内容
这是日常写代码频率极高的需求,方法很多,我按优先级列一下。
const str = 'hello world, this is js'; // 最推荐:includes,语义清晰,返回布尔值 str.includes('world'); // true str.includes('none'); // false // indexOf 也可以,但需要手动判断 -1 str.indexOf('world') !== -1; // true // 正则表达式,适合需要模式匹配时 /world/.test(str); // true // 开头结尾判断 str.startsWith('hello'); // true str.endsWith('js'); // true实际开发里有一个容易忽略的细节:includes 对大小写敏感。如果你要做忽略大小写的匹配,直接把两边的字符串都转成小写,或者用正则加i修饰符。
5.2 对象深拷贝,手写一个可靠的版本
浅拷贝只复制对象第一层属性,嵌套对象还是引用。深拷贝则要递归复制所有层级。很多人图省事直接用 JSON 法,但 JSON 法有三个缺陷:函数和 undefined 会被丢弃、Date 会变成字符串、对象循环引用会直接报错。生产环境要求高时,我会手写递归深拷贝,关键代码其实没多少。
function deepClone(target, map = new WeakMap()) { if (target === null || typeof target !== 'object') return target; const date = target instanceof Date; if (date) return new Date(target); if (target instanceof RegExp) return new RegExp(target.source, target.flags); if (map.has(target)) return map.get(target); const result = Array.isArray(target) ? [] : {}; map.set(target, result); Object.keys(target).forEach(key => { result[key] = deepClone(target[key], map); }); return result; }这里用 WeakMap 解决循环引用问题,它比普通 Map 更适合做缓存,因为不会阻止垃圾回收。我实测过,这个方法对普通对象、数组、嵌套结构和循环引用都稳。
5.3 用函数式思路处理数组里的对象
实际项目中,拿到一坨对象数组然后筛筛选选、改改结构,太常见了。记住一个原则:优先用 map、filter、reduce 组合,不要用 for 循环去改原数组。这种代码可读性更好,也更好测试。
const orders = [ { id: 1, category: 'food', amount: 20 }, { id: 2, category: 'drink', amount: 10 }, { id: 3, category: 'food', amount: 35 }, ]; // 筛掉金额为 10 的订单,再把金额格式化 const formatted = orders .filter(order => order.amount > 10) .map(order => ({ ...order, amountText: `¥${order.amount}`, })); // 统计 food 分类总金额 const total = orders .filter(order => order.category === 'food') .reduce((sum, order) => sum + order.amount, 0);一个小技巧:展开运算符...order可以克隆一层对象,配合局部修改字段非常好用。这也是 React 生态里 immutable 更新的惯用姿势。
6. 常见报错与排查技巧实录
6.1 npm 命令无法识别,环境变量出问题了
有些朋友刚装完 Node.js,在终端敲npm -v,结果报“npm 不是内部或外部命令”或者“无法将 npm 项识别为 cmdlet、函数、脚本文件或可运行程序的名称”。这个报错几乎都是环境变量的问题,Node 安装好了但安装目录没加进 PATH。
解决办法第一步,找到 Node 的安装目录,确认里有 npm 和 node 的可执行文件。第二步,打开系统环境变量,把 Node 安装目录手动追加到 PATH 中,然后新开一个终端再试。第三步,如果你用的是非官方安装包,卸载后重装,安装向导里勾选自动添加到 PATH 的选项,一次解决。这个报错和 JS 本身的函数对象没关系,但它是前端开发环境最常遇见的拦路虎,我顺手提一下。
6.2 TypeError: xxx is not a function
这个报错基本是“调用了一个不存在的函数”。常见原因有三个:第一个,解构对象方法时 this 没绑定,导致拿到的其实是 undefined;第二个,某变量预期的函数类型,实际被赋成了别的值;第三个,函数在代码执行时还没有初始化。
const obj = { name: 'tom', greet() { console.log(this.name); }, }; const { greet } = obj; // greet 本身是存在的,但如果下面这样调用就会出问题 // 原因是某些场景里 this 变成 undefined,但不是报 not a function真正稳定触发not a function的场景是解构了一个对象上不存在的方法。排查套路很简单,在报错那行前面打console.log(xxx),看那个变量到底是不是函数。九成问题当场就能看出来。
6.3 undefined 与 null 的混用陷阱
访问对象不存在的属性会得到 undefined,这个大家知道,但实际开发里坑常在别处。比如JSON.stringify遇到值为 undefined 的属性会直接略过它,导致前后端数据对不上。又比如空数组的 reduce 不传初始值会报错,因为初始值 undefined 不能当累加器。
const obj = { a: 1, b: undefined, }; JSON.stringify(obj); // '{"a":1}',b 丢了现代 JS 提供了两个好用的兜底写法:可选链?.和空值合并??。它们组合起来,处理嵌套对象非常舒服。
const user = { profile: { name: 'tom' } }; // 以前要这样写 const name = user.profile && user.profile.name; // 现在直接 const name = user?.profile?.name ?? '默认值';最后我想分享一个小经验:函数和对象学到最后,其实都是在学“数据的流动方式”。函数是流程的抽象,对象是数据的容器,回调和高阶函数是把两者接起来的管道。你在实际项目里遇到的很多所谓“魔法”,拆开看都是对象与函数的组合游戏。深入学习一段时间后,再回去看 Vue、React 的源码,就会发现那些语法糖底下全是这套基础逻辑在撑。所以别急着堆框架,先把这两个核心概念打通,后面写什么代码都会顺很多。