带过不少新人之后,我越来越确定一件事:大部分人写 JavaScript 属于"能用",但离"可靠"还有一段距离。能把fetch调通、能把页面交互跑起来,这不难;难的是当代码量涨到几千行、出了诡异报错、或者要跟原生端做交互时,很多人的基础短板就彻底暴露了。这篇东西不是官方文档的复读,而是我这些年调代码、审代码、教人写代码积攒下来的一份"JavaScript 基础"私房笔记。它的核心价值在于:把类型判断、函数作用域、事件循环、报错排查这些最容易被轻视的知识点,用实际场景掰开揉碎讲清楚。适合刚入行半年到两年、想从"会调 API"进阶到"能写稳代码"的前端开发者,也适合后端同学想快速搞懂 JS 的运行为什么这么"古怪"。
1. JavaScript 不是"Java 的脚本":先搞清楚语言定位和运行环境
1.1 解释型、动态语言与单线程的底层设定
很多人第一个认知误区就是名字带来的。JavaScript 和 Java 除了语法上有一丁点相似,从设计哲学到运行机制几乎没有任何血缘关系。JavaScript 是解释型语言,不需要编译步骤,浏览器拿到源码后由引擎逐行解释执行。这意味着代码在运行之前不会像 Java 那样经过严格的编译期检查,很多低级错误只能在运行到那一行时才暴露。
它又是动态弱类型语言。变量没有固定类型,同一个变量今天装字符串、明天装数字、后天装对象,引擎都不拦着。这种灵活性的好处是开发速度快,坏处是代码稍微一多,类型混乱就会引发连锁故障。我在实际项目里见过最典型的翻车现场:接口返回的count字段在某种异常情况下变成了字符串"5",前端直接拿去做count + 1,结果得到"51",页面上显示了一个令人摸不着头脑的数字。弱类型不是罪,但不主动管理类型就是给自己埋雷。
还有一个底层设定必须刻在脑子里:JavaScript 是单线程的。它没有真正的多线程并行执行,而靠事件循环机制调度任务。这个设计来源于浏览器交互场景——如果两个线程同时操作同一个 DOM,渲染状态就乱了。所以 JS 采用的是"轮流干活、干完再换人"的方式。理解单线程,才能理解后面所有关于异步、事件、性能优化的讨论。
1.2 浏览器环境、Node.js 环境与嵌入式脚本环境的差异
同样是 JavaScript,跑在浏览器里和跑在 Node.js 里,环境差异非常大。浏览器环境有window、document、localStorage这些全局对象,专门用来操作页面和用户数据;Node.js 环境则没有window,取而代之的是globalThis、process、Buffer等,擅长处理文件、网络、系统级操作。初学者写了一段含document.getElementById的代码丢到 Node 里跑,立刻报document is not defined——不是语法错了,是"环境不支持这个对象"。
还有一个经常被忽略的场景:嵌入式脚本环境。不少工具软件内部直接内置了 JavaScript 引擎作为脚本扩展,比如 Kettle(ETL 数据抽取工具)里的"JavaScript 代码"步骤,很多做数据清洗的同学就是在那里第一次正式接触 JS。在那种环境里没有 DOM、没有 BOM,只有纯粹的 ECMAScript 核心语法和工具暴露出来的专用对象。所以遇到这种需求时,能不能分清"标准 ECMAScript 语法"和"宿主环境 API",直接决定了脚本能不能跑通。
1.3 为什么理解运行环境能解决一大半"玄学报错"
我在帮人排查问题的时候,第一步永远是问"这段代码在什么环境里跑"。因为大量所谓的神秘报错,归根结底是环境认知缺失。比如this的指向为什么时对时错?因为严格模式下普通函数的this是undefined,非严格模式下则指向全局对象window;箭头函数的this是词法绑定,继承定义位置的this。这些行为差异全都和运行环境、执行模式绑定在一起。再比如var声明的变量在全局作用域会成为window的属性,而let和const不会——在 Node 环境的全局模块里又另有一套表现。基础扎实的人看到报错就能猜到"八成是环境问题",基础薄弱的人只能一遍遍试。
2. 类型判断:typeof只是第一步,组合拳才能查清户口
2.1typeof的五个坑:null、数组、NaN与历史包袱
当年我还在写业务的时候,第一个让代码炸掉的 bug 就是类型判断。后来总结经验:typeof这个操作符有历史包袱,必须清楚它的"能力边界"。
typeof null返回"object",这是从第一版 JS 就留下的著名 bug,修复成本太高所以一直保留。数组用typeof判断也是"object",无法区分普通对象和数组。typeof NaN返回"number",数值类型本身没问题,但 NaN 在语义上是"非数值",直接用它判断数据合法性就行不通。还有函数:typeof function() {}返回"function",这算是 JS 给函数开的特权,因为函数确实是"可调用的对象"。
只看typeof一张报表就下结论,等于只通过户口本封面判断一个人的全貌。
2.2 判断类型的三板斧:Object.prototype.toString、instanceof与Array.isArray
真正可靠的全类型判断方案,我在生产环境里用了很多年的是这套组合:
// 万能类型判断:借助 Object.prototype.toString.call Object.prototype.toString.call(123); // "[object Number]" Object.prototype.toString.call("abc"); // "[object String]" Object.prototype.toString.call(null); // "[object Null]" Object.prototype.toString.call(undefined); // "[object Undefined]" Object.prototype.toString.call([]); // "[object Array]" Object.prototype.toString.call({}); // "[object Object]" Object.prototype.toString.call(new Date()); // "[object Date]"这个方案的优势在于它能区分所有内置类型,返回值非常稳定。调用别人的toString可能会被重写,但Object.prototype.toString这个原始方法到目前为止还没有被修改的风险。
instanceof是另一个工具,它判断的是"原型链上是否有某个构造函数的原型"。比如[] instanceof Array返回true,new Date() instanceof Date返回true。但它有两个限制:一是只能判断"对象类型",原始值"abc" instanceof String是false;二是跨 iframe 或跨执行环境时,Array 的构造函数不是同一个,判断会失效。所以数组判断我基本都是直接用Array.isArray(),它不依赖原型链,更可靠。
| 判断方式 | 适用场景 | 注意点 |
|---|---|---|
typeof | 区分 number、string、boolean、undefined、function | typeof null为"object",无法区分对象/数组 |
instanceof | 判断是否属于某个"类/构造函数" | 跨 iframe 失效,原始值判断为 false |
Object.prototype.toString.call | 全类型辨识,生产力最高 | 返回字符串,需要配合解析 |
Array.isArray | 精准判断数组 | 最推荐用于数组场景 |
2.3 数字处理的常见边角:保留两位小数为什么会翻车
热搜里"javascript 保留两位小数"经久不衰,说明这个看着简单的问题实际坑了不少人。最常见做法是toFixed(2),但它返回的是字符串。有人拿它继续做数学运算,结果变成字符串拼接,这又回到了类型问题。正确的姿势是:
function formatTwo(num) { return Number(num.toFixed(2)); // 先保留两位,再转回数字 }toFixed还有个不足,它本质是四舍五入,但浮点数的二进制表示会导致一些看起来"没问题"的数字出现意外。我在项目里处理金额时,更推荐用"先乘后除"的方式规避浮点误差:
// 避免直接做 0.1 + 0.2 这类运算 function toFixedSafe(num, precision = 2) { const factor = Math.pow(10, precision); return Math.round(num * factor) / factor; }核心建议:涉及金额、百分比这类业务字段,前后端最好约定用整数(分)传递,不在 JS 里做浮点运算。
3. 函数:声明方式、作用域和闭包,决定了代码能不能"长大"
3.1 三种声明方式的差异,比你想的重要得多
函数在 JavaScript 里非常特殊,通常被称为"一等公民"——可以赋值给变量、作为参数传递、作为返回值返回。但声明方式不同,行为细节完全不同,我见过太多因为混用声明方式导致的诡异 bug。
第一种是函数声明:
function add(a, b) { return a + b; }这种写法会被"提升"(hoisting),也就是说你在声明之前调用它也不会报错。这个特性对组织代码很友好,可以把工具函数放文件后面,前面先写调用逻辑。
第二种是函数表达式:
const add = function(a, b) { return a + b; };这种写法不会提升,赋值执行之前变量是undefined,提前调用会报TypeError: add is not a function。它由const声明,也保证了引用不会被意外覆盖。
第三种是箭头函数:
const add = (a, b) => a + b;箭头函数没有自己的this、arguments、super,也不能用new调用。做函数式编程非常顺手,但如果把它当普通函数在对象方法里用,this就会悄悄指向外层,很多"为什么突然取不到this.xxx"的谜案就是这么来的。
3.2 作用域链和闭包:变量"看不见"和"忘不掉"的原理
作用域决定了变量在哪些地方可以被访问。ES6 之前只有全局作用域和函数作用域,var没有块级作用域,所以for循环里声明的变量循环结束后依然存在。ES6 引入let和const之后才有了"块级作用域",这是两代代码风格的分水岭。
闭包这个概念被很多人说得玄乎,其实核心就一句话:函数在定义位置记住了它所见到的变量环境,即使外部函数已经执行完毕,这些变量也不会被销毁。举个例子:
function createCounter() { let count = 0; return function() { count += 1; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2createCounter执行完返回内部函数后,普通函数的局部变量通常会被垃圾回收,但count还被返回的内部函数引用着,所以它一直活着。闭包是JS里最常用的高阶技巧,比如防抖、节流、柯里化、私有变量模拟,全都靠它支撑。但闭包用多了也有内存风险——如果闭包引用了大型对象,且闭包本身被长期保留(比如挂到了全局事件上),这块内存就释放不掉。排查内存泄漏时,优先查有没有无意制造的闭包长期引用。
3.3this的四条规则:别再靠"猜"来确定指向
this是 JavaScript 初学者最容易绕晕的点,但它其实是有清晰规则的。我用一句话概括它:"this 指向调用者"。
- 作为对象方法调用,
this指向该对象:obj.say()里this是obj。 - 作为普通函数调用,非严格模式下
this是全局对象,严格模式下是undefined。 - 使用
call、apply、bind调用,this指向被显式绑定的对象。 - 箭头函数没有自己的
this,它用定义时所处作用域的this。
把规则对照着用,绝大多数this问题都能自解。最难的一类其实是"方法被拆出来用"。比如const fn = obj.say; fn();这种做法把方法从对象上拆下来,再当作普通函数调用,this就不再指向obj了。很多回调函数里的this丢失,都是这种场景。
4. 事件循环:为什么setTimeout不准时,以及事件委托的实际意义
4.1 宏任务与微任务:一份延时任务的"排队叫号"模型
单线程的 JavaScript 靠事件循环处理所有任务。可以把它想象成餐厅只有一个服务员的排队叫号系统:主线程就是服务员,一次只能接待一桌客人;任务队列就是等位的顾客。同步任务立即处理,异步任务被放到队列里,等主线程空了再处理。
异步任务又分宏任务(setTimeout、setInterval、I/O、UI 渲染)和微任务(Promise.then、queueMicrotask、MutationObserver)。在执行完一个宏任务后、渲染新画面之前,引擎会把目前队列里所有微任务全部清空,再取下一个宏任务。所以下面这段代码的输出是start、promise、timeout,而不是按书写顺序:
console.log("start"); setTimeout(() => { console.log("timeout"); }, 0); Promise.resolve().then(() => { console.log("promise"); }); console.log("end"); // 输出: start, end, promise, timeout理解了这一层,你就明白为什么setTimeout(fn, 0)不可能是"立刻执行"——它只是把回调扔进宏任务队列,要等当前同步任务和微任务全部跑完才轮得到。所以想在某个逻辑之后尽快执行一段小任务,用微任务(Promise.resolve().then)通常比setTimeout(0)更"快",但要小心微任务太多会让渲染持续卡顿。
4.2 冒泡与捕获:事件流动的完整链路
DOM 事件在传播时有三个阶段:捕获阶段(从window一路向下到达目标)、目标阶段、冒泡阶段(从目标一路向上回到window)。默认在冒泡阶段监听事件,所以点击一个按钮,事件会从按钮向外层的div、body、document一路传递上去。如果想要"只在到目标前拦截",在监听函数里调用stopPropagation()就能阻断继续传播。
有个真实的坑:在移动端 WebView 里做原生与 JS 互调时,早期 iOS 端的 WKWebView 对事件的处理时机跟浏览器不完全一致,搞不清楚捕获和冒泡的阶段差异,就会出现"原生端已经响应了,页面的 JS 回调还没触发"的错位现象。这种跨界调试特别费时间,根因往往不是 API 写错,而是对事件传播模型理解不到位。解决方案是在 WebView 注入的代码里尽量用document级别监听并手动分发,避免依赖多层冒泡的时序。
4.3 事件委托:用最少的事件监听搞定动态内容
事件委托是"冒泡"带给我们最实用的能力。与其给每个子元素单独绑定事件,不如给父容器绑定一个监听器,利用冒泡机制捕获所有子元素触发的事件。最经典的场景是表格里的删除按钮:
document.querySelector("#table").addEventListener("click", function(e) { const target = e.target.closest("button[data-action='delete']"); if (!target) return; const id = target.dataset.id; // 执行删除逻辑 });这样哪怕后面用 Ajax 动态往表格里插入新行,新按钮也不用重新绑定事件。同时如果某个按钮短暂被点击了很多次(比如抢购、双击提交),也可以在这个统一监听里做节流/防抖。上述两个能力叠加起来,能大幅减少事件监听器的数量,也就降低了内存占用和"高负载交互"场景下的卡顿风险。这类"屏蔽高负载交互"的需求,本质上不是靠事件系统本身解决,而是靠委托+节流+防抖这类设计模式。
事件相关的最后一个建议:页面卸载(beforeunload)和浏览器切后台(visibilitychange)这些事件不要随手丢弃。埋点统计、心跳请求、草稿保存这些"看起来不起眼"的功能都依赖它们,基础不牢的团队经常用一堆setInterval轮询补救,性能和电量双输。
5. 运行时报错排查:把ReferenceError、TypeError和undefined当成线索
5.1 常见报错类型的第一现场
面对 JavaScript 运行时报错,第一反应不该是"代码为什么坏了",而应该是"这个错误类型告诉了我什么"。几个高频报错必须门儿清:
ReferenceError: xxx is not defined:某个变量压根没有被声明,或者是拼写错误。TypeError: xxx is not a function:拿到一个值想当函数调用,但它不是函数。最常见来源是接口没返回预期字段,把undefined当函数调了。TypeError: Cannot read property 'name' of undefined/null:在undefined或null上访问属性。这是业务代码里最普遍的报错。SyntaxError:语法错误,整个脚本可能都不会执行,问题直接出现在解析阶段。RangeError: Maximum call stack size exceeded:递归没有出口,栈被撑爆了。
报错信息里的英文单词不多,逐字读一下,90% 的问题能定位方向。但很多人扫一眼报错就直接去网上复制代码,反而越查越乱。
5.2 一条真实排查链路:从"页面白屏"到"罪魁祸首"
我复盘一次真实的排查过程,可以完整演示"把报错当线索"的思维。某后台管理系统出现偶发白屏,控制台报错:
Uncaught TypeError: Cannot read properties of undefined (reading 'map')第一步:根据报错关键词搜索源码里所有.map调用,锁定是哪一行。找到了:一个对象数组的map,预期data.list.map(...)。
第二步:在报错行打条件断点,条件设为!data.list,刷新页面,断点命中。此时看到data有值,但list字段不存在。
第三步:回头看接口响应。发现接口在数据为空时返回的是data: { list: null },而当天的特殊请求里连list字段都没给。后端认为"没有就是空",前端默认"这个字段一定有数组"。
第四步:补上防御逻辑:
const items = Array.isArray(data && data.list) ? data.list : []; items.map(...)这条链路里的每个环节都不复杂,但整个排查能走通,靠的是对TypeError语义的准确理解和对数据契约的敏感度。排查报错真正要练的不是找答案的速度,而是按图索骥的顺序。
5.3 调试工具使用习惯:Sources面板和console的好习惯
在浏览器开发者工具里,Sources面板的价值被很多人低估了。它不只用来"看代码",更是一个完整的调试器:可以打断点(包括条件断点)、单步执行、查看作用域链和调用栈。当报错发生时,Call Stack 窗口直接展示"这个报错是被哪一串调用触发的",这是最有效的问题定位路径。
console也有学问。别把所有信息都console.log打出来,打在对象上时会显示引用,经过后续修改后你再回头看早期打印值,看到的可能已经是新值。这种"日志看起来不对"的困惑常常误导排查方向。我习惯在关键节点用console.warn、console.error区分级别,再配合console.table看数组结构、console.time测性能,信息密度大幅提升。
6. Canvas、FullCalendar、框架与工具链:基础扎实了,这些场景才拿得下
6.1 Canvas 绘图入门:坐标系、绘制上下文与性能基线
"javascript canvas" 是热搜里的常客。Canvas 本身是一个 HTML 元素,真正的绘制能力来自它提供的 2D 绘图上下文。入门只需三步:拿到元素、获取上下文、调用绘图 API。
<canvas id="board" width="640" height="360"></canvas>const cv = document.querySelector("#board"); const ctx = cv.getContext("2d"); ctx.fillStyle = "skyblue"; ctx.fillRect(20, 20, 120, 80);几步之后就能画出矩形,暖手完成。接下来要理解 Canvas 的基础坐标系:原点在左上角,x向右增长,y向下增长。这与数学课上习惯的坐标系上下相反,很多人第一次画图都会犯"图形跑到底部去了"的错误。
Canvas 真正难的是性能优化。它不像 DOM 那样按元素来管理,整个画布是一张位图,任何局部重绘都可能引起整帧刷新。做动画时一个巨大的优化点是避免在requestAnimationFrame里做不必要的样式计算、离屏缓存那些不变化的图层。想拿 Canvas 做正经可视化项目的同学,建议先搞明白"什么时候该用save/restore管理状态""什么时候该分层多画布",这些概念的优先级远高于记 API。
6.2 组件库与插件:FullCalendar 这类"开箱即用"背后的前置知识
搜"fullcalendar javascript"的人通常是想在项目里快速集成一个日历组件。FullCalendar 是一款功能完善的日程日历插件,自带视图切换、事件拖拽、资源管理。但我的真实建议是:先会写原生 JS,再上组件库。因为组件库无论多好用,配置项出现问题时,你终究要在它生成的 DOM 结构上做二次开发,或者在它的回调里操作数据和事件对象。如果连事件委托、this绑定、数组方法都不熟,集成一个日历会变成一场漫长的黑盒调试。
这类组件库的能力边界通常是:数据结构规则由库来决定,业务逻辑由你来写。比如 FullCalendar 要求事件对象有title、start、end字段,这些字段怎么从后端接口映射过来,库管不了,全靠你自己写适配层。
6.3 框架和库为什么被称作"跨浏览器兼容代码工具集",以及学习顺序的忠告
社交媒体上关于"javascript 框架或库是一组能轻松生成跨浏览器兼容的 javascript 代码的工具和函数"这个描述,至今仍是一个很好的定义。它道出了框架存在的初衷:屏蔽浏览器差异、封装重复逻辑、提高开发效率。jQuery 当年就是靠"写一遍,到处跑"解决了 IE 和标准浏览器之间的巨大差异;今天的 Vue、React 则在组件化、状态管理层面进一步解放了生产力。
对这个定义的常见误解是"学了框架就不用学基础"。"框架能帮你轻松生成兼容代码"不假,但前提是你自己知道为什么需要兼容、兼容的是什么。框架文档不会教你什么是事件冒泡、什么是闭包、什么是宏任务微任务。当框架封装好的东西出现你没见过的报错时,能依赖的还是底层知识去排查。
6.4 我的学习路线建议:先扎实 ECMAScript,再碰框架,最后深入工具链
根据我带新人的经验,一个稳妥的 JavaScript 基础进阶路线是分三步走。
第一步,先把 ECMAScript 核心语法吃透。变量声明与作用域、运算符与类型转换、数组的map/filter/reduce、函数的闭包与this、异步三大件(回调/Promise/async await)、ES6 模块化。这期间建议用 Node.js 做练习,因为它不涉及 DOM,可以纯粹验证语言特性。
第二步,再接触 DOM 和浏览器 API。这时候学习事件模型、元素选择、fetch网络请求、localStorage持久化、Canvas 绘图,并且开始关注浏览器兼容性问题。这个阶段可以给自己布置实际的小项目——比如做一个"待办事项日历",用上事件委托、数据存储、组件拆分。
第三步,才建议上框架。选一个当前生态最成熟的方向(比如 React 或 Vue),系统学习组件化开发和状态管理。但原则依然是:每遇到一个框架概念,都要试着回到底层去理解它到底封装了什么。只有做完这三步,你再回头看"框架或库是能轻松生成跨浏览器兼容代码的工具和函数"这句话时,才能真正读出蕴含在他们里的价值——它降低的是重复劳动的密度,而不是替代你理解基本原理的必要性。
我在实际项目里还有一条经验:每隔一段时间拿出旧代码做一次重构练习,专门把那些"var满天飞、回调写五层、类型全靠猜"的实现改造成规范写法。这种练习不需要大项目,一个小工具模块足够。改完之后你会明显感觉到,基础扎实带来的不是"会背 API",而是遇到问题时的底气——因为你不再需要靠猜和试,而是真正"知道"代码会怎么运行。这也是我坚持写这篇长文的核心理由。