
学JS学到第三个阶段最大的感受是基础语法只是门槛真正决定你能不能独立写业务逻辑的是函数、原型链、事件循环这一层的东西。这篇学习笔记三整理的是我这周从字符串与数组的高频操作包含判断、忽略大小写、排序、遍历中断、函数进阶、原型链、事件循环再到串起来做的小案例的全过程。如果你也处在“看得懂语法、写不出逻辑”的阶段这篇应该能给你一些参考少走点我踩过的弯路。1. 字符串与数组先把最容易踩坑的两个基础夯实字符串和数组是JS里用得最频繁的两类数据结构但正因为太常用反而有很多细节被一带而过。这周我集中把几个高频操作翻出来重新捋了一遍才发现之前的代码里藏着不少隐患。1.1 判断字符串包含includes、indexOf与正则谁更合适判断一个字符串里有没有另一个子串日常见到的大概有四种写法includes、indexOf、search、正则的test。我之前的习惯是拿起indexOf就用后来才发现它们之间差异还挺大。const str Hello JavaScript World; // includes返回布尔值语义最直白 str.includes(JavaScript); // true str.includes(javascript); // false大小写敏感 // indexOf返回下标找不到返回 -1 str.indexOf(Script); // 7 str.indexOf(script); // -1 // search接受正则返回下标 str.search(/Script/); // 7 str.search(/script/i); // 7 // RegExp.test正则匹配返回布尔值 /JavaScript/.test(str); // true这里有个容易翻车的点includes和indexOf不能直接传正则传进去会被当成普通字符串处理。比如str.includes(/Script/)实际查询的是字符串/Script/结果永远是false。如果匹配规则稍微复杂一点比如忽略大小写、匹配多个关键词直接用正则的test或者search更合理。还要留意indexOf的判空逻辑。新手最容易写出if (str.indexOf(abc))这种判断当子串恰好出现在下标0的位置时indexOf返回0被转成false判断就反了。正确写法是if (str.indexOf(abc) ! -1)。这个坑看着小线上代码里真不少见我们项目里就出过一次搜索关键词排在第一位时死活不进业务逻辑。1.2 忽略大小写统一大小写和正则/i两种思路各有适应场景热搜词里那个“js忽略大小写”我猜是指判断字符串时不想区分大小写。做法有两种一种是把两边都转成同样的大小写再比较另一种是正则加i标志。// 方式一统一转小写 const a Hello; const b hello; a.toLowerCase() b.toLowerCase(); // true // 方式二正则忽略大小写 /^hello$/i.test(Hello); // true实际项目中哪种方式更好取决于场景。如果是两个变量之间的比较比如用户名去重、关键词匹配统一转小写最直观不会引入正则的额外心智负担。如果是表单校验比如验证邮箱、手机号格式用带i的正则更灵活因为可以顺带把格式一起验证了。有一个性能上的经验如果在一个大循环里反复做忽略大小写的比较尽量提前把要比较的字段转换好而不是每次循环里调用toLowerCase。比如一万条数据做查重循环里临时转换会产生大量中间字符串GC压力会增加不少。我自己写过一段导出筛选功能明明就是个大小写匹配的问题刚开始没注意一次性处理两万行数据时页面直接卡顿改成预处理后才顺畅。1.3 数组排序sort的字典序陷阱和几种手写排序数组排序估计每个人都写过但sort()的默认行为很容易让人措手不及。JS的sort默认把元素转成字符串按UTF-16码元顺序排不是按数值大小排。const nums [3, 30, 10, 2]; nums.sort(); // 结果 [10, 2, 3, 30]因为是按字符串排的 nums.sort((a, b) a - b); // 结果 [2, 3, 10, 30]比较函数返回负值、零、正值分别代表a排在b前、不变、a排在b后。这里我习惯写(a, b) a - b而不是(a b ? 1 : -1)因为前者更简洁也不容易漏掉相等时返回0的情况。ES2019之后引擎的sort是稳定排序相同元素的相对位置不会变去重或二次排序时可以放心用。除了sort我还把几种基础排序手写了一遍不是为了项目里用是为了理解排序的底层逻辑。// 冒泡排序 function bubbleSort(arr) { const result [...arr]; for (let i 0; i result.length - 1; i) { for (let j 0; j result.length - 1 - i; j) { if (result[j] result[j 1]) { [result[j], result[j 1]] [result[j 1], result[j]]; } } } return result; } // 快速排序简化版 function quickSort(arr) { if (arr.length 1) return arr; const pivot arr[0]; const left arr.slice(1).filter(item item pivot); const right arr.slice(1).filter(item item pivot); return [...quickSort(left), pivot, ...quickSort(right)]; }手写排序最大的价值是理解复杂度。比如冒泡是O(n²)数据量一大就秒挂快排平均O(n log n)但简化版每次filter会新建数组空间占用偏高。实际开发中绝大多数场景直接用sort就好不需要自己实现。有一类情况例外排序时依赖某个异步结果或者排序规则需要从接口动态获取那时候就得自己写排序逻辑或者先拉数据再排序。顺手说一个洗牌算法。热搜里有“js扑克牌”其实就是在问随机打乱。很多人会写成arr.sort(() Math.random() - 0.5)这种做法虽然能用但分布不均匀V8引擎早就有人验证过。要均匀洗牌用Fisher-Yatesfunction shuffle(arr) { const result [...arr]; for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [result[i], result[j]] [result[j], result[i]]; } return result; }1.4 forEach打断return不行那就some、every或者干脆换循环“js foreach 打断”这个热搜算是初学者集体困惑榜前列了。forEach里写break会直接报错Illegal break statement写return只是跳过当前这一次迭代并没有真正中断循环。很多人试过之后产生一个误解forEach不能中断。准确说是JavaScript原生forEach没有提供中断机制因为设计上它就是for循环的简化版故意不支持break。想中断遍历有几个常见方案// 方案一用for...of这是最直接的替代 for (const item of list) { if (item.id targetId) break; } // 方案二用some回调返回true就停止 list.some(item { if (item.id targetId) return true; }); // 方案三用every回调返回false就停止 list.every(item { if (item.id targetId) return false; return true; }); // 方案四用find找到直接拿值 const found list.find(item item.id targetId);我自己的习惯是只需要遍历副作用就优先考虑for...of需要拿第一个符合条件的元素就用find/findIndex。some/every虽然能中断但语义读起来有点绕——some的本意是“是否存在一个”拿它当中断工具会让同事困惑。还有用try/catch抛异常中断的写法理论可行但会让代码看起来很怪除非特殊场景否则不推荐。2. 函数进阶回调、this与闭包难啃但绕不过去函数是JS的一等公民理解到不到位直接决定你能不能看懂别人的代码。这一周我重点补了this指向、闭包和定时器回调发现很多困惑其实是这几个概念叠加在一起造成的。2.1 函数声明、箭头函数和this指向别等调错才回头看this的指向问题可以说是JS新手的第一个劝退点。我之前一直靠“谁调用就指向谁”来记后来发现不够用尤其碰到箭头函数。const obj { name: demo, log() { console.log(this.name); // 普通函数this指向obj }, arrowLog: () { console.log(this.name); // 箭头函数this指向外层这里可能是undefined或globalThis } };普通函数的this在调用时才确定箭头函数则是在定义时捕获外层作用域的this之后无论怎么调用都不会变。这个特性在实际项目中很有用比如在setInterval回调里想访问外层对象的属性class Timer { constructor() { this.count 0; // 用箭头函数this永久指向Timer实例 setInterval(() { this.count; }, 1000); } }如果这里用普通函数this会变成全局对象或undefined严格模式下count就更新不了了。以前老项目里常见解决方案是var self this;然后把self存起来用箭头函数普及后这个方案基本退役了。另外别搞混call/apply/bind。三者都能手动指定thiscall和apply是立即调用区别只在传参方式bind则是返回一个绑定了this的新函数不立即执行。方法需要作为回调传给别处时bind最常见。function greet(greeting) { console.log(greeting this.name); } const user { name: Tom }; greet.call(user, Hello); // Hello Tom greet.apply(user, [Hi]); // Hi Tom const bound greet.bind(user, Hey); bound(); // Hey Tom2.2 闭包就是函数记住了外部变量防抖、节流和循环陷阱闭包这个概念被包装得有点神秘说白了就是函数定义时所在的词法作用域被保存下来了外部变量不会随函数执行结束而消失。最典型的使用场景是防抖和节流。function debounce(fn, delay 300) { let timer null; return function(...args) { if (timer) clearTimeout(timer); timer setTimeout(() { fn.apply(this, args); }, delay); }; } // 用法 const onSearch debounce(keyword { console.log(请求搜索, keyword); }, 500);每次调用onSearch时内部的timer不会重新初始化因为闭包已经把上一次的timer记住了这就是闭包在实战里最常见的形式。节流也类似只是逻辑从“重置延时”变成“判断是否已经触发过”。闭包也有代价就是内存占用。一个被闭包引用的变量即使外部函数执行完了只要闭包还在变量就不会被GC回收。我之前写过一个页面动态创建了大量DOM并绑定了各自的闭包回调结果每个回调都把整个父级作用域挂在身上页面切了几十次之后内存蹭蹭涨。后来改成事件委托用统一回调配合data属性区分目标内存问题就消失了。做长列表或者频繁销毁重建的场景一定要留意闭包引用链。还有一个经典坑结合循环和定时器最容易被考到for (var i 0; i 3; i) { setTimeout(() { console.log(i); }, 100); } // 输出 3 3 3而不是 0 1 2原因很简单var没有块级作用域三个闭包共享同一个i定时器触发时循环已经结束i变成3。把var改成let就能得到0 1 2因为每次循环都会创建一个独立的i。这是闭包和事件循环交叉的考题原理清楚了以后类似问题基本不会再错。2.3 setTimeout调用函数时的参数传递和引用丢失setTimeout不只是用来延时的很多人写代码时忽略了它调用回调的机制。直接传函数名有一个微妙的坑如果函数内部引用了this调用时this已经不是原来的对象了。另一个坑是传参方式。function greet(name) { console.log(Hello, name); } // 不要用字符串方式相当于eval不规范 setTimeout(greet(Tom), 100); // 推荐写法第三个参数起作为回调参数传入 setTimeout(greet, 100, Tom); // 但有时候只想传参数、不想改变this可以包一层箭头函数 setTimeout(() greet(Tom), 100);我倾向于用箭头函数包一层虽然多多少少破坏了装饰器风格但意图更明确调试的时候肉眼就能看出要执行什么。如果定时器回调里要用到外层的this比如更新组件状态箭头函数能安全捕获this用普通函数加参数的方式this会丢还得额外bind。3. 原型和原型链对象继承的根基原型链是JS里最容易被囫囵吞掉的部分。说真的如果只背“__proto__指向prototype”这句话遇到实际问题照样懵。这周我花了大半天重新画了一遍原型链的图才算真正理解。3.1 prototype、__proto__与constructor三者关系要理清先厘清概念。每个JS函数都有一个prototype属性它指向一个对象这个对象会被作为“模板”在new的时候关联到新实例上。每个对象都有一个__proto__属性内部是[[Prototype]]指向它构造函数的prototype。constructor是prototype里的一个属性指回构造函数本身。function Dog(name) { this.name name; } Dog.prototype.bark function() { console.log(this.name 叫了); }; const d1 new Dog(旺财); d1.__proto__ Dog.prototype; // true Dog.prototype.constructor Dog; // true画了个简化的链条d1 的 __proto__ → Dog.prototype Dog.prototype 的 __proto__ → Object.prototype Object.prototype 的 __proto__ → null原型链就是从这个链条的末端开始查找属性访问d1.bark时先看d1自己有没有没有就顺着__proto__往上在Dog.prototype里找到。再到Object.prototype里找再往上就是null找不到就返回undefined。这就是“继承”的本质子对象自己没有的属性沿着链条找父对象要。注意箭头函数没有自己的prototype所以不能当构造函数。class语法本质上也是基于原型链实现的只是披了一层好用的语法糖。学习时不要被class迷惑class只是让你少写prototype样板代码底下的原型链机制一点都没变。3.2 继承的几种方式和使用场景ES6之前写继承最正统的是寄生组合式继承但现在有了class和extends个人觉得完全没必要自己手搓。class Animal { constructor(name) { this.name name; } eat() { console.log(this.name 在吃东西); } } class Cat extends Animal { constructor(name, color) { super(name); this.color color; } meow() { console.log(this.name 在喵喵叫); } } const cat new Cat(花卷, orange); cat.eat(); // 花卷 在吃东西 cat.meow(); // 花卷 在喵喵叫extends做的事其实就是建立Cat.prototype到Animal.prototype的原型链让Cat的实例能沿着链找到Animal的方法。用class写继承代码可读性远高于手动操作prototype出bug的概率也低得多。理解原型链还有一个实际价值instanceof的原理。instanceof判断的是“右侧构造函数的prototype是否出现在左侧对象的原型链上”而不是字面上“对象是谁创建的”。cat instanceof Cat; // true cat instanceof Animal; // true cat instanceof Object; // true这就是为什么所有对象instanceof Object都是true。有些面试题喜欢在这个位置绕搞懂原型链自然就通了。另外也提醒一句不要随便修改内置对象的prototype比如Array.prototype上挂自定义方法这在团队项目里影响面很大命名一旦和其他库冲突排查成本极高。4. 事件循环与setTimeout执行顺序里藏着半个JS引擎JS是单线程语言但很多初学者疑惑既然是单线程为什么页面还能同时处理点击、滚动、网络请求答案就是事件循环。这周我把事件循环和一些定时器细节认真捋了一遍有几个之前完全没注意到的问题。4.1 setTimeout的返回值范围那个“有0存在么”的问题热搜里那个“js settimeout 返回值的范围 有0存在么”我当时看到也愣了一下因为平时压根没注意过setTimeout返回什么。返回的是一个定时器ID传给clearTimeout用来取消。关键是这个ID可以是0吗从规范角度说HTML标准没有强制ID从1开始只是说返回一个非负整数long类型。现代主流浏览器Chrome、Firefox、Safari的实现都是从1开始递增所以正常情况下你拿到的ID都是正整数不会见到0。但在一些旧版本的浏览器内核、嵌入式的WebView环境里或者某些自动化和脚本工具中第一个定时器ID确实可能是0。这里真正的坑是如果你习惯用if (timerId)来判断一个定时器是否存在而环境里timerId恰好为00是假值判断就失效了。靠timerId清掉定时器的代码可能不会执行。更稳的写法是显式判断if (timerId ! undefined timerId ! null)。在生产代码里尤其是要兼容多种浏览器内核的场景别把timerId当布尔值用。另外setTimeout的最小延迟时间也不是0。HTML规范里嵌套层数超过5层后最小延迟会被强制设为4毫秒。所以setTimeout(fn, 0)也不是真的零延迟至少要排在当前执行栈之后具体多少取决于嵌套层级。如果项目里有对这个延迟敏感的逻辑要么改设计要么就别纠结那几毫秒。4.2 宏任务与微任务执行顺序的完整流程事件循环里最核心的规则是执行完一个宏任务后把微任务队列清空再取下一个宏任务。宏任务包括setTimeout、setInterval、I/O操作微任务包括Promise.then、MutationObserver、queueMicrotask。console.log(start); setTimeout(() { console.log(timeout); }); Promise.resolve().then(() { console.log(promise); }); console.log(end); // 输出顺序start → end → promise → timeout为什么promise在timeout之前因为同步代码执行完先查看微任务队列Promise回调在这里而setTimeout属于宏任务本轮循环还没轮到它。只有微任务队列清空后事件循环才会从宏任务队列里取出下一个任务执行。这个规则在真实场景里影响很大。比如在一个循环里连续创建Promise和setTimeout或者fetch请求返回后再做一堆操作理解微任务先执行的规律才不会对输出结果感到莫名其妙。React或Vue里很多源码层面的状态更新调度也用到了微任务的机制搞懂这一块读框架源码也轻松点。还有一个很多人问的setInterval和setTimeout谁先执行严格说没有固定答案取决于谁先被加入队列还要考虑延迟时间和微任务的影响。与其背结论不如建立“先执行栈、再微任务、再宏任务”的模型再复杂的情况也能推理出来。5. 综合实战把知识串起来的小案例光记概念没有用得动手。这周我拿三个小需求练手把前面整理的知识点串起来发现比闷头看书有效得多。5.1 URL有效性校验构造函数和正则选哪种热搜里有“js验证url有效性”这个场景很常见比如用户填写个人主页、上传图片地址时前端就要做校验否则后端传回来的错误提示又慢又难看。// 方式一用URL构造函数最推荐 function isValidUrl(str) { try { new URL(str); return true; } catch { return false; } } // 方式二正则校验只适合简单场景 const urlPattern /^https?:\/\/[\w\-](\.[\w\-])[/#?]?.*$/; function isValidUrlByRegex(str) { return urlPattern.test(str); }URL构造函数的优势是它能识别各种合法URL包括带端口、带路径、带query、带哈希的甚至相对路径的规则也能处理。正则想覆盖完整的合法URL非常困难一个不小心就会把合法链接拦下来或者放过了不该放过的。之前我们线上遇到“网页不能访问”的问题查到最后是正则写得太严格把带参数的链接误判成非法链接直接导致跳转被拦住了。从那以后我们组内定了个规矩校验URL一律用URL构造函数不再写正则。不过URL构造函数兼容性最早支持到很古老的环境基本可以放心用。还有个小细节new URL(ftp://xxx)也是合法的如果你只允许http和https需要再判断一下protocol。5.2 三级联动数据结构和事件绑定的组织三级联动省/市/区是前端很经典的练习。我第一次写的时候被DOM操作绕晕了后来发现核心不是DOM而是数据结构设计。const areaData { 广东省: { 广州市: [天河区, 越秀区, 海珠区], 深圳市: [南山区, 福田区, 罗湖区] }, 浙江省: { 杭州市: [西湖区, 余杭区, 滨江区], 宁波市: [海曙区, 鄞州区] } };页面结构是三个select省、市、区。选省时根据省的key去areaData里取对应的城市列表重新渲染市的options选市时再取区列表。这里最关键的是change事件的处理以及每次更新时先清空旧的options不然会出现重复选项或残留选项。我看很多新手会写三个change事件处理函数每个函数里一堆DOM拼接。我的做法是写一个通用的renderSelect函数function renderSelect(selectEl, options) { selectEl.innerHTML ; options.forEach(text { const option document.createElement(option); option.textContent text; option.value text; selectEl.appendChild(option); }); }这样三个select的更新逻辑统一了代码量少了一大半。事件绑定上用事件委托也可以但三级联动结构简单直接绑定也无妨。这个小案例很好地体现了“先组织数据再做DOM操作”的顺序数据不对后面全是白忙。5.3 动态创建表格的单元格合并从行数据模型开始另一个练手是动态创建表格并合并单元格。热搜原话是“js动态创建的表格合并怎么弄成一个”我理解为动态生成表格时相邻相同内容的单元格要合并成一个用rowSpan或colSpan属性来实现。直接操作DOM去合并很痛苦我踩了一次坑后总结的经验是先构造成数据再渲染DOM最后根据数据特征设置rowSpan。比如从接口拿回来的数据有多行重复分类先遍历统计每个分类出现的次数再在渲染时给起始行设置rowSpan属性后续相同分类的行直接跳过。// 假设data是接口返回的原始数据其中category有重复 const data [ { category: A, value: 1 }, { category: A, value: 2 }, { category: B, value: 3 }, ]; // 统计每个category的出现次数 const categoryCount {}; data.forEach(item { categoryCount[item.category] (categoryCount[item.category] || 0) 1; }); // 渲染时根据统计结果决定是否设置rowspan const table document.createElement(table); const tbody document.createElement(tbody); const seen {}; data.forEach(item { const tr document.createElement(tr); if (!seen[item.category]) { const td document.createElement(td); td.textContent item.category; td.rowSpan categoryCount[item.category]; tr.appendChild(td); seen[item.category] true; } const tdValue document.createElement(td); tdValue.textContent item.value; tr.appendChild(tdValue); tbody.appendChild(tr); }); table.appendChild(tbody); document.body.appendChild(table);核心思路是用对象统计合并的跨度用seen记录哪些分类已经渲染过后续同类行就跳过分类单元格只渲染数值单元格。如果数据没有规律合并逻辑会更复杂但思路不变——尽量在数据层做预处理别在渲染层反复改DOM结构。这里也顺便用上了forEach的遍历中断技巧categoryCount统计时没有中断需求但seen标记的方式本质上就是一种遍历控制。6. 阶段性体会和踩坑清单写完这些最大的感受是JS的问题往往不是孤立的一点语法而是作用域、this、异步、原型链这四件事交叉影响的结果。单个概念看着都懂组合在一起就懵。比如闭包陷阱里的循环问题同时涉及作用域和事件循环prototype和extends涉及原型链和对象模型。学的时候一定要把彼此之间的关系串起来而不是一个一个孤立地背。我把自己这个阶段踩过的坑列成一张清单也方便你自查场景错误写法正确做法判断子串位置if(str.indexOf(a))if(str.indexOf(a) ! -1)数字排序arr.sort()arr.sort((a,b)a-b)forEach中断forEach里写break用for...of循环或some()定时器回调传参setTimeout(fn(1), 100)setTimeout(fn, 100, 1)推荐箭头函数定时器ID判空if(timerId)if(timerId ! undefined timerId ! null)URL校验手写复杂正则用new URL()加try/catch闭包内存动态创建大量DOM绑闭包事件委托统一回调下一步我准备结合Vue框架看看这些JS基础在组件化体系里是怎么被组织起来的到时候再更新后续的学习笔记。也在考虑整理一份自己用着顺手的前端脚手架配置把开发时常用的构建、调试、目录结构沉淀成模板会一并记录下来。