这几年陆陆续续带了几个刚入行的新人,我发现一个挺有意思的现象:很多人学JavaScript是直接从框架开始的,上来就是React或者Vue。框架确实能帮你很快写出页面,但只要稍微脱离套路,遇到个报错、做个复杂交互,就会卡住半天。根子上的问题,是JavaScript这门语言本身的语法细节、类型系统和运行机制没吃透。
这篇文章我想用比较实际的视角,把从零开始学JavaScript最应该掌握的东西理一遍。不是那种翻文档式的罗列,而是把我在实际开发里经常遇到、面试里经常问、写代码时真正在用的那部分语法和机制讲清楚。不管你是完全没写过代码、想转行做前端,还是写过一点但自觉基础不扎实,这一篇应该都能帮你把骨架搭起来。
1. 先搞清楚JavaScript到底能做什么,再决定怎么学
很多新手有个误区,觉得JavaScript就只是给网页加点交互效果的脚本语言。这是十年前的认知了。现在的JavaScript早就不是当年那个只能在浏览器里跑的"小程序"了。
1.1 从"网页小脚本"到"全栈主力"的演变
JavaScript诞生于1995年,最初确实只是用来做表单校验、弹窗这类简单的页面交互。但2010年前后发生了几个改变格局的事情:Google的V8引擎把JavaScript执行效率提升了几个数量级;Node.js出现,让JavaScript可以脱离浏览器,直接在服务器端运行;再后来React、Vue、Angular这些框架横空出世,前端从"写页面"变成了"做应用"。
到今天,JavaScript能做的事包括但不限于:
- 浏览器端:控制DOM、处理用户事件、发网络请求、做动画、操作Canvas绘图、处理音视频
- 服务端:用Node.js写后端API、做中间层、写命令行工具、做爬虫
- 桌面应用:Electron框架,比如VS Code、Slack、Discord都是这么做的
- 移动端:React Native、Flutter的Web模式,一套JS代码跑iOS和Android
- 物联网:甚至是嵌入式设备上的JavaScript运行时
所以现在学JavaScript,已经不是单纯为了写网页了。它是一套能覆盖"前端-后端-桌面-移动"的通用技术栈。这也意味着,JavaScript的语法基础比任何框架都重要——因为无论你以后用哪个框架,底层跑的都是这门语言。
1.2 学JavaScript和学框架应该是什么关系
我给新人的建议都是同一个顺序:先花3到4周把纯JavaScript(原生JS,不碰任何框架)练扎实,再去碰框架。原因很简单,框架本质上是对JavaScript的封装和约定。React的JSX语法、Vue的响应式系统,底层全部依赖JavaScript的语言特性在运作。你不懂原生JS里对象和数组的引用关系,就很难理解为什么Vue要强调响应式数据的替换;不懂事件循环,就不明白为什么有时候数据变了视图没变。
面试里有个经典问题:框架和库的区别是什么。这个热搜词对应的其实就是同一个道理。库(比如lodash)是你可以按需调用的工具箱,框架(比如Vue)是帮你搭好骨架、要求你按它的规则生活的地方。不管哪种,你都要先会写JavaScript本身,才有资格谈用工具提升效率。
2. JavaScript的语法地基:变量、数据类型与运算规则
语法基础这部分看似简单,但我不打算只列概念。我会把那些初学者最容易犯迷糊的点和实际后果一起讲。
2.1 变量声明的三个关键字怎么选:var、let还是const
很多人刚学的时候用的是var,但现代JavaScript开发里var已经基本退出主流了。记住这个选择逻辑:
- const:默认选择。声明之后不能再重新赋值,适合绝大多数场景
- let:需要重新赋值的时候用,比如循环计数器、状态切换
- var:老代码里会看到,新代码不要再用了
// 推荐:尽量用const,需要改再改成let const maxRetryCount = 3; let currentPage = 1; // var的问题:函数作用域+变量提升,容易出隐蔽bug function example() { console.log(a); // undefined,不报错!这就是var的提升问题 var a = 5; }var的变量提升机制是历史遗留问题。它会把声明提升到函数顶部,但赋值留在原地,所以代码里会出现"使用了一个尚未赋值的变量但没报错"的诡异情况。let和const是块级作用域,访问未声明区域会直接抛ReferenceError,反而能帮你尽早发现错误。
2.2 原始类型和引用类型的本质差异
这是JavaScript里最值得花时间理解的概念之一,也是很多隐蔽bug的来源。数据类型分两大类:
原始类型(Primitive):string、number、boolean、null、undefined、symbol、bigint。存的是值本身,复制的时候是"拷贝值"。
引用类型(Reference):object、array、function。存的是内存地址,复制的时候是"共享地址"。
// 原始类型:复制后互不影响 let a = 10; let b = a; b = 20; console.log(a); // 10,a没被影响 // 引用类型:复制的是地址,互相牵连 let obj1 = { count: 1 }; let obj2 = obj1; obj2.count = 100; console.log(obj1.count); // 100,obj1也被改了!这个差异的实际影响有多大?我举个真实场景。你写了个函数,接收一个配置对象,在里面改了个字段,结果调用方传进来的原对象也被改了——如果调用方不知道,排查起来非常痛苦。理解了引用类型的原理之后,你会在修改对象参数前先做个拷贝,或者明确约定函数内不修改入参。
2.3 隐式类型转换与"=="的坑
JavaScript是弱类型语言,意味着它在运算时会自动做类型转换。这个特性用好了很顺手,用不好就是事故现场。
console.log(1 + "2"); // "12",数字被转成了字符串拼接 console.log("5" - 3); // 2,字符串被转成了数字做减法 console.log(true + 1); // 2,true被转成了1 console.log([] + {}); // "[object Object]",空数组转成了空字符串这也是为什么业界有个铁律:全等比较优先于宽松比较。==会做类型转换,5 == "5"返回true;===要求类型和值都相等,5 === "5"返回false。现代代码规范里基本只用===和!==,避免隐式转换带来的不确定性。
3. 函数:JavaScript这门语言真正的分水岭
到了函数这块,就不再是记住了就能用的程度了。函数的理解深度,直接决定了你能不能驾驭JavaScript。
3.1 函数声明、函数表达式、箭头函数的选型逻辑
三种写法各不相同,适用的场景也有区别:
// 函数声明:会提升,可以在定义之前调用 function add(a, b) { return a + b; } // 函数表达式:不会被提升,必须定义后调用 const subtract = function(a, b) { return a - b; }; // 箭头函数:语法简洁,写法更接近"表达式"而非"声明" const multiply = (a, b) => a * b;this的指向是三者最核心的区别。普通函数(声明和表达式)的this取决于怎么被调用——谁调用就指向谁,在对象方法里指向对象,在普通调用里指向全局。箭头函数没有自己的this,它会捕获定义时外层作用域的this。这个特性在回调函数里非常好用:
const timer = { count: 0, start() { // 传统写法:this是setTimeout回调里的,指向全局,取不到count // 所以以前要 const self = this; 来绕 setTimeout(function() { this.count++; // 报错,this不是timer }, 1000); // 箭头函数:捕获定义处的this,也就是timer setTimeout(() => { this.count++; }, 1000); } };3.2 闭包:不只是面试题,是每天都在用的工具
闭包的定义一句话:函数能记住并访问它定义时所在作用域的变量,即使这个函数在其他地方被调用了。听起来抽象,看代码一下就明白了:
function createCounter() { let count = 0; return function() { count++; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2 // count这个变量没有被回收,还在"闭包"里存着实际开发里闭包最常见的应用场景:防抖和节流函数的设计、函数工厂、模块化封装私有变量。比如前端最常见的防抖:
function debounce(fn, delay = 300) { let timer = null; return function(...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => { fn.apply(this, args); }, delay); }; }这个timer变量就是因为闭包的存在,才能在多次调用之间保持记忆。理解闭包之后,你才能理解为什么防抖代码要这么写,而不是背下来。
3.3 回调函数与回调地狱:为什么需要Promise
JavaScript是单线程的,但很多操作(网络请求、文件读取、定时器)是异步的。异步完成之后要做什么,早期全靠回调函数:
// 第一个请求成功后再发第二个,嵌套就来了 getUser(id, function(user) { getOrders(user.id, function(orders) { getOrderDetail(orders[0].id, function(detail) { // 再来一层就疯了 }); }); });回调地狱的痛点不只是缩进难看,更重要的是错误处理混乱、流程控制困难。Promise的出现就是为了解决这个问题:
getUser(id) .then(user => getOrders(user.id)) .then(orders => getOrderDetail(orders[0].id)) .then(detail => console.log(detail)) .catch(err => console.error(err));再到ES2017的async/await,把异步流程写得更像同步代码:
async function getUserDetail(id) { try { const user = await getUser(id); const orders = await getOrders(user.id); return await getOrderDetail(orders[0].id); } catch (err) { console.error(err); } }4. 判断数据类型:从typeof到toString,一次说清楚
"JavaScript判断数据类型"这个热搜词,说明这是几乎每个人都会遇到的问题。别小看这道题,它藏着不少语言层面的细节。
4.1 typeof的局限性与适用场景
typeof是最直接的判断关键字,但它只能区分大致类型:
typeof "hello"; // "string" typeof 42; // "number" typeof true; // "boolean" typeof undefined; // "undefined" typeof function(){}; // "function" typeof {}; // "object" typeof []; // "object" —— 注意,数组返回的是object! typeof null; // "object" —— 这是历史bug,null返回object两个反直觉点必须记住:数组和普通对象的typeof都是"object";null的typeof也是"object"(这是JavaScript最初设计时的bug,但因为兼容性考虑一直没有修复)。所以typeof只适合判断基本类型,判断null需要搭配全等比较:x === null。
4.2 instanceof判断引用类型时的判断原理
instanceof用来判断一个对象是不是某个构造函数的实例,它的原理是检查构造函数的prototype是否出现在对象的原型链上。
[] instanceof Array; // true [] instanceof Object; // true,因为Array的原型链上也有Object {} instanceof Array; // false new Date() instanceof Date; // true但instanceof有个跨环境问题:如果代码在多个iframe或者不同window环境里运行,每个window的Array构造函数不是同一个引用,就会出现数组 instanceof Array返回false的诡异情况。这就是为什么业界更推荐用下面的方案。
4.3 掌握Object.prototype.toString.call这个"终极大法"
判断数据类型的完整方案,用Object.prototype.toString.call():
Object.prototype.toString.call("hello"); // "[object String]" Object.prototype.toString.call(42); // "[object Number]" Object.prototype.toString.call([]); // "[object Array]" Object.prototype.toString.call({}); // "[object Object]" Object.prototype.toString.call(null); // "[object Null]" Object.prototype.toString.call(undefined); // "[object Undefined]" Object.prototype.toString.call(function(){}); // "[object Function]" Object.prototype.toString.call(new Date()); // "[object Date]"这个方法几乎能准确判断所有内置类型,因为它是读取对象内部的Symbol.toStringTag属性。实际开发里,很多人会封装一个简易的类型判断工具函数:
function getType(value) { const typeStr = Object.prototype.toString.call(value); // 去掉"xxx"格式里的开头和结尾,只取类型名 return typeStr.slice(8, -1).toLowerCase(); } getType([]); // "array" getType({}); // "object" getType(null); // "null" getType(new Date()); // "date"日常开发里我还比较常用Array.isArray()这个专用方法,它是目前最可靠、最推荐的数组检测方式,不会出现instanceof那种跨window问题。
4.4 类型判断在实际开发中的典型场景
接口返回数据不可控时,类型判断就派上用场了。比如后端可能返回null、可能返回数组、也可能只返回单个对象,前端拿到的数据格式并不稳定:
function handleList(data) { if (Array.isArray(data)) { return data.map(formatItem); } if (data && typeof data === "object") { return [data].map(formatItem); } return []; }这种防御式写法在对接第三方API时很常见。掌握好类型判断,就是在为复杂的现实世界做防御。
5. 数字计算中的隐性坑:保留两位小数远远没有表面简单
"JavaScript保留两位小数"这个热搜词,看起来是个小功能,但里面涉及到JavaScript的浮点数精度问题,是个深坑。
5.1 为什么0.1加0.2不等于0.3
在控制台试一下:
console.log(0.1 + 0.2); // 0.30000000000000004原因:JavaScript的number类型遵循IEEE 754标准,用二进制浮点数存储。0.1和0.2在二进制下是无限循环小数,计算机只能存储有限的精度,所以计算时累加的误差就暴露了。这不是JavaScript独有的问题,几乎所有用浮点数运算的语言都有,只是JavaScript把这个问题直白地暴露给了开发者。
5.2 toFixed的舍入规则比想象中复杂
点击次数很多的toFixed()方法也有自己的脾气:
(1.005).toFixed(2); // 结果是 "1.00",不是期望的"1.01"原因是1.005在二进制浮点里的实际值大约是1.0049999999999999,所以四舍五入后变成了1.00。另外早期版本的JavaScript引擎把toFixed(0)对某些数字的处理规则跟正规的"四舍六入五成双"也有出入,导致同一个浏览器在不同版本下结果都可能不同。
5.3 正确的保留两位小数方案
结合我的经验,处理金额、百分比这类需要精确展示的场景,有几种不同选择。
日常显示型需求,可以这样处理:
// 方案一:用Math.round配合放大倍数 function roundToTwo(num) { return Math.round(num * 100) / 100; }但注意,这个方案本身也会有浮点误差,accuracy极端值时仍然可能翻车。更稳妥的做法是把小数点变成整数运算,或者直接用字符串处理:
function formatToTwo(num) { // 先转字符串,避免浮点误差 const str = String(num); const dotIndex = str.indexOf("."); if (dotIndex === -1) return str + ".00"; const integerPart = str.slice(0, dotIndex); let decimalPart = str.slice(dotIndex + 1); // 不足两位补零,超过两位截断或进位 while (decimalPart.length < 2) decimalPart += "0"; return integerPart + "." + decimalPart.slice(0, 2); }对于金融级场景,我的建议是:后端算好金额再返回,前端只管展示;或者引入专门的精度处理库。自己手写浮点数运算的舍入逻辑,很容易在边界情况下出问题。
6. JavaScript运行时报错:四年老司机都会遇到的错误类型盘点与排查方法
"JavaScript运行时报错"这个热搜词背后,是每天从上万个浏览器控制台里弹出的真实求救信息。我总结一下实际开发里出现频率最高的几类错误,以及遇到之后的排查思路。
6.1 TypeError、ReferenceError、SyntaxError、RangeError怎么区分
我见过不少新人一看到英文报错就慌,实际上报错信息已经把答案写得很清楚了。
| 错误类型 | 触发场景 | 典型报错 | 常见原因 |
|---|---|---|---|
| TypeError | 对值做了它不支持的操作 | xxx is not a function | 调用了未定义的函数变量 |
| TypeError | 读取了不存在的方法 | Cannot read properties of undefined | 访问了undefined的属性 |
| ReferenceError | 引用了未定义的变量 | xxx is not defined | 变量名拼写错误或用前未声明 |
| SyntaxError | 解析代码阶段就失败 | Unexpected token | 少括号、少分号、JSON格式错 |
| RangeError | 数值超出合法范围 | Maximum call stack size exceeded | 无限递归或数组长度非法 |
其中Cannot read properties of undefined应该是前端报错里出现频率最高的一个。这类错误的典型场景:接口返回数据结构跟你预期的不一样,比如你写得是data.list[0].name,但后端返回的data是null,代码瞬间爆炸。解决方案是给每一层访问加保护:
// 老写法:满屏if判断 if (data && data.list && data.list.length > 0) { console.log(data.list[0].name); } // ES2020的可选链写法 console.log(data?.list?.[0]?.name);可选链?.会在中间某一步是null或undefined时直接短路返回undefined,不会继续往下访问,代码干净很多,也安全很多。
6.2 排查报错的完整思路:从控制台到源码定位
遇到报错,我的排查链路基本是固定的:
第一步,看控制台报错信息的第一行,确认错误类型和文件名。浏览器的堆栈信息会精确告诉你哪个文件、第几行、哪个函数调用链上出的错。
第二步,点击堆栈里的文件名跳转到Sources面板,看那行代码具体的上下文。很多时候错误不是那行本身的问题,而是那行接收到了一个"不该有的值"。
第三步,在报错前打断点,或者加console.log打印关键变量的值,确认数据结构是不是预期中的样子。
第四步,如果是异步代码里的报错,特别要注意Promise链或者事件回调里的异常。try/catch只能捕获同步代码里的错误,异步回调里的错误需要放到回调内部去捕获,或者用Promise.catch、window.onerror这类全局兜底。
// 错误示范:try/catch捕捉不到setTimeout里的异常位置 try { setTimeout(() => { throw new Error("看看能不能被抓住"); }, 100); } catch (e) { console.log("catch住了", e); } // 真实结果:这里catch不住,错误直接抛到全局 // 正确做法:把try/catch放进异步回调内部 setTimeout(() => { try { // 真正会报错的代码 } catch (e) { console.log("在这里catch", e); } }, 100);6.3 低代码/无代码时代为什么要会看报错
现在有不少低代码平台、可视化搭建工具,网页上还会出现"屏蔽高负载JavaScript"这类浏览器提示。很多人觉得"我不写代码就不需要懂JavaScript了",但实际情况是,哪怕你在低代码平台上拖拽组件,底层仍然在生成JavaScript。遇到平台报错、性能优化、交互异常,懂原理和不懂原理的人,处理效率天差地别。
浏览器提示"屏蔽高负载JavaScript",说的其实是某个脚本占用了太多CPU时间,导致页面卡顿。如果你懂JavaScript的事件循环和性能机制,就能判断是死循环、过度渲染,还是接口轮询太频繁。不懂的人只能干等。
7. 从语法走向实战:数组、字符串、DOM操作与事件处理
最后这部分,我把前面讲的所有内容串起来,落到实际开发最常用的几个操作上。这些是每天写业务代码真正会用到的东西。
7.1 数组操作三板斧:map、filter、reduce
数组方法里使用频率最高的三个,分别是转换、筛选、归纳:
const orders = [ { name: "咖啡", price: 32, count: 2 }, { name: "蛋糕", price: 28, count: 1 }, { name: "汉堡", price: 25, count: 3 } ]; // map:把数组每一项映射成新值 const names = orders.map(item => item.name); // ["咖啡", "蛋糕", "汉堡"] // filter:筛选出符合条件的项 const expensive = orders.filter(item => item.price > 26); // [{ name: "咖啡", price: 32, count: 2 }] // reduce:把数组归纳成一个值 const total = orders.reduce((sum, item) => sum + item.price * item.count, 0); // 32*2 + 28*1 + 25*3 = 167这组方法之所以重要,是因为它代表了一种声明式编程的思维:你告诉代码"要什么",而不是手写循环一步步"怎么做"。配合箭头函数和链式调用,代码的可读性和维护性会明显提升。我看到很多初学代码里全是for循环加if,同样的逻辑用map + filter + reduce可以短一半。
7.2 字符串模板与常用方法
模板字符串是ES6之后最值得先学会的写法:用反引号和${}做字符串拼接,支持换行,支持嵌套表达式:
const user = { name: "张三", age: 28 }; // 老写法 const info = "我叫" + user.name + ",今年" + user.age + "岁"; // 模板字符串写法 const info2 = `我叫${user.name},今年${user.age}岁`; // 甚至可以直接调方法 const info3 = `我的名字是${user.name.toUpperCase()}`;日常开发里我还会高频使用这些字符串方法:includes判断是否包含子串、startsWith/endsWith做前缀后缀判断、split拆分成数组、replace替换内容、trim去除首尾空格。
7.3 DOM操作:从获取元素到动态渲染
浏览器里操作页面,核心就是DOM(文档对象模型)。最常用的几个操作:
// 获取元素 const button = document.querySelector("#submitBtn"); const inputs = document.querySelectorAll(".form-input"); // 修改内容和样式 button.textContent = "提交中..."; button.classList.add("loading"); button.classList.remove("disabled"); // 创建新元素并插入到页面 const div = document.createElement("div"); div.textContent = "新内容"; document.body.appendChild(div); // 事件监听 button.addEventListener("click", function(event) { event.preventDefault(); console.log("按钮被点击了"); });有一个常被新人忽略的点:addEventListener和onclick的区别。onclick是赋值属性,后赋值的会覆盖前面的;addEventListener是注册监听器,可以同时挂多个处理函数,不会互相覆盖,还能用removeEventListener精准解绑。
7.4 从JavaScript到Canvas:可视化的第一块敲门砖
热搜词里出现了"JavaScript canvas",这个值得稍微提一嘴。Canvas是HTML5提供的绘图能力,JavaScript通过它可以在网页上绘制图形、图表、游戏画面。它跟普通DOM操作的区别在于:DOM是操作元素,Canvas是直接操作像素级别的画布。
const canvas = document.getElementById("myCanvas"); const ctx = canvas.getContext("2d"); // 画一个圆 ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2); ctx.fillStyle = "#ff6600"; ctx.fill(); // 画一条线 ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(200, 200); ctx.strokeStyle = "#333"; ctx.stroke();Canvas是很多前端可视化图表库的底层实现。你如果之后想深入做数据可视化、游戏、图像处理,Canvas的API和它在动画帧里的绘制逻辑是绕不开的基础。
7.5 跨端场景里的JavaScript:以OC与JavaScript互相调用为例
热搜词里有"oc和javascript互相调用",这个场景现在很多——iOS的WebView里加载H5页面,原生Objective-C代码和页面里的JavaScript互相传数据。在WKWebView时代,原生调用JS用evaluateJavaScript,JS调用原生通过WKScriptMessageHandler注册消息处理器并postMessage:
// 在页面里——把数据发给原生层 window.webkit.messageHandlers.AppBridge.postMessage({ action: "openPage", url: "https://example.com" }); // 原生层注入的js方法——原生调JS // window.receiveFromNative = function(data) { ... }这段设计思路很值得体会:在同一份代码里,JavaScript既是被调用方(原生层执行JS函数),也是主动发起方(通过消息通道通知原生层做事)。两边并不知道对方的具体实现,靠约定的"桥接协议"通信。理解了这种模式,你以后再遇到JS与Android、与桌面端WebView互通的需求,思路都是同一套。
最后说点实在的
我带过很多从零起步的人,观察到一个共性规律:那些进步快的,不是记性有多好,而是每学一个新语法都会亲手敲一遍,然后故意把它写错再来反复调试几遍。JavaScript是一门实践性极强的语言,只看不写基本等于没学。从最简单的变量声明开始,到闭包、原型链、异步流程,每个概念都建议配合控制台反复验证。
我自己的经验是:把每一段代码都当成"可变薯条"去折腾,改了这里看那里变什么,拆了东墙看西墙塌不塌,反而比按部就班背文档效率高得多。只要把这篇里讲到的类型判断、函数机制、数组方法、报错排查这四块内容真正练熟,你已经具备独立上手做事的底子了。看框架文档、翻API参考、逛技术社区的时候,再也不会觉得底层的东西是黑箱。