ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript基础核心知识点与实战避坑指南:从类型判断到异步编程

JavaScript基础核心知识点与实战避坑指南:从类型判断到异步编程 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和constIIFE用得少了但在一些老代码和某些工具库封装里还是会见到。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()); // 1c1和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指定thisnew绑定构造函数里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/await5.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.allconst [users, posts] await Promise.all([ fetch(/users), fetch(/posts) ]);错误处理方面try/catch配合await是基本操作。但还有个技巧await一个被rejected的Promise异常会被抛出如果没有try/catch包裹会变成unhandledrejection可能静默失败。所以规范做法是每个await路径都要有兜底或者在函数内部统一catch后转成正常返回值。5.3 并发控制与Promise.allSettledPromise.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本地存储常用的三种特性localStoragesessionStoragecookie大小约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现在很多开发者一上来就学框架忽略了原生能力。但无论什么框架底层都依赖这些APIObject.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这条路很长保持好奇多敲多练才是唯一的捷径。
返回列表