好多人在 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); // undefined,this 丢了 }); } }; 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.prototype,toString这些名字都被占用了;用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
字符串处理是大家在项目里天天碰的,最简单也最实用的就是“判断字符串是否包含某个子串”。很多人还在用indexOf:
const url = 'https://example.com/path?id=123'; if (url.indexOf('id=') > -1) { ... }这种写法能跑,但可读性不好,> -1容易被新同学理解成魔法数字。ES6 提供了更语义化的includes:
if (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 监听器,每个实例都留着一份回调。
别看这些问题都不大,线上和长时间运行的场景,它们会慢慢积累成卡顿和崩溃。写代码的时候多想一句“这个函数、这个监听器需要活多久”,就能避掉绝大多数坑。
我自己这些年折腾下来,最大的感觉是函数和对象这两块,没有一次性能“完全掌握”的,都是在项目里碰了问题、回去看原理、再碰新问题、再看一遍,循环迭代。你现在把刚才这些机制和场景走一遍,再回去看手头代码,肯定能发现不少以前只是“碰巧能跑”的写法。进阶这件事,慢一点没关系,足够扎实才走得远。