
好多人在 JS 上写了几年函数和对象天天用但真被问到“为什么这样写”的时候还是会愣住。我自己带过不少前端同事发现大家几乎都是卡在同一个地方能写能跑但说不清原理。函数和对象就是 JS 进阶路上的两道硬门槛跨过去了再看框架源码、再排查线上问题完全是两个世界。这篇内容我不打算从头讲语法那太浪费你的时间了。我按自己实际项目里用出来的经验把容易踩坑的细节、容易被忽视的机制、以及高频场景的写法拆开聊适合已经能写基本 JS、想往中高级走的同学。1. 重新认识函数从会用到用对1.1 函数声明与函数表达式提升机制带来的差别函数这事儿第一步就得分清声明式和表达式。表面看都是function关键字开头实际执行时机完全不一样。// 函数声明 function foo() { console.log(foo); } // 函数表达式 const bar function() { console.log(bar); };区别就在于“提升”。函数声明会整体提升到当前作用域顶部所以你在声明之前调用也没问题。函数表达式不行它只提升了变量的声明赋值过程还是在原位置执行。你如果在赋值之前调用会直接报TypeError: bar is not a function。这个差异在团队协作里特别容易埋雷。我见过有人喜欢在条件判断里写函数声明比如if (isDev) { function log() { console.log(dev); } } else { function log() { console.log(prod); } }这玩意在不同浏览器、不同严格模式下的表现都不一样有的环境里最后一个声明覆盖前一个有的环境里条件根本不起作用。真要在条件分支里定义函数老老实实用函数表达式赋值给变量或者用箭头函数。这是我在 code review 里反复强调的一条函数声明只放在顶层条件分支里一律用表达式。提升机制背后其实是编译阶段做的“预解析”。JS 引擎在执行前会先扫一遍代码把函数声明的引用先登记到环境记录里。理解了这个你就明白为什么 IIFE立即执行函数能做模块隔离了——它本质上是用函数表达式加上立即调用的语法避开了提升带来的全局污染。1.2 箭头函数写法简洁背后的 this 重绑定箭头函数现在很常见但很多人只是因为它“短”才用。其实箭头函数写法最大的价值不是短是它彻底改变的this绑定逻辑。普通函数的this是在调用时才确定的谁调用它this指向谁。箭头函数完全没有自己的this它沿用的是定义时所在作用域的this而且这个绑定在函数创建那一刻就定死了后面改不了。const obj { name: obj, normalFn: function() { console.log(this.name); // 调用时绑定输出 obj }, arrowFn: () { console.log(this.name); // 定义时绑定输出 window/undefined } };obj.arrowFn()里箭头函数的this指向的是定义obj的那个外层作用域而不是obj。很多面试题爱考这个实际项目里也特别容易中招比如在 Vue 的生命周期里用箭头函数定义方法或者在 React 组件的类属性里直接用箭头函数。我这里有一个实用的判断方法你要用this吗要用就别用箭头函数。尤其是在对象方法、原型方法、构造函数这三个场景里老老实实用普通函数。反过来在事件回调、定时器回调、数组的高阶函数回调里箭头函数通常就是更好的选择因为你往往希望this保持为外层上下文而不是被回调调用方绑定走。箭头函数还少了arguments对象它自己没有实参列表但可以用剩余参数语法替代const sum (...args) args.reduce((a, b) a b, 0);另外有个冷门注意点箭头函数不能做构造函数不能new也没有prototype属性。这种限制本身是好事它在语法层面就把一些容易出错的设计挡掉了。1.3 回调函数执行时机与调用者决定了 this回调函数是 JS 里最基础也最容易糊涂的一个概念。别把它想复杂了一句话把一个函数作为参数传给另一个函数在合适的时机被调用这就是回调。难点从来不是“怎么传”而是“回调执行的时候this是谁”。我举个最常见的问题const handler { data: [1, 2, 3], process() { this.data.forEach(function(item) { console.log(this.data); // undefinedthis 丢了 }); } }; handler.process();forEach里那个普通函数的this指向的不是handler。因为forEach内部是普通调用不涉及对象方法调用所以this就丢了。解决方式无非三种一是外层先用const self this存一下二是回调改用箭头函数三是直接给forEach传第二个参数指定this。我实际项目里的倾向很明确能用箭头函数解决就不引入额外变量。但不代表箭头函数事事都行像某些第三方库的 API 会主动往回调里注入特定的this值比如事件对象、DOM 元素这时候箭头函数反而会把那个绑定又抢走。所以用之前先翻一下对应库的文档确认回调的this是由调用方控制还是由你控制。回调这块还有个进阶理解回调不是“异步”的同义词。[1,2,3].map(x x * 2)里也是回调但它同步执行完了。真正让回调变成异步难题的是配合事件循环时的执行顺序问题。setTimeout(fn, 0)里的fn是回调它会在当前同步代码执行完之后才走。你在调试异步代码的时候先想清楚“这批代码先执行回调后执行”这个顺序很多玄学问题就浮出水面了。2. 对象真正理解“万物皆对象”2.1 对象的创建方式与适用场景对象创建大多数人随手就是字面量{}。这是最合理的选择但面试或者排查问题的时候你还得知道其它几种方式的存在意义。字面量、new Object()、Object.create()、构造函数、class我分别说说它们各自解决什么问题。// 字面量 const a { x: 1 }; // new Object() const b new Object({ x: 1 }); // Object.create() const c Object.create(null); const d Object.create(somePrototype); // 构造函数 function Person(name) { this.name name; } const e new Person(yun); // class class Animal { constructor(name) { this.name name; } } const f new Animal(cat);new Object()和字面量在实际使用里几乎没有差别选字面量就够。Object.create()才是真正的分水岭它允许你显式指定新对象的原型这是实现继承和原型链操作的关键工具。Object.create(null)可以创建一个完全没有原型的“干净对象”没有toString、没有hasOwnProperty特别适合用来做字典、缓存这类场景避免 key 跟原型方法冲突。我在项目里见过一个经典 bug拿普通对象当字典存一个 key 叫toString结果读取的时候返回的是函数一堆判断直接炸。普通{}继承了Object.prototypetoString这些名字都被占用了用Object.create(null)就彻底没这个问题。这是很多人不知道的实用技巧。构造函数和class聊到后面再说但有一点你现在就可以记住普通数据对象用字面量特殊数据结构用Object.create(null)涉及继承和实例化的再用构造函数或者class。顺手提一句Object.freeze在配置类对象上我常用它冻结只读数据防止某个同事在别的模块里无意改掉全局配置。2.2 原型链属性查找的完整路径对象跟函数一样也有一套底层机制那就是原型链。这个搞不透后面看class、看继承、看 Vue/React 源码都会磕磕绊绊。原型链总结成一句话访问obj.prop时先从obj自身属性里找找不到就去obj.__proto__指向的原型对象上找还找不到就继续往上层直到null为止。这条链接起来就是原型链。三个容易混淆的属性名prototype、__proto__、constructor。prototype是函数才有的属性指向该函数作为构造函数时赋值给实例原型的对象。__proto__是实例上的属性指向实例的原型也就是构造它的那个函数的prototype。constructor是原型上的属性指向构造函数本身。function Person() {} const p new Person(); // p.__proto__ Person.prototype // Person.prototype.constructor Person实测中想读取某对象的原型我更推荐Object.getPrototypeOf(obj)而不是直接访问__proto__因为后者在各种环境里的表现不完全一致语法层面它其实是历史遗留。原型链有一个安全大坑不要给Object.prototype添加可枚举属性。一旦加了for...in遍历任意普通对象时都会被扫出来各种hasOwnProperty判断失效线上问题查半天。早年间有过“原型污染”漏洞就是攻击者通过特殊构造的 key 改掉Object.prototype让整个应用崩溃或者被篡改。现在主流框架都会针对这一点做防护但你自己写的代码也要有这个意识——给原型加方法可以加枚举属性绝对不行能不加就不加。2.3 深拷贝与浅拷贝五种方案的取舍对象操作里被问得最多、也最容易写错的就是拷贝。先说结论绝大多数业务场景下你要的是浅拷贝还是深拷贝取决于“改了一个对象的属性另一个会不会跟着变”。浅拷贝只拷贝第一层嵌套的对象仍然共享引用。JS 里写浅拷贝太简单了const clone { ...original }; // 或者 const clone Object.assign({}, original);这两个方法都不错但都是浅的。第一层是基本类型就彻底复制第一层是对象的话复制的是引用地址改新对象里嵌套的那个对象原对象也会被改。深拷贝要真正递归复制每一层的对象和数组。很多人的第一反应是JSON.parse(JSON.stringify(obj))这个方法确实好用但坑不少undefined、函数、Symbol值会被直接丢掉。Date对象会变成字符串。RegExp对象会变成空对象{}。循环引用直接报错TypeError: Converting circular structure to JSON。NaN和Infinity会变成null。我实际验证过一个后端接口返回的复杂配置对象里带了个Date类型的过期时间用 JSON 深拷贝之后变成了字符串导致前端判断过期时间的逻辑全错。这种问题在测试环境不一定触发等上了生产、数据一复杂就成现场事故了。现在浏览器提供了原生方案structuredClone。它支持循环引用支持 Date、RegExp、Map、Set、ArrayBuffer 等更多的类型绝大多数场景直接用它就行。老环境的话可以用递归来写一个精简的深拷贝但要注意判断数组和对象还要处理循环引用可以用一个WeakMap记录已经拷贝过的对象function deepClone(obj, cache new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (cache.has(obj)) return cache.get(obj); const clone Array.isArray(obj) ? [] : {}; cache.set(obj, clone); for (const key of Object.keys(obj)) { clone[key] deepClone(obj[key], cache); } return clone; }实际项目里如果数据结构不复杂也没到非要原生支持特殊类型的地步structuredClone够用再不行就上 Lodash 的cloneDeep它是经过大规模验证的覆盖的类型处理和循环引用的健壮性都比自己手写靠谱。别为了追求“手写深拷贝”这种炫技在核心业务里埋风险。你自己写可以但线上代码我建议用成熟库。3. 函数与对象的协同作战3.1 高阶函数函数作为参数的威力高阶函数这个概念听着高大上实际就是我们前面说的把函数当作值来传来传去。JS 的函数是一等公民这意味着函数可以赋值给变量、可以放进数组、可以当参数传、可以被另一个函数返回。当参数传的高阶函数太常见了数组三件套map、filter、reduce就是最好的教练。const users [ { name: 小明, age: 20, active: true }, { name: 小红, age: 17, active: false }, { name: 小刚, age: 30, active: true }, ]; // 提取姓名 const names users.map(user user.name); // 筛选成年且活跃的用户 const adults users.filter(user user.age 18 user.active); // 汇总年龄 const totalAge users.reduce((sum, user) sum user.age, 0);说实话我见过太多同事处理数组还是一层层for循环嵌套。循环当然没问题但map/filter/reduce的语义更明确一眼就能看出来“提取、筛选、聚合”的意图代码的可读性和可维护性高很多。当返回值的高阶函数也值得掌握。比如函数柯里化把一个多参数函数拆成多个单参数函数逐次传入参数最后再执行const withLog (tag) (fn) (...args) { console.log([${tag}], args); return fn(...args); }; const add (a, b) a b; const loggedAdd withLog(add)(add); loggedAdd(2, 3); // 输出 [add] [2, 3]然后返回 5这个模式在做日志增强、鉴权包装、节流包装的时候特别好用可以不下钻函数内部逻辑就扩展它的行为。柯里化的本质是“闭包记住了外层参数”这也是为什么我总说理解闭包是能不能写出高阶函数的关键。3.2 闭包捕捉状态的能力与代价很多人背过闭包定义“函数能够记住并访问它定义时的作用域即使这个函数在其他地方执行。”但背定义没有用你得知道闭包到底解决了什么问题又带来了什么代价。闭包最常见价值是“私有状态”。比如计数器function createCounter() { let count 0; return function () { count 1; return count; }; } const counter createCounter(); counter(); // 1 counter(); // 2count不挂在全局上外部改不到只能通过返回的函数操作。这个思路用在封装缓存、封装请求队列、封装防抖节流上都特别有用。防抖节流就是闭包加定时器的经典组合每次调用都记住上一次的定时器 ID 和参数。不过闭包也是内存问题的常客。闭包会让被捕获的作用域对象长期存在如果里面引用了一个超大对象即使外部已经不再使用它只要闭包函数还活着这块内存就释放不掉。我在排查一次页面卡顿问题时发现一个被setInterval拿着引用的闭包里面捕获了一个不断变大的日志数组导致页面越跑越慢内存蹭蹭涨。后来换成在闭包内部手动清空数组才彻底解决。所以规范就是闭包不是不用而是要用得省。闭包里只捕获需要的数据别图方便把一大片上下文全放进去。用完的定时器、监听器记得清理。3.3 面向对象编程的演进与选择JS 的面向对象从最早的构造函数 原型方法到后来Object.create再到 ES6 的class演进脉络其实非常清晰——都是在用一种更接近传统面向对象语言的方式把原型链操作封装得更顺手。ES6 的class本质还是函数和原型链别被它的语法骗了。class Animal { constructor(name) { this.name name; } speak() { console.log(${this.name} makes a sound); } } class Dog extends Animal { constructor(name) { super(name); } speak() { console.log(${this.name} barks); } }class的方案已经非常接近 Java/TypeScript 的习惯代码结构清晰继承用extends、调用父类用super。但我遇到过一个隐藏坑类方法里的this同样遵循“调用时绑定”的规则如果把方法单独拆出来用this会丢。class Counter { constructor() { this.count 0; } add() { this.count 1; } } const counter new Counter(); const addFn counter.add; addFn(); // 报错this 是 undefined解决办法有几种在构造函数里this.add this.add.bind(this)或者先调用时用箭头函数包装或者在定义阶段就把方法写成箭头函数属性的形式类属性需要额外配置。React 早期版本里处理事件方法时要手动 bind就是这个原因。那我到底选class还是工厂函数我的经验是如果只是创建一组结构相似的数据对象没有任何方法逻辑就用工厂函数如果有类型判断、继承层次、多个实例共享同一套方法就用class。项目里大量使用接口数据模型时工厂函数更轻写领域模型的时候class更合适。4. 结合真实场景的高频操作4.1 字符串包含判断从 indexOf 到 includes字符串处理是大家在项目里天天碰的最简单也最实用的就是“判断字符串是否包含某个子串”。很多人还在用indexOfconst url https://example.com/path?id123; if (url.indexOf(id) -1) { ... }这种写法能跑但可读性不好 -1容易被新同学理解成魔法数字。ES6 提供了更语义化的includesif (url.includes(id)) { ... } if (url.startsWith(https://)) { ... } if (url.endsWith(.html)) { ... }includes和startsWith、endsWith都支持第二个参数指定起始搜索位置你要是需要在特定偏移之后判断就非常方便。有个实际问题大小写。URL 参数、文件名、用户输入这些来源的字符串大小写不一定可控。稳妥做法是先归一化再判断const lower source.toLowerCase(); if (lower.includes(admin)) { ... }正则test有时候更灵活尤其是需要考虑“单词边界”而不是任意子串的时候const hasId /(\?|)id/.test(url);如果你在做类似搜索筛选的功能频繁调用包含判断可以考虑把字符串条件整理成一个数组再every或some结合起来代码逻辑比一串串if清晰得多。4.2 对象数组提取与转换一行代码解决大部分需求接口返回的通常是一个对象数组但前端要的数据形态往往跟接口不一样可能只需要其中几个字段可能要分组可能要拼接。这类需求用map/filter/reduce组合处理效率极高。比如后端返回这么一坨const list [ { id: 1, name: 苹果, category: fruit, price: 5 }, { id: 2, name: 白菜, category: veg, price: 3 }, { id: 3, name: 香蕉, category: fruit, price: 4 }, ];只想取 id 和 name 字段const options list.map(({ id, name }) ({ id, name }));想按 category 分组还能顺便分组后求每组总价const grouped list.reduce((acc, item) { (acc[item.category] ?? []).push(item); return acc; }, {});数组去重也是高频场景如果根据对象里的某个字段去重const seen new Set(); const unique list.filter((item) { if (seen.has(item.category)) return false; seen.add(item.category); return true; });用Set做去重比indexOf或者双层循环性能好而且语义清楚。我建议把这类“提取、筛选、去重、分组”的代码写成纯函数放到一个utils模块里集中管理测试也方便。实际项目里逻辑可能存在多个页面复用纯函数加单元测试是最稳的组合。4.3 三级联动数据模型与联动逻辑的完整设计三级联动是很多后台管理系统里的标配需求省份、城市、区县三个下拉框一级变化触发二级刷新二级变化触发三级刷新。网上搜js三级联动能找到一堆老代码但很多都是 DOM 操作硬拼的实现思路倒是一脉相承理解清楚了这个换成 Vue/React 都不难。核心在于数据模型。我推荐用扁平的数组加父子关联而不是嵌套结构const regions [ { code: 11, name: 北京, parent: null }, { code: 1101, name: 市辖区, parent: 11 }, { code: 110101, name: 东城区, parent: 1101 }, ];扁平数据的好处是通过parent快速过滤子集结构不臃肿后续如果后端直接给扁平列表前端不需要做任何转换。联动逻辑可以抽象成一个函数function getChildren(parentCode, data) { return data.filter(item item.parent parentCode); }然后三个下拉框各自维护自己的选中值change事件触发时清空下级选中值和数据重新从数据源里取子级列表。要注意的是初始化回显的场景不能机械地只刷当前级得根据已有选中值反推上一级是否还需要重新加载。我在实际项目里踩过一个坑联动刷新太快用户连续点击两个下辖市第二个请求还没回来旧的请求先返回了导致列表内容错乱。解决思路就是在更新数据源之前对比一下当前选中的code和请求发起时的code是否一致不一致就不更新或者用AbortController把旧的请求直接取消掉。前端框架里做三级联动其实更简单因为视图更新由框架接管了你只需要维护状态和派发事件比如 Vue 里用computed根据province派生出cityList用watch监听变化来重置下级。数据模型和联动逻辑没变变的只是渲染层。5. 常见问题与排查技巧实录5.1 高频报错的诊断思路JS 开发里最常见的报错之一就是 “Cannot read property ‘xxx’ of undefined” 或者 “未将对象引用设置到对象的实例”。前者是浏览器/Node 的报错习惯后者是 .NET 生态的类似表达。不管哪种本质都一样你在一个undefined或者null值上访问了属性或调用了方法。我处理这种报错的套路是三步走先看报错在哪一行确定访问的是哪个变量。向上溯源这个变量从哪来的看它在哪个环节变成了undefined。在源头加防御如果是接口数据就用可选链?.或者给默认值兜底。const city response.data?.city?.name ?? 未知;可选链和空值合并运算符是我现在写代码的标配它可以大幅减少“层层判空”这种丑陋代码。但要记得??只处理null和undefined不会拦截空字符串和 0别把判断条件写拧了。还有一个特别常见的环境问题很多人第一次配 Node 环境时都会遇到终端直接提示“无法将‘npm’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”。这通常不是代码问题而是 Node.js 没装、装完没重开终端、或者 PATH 环境变量没配置好。检查方法很简单先确认node -v能不能输出能输出但npm不行就去检查环境变量里有没有npm所在路径都不能输出就重新安装 Node.js安装完记得重启终端。自己排查过的坑里还有一个和this相关的回调里的this变成了undefined。这种多半是普通函数回调在非严格模式下指向全局对象、在严格模式下指向undefined而开发者期望它指向外层的某个对象。排查思路就是回到函数定义方式用箭头函数或者bind修正。5.2 调试与类型判断的实用技巧现在的调试工具很强但很多人还是只会console.log打天下。倒也没问题只是有几个小技巧能让效率提升不少。console.log搭配对象展开符避免只看一个实时变化的“引用快照”console.log({ ...someObj });如果只想看某个函数的实现能不能快速确认它内部逻辑可以用Function.prototype.toString直接把函数源码打出来。很多人搜js逆向会用到这个技巧其实它就是一个非常正常的调试手段看第三方库、看压缩混淆过后的代码结构都很有效。尤其当你想确认一个从接口动态拼接出来的函数到底是不是你期待的内容直接console.log(fn.toString())一看便知。类型判断就别再靠typeof全包了typeof []都是object根本分不出数组和对象。要区分精确类型用Object.prototype.toString.call([]); // [object Array] Object.prototype.toString.call(new Date()); // [object Date] Object.prototype.toString.call(null); // [object Null]这个方法在我处理后端各种奇怪的字段类型时非常可靠比instanceof稳定因为instanceof跨 iframe、跨执行上下文会失效。再推荐一个容易被忽略的调试器能力在Sources面板里打断点然后看右侧的Scope面板可以直接看到当前作用域链上的所有变量比console.log打变量名快得多。有时候变量在某个闭包里被引用你又不知道它当前值是多少打断点看Scope是最直观的。5.3 性能与内存的注意事项最后聊点性能和内存这块不是让你一上来就各种“优化”而是提醒你别写出明显不合理的代码。第一循环里不要创建函数。我以前在给一个列表组件加排序处理时写了items.map(item { return someFn(item) })本来没问题但someFn如果要在每次循环里通过闭包捕获一个比较大的配置对象那每次迭代都会创建一个新的闭包列表一大内存分配和 GC 压力就上来了。更好的做法是把公共数据提取到循环外面让函数复用同一个闭包上下文。第二频繁操作对象属性时尽量避免过长链式访问。a.b.c.d.e每一次点操作都有类型检查成本虽然现代引擎优化得很好但在热路径上依然避讳。如果有循环逻辑先把a.b.c提取成局部变量const target a.b.c.d; for (let i 0; i target.length; i) { ... }第三对已经不用的对象引用该置空就置空。尤其是单页应用里组件销毁后还被全局数组、事件引用着就会出现内存泄漏。常见场景是addEventListener添加后没有对应移除或者定时器没有清。写组件的时候养成习惯destroy/unmount生命周期里把监听器和定时器全部清一遍。我之前排查过一个页面切来切去几次就变卡的问题最后就是之前一个图表组件销毁时没移除窗口的 resize 监听器每个实例都留着一份回调。别看这些问题都不大线上和长时间运行的场景它们会慢慢积累成卡顿和崩溃。写代码的时候多想一句“这个函数、这个监听器需要活多久”就能避掉绝大多数坑。我自己这些年折腾下来最大的感觉是函数和对象这两块没有一次性能“完全掌握”的都是在项目里碰了问题、回去看原理、再碰新问题、再看一遍循环迭代。你现在把刚才这些机制和场景走一遍再回去看手头代码肯定能发现不少以前只是“碰巧能跑”的写法。进阶这件事慢一点没关系足够扎实才走得远。