JS基础这一块,我估计是每个前端人都绕不过去的坎。哪怕你后面用了再多的框架,Vue、React、Angular,绕来绕去,最后啃的其实还是原生JavaScript这颗硬骨头。我在带团队的时候发现一个规律:基础语法扎实的人,看框架源码就像看小说,遇到bug也能快速定位;基础不牢的,光一个this指向问题就能折腾一上午。这篇笔记是我自己整理的核心知识点,加上这些年踩过的坑,希望能帮新手捋顺思路,也能帮进阶的朋友查漏补缺。
1. 数据类型与类型判断:从底层逻辑到面试高频坑
1.1 基础数据类型详解
JavaScript里数据类型分两大类:原始类型(Primitive)和引用类型(Reference)。原始类型有七种:string、number、boolean、null、undefined、symbol、bigint。引用类型主要就是object,包括array、function、date、regexp、map、set这些。
这里有个非常容易踩坑的点:typeof null返回的是"object",这是个历史遗留bug,从ES1时代就存在,官方也没打算修。所以判断null不能依赖typeof,要用Object.prototype.toString.call()或者直接=== null。
再说说number类型。它不是纯粹的整数或浮点数,是双精度64位二进制浮点格式。最经典的坑就是0.1 + 0.2 !== 0.3,这个不用多讲了。实际开发中遇到金额计算,我一般建议用整数分来算,或者引入decimal.js这类库。NaN也很特殊,typeof NaN返回number,但NaN === NaN是false。判断NaN我用Number.isNaN()而不是全局的isNaN(),因为后者会把字符串也转换了再判断,容易误判。
1.2 typeof与instanceof的适用边界
typeof适合判断原始类型,但区分不了具体对象类型。instanceof适合判断引用类型,但有个问题:它能沿着原型链往上找。所以如果你有跨iframe的场景,instanceof可能失效。
我给个经验结论:
- 判断字符串、数字、布尔、undefined、symbol、bigint,用
typeof - 判断null,用
value === null - 判断数组,用
Array.isArray(value) - 判断纯对象,用
Object.prototype.toString.call(value) === '[object Object]' - 判断日期,用
value instanceof Date配合!isNaN(value.getTime())
1.3 动态类型与隐式转换的避坑指南
JS是动态类型语言,变量可以随意赋不同类型的值。这带来灵活性的同时,也带来一堆隐式转换的坑。
核心规则:+运算符,如果任一侧是字符串,就做字符串拼接;其他运算符尽量会把两边转成数字。==会做类型转换,===不做。我强烈建议项目里全局禁用==,ESLint规则eqeqeq打开,省去一堆破事。
还有一个常见场景:if判断里,0、空字符串、null、undefined、NaN、false都会被转成false。所以if (someString)这种写法要小心,空字符串也会走false分支。严谨的判断应该是if (typeof someString === 'string' && someString.length > 0)。
2. 函数与作用域:理解闭包和执行上下文的本质
2.1 函数声明、函数表达式与立即执行函数
函数有三种常见的定义方式,它们之间有微妙差异。
函数声明会提升(hoisting),函数表达式不会。提升的意思是,在代码执行前,解析阶段就把函数名和函数体绑到了当前作用域顶部。所以可以这样:
// 函数声明:可以提前调用 sayHello(); // 正常运行 function sayHello() { console.log('hello'); } // 函数表达式:会报错,sayHi是undefined sayHi(); // TypeError: sayHi is not a function var sayHi = function() { console.log('hi'); };因为var声明的变量会提升声明但不会提升赋值,所以调用时是undefined,自然报错。
立即执行函数(IIFE)的写法我常用两种:(function(){ ... })()和(function(){ ... }())。作用就一个:创建一个独立的作用域,不污染外部。现在有了块级作用域let和const,IIFE用得少了,但在一些老代码和某些工具库封装里还是会见到。
2.2 闭包的形成机制与应用场景
闭包是JS中一个绕不开的概念。简单说:如果一个函数能访问其外部函数作用域中的变量,那么这个函数和它引用变量的组合就是闭包。
形成闭包有几个必要条件:函数嵌套函数、内部函数引用外部变量、内部函数被保留下引用。比如:
function counter() { let count = 0; return function() { count++; return count; }; } const c1 = counter(); const c2 = counter(); console.log(c1()); // 1 console.log(c1()); // 2 console.log(c2()); // 1,c1和c2各自独立的count这里最有意思的细节是:每调用一次counter(),会创建一份全新的作用域,count不再共享。这也是为什么闭包常被用来做数据私有化。
闭包有个性能隐患,就是变量无法被垃圾回收,因为引用链一直存在。所以如果一个闭包长期挂在全局变量上,它捕获的对象会一直存活。我一般会建议,用完的闭包引用,置为null,帮GC一把。
2.3 作用域链与let/const/var的本质差异
作用域链的查找规则是:当前作用域找不到,就向上一级找,直到全局。全局再找不到,非严格模式就隐式创建全局变量(这会污染window),严格模式直接报错。
var是函数作用域,let和const是块级作用域。块级作用域包括if、for、while这些{}包裹的区域。
if (true) { var a = 1; let b = 2; } console.log(a); // 1 console.log(b); // ReferenceError: b is not definedvar还有个坑:在for循环里用var声明的循环变量会泄漏到循环外面,而且所有异步回调共享同一个变量。用let就没这个问题,因为每次迭代都会创建新的绑定。这是闭包经典面试题的本质解法。
2.4 this指向规则与箭头函数的例外
this的指向在普通函数里,由调用方式决定,有五种规则:
- 默认绑定:独立调用,非严格模式指向全局对象,严格模式是undefined
- 隐式绑定:作为对象方法调用时,指向这个对象
- 显式绑定:
call、apply、bind指定this - new绑定:构造函数里,this指向新创建的对象
- 绑定优先级:new > 显式 > 隐式 > 默认
箭头函数没有自己的this,它继承外层作用域的this。这个特性让它特别适合用在回调函数里,不用再写var self = this那套了。
const obj = { name: 'demo', init() { setTimeout(() => { console.log(this.name); // demo,箭头函数继承init的this }, 100); } };但如果箭头函数在全局作用域定义,this就是全局对象(严格模式下也是全局对象)。这个要注意区分。
3. 运算符与流程控制:表达式计算的优先级陷阱
3.1 算术、比较、逻辑运算符的实际运用
算术运算符里,+最复杂,涉及字符串拼接和数字相加的判断。++和--有前后缀差异,前缀先自增再返回值,后缀先返回值再自增。这个在写循环或者计数器的时候容易出边界问题。
比较运算符有个经典场景:比较对象时,会先调用对象的valueOf()或toString()方法,再比较。所以两个对象不管内容是否一样,用==或===比较都是false,因为比较的是内存引用地址。
逻辑运算符返回的不一定是布尔值,它返回的是操作数的值。&&和||是短路运算:
const result = a || defaultValue; // 如果a是假值,返回defaultValue const value = obj && obj.name; // 如果obj是空,返回undefined,避免报错??空值合并运算符是ES2020新增的,它只在左侧是null或undefined时返回右侧值,不会像||那样把0、空字符串也替换掉。这个区别很实用。
3.2 保留两位小数与运算符优先级
处理浮点数保留两位小数,我之前常被问。toFixed(2)是最简单的,但有个坑:它返回的是字符串,且四舍五入行为和预期可能不一致(底层是浮点数精度问题)。如果只是展示用还好,做计算还是得小心。
更稳妥的做法:
function roundToTwo(num) { return Math.round((num + Number.EPSILON) * 100) / 100; }Number.EPSILON是用来修正浮点数误差的,加上它再四舍五入,结果更符合直觉。
运算符优先级方面,最容易出问题的是:&&优先级高于||,赋值运算符=优先级极低。还有三元运算符?:的嵌套可读性很差,我一般建议嵌套超过两层就改写为if分支。
表达式里的分号自动插入(ASI)机制也值得提一句。虽然JS引擎会自动补分号,但某些场景它的策略和你想的不一样。比如:
function demo() { return { ok: true }; } // 实际返回undefined,因为return后面自动补了分号这种“return换行导致bug”的问题,我踩过好机会,后来习惯在return后把{放在同一行。
3.3 解构赋值与展开运算符的进阶用法
解构赋值真的是提升代码可读性的利器。数组解构支持默认值、跳过元素、剩余元素:
const [first, , third, ...rest] = [1, 2, 3, 4, 5]; console.log(first); // 1 console.log(third); // 3 console.log(rest); // [4, 5]对象解构支持重命名和默认值:
const { name: username = 'default', age = 18 } = person;展开运算符可以用来做浅拷贝:const copy = [...arr]、const objCopy = {...obj}。但记住,只是浅拷贝,嵌套对象还是共享引用。
有个小技巧:合并对象时,Object.assign({}, a, b)和{...a, ...b}在功能上等价,但后者更简洁。如果属性值是undefined,前者会忽略,后者也会忽略(在对象字面量展开时)。
4. 事件机制:从DOM事件流到委托与自定义事件
4.1 DOM事件流的三个阶段
事件在浏览器里从window出发,经过目标元素再返回window,形成三个阶段:捕获阶段、目标阶段、冒泡阶段。
addEventListener的第三个参数,如果传true或{ capture: true },就是在捕获阶段触发;默认是false,在冒泡阶段触发。
我实际工作中,大部分场景都在冒泡阶段处理事件,因为它更符合一般的交互直觉。但focus、blur等事件是不冒泡的,如果需要在捕获阶段处理,就要用addEventListener配合true。
4.2 事件委托的性能与正确姿势
事件委托核心原理是利用事件冒泡:把事件监听器挂到父元素上,通过event.target判断实际触发元素。这有两个好处:性能好(监听器少)、动态元素无需重复绑定。
document.querySelector('#list').addEventListener('click', (event) => { const target = event.target.closest('li'); if (!target) return; console.log(target.dataset.id); });这里用closest('li')而不是target.tagName === 'LI',是为了处理用户点到li里面的span这类子元素的情况。closest会向上查找匹配选择器,更健壮。
4.3 Event对象常用属性与自定义事件
event对象里我经常用的属性:target(实际事件源)、currentTarget(当前绑定监听器的元素)、eventPhase(当前阶段)、preventDefault()(阻止默认行为)、stopPropagation()(阻止冒泡)、stopImmediatePropagation()(阻止冒泡且阻止同元素上其他监听器执行)。
自定义事件开发中很有用,尤其做组件通信:
const event = new CustomEvent('refresh', { detail: { id: 1 } }); element.dispatchEvent(event);配合detail字段传数据,比全局事件总线干净得多。
4.4 浏览器事件循环:setTimeout与Promise的执行顺序
事件循环是理解异步代码执行顺序的钥匙。宏任务(macrotask)和微任务(microtask)是两个队列。每次执行完一个宏任务,都会清空微任务队列,再取下一个宏任务。
setTimeout(() => console.log('timeout'), 0); Promise.resolve().then(() => console.log('promise')); console.log('sync'); // 输出顺序:sync -> promise -> timeout微任务包括Promise.then、MutationObserver、queueMicrotask。渲染和事件处理都发生在宏任务之间。所以如果你有数据变化的通知,放在微任务里,能保证在下次渲染前执行。
注意事项:嵌套的setTimeout层层加深度,可能触发浏览器的嵌套定时器最小间隔限制(5ms),但Promise的微任务没有这个限制,所以某些循环用微任务效率更高。
5. 异步编程:从回调地狱到Promise和async/await
5.1 Promise的三种状态与链式调用
Promise对象有三种状态:pending(进行中)、fulfilled(已完成)、rejected(已失败)。状态只能从pending转变到其他两个,一旦转变就不可逆。
链式调用的核心规矩:then或catch都会返回新的Promise,所以可以无限链下去。then回调里如果抛异常,会被下一个catch捕获;如果返回普通值,会被下一个then接收。
一个容易被忽略的点:如果then回调里返回一个Promise,下一个then会等这个Promise resolve后再执行,相当于展平了一层异步嵌套。
5.2 async/await错误处理的关键技巧
async函数其实就是生成器加Promise的语法糖。await会暂停函数执行,等待Promise settled后再继续。它让异步代码看起来像同步代码,但要注意并发问题。
我见过很多项目在请求数据时,多个相互独立的请求用了串联的await,白白浪费了大量等待时间。正确做法是用Promise.all:
const [users, posts] = await Promise.all([ fetch('/users'), fetch('/posts') ]);错误处理方面,try/catch配合await是基本操作。但还有个技巧:await一个被rejected的Promise,异常会被抛出,如果没有try/catch包裹,会变成unhandledrejection,可能静默失败。所以规范做法是每个await路径都要有兜底,或者在函数内部统一catch后转成正常返回值。
5.3 并发控制与Promise.allSettled
Promise.all有个缺陷:只要有一个rejected,整个Promise直接变成rejected,其他结果都拿不到。如果某个请求失败,你就全丢了。这时候用Promise.allSettled更适合:
const results = await Promise.allSettled([p1, p2, p3]); // results里每项是 {status: 'fulfilled', value} 或 {status: 'rejected', reason}还有Promise.race,只取最快的那个结果,常用于超时控制:
const withTimeout = Promise.race([ fetch('/data'), new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 5000)) ]);注意timer要clearTimeout清理,不然定时器还会触发,只是reject被忽略了而已。
6. 运行时报错排查与console调试技巧
6.1 常见运行时错误分类
前端错误大致分几类:
- 语法错误:代码无法解析,整个脚本都不会执行
- 引用错误(ReferenceError):变量未定义或不在作用域
- 类型错误(TypeError):访问了undefined的属性、调用了不是函数的东西
- 范围错误(RangeError):数组长度非法、栈溢出
- 自定义错误类,可以用
throw new Error('xxx')主动抛出
有一个高频错误:Cannot read properties of undefined (reading 'xxx')。排查思路是找到哪个对象是undefined,往上回溯它的赋值来源。我会在怀疑点用console.log或debugger打断点,逐步确认。
6.2 console全家桶的实用姿势
console.log就不说了。其他几个很有用:
console.log('%c...', 'color:#f00;font-size:20px'):带颜色输出,排查分组信息很方便console.table(arr):把数组或对象以表格形式打印,看结构化数据一目了然console.time/console.timeEnd:测代码块执行时间console.trace():打印调用栈,比log更强大,能查到函数是哪里被调用的console.assert(false, 'msg'):条件为false时打印消息,适合在循环里做断言
6.3 浏览器开发者工具Sources断点调试
用断点调试比console.log效率高很多。在Sources面板里点击行号即可打断点。然后重点关注几个面板:
- Scope面板:查看当前作用域变量、闭包变量
- Call Stack面板:查看完整调用栈,有助于定位调用源头
- Watch面板:添加你关注的表达式,实时变化一目了然
条件断点很实用,比如想要在count大于5时才断住,右键断点编辑条件count > 5即可,省去大量手动跳过干扰步骤。
7. 常用开发工具与浏览器API扩展
7.1 严格模式与ESLint的必要性
文件顶部写'use strict';或者通过ESM模块天然启用严格模式后,JS会避免一些静默错误:未声明变量直接赋值会报错、this在全局不再指向window、delete不可删除属性会报错。
配合ESLint,建议规则集:equeeq禁用==、no-var禁用var、no-unused-vars禁未使用变量、prefer-const优先const。这些规则能拦截很多低级错误。
7.2 JSON序列化与深拷贝的实现
处理JSON字符串时有个经典坑:JSON.stringify遇到undefined、函数、symbol值,会直接跳过这些属性。NaN和Infinity会变成null。所以不能把JSON当普通对象的通用序列化方式。
深拷贝除了引库或JSON.parse(JSON.stringify(obj))外(有局限,不支持Date、RegExp、循环引用),可以用structuredClone(支持循环引用和更多类型)。它是浏览器原生API,能在window和worker之间传递数据,对性能要求高的场景可以开transfer列表。
7.3 浏览器存储方案对比:cookies、localStorage与IndexedDB
本地存储常用的三种:
| 特性 | localStorage | sessionStorage | cookie |
|---|---|---|---|
| 大小 | 约5MB | 约5MB | 约4KB |
| 生命周期 | 永久,除非清除 | 标签页关闭即清除 | 根据expires/Max-Age |
| 请求自动携带 | 否 | 否 | 是(同域) |
| 数据格式 | 字符串 | 字符串 | 字符串 |
cookie会自动附加到请求头,所以尽量只存会话标识,不要存业务数据。localStorage是同步的,写入大量数据时可能阻塞主线程。IndexedDB是异步的,适合存储结构化大对象。
读取localStorage有个注意点:键不存在时返回null,读取的结果要用JSON.parse并包一层try/catch,因为一旦数据损坏或被篡改,解构会直接抛异常,应用直接崩溃。
8. 浏览器兼容性实践:从差异化封装到构建降级
8.1 兼容性判断基础
判断一个API能不能用,标准做法是查Can I Use,或直接看MDN的浏览器兼容性表。但日常开发时,工具链的处理可能更高效:
- Babel把ES6+语法转成ES5,但要明白:语法能转,API问题它管不了。
Promise、Array.from这些需要polyfill - 打包工具会把代码转换成目标浏览器能跑的样子,但targets配置对了才有效
8.2 特性检测代替浏览器检测
一个正确的思路是“特性检测”而非“浏览器版本检测”。比如判断是否支持IntersectionObserver做懒加载:
if ('IntersectionObserver' in window) { // 新浏览器 } else { // 老浏览器 }不推荐用navigator.userAgent判断,因为字符串可以被篡改,而且新版本浏览器特征一直在变。用特性检测写出来的代码天然就更面向未来。
9. Canvas与基础算法:从图形绘制到性能调优
9.1 Canvas基础绘制流程
Canvas的起点很简单:获取2D上下文,然后开始绘制。
const canvas = document.getElementById('myCanvas'); const ctx = canvas.getContext('2d'); ctx.fillStyle = '#3498db'; ctx.fillRect(50, 50, 100, 100);但有个细节坑:canvas的宽高属性和CSS宽高不一样。如果只想用CSS控制显示大小,必须同时设置canvas.width和height与CSS一致,否则会出现画布内容模糊或被拉伸问题。
绘制路径:beginPath、moveTo、lineTo、closePath、stroke或fill。beginPath之后别忘了每次重绘时清屏,最常见的动画污染问题是没调用clearRect,导致上一帧的图像残留。
用requestAnimationFrame做动画,它比setInterval更平滑,且能跟随显示刷新率。同时注意:requestAnimationFrame在每个宏任务里只会执行一次,如果在里面又注册一帧,还能继续下一帧循环。
9.2 高频操作的数据结构与算法基础
写交互组件时,数据结构的选择直接影响性能。常见场景:
- 大量列表筛选:用
filter遍历没问题,但如果超过万级,用Set或Map做索引更快 - 防抖与节流:防抖是等用户停止输入再执行;节流是固定时间间隔执行一次。这两种都是性能优化里非常基础的模式
function debounce(fn, wait) { let timer = null; return function(...args) { clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), wait); }; }- 深比较:如果对象层级复杂,没必要手写,用
JSON.stringify比较粗略可以,但属性顺序不同会导致误判。更严谨的是递归比较每个属性。
9.3 跨平台开发中的JavaScript运行环境差异
JavaScript不只跑在浏览器里。Node.js环境里没有window、document,而是有process、Buffer。有些桌面开发框架中,渲染进程和主进程之间通信也和浏览器不同。
还有一个“运行环境差异”的典型场景:Object的遍历顺序。整数键按升序遍历,字符串键按插入顺序遍历,symbol键最后。这套规则在Node和浏览器里一致,但跨引擎如果实现差异,就容易出bug。
遇到跨环境代码,建议每个环境都做一遍冒烟测试,尤其注意typeof window === 'undefined'的判断,用来区分浏览器和Node。
10. 框架与工具链视角:如何反哺基础语法
10.1 框架外的JavaScript,依然要掌握的核心API
现在很多开发者一上来就学框架,忽略了原生能力。但无论什么框架,底层都依赖这些API:
Object.keys/Object.values/Object.entries:遍历对象的三种方式Array.prototype.map/filter/reduce/forEach:函数式操作数据String.prototype.includes/startsWith/padStart:字符串处理URLSearchParams:读写URL参数FormData:处理表单提交
这些API是框架的基石,也是面试里逃不过的重点。刷一遍文档,再结合小项目用一用,基本上就能融会贯通。
10.2 组件化开发中如何利用基础语法优化状态管理
状态管理无论用什么库,本质还是JavaScript对象和数组的变更检测。有些框架依赖数据劫持(Vue的reactive),有些靠不可变数据(React的setState)。
如果你用React,理解不可变更新很重要:不能直接修改state对象里的属性,要返回新对象。这里用展开运算符就比深拷贝高效:
setState(prev => ({ ...prev, field: 'newValue' }));如果你用Vue,要小心响应式丢失:用索引直接修改数组项、添加新的对象属性,可能不会被侦测到。正确姿势是用Vue.set或$set,或者干脆整体替换。
10.3 框架时代为什么还要夯实基础
我经常和新同事说:框架能帮你解决80%的重复劳动,但剩下的20%——性能优化、复杂交互、内核级调试——必需原生JavaScript的功底。
很多框架的源码其实都是原生语法的高级运用:比如Vue的Object.defineProperty和Proxy的配合、React的链表结构调度。你如果连Object.defineProperty的特性都不清楚,读源码就像在看天书。
另外,无论是npm install的依赖升级,还是底层API的兼容降级,都有可能需要手动处理原生暴露出来的差异。基础语法越扎实,处理这些边界情况时就越从容。
写在最后的一些体会
回头再看这份笔记,我删掉了很多教科书式的赘述,尽量把每个知识点都和我实际写代码时踩过的坑挂钩。尤其是数据类型判断、事件委托、异步并发、Canvas宽度设置、Object.defineProperty响应式这些,几乎每个月都会在项目里遇到相关的问题。基础语法从来不是背出来的,是在写代码、出bug、修bug的过程中慢慢内化的。如果这份笔记能帮你少走几个弯路,或者在工作里理清某个模糊点,我就觉得值了。JS这条路很长,保持好奇,多敲多练,才是唯一的捷径。