尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript核心语法精要:变量、函数、闭包与调试实战

JavaScript核心语法精要:变量、函数、闭包与调试实战 1. 重新理解JavaScript核心语法它到底帮你解决什么问题1.1 核心语法不是API清单而是执行模型很长一段时间我都在想一个问题为什么看了一堆框架文档真要写业务代码时还是卡壳后来带过不少新人才明白问题往往不在框架而在JavaScript核心语法本身。这份JavaScript核心语法精要指南不只是给你列几个API清单而是把我平时写代码时反复用到的那些语法细节、运行机制和坑位全部摊开来讲清楚。不管你是刚开始学JavaScript基础还是已经用React或Vue写过一阵子但总觉得根基不稳这篇文章都适合你。关于JavaScript基础我最想先纠正一个误解很多人以为背熟内置函数、记住API返回值就算学会核心语法了。但实际开发中真正的瓶颈是理解这套语言在运行时到底按什么规则执行。JavaScript是解释执行的语言不需要编译阶段就能跑这带来一个很大的特点代码的上下文决定了变量的可见范围、函数的调用方式以及this的指向。这些规则不会因为你用哪个框架就改变所以底层语法不扎实的人写React会遇到闭包陷阱写Vue会遇到响应式失效的困惑甚至调一个FullCalendar日历库都可能因为事件绑定方式不对而拿不到正确的回调参数。我之前遇到一个真实案例一位同事用第三方日历库绑定点击事件怎么都取不到点击的那一天。代码看起来没问题事件也绑了回调也写了但数据就是不对。最后排查下来根本原因是他把一个函数声明写在了条件语句里不同分支下函数被重复声明导致事件回调绑定的是上一次的函数实例。这种问题不涉及任何框架API纯粹就是核心语法理解不到位。所以说掌握JavaScript核心语法相当于把最底层的地基打牢上层用工具时才能心中有数。1.2 框架和库再花哨最终都要回归核心语法总有人问我现在前端都用工程化脚手架还有必要手写原生JavaScript吗我的回答一直是必要条件不是要不要去记DOM方法而是你必须能够在没有框架的情况下理解一段代码在做什么。框架或库是一组能轻松生成跨浏览器兼容代码的工具和函数封装可它们不能替你理解作用域、原型链、事件循环这些底层机制。React的Hooks依赖闭包Vue的响应式依赖Object.defineProperty或ProxyjQuery的选择器引擎依赖正则与DOM遍历FullCalendar的插件机制依赖事件发布订阅——这些底层无一不是核心语法的应用。拿一个很常见的场景来说你在页面上写了一段视频旋转逻辑想通过JavaScript把一个元素旋转一定角度。看起来只需要改style属性可一旦涉及多个元素、多个事件、多次执行变量污染、作用域混乱、事件重复绑定这些问题就会接连冒出来。我在实际项目里就见过为了旋转一个视频元素有人用全局变量存状态结果页面里跑了多个播放器状态互相覆盖改了半天都不知道数据去哪了。所以我在带人的时候始终要求先过一遍JavaScript核心语法而且要带着运行时视角去过。什么意思就是每写一段语法脑子里要模拟这段代码执行时变量怎么存储、函数怎么入栈、事件怎么排队。这个习惯了之后你再看任何库的源码基本都能顺着执行流程摸清它的设计意图。2. 变量与数据类型的深度拆解2.1 var、let、const怎么选才不出问题变量声明是JavaScript基础里最容易糊弄过去的部分但恰恰是这里藏着大量线上bug。早期JavaScript只有var它的特点是函数作用域加变量提升。函数作用域意味着在for循环或if块里用var声明的变量循环结束后依然存在变量提升意味着你可以在声明之前使用这个变量只是值为undefined。这两个特性在今天看来都是坑但在ES6标准出台前所有老代码都这么写过来的。我个人的建议非常简单新代码一律用const优先确实需要重新赋值的用letvar只在你维护老代码时才会碰到。为什么优先const因为const声明的是常量绑定它的好处不是不能改而是它强制你在设计阶段想清楚这个变量到底会不会变化。一旦你用const声明了一个变量后面的代码如果无意中给它重新赋值运行时立刻报错这个报错就是一种保护机制能帮你提前发现逻辑混乱。let比var合理的地方在于它是块级作用域for循环里的计数器在循环结束后就不存在了这非常符合直觉。但let也有一个不太容易被新手注意到的细节暂时性死区。在let声明之前访问该变量不会得到undefined而是直接抛出ReferenceError。这个特性在面试里经常被考实际开发中倒不常踩因为正常人都不会在声明前用变量。不过当代码变复杂以后特别是函数参数的默认值引用后面声明的变量时很容易触发暂时性死区。关于好处的选择实际操作原则是这样的模块顶层和组件内部的状态能用const就用const循环变量、累加器、临时交换变量这类需要重新赋值的用let遇到需要向后兼容老浏览器或维护老项目的场景再考虑var。这套规则简单粗暴但能避免绝大多数由变量作用域引发的bug。2.2 判断数据类型的完整方案JavaScript判断数据类型这个关键词常年出现在搜索热榜上说明这是所有学习者绕不开的坎。很多人一开始只会用typeof但typeof有相当多的局限性。第一typeof null返回object这是语言设计之初留下的历史bug官方也没打算修第二typeof对数组、日期、正则都返回object根本区分不开第三typeof对一个未声明的变量不会报错而是返回undefined这一点在严格模式之外的场景里容易掩盖错误。完整的判断方案其实需要多个工具组合。typeof适合判断基础类型string、number、boolean、undefined、symbol、bigint、function。凡是判断结果里有object这个词的基本都要进一步排查。数组用Array.isArray最直接这也是ES5以后的标准做法。日期、正则、Map、Set这些内置对象最可靠的方式是借用Object.prototype.toString方法。这个方法的返回值格式统一为[object 类型]比如[object Array]、[object Date]、[object RegExp]。实现上可以封装一个通用的类型判断函数把Object.prototype.toString.call(x)的结果做一层解析返回具体的类型名称。我自己在实际项目里写过这样一个工具函数function getType(value) { const raw Object.prototype.toString.call(value); return raw.slice(8, -1); } console.log(getType([])); // Array console.log(getType({})); // Object console.log(getType(new Date())); // Date console.log(getType(/abc/)); // RegExp console.log(getType(null)); // Null这个函数基本覆盖了日常开发中90%的类型判断场景。还有两个场景要特别提一下判断对象是否为普通对象也就是没有自定义构造函数创建的对象可以用Object.getPrototypeOf(obj) Object.prototype来判断判断两个值是否严格相等需要区分对象引用相等和值相等的概念引用相等用值相等在对象比较时需要自定义递归方案。别小看类型判断很多运行时错误都是因为某个值和你预期的类型不一致又在调用方法时没有做保护结果报出一堆TypeError。3. 函数水平高低的分水岭3.1 函数声明、函数表达式、箭头函数怎么选JavaScript函数是整个语言里最核心也最深的部分热搜词里javascript函数和javascript学习手册八js函数都在暗示大家对函数理解的渴求。函数在JavaScript里有一等公民的地位这意味着函数可以赋值给变量、作为参数传递、作为返回值返回也可以被动态创建。正因为函数如此灵活声明方式也多到让人选不过来。函数声明是最古老的形式function关键字加函数名。它的特点是存在函数提升也就是在代码执行之前函数就已经被创建了。你可以把它叫做在声明语句之后但实际上整个作用域里都能调用。这个特性很实用所以早期的JavaScript代码经常会把函数声明写在后半段调用写在前半段代码读起来反而更自然。函数表达式则是把函数赋值给变量此时函数没有自己的名字或者名字只在函数内部可见。它不参与提升所以必须在赋值之后才能调用。这个区别在实际开发中很关键如果你把函数表达式用在事件回调里而事件的触发时机在赋值之后那没有问题但如果因为代码顺序问题在赋值之前就触发调用就会得到xxx is not a function的报错。箭头函数是现代开发里使用频率最高的但它和传统函数有本质区别。箭头函数没有自己的this它继承定义时所在作用域的this箭头函数也没有arguments对象需要用剩余参数替代箭头函数不能作为构造函数也就不能new。这些特性让它非常适合做回调函数因为this不会因为调用方式不同而改变。但也因为this的特殊性箭头函数不能用在需要动态this的场景比如给对象定义方法时如果希望方法里的this指向当前对象那最好用普通函数而非箭头函数。我在实际编码中给出的建议是三步判断法第一函数需要被提升以便在文件前部调用函数声明第二函数需要动态this比如对象方法、事件监听里的普通函数使用函数声明或函数表达式第三回调、闭包、高阶函数里的匿名逻辑优先箭头函数。3.2 this指向的四类场景与实战判断this的指向是JavaScript函数里最折磨人的知识点没有之一。我曾经在一场内部技术分享里做过统计让十个人写出一个对象方法嵌套回调里this的指向只有两个人完全答对。这充分说明this的规则不符合直觉但它其实有规律而且规律比想象中简单。this指向主要取决于函数的调用方式而不是函数定义在哪。四个核心场景第一作为普通函数调用this指向全局对象浏览器里是windowNode环境里是global严格模式下是undefined第二作为对象方法调用this指向该对象第三使用call、apply、bind方法调用this指向你手动传入的第一个参数第四使用new调用构造函数this指向新创建出来的实例对象。话虽然是这么说实际操作里最容易出问题的还是回调函数里的this。比如你有一个对象对象里有个方法方法里用了setTimeoutconst player { name: videoPlayer, rotate() { setTimeout(function() { console.log(this.name); }, 1000); } }; player.rotate(); // 输出undefined因为setTimeout回调里的this指向全局很多人在这一步就懵了。解决办法有两个一个是把this保存到变量中常见的变量名有that、self、_this老代码里到处可见另一个是用箭头函数写回调箭头函数没有自己的this它会从定义位置的外层作用域继承所以这里用箭头函数就能正确拿到player对象。我在带人时教他们一个不怎么需要背的办法写代码之前先问自己一句话这个函数将来以什么方式被调用如果答不上来那就用箭头函数保证词法this。如果你明确知道它会被作为对象方法调用用普通函数也没问题。实战里还有一个判断技巧在浏览器的事件监听器里监听器函数内部的this指向绑定事件的元素所以如果你在React的JSX里写onClick{this.handleClick}就必须提前把this绑定好否则React调用这个函数时this已经丢了。React社区早期大量使用bind(this)或箭头函数来修复这个问题本质上都是在规避this的动态性。3.3 闭包与高阶函数写出可复用逻辑闭包这个词被说到滥但真正理解它的人并不算多。闭包的定义不长一个函数能够访问其外部作用域中的变量即使这个函数在外部作用域已经执行结束后被调用。换句话说函数记住了它出生时的环境。这个机制的底层实现是作用域链加词法环境运行时引擎会把函数引用的外部变量打包进一个环境记录只要这个函数还存在外部变量就不会被垃圾回收。闭包的实际价值首先是封装私有状态。在JavaScript里对象没有真正意义上的私有属性但你可以用闭包模拟function createCounter() { let count 0; return { increment() { count; return count; }, decrement() { count--; return count; }, getCount() { return count; } }; } const counter createCounter(); counter.increment(); counter.increment(); console.log(counter.getCount()); // 2外部没有任何办法直接修改count变量只有通过返回的方法访问。这就是闭包最典型的应用。React里的useState、Vue的setup函数本质上都是闭包机制的应用它们把状态藏在函数内部通过返回的函数来更新值。高阶函数指的是接收函数作为参数或者返回函数的函数。JavaScript的数组方法比如map、filter、reduce、forEach全部是高阶函数。使用高阶函数能写出非常紧凑且可复用的逻辑比如数据可能来自各种接口但处理过程是一致的封装成纯函数后很容易测试和复用。我常跟新人说一句话如果你发现自己重复写了三遍同样的数据处理逻辑说明你需要把它抽象成函数而如果你发现这个逻辑还依赖外部状态那就要考虑用高阶函数注入它这就是函数式编程的起步思维。4. 字符串、数组与常用数据处理细节4.1 字符串操作的隐藏细节热搜词里有javascript学习手册九字符串说明字符串处理也是日常开发的高频动作。字符串看似简单但在JavaScript里有几个细节值得特别注意。第一个是字符串的不可变性在JavaScript中字符串一旦创建就不能修改所有看起来会修改字符串的方法比如toUpperCase、slice、replace实际上都返回一个新字符串原字符串不变。这个特性对性能有一定影响在循环里大量拼接字符串时会频繁创建新对象所以开发老手常用数组的join方法或者数组push之后再join来替代字符串拼接避免大量临时对象产生。第二个是字符串比较。JavaScript里用比较字符串没问题但是用、比较字符串时按的是字符编码顺序而不是自然语言顺序。如果你在处理中文排序直接用默认比较会得到非常诡异的结果最好用localeCompare方法它能按照区域语言规则排序。第三个是正则配合字符串。replace方法接受正则表达式作为第一个参数配合分组捕获可以完成复杂替换。我经常在数据清洗场景里用它把列表里的冗余字段剔除掉代码量比逐个拼接小很多。4.2 数组方法与函数式写法数组在JavaScript里是万能的数据容器日常开发中处理列表、表格数据、组件状态都离不开它。最常用的基础方法是push、pop、shift、unshift、splice它们直接修改原数组。从ES5开始数组的forEach、map、filter、reduce、some、every逐渐成为主流它们不修改原数组而是返回新数组或布尔结果。这个区别至关重要React社区的惯例是不可变数据直接修改原数组可能导致组件渲染不更新因为你修改了同一个引用React比较新旧状态时发现是同一个对象就会跳过渲染。我在后端对接场景里写过不少Kettle中的JavaScript代码数据以数组形式进来需要逐条转换后传出。这个场景里最实用的就是map加解构赋值。举个例子接口返回一个用户列表每个用户对象里有id、name、age字段最后一步经手就足够也可以用delete去除敏感字段。但更好的写法是用解构排除字段让原数据不被修改避免污染上游const users [ { id: 1, name: Tom, age: 25, password: xxx }, { id: 2, name: Jerry, age: 30, password: yyy } ]; const cleaned users.map(({ password, ...rest }) rest);箭头函数加解构的组合让代码非常简洁只要团队成员都能看懂这种写法效率提升非常明显。reduce方法则是把数组收敛为单个值比如求和、分组、统计频率在数据报表类项目里使用率极高。我建议每个JavaScript开发者都把map、filter、reduce三个方法练到肌肉记忆因为它们是处理列表数据的核心武器。5. 事件机制与DOM操作的高频实践5.1 事件捕获、目标、冒泡到底怎么走JavaScript事件是前端交互的基础热搜词里javascript 事件单独出现说明这是一个独立且重要的学习单元。浏览器的事件传播分为三个阶段捕获阶段、目标阶段、冒泡阶段。事件从window对象出发沿DOM树向下传播到目标元素这是捕获阶段到达目标元素本身这是目标阶段然后再从目标元素沿DOM树向上传播回window这是冒泡阶段。默认情况下通过addEventListener添加的事件监听器只在冒泡阶段触发除非第三个参数传入true监听器才会在捕获阶段触发。理解这个机制的直接作用是处理事件委托和解决事件触发顺序问题。比如一个列表里有很多按钮每个按钮都想绑定点击事件。如果按传统方式给每个按钮都加监听器在按钮数量多、且经常动态增删的情况下监听器数量会越来越多每次都绑定新监听器还容易造成内存泄漏。事件委托的做法是在共同的父容器上绑定一次监听器利用事件冒泡的特性判断event.target是否匹配目标按钮再执行对应逻辑。这样不管列表里新增多少子元素都不需要重新绑定事件性能优势明显。还有一类高频需求是阻止默认行为和阻止事件传播。比如在一个链接的点击事件里想拦截跳转用event.preventDefault()想不让事件继续冒泡到父元素用event.stopPropagation()。这两个方法名字容易混淆我建议这么记preventDefault针对的是浏览器默认动作stopPropagation针对的是事件传播路径。前者是不让浏览器干它想干的事后者是不让事件继续跑。5.2 事件委托与性能优化事件委托在很多场景里是必选项。动态列表、懒加载列表、表格行操作按钮这些场景里的子元素数量不定、出现时机不定给每个子元素单独绑定事件都是不可维护的写法。事件委托的正确姿势是这样的document.querySelector(#list).addEventListener(click, (event) { const btn event.target.closest(.delete-btn); if (!btn) return; const id btn.dataset.id; handleDelete(id); });这里用closest方法从点击目标向上查找最近的匹配元素比手动判断tagName和className更健壮。dataset属性用来读取data-id这类自定义属性非常方便。我在实际项目里还遇到过一个细节使用input事件监听输入时事件委托照样成立因为input事件也是冒泡事件父容器能收到子输入框的input事件。但如果用React这类框架它的合成事件系统其实已经把事件委托封装好了事件都绑定在根容器上这时你就不需要再手动做一层委托理解底层机制能帮你判断什么时候该自己处理什么时候框架已经搞定。事件相关的另一个常见问题是重复绑定。如果每次渲染都重新addEventListener而不移除旧监听监听器数量会累积导致回调执行多次。解决办法有两种一种是每次绑定前先用removeEventListener解绑同名函数另一种是使用AbortController或事件委托从根上减少重复绑定。EventTarget的addEventListener目前支持传入signal参数配合AbortController既可以解绑任意事件还能在组件卸载时统一清理这是现代浏览器提供的新能力我在新项目里会优先用它。6. 运行时报错与调试实战6.1 常见运行时错误类型识别javascript运行时报错是每个开发者都会隔三差五搜索的词。我梳理一下日常开发中最高频的几类错误先能做到一眼认出再谈修复。SyntaxError是语法错误代码在解析阶段就失败了整个脚本不会执行。这类错误最好排除编辑器一般直接标红。ReferenceError是引用错误通常是你使用了未声明的变量比如拼写错误、变量溢出作用域、或者在暂时性死区里访问let变量。TypeError是类型错误最常见的是对null或undefined调方法比如接接口数据时某个字段在部分情况下是空的你直接调它的内置方法就会炸。RangeError是范围错误数组越界、栈溢出递归没有终止条件都会报这个。上面这几类里TypeError出现频率最高。我在项目里见过太多的定位教训都是同一个套路后端返回的数据结构在部分条件下缺字段前端又没有兜底然后访问data.list.length时抛出TypeError页面白屏。解决这个问题的方法是在边界做保护常见的写法包括可选链操作符?.、空值合并操作符??和防御性判断比如const length response?.data?.list?.length ?? 0;这套写法我在所有新项目里都会推广。可选链让多层属性访问不再需要写一长串if判断空值合并则能安全提供默认值。很多老工程师面对此类信息会有心理障碍我不建议在一次提交里把全部旧代码改成可选链那是另一场坟场的开始但新代码直接使用它们没有任何问题。6.2 try/catch、finally与错误采集错误处理是JavaScript运行时里最容易忽略的部分但它直接决定了你的应用在线上环境里的稳定性。try/catch的用法很简单把可能抛出异常的代码放进try块如果不小心抛了错catch块就能接住程序继续往下走。catch块接收错误对象里面包含message和stack两个字段message是错误文字描述stack是完整的调用堆栈这个堆栈就是排查问题时的路线图。但try/catch不是万能的有三个认知必须建立。第一异步错误无法用同步try/catch捕获。setTimeout回调里出错、Promise里reject、事件监听器里出错都不会被外层try/catch捕获。要捕获异步错误需要给Promise加catch方法或者使用async/await的try/catch。第二try/catch捕获到错误不代表问题消失你还需要决定怎么处理是提示用户、重试、还是静默降级。最好的处理策略是把错误信息上报到日志系统。前端项目里可以封装一个统一错误采集函数把错误对象发送到后端日志接口。第三finally块无论是否发生错误都会执行适合放清理逻辑比如清除计时器、关闭加载状态。我个人比较推荐在业务代码里采用最小化的try/catch策略只在真正可能出错的边界处包裹比如解析JSON、调用第三方SDK、读取本地存储而不是把整个函数体都包进try里。过度使用try/catch会让错误被静默吞掉调试时更难定位问题。错误日志必须包含足够信息错误消息、调用堆栈、发生错误时的用户操作路径和当前路由这样排查起来才不会大海捞针。6.3 调试技巧console与断点调试JavaScript的工具有很多但很多人只会console.log而且是无脑地用。我建议把console当成一个多功能的工具箱而不是只能打印字符串的工具。console.log适合打印普通调试信息console.table可以把数组或对象打印成表格形式非常直观console.time和console.timeEnd可以测量一段代码的执行耗时console.group可以给输出分组把相关日志折叠到一起console.warn和console.error还带有特殊的样式输出让日志在控制台里更显眼。控制台断点调试是更高效的定位手段。在浏览器开发者工具的源码面板里找到对应文件在怀疑有问题的行号上点击打断点然后刷新页面代码执行会停在断点位置。此时你可以通过右侧面板查看所有变量的当前值一步步单步执行观察每一行代码产生的变化。这个方式比console.log的好处在于不需要反复添加和删除日志语句也不会漏掉变量变化的时间点。我还在常用的一个技巧是条件断点。当你需要调试一个在循环里执行了很多次的函数只在某个特定条件下停下来可以在断点上右键编辑条件。比如只关心index大于5的情况就写条件index 5这样调试过程不会被大量无关的循环噪音淹没。另一个实战技巧是使用debugger语句代码运行到debugger关键字时会自动停住方便你把断点写在代码里这在测试环境定位问题时尤其好用。6.4 常见问题速查表我把日常答疑里最常遇到的一批问题整理成一张表方便大家直接对照排查。表现现象常见原因处理建议页面白屏控制台报TypeError对null或undefined调用方法使用可选链?.和空值合并??对接口数据做边界判断回调里拿不到正确的thisthis指向动态调用方使用箭头函数或提前绑定bind事件回调执行多次监听器重复绑定使用事件委托或AbortController统一管理变量值莫名其妙变化全局变量污染或被闭包捕获使用const/let并限制作用域避免隐式全局异步数据返回后界面不更新没有触发重新渲染或setState使用不当排查状态更新逻辑确认异步回调里是否持有最新状态数组排序结果不对默认按字符串编码排序传入比较函数字符串排序用localeCompare代码有时运行有时不运行变量提升和暂时性死区问题声明统一放在作用域顶部这张表不能覆盖所有问题但覆盖了大多数新人在核心语法阶段最常见的困惑。遇到问题先对照现象再按照对应的原因去查通常能省下不少时间。我自己在排查问题时还会多做一步复现最小示例。把怀疑有问题的逻辑从业务代码里抽出来放到一个独立的小文件里跑一遍环境越简单越容易定位问题。很多时候在简化示例的过程中答案自己就浮出来了。7. 写在最后一点个人经验JavaScript核心语法这块内容我前前后后读过不下五本相关书籍也刷过大量社区答疑帖但真正让我对它有把握的还是在一线项目里踩坑后回头看文档的循环过程。如果你现在正处于每次报错都不知道怎么排查的阶段我的建议很简单不要跳过基础去追逐最新框架抽出两到三周的时间把核心语法过一遍每学一个知识点就写一个小示例去验证然后把示例代码保留下来。把这些零散的示例按数据类型、函数、事件、错误处理分类归档之后写业务代码遇到疑问时直接查自己的笔记效率远比每次重新搜索高得多。另外想分享的一个小技巧是遇到不理解的行为多去看看源码即使是别人已经封装好的库翻到它内部实现的那一刻很多语法滞涩感会立刻消除。我在调试一个比较难的bug时往往会顺藤摸瓜看到库的作者怎么处理边界情况看多了自己的代码品味也会逐步提升。JavaScript是一门生命力极强的语言语法体系庞大但核心并不复杂把地基打牢后面无论怎么折腾心里都有底。
返回列表