
写 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); // 0length 是形参个数 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()指向 objcall / 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 的源码就会发现那些语法糖底下全是这套基础逻辑在撑。所以别急着堆框架先把这两个核心概念打通后面写什么代码都会顺很多。