1. 变量声明三兄弟:var、let、const 的选择困境
1.1 作用域差异:为什么 var 逐渐被边缘化
很多人刚接触 JavaScript 时,第一行代码往往是从var开始的。但如果你现在去翻一些大厂的面试题或者开源项目的代码规范,var出现的频率已经越来越低了。不是说它不能用,而是它自身的作用域机制在现代前端工程中问题太多。
var的关键特征是函数级作用域。什么叫函数级作用域?就是在一个函数内部用var声明的变量,在整个函数体内都有效,哪怕你是在一个if块或者for循环块内部声明的:
function demo() { if (true) { var name = '张三'; } console.log(name); // 输出 '张三',因为 var 没有块级概念 } demo();这在其他语言(比如 C、Java)里基本是不可思议的——if块内部声明的变量,块外面居然还能访问。更麻烦的是变量提升(hoisting),var声明的变量会被提升到函数顶部,但赋值不会被提升,所以会出现这种经典怪象:
function tricky() { console.log(count); // undefined,不是报错 var count = 10; } tricky();代码执行顺序看起来是先打印后赋值,按理说应该报ReferenceError,但因为变量提升,count在函数开头已经被"声明"了,只不过值是undefined。这种隐性机制很容易掩盖代码逻辑上的问题,你以为是执行了某个分支才产生的变量,实际上整个函数都能用。
let和const则引入了块级作用域,花括号{}包裹的区域就是一个独立作用域:
function demo() { if (true) { let name = '张三'; const age = 20; } console.log(name); // ReferenceError: name is not defined }在if块外面访问name直接报错,这符合绝大多数人对"块内变量"的直觉认知,也大大减少了变量意外泄漏到外层作用域导致的数据被篡改问题。
1.2 暂时性死区(TDZ):let 和 const 的初始化保护
let和const同样存在变量提升,但机制完全不同。它们的声明会被提升,但在声明语句执行之前,变量处于一个"暂时性死区"(Temporal Dead Zone,简称 TDZ),任何对这个变量的访问都会直接抛 ReferenceError,而不是像var那样默默返回undefined。
function tdzDemo() { console.log(x); // ReferenceError: Cannot access 'x' before initialization let x = 5; }这个机制刻意把"提前访问"变成错误而非静默的undefined,目的是逼迫开发者在变量声明之后再去使用它。实际开发中,TDZ 最常出现的场景是函数参数的默认值引用:
function example(a = b, b = 2) { return a; } // 调用 example() 会报错,因为 a 默认值引用的 b 还在 TDZ 中我见过不止一次这种代码出现在业务开发里,默认参数互相依赖的写法相当危险。如果你依赖参数的先后顺序完成默认值的赋值,最好放在函数体内部重新处理。
1.3 实际选型建议
开发中有一个很实用的选型原则:默认全部用const,只有当变量确实需要重新赋值时才改为let,var直接抛弃,除非你要维护 2015 年之前的旧浏览器项目。
原因很简单,const能帮你守住一个非常重要的底线——变量绑定不被意外重新赋值。很多人误以为const声明的是常量,其实const锁定的是绑定关系而不是值的不可变性。对于对象和数组来说,const声明的变量依然可以修改内部属性或元素:
const person = { name: '张三' }; person.name = '李四'; // 合法,可以改属性 // person = { name: '王五' }; // 报错,不能重新绑定变量我用const写业务逻辑时,通常会在函数体内把"只读引用"和"可变数据"的边界理清楚。对象内部想怎么改就怎么改,只要不重新给变量赋值,代码的可读性和可维护性都有明显提升。团队协作时,代码 review 看到const就知道这个变量后续不会换绑定,排查"这个变量怎么突然变了个值"这类问题时能缩小一倍范围。
2. 数据类型户口本:从原始类型到引用类型
2.1 原始类型七姐妹
ECMAScript 标准把数据类型分成了两大类:原始类型(primitive types)和引用类型(reference types)。先看原始类型,目前一共有七种:string、number、boolean、null、undefined、symbol、bigint。
大部分初学者对前三种比较熟悉,从第四种开始就有人分不清了。先看一段代码:
let a; console.log(a); // undefined,变量声明了但没有赋值 let b = null; console.log(b); // null,变量被主动赋了一个"空值"undefined表示"还没有赋值",系统默认的状态;null表示"这个位置本来就应该有值,但现在是空的",往往是开发者主动设置的。所以判断场景时有个约定俗成的习惯:如果变量需要显式声明"暂无对象、暂无数据",用null;如果只是声明了变量准备后续赋值,交给undefined即可。
symbol是从 ES6 开始引入的,它最核心的作用是生成一个绝对唯一的标识符。比如你在业务里需要一个永远不会跟其他属性名冲突的键,Symbol()就是神器:
const uniqueKey = Symbol('description'); const obj = {}; obj[uniqueKey] = 'secret value'; console.log(obj[uniqueKey]); // 'secret value' // 常规方法遍历对象属性时,这个属性默认不会被枚举到bigint是 ES2020 引入的,用于表示超过Number.MAX_SAFE_INTEGER(9007199254740991)的整数。比如后端返回了一个雪花算法生成的 ID,长度达到 19 位,用普通number存会丢失精度,这时候就需要BigInt出场:
const bigId = 9007199254740993n; // 也可以转换字符串:BigInt('9007199254740993') console.log(typeof bigId); // 'bigint'2.2 引用类型的本质:地址与内存
引用类型在 JavaScript 里最典型的就是对象object。数组、函数、日期、正则本质上都是对象的"变体",它们通过引用(可以类比为内存地址)来访问实际数据。下面这段代码是理解引用与原始类型差异的经典入门题:
// 原始类型:值传递 let x = 10; let y = x; x = 20; console.log(y); // 10,y 完全不受 x 影响 // 引用类型:引用传递 const obj1 = { count: 10 }; const obj2 = obj1; obj1.count = 20; console.log(obj2.count); // 20,obj1 和 obj2 指向同一个对象地址obj2 = obj1这一步,复制的是内存地址,而不是对象本身。所以修改obj1.count,obj2.count跟着变化,因为它们本就指向同一份数据。Java、Python、C# 这些语言也是类似机制,理解了引用传递,后面学深拷贝浅拷贝、状态管理框架(如 Redux、Vuex)时都会轻松很多。
函数传参时也一样——参数是原始类型,就是值拷贝;参数是对象,传的就是引用:
function changeValue(v) { v = 100; } let num = 50; changeValue(num); console.log(num); // 50,原始类型在函数内部被重新赋值不影响外部 function changeObj(o) { o.name = '修改后的名字'; } const person = { name: '原始名字' }; changeObj(person); console.log(person.name); // '修改后的名字',对象属性被改的就是原对象2.3 number 类型的精度陷阱
number类型是 JavaScript 里最容易暗算人的地方,尤其浮点运算。经典现场:
console.log(0.1 + 0.2 === 0.3); // false console.log(0.1 + 0.2); // 0.30000000000000004不是 JavaScript 的 bug,而是所有遵循 IEEE 754 标准的语言(Java、Python、C++ 都一样)都会遇到的问题。计算机用二进制存储十进制小数时,像0.1这种数无法用有限的二进制位精确表示,只能无限接近。日常业务里如果涉及金额统计,永远不要用浮点数直接做加减乘除后再比较。
我处理金额的核心原则:前端展示用分而不是元做最小单位,所有计算都用整数;如果实在要浮点数,用toFixed或者做一次"乘 100 再除以 100"的整数化转换。比如:
// 以「分」为单位进行整数运算 const priceFen = 1990; // 19.90元 const count = 3; const totalFen = priceFen * count; // 5970分 // 最后展示的时候再转成带两位小数的字符串 const totalYuan = (totalFen / 100).toFixed(2); // '59.70'除了精度,NaN也是个大坑。NaN(Not a Number)本身属于number类型,但它跟任何值都不相等,包括它自己:
console.log(NaN === NaN); // false console.log(Number('abc')); // NaN // 判断 NaN 的正确方式 console.log(Number.isNaN(Number('abc'))); // trueNumber.isNaN()和全局的isNaN()有区别,全局isNaN会先把参数转成数字再判断,容易误判:
console.log(isNaN('123')); // false,因为它把字符串转成了数字 123 console.log(Number.isNaN('123')); // false,更严格,不会做类型转换还有一个经常被忽略的负数零-0:
console.log(0 === -0); // true console.log(Object.is(0, -0)); // false,Object.is 能区分平时很少碰到-0的问题,但做数据可视化、地图坐标计算或者需要精确数学判断时,这个差异可能会引入难以定位的 bug。
3. 类型判断的三大流派与适用边界
3.1 typeof:简单但粗糙
类型判断是 JavaScript 开发中最高频的操作之一。最基础的自然是typeof操作符,返回的结果是一个表示类型的字符串。
console.log(typeof 'hello'); // 'string' console.log(typeof 42); // 'number' console.log(typeof true); // 'boolean' console.log(typeof undefined); // 'undefined' console.log(typeof Symbol()); // 'symbol' console.log(typeof 123n); // 'bigint' console.log(typeof {}); // 'object' console.log(typeof []); // 'object' console.log(typeof null); // 'object' —— 历史遗留 bug console.log(typeof function(){}); // 'function'typeof null返回'object'是 JavaScript 语言设计早期的历史问题,但官方也明确不会修复,避免破坏存量代码。所以要判断变量是否为null,不能直接typeof,只能用=== null严格比较。
typeof的定位是粗判断,它能清晰区分未定义的值、原始类型和函数,但对对象内部的细分无能为力。它不管你是数组、日期、正则还是普通对象,统统返回'object'。
3.2 instanceof:能判断继承关系但伴随有效期问题
instanceof操作符用来判断"某个对象是否是某个构造函数的实例",原理是沿着原型链(prototype chain)向上查找,看能否找到该构造函数的prototype。
const arr = [1, 2, 3]; console.log(arr instanceof Array); // true console.log(arr instanceof Object); // true,数组也是对象 const date = new Date(); console.log(date instanceof Date); // trueinstanceof有一个非常隐蔽的坑:如果变量来自不同的 JavaScript 执行环境(比如 iframe 或者浏览器窗口之间),原型链会指向各自的全局对象。在 A 窗口创建的数组,拿到 B 窗口用instanceof Array判断可能直接返回false。现代前端开发中,如果你处理的数据可能来自第三方脚本或者跨域 iframe,最好别只依赖instanceof。
此外instanceof只能处理引用类型,用它判断原始类型没有任何意义:
console.log('hello' instanceof String); // false,因为原始字符串不是 String 对象实例3.3 Object.prototype.toString:最可靠的兜底方案
要正确区分所有内置类型,业界的标准做法是借助Object.prototype.toString:
function getType(data) { return Object.prototype.toString.call(data).slice(8, -1); } console.log(getType('hello')); // 'String' console.log(getType(42)); // 'Number' console.log(getType(true)); // 'Boolean' console.log(getType(null)); // 'Null' console.log(getType(undefined)); // 'Undefined' console.log(getType([])); // 'Array' console.log(getType({})); // 'Object' console.log(getType(new Date())); // 'Date' console.log(getType(/regex/)); // 'RegExp' console.log(getType(new Map())); // 'Map' console.log(getType(new Set())); // 'Set' console.log(getType(function(){})); // 'Function' console.log(getType(Symbol())); // 'Symbol' console.log(getType(123n)); // 'BigInt'这个方案可靠是因为对象的内部属性[[Symbol.toStringTag]](ES6 之后)和Symbol.toStringTag会决定toString返回的标签。注意调用方式必须是Object.prototype.toString.call(...),直接拿obj.toString()很可能会被子类重写,尤其是数组,跑出来的结果是1,2,3。
实际工程中我最常遇到的场景是:后端数据格式不确定、接口返回状态码的正常值与数组混淆、日期对象被误判成字符串。用Object.prototype.toString写一个统一的getType工具函数放在项目公共模块里,比到处散落typeof和instanceof可靠得多。
4. 类型转换的隐式江湖:== 与 + 号引发的血案
4.1 强制转换的三类操作
JavaScript 是弱类型语言,类型之间可以互相转换。强制转换比较好理解,就是我们主动调用转换方法:
// 转字符串 String(123); // '123' String(true); // 'true' String(null); // 'null',注意 null 转字符串不是报错 String(undefined); // 'undefined' // 转数字 Number('123'); // 123 Number('12.3'); // 12.3 Number(''); // 0,空字符串转数字是 0,容易踩坑 Number('abc'); // NaN Number(true); // 1 Number(false); // 0 Number(null); // 0 Number(undefined); // NaN,null 和 undefined 转数字结果不一样在日常业务开发中,比较稳妥的转换方式是使用parseInt和parseFloat,因为它们更宽容,解析到非法字符就停下来:
parseInt('123px'); // 123,直接忽略单位 parseFloat('3.14em'); // 3.14 parseInt('abc123'); // NaN,开头就是非法字符 Math.trunc(3.9); // 3,去小数位做表单校验时,我基本都会用一元加号做一次快速数字转换:+value,这等同于Number(value)。
4.2 隐式转换规则拆解
隐式转换是 JavaScript 众多"坑"的源头,不说别的,就一个==宽松相等运算符,就能写出来一篇论文。先看三组让人头皮发麻的比较:
console.log('5' == 5); // true,字符串被转成了数字 console.log(0 == ''); // true,空字符串转成 0 console.log(false == '0'); // true,false 转 0,字符串 '0' 转 0,所以相等规则我整理成这样一张表,方便后续开发时查阅:
| 左右比较类型 | 转换规则 | 示例 |
|---|---|---|
| 字符串 vs 数字 | 字符串转数字 | '5' == 5为 true |
| 布尔值 vs 任意 | 布尔值转数字(true 为 1,false 为 0) | true == 1为 true |
| null vs undefined | 仅它们两个互相相等 | null == undefined为 true |
| null 或 undefined vs 其他 | 不转换,永远不等 | null == 0为 false |
| 对象 vs 原始值 | 对象先调valueOf再调toString | [] == 0为 true |
最后一行[] == 0为 true,原因是空数组[]先转成原始值'',''再转成数字0。如果你在代码里写这种比较,基本就是给自己埋雷。
与之类似的还有+运算符的双重身份。一边是字符串拼接,一边是数字相加,具体走哪条路,取决于操作数的类型:
console.log(1 + 1); // 2,数字相加 console.log('1' + 1); // '11',字符串拼接优先 console.log(1 + '1'); // '11' console.log(1 + 2 + '3'); // '33',先 1+2 得 3,再拼成 '33' console.log('1' + 2 + 3); // '123',字符串左到右拼接如果一个操作数是字符串,那另一个操作数就会被转成字符串,所以数字 1 变成了字符串 '1'。加法从左到右计算,因此1 + 2 + '3'是先算数字加法,再拼接。
4.3 实际开发中的转换策略
我在项目里处理复杂对象拼接参数时,发现隐式转换经常造成非常隐蔽的 bug。比如你从 input 框拿到的是'0'字符串,做if (value)判断时它被当成了 truthy;但如果拿到的是0数字,if判断又会走false分支。这种因为"类型不同"带来的逻辑分叉,排查起来特别费时间。
所以现在我的规矩很简单:
- 用户输入一律显式转换为目标类型,并且做合法性校验后再使用
- 比较时尽量用
===严格相等,天然避免隐式转换 - 字符串拼接建议直接使用模板字符串
- 布尔判断只看 falsy 值集合(
false、0、-0、''、null、undefined、NaN),不要自己脑补其他规则
比如看到这段代码:
const inputValue = userInput.value; // '0' if (inputValue) { // 用户以为这里是"值存在",但 '0' 也是存在 }正确的写法应该是:
const inputValue = userInput.value.trim(); if (inputValue !== '') { // 明确判断空串与非空串 }5. 数据结构实战:数组与对象的高频操作
5.1 数组的增删改查与迭代选择
数组是 JavaScript 里用得最多的引用类型之一。日常开发中,我习惯把数组操作分为两拨:修改原数组和返回新数组。
修改原数组的方法有push、pop、shift、unshift、splice、sort、reverse。返回新数组的方法有map、filter、slice、concat。这个区分很重要,因为你会不会改变原数据,直接决定了业务逻辑是否产生副作用。举个例子:
const original = [3, 1, 2]; const sorted = original.sort(); // 你期望 sorted 是排序后的新数组,但 original 也被改了 console.log(original); // [1, 2, 3]sort是原地排序,它会直接修改原数组。如果你不想动原始数据,必须先拷贝一份再排序:
const original = [3, 1, 2]; const sorted = [...original].sort(); console.log(original); // [3, 1, 2],原数组没动 console.log(sorted); // [1, 2, 3]数组迭代方法的选择也有讲究。forEach就是遍历一遍,不产生返回值,适合执行副作用;map要求一一映射生成新数组;filter用来过滤生成子集;reduce适合把数组折叠成单个值(累加、分组、扁平化都可以用它)。现在项目里我几乎用的是map/filter/reduce三剑客,很少用传统for循环,原因是链式调用可读性更高、逻辑意图更清晰。
5.2 对象的键名陷阱与遍历顺序
对象虽然看起来简单,但在键名处理上暗藏不少细节。最典型的是键名默认被转换成字符串:
const obj = {}; obj[true] = 1; obj[null] = 2; obj[undefined] = 3; console.log(obj); // { true: 1, null: 2, undefined: 3 }键确实是字符串形式的'true'、'null'、'undefined'。如果你用数字作为键,它们也会被转成字符串,这导致遍历顺序不是简单的插入顺序,而是"整数键优先按升序排列、字符串键按插入顺序排列":
const obj = {}; obj['3'] = 'c'; obj['1'] = 'a'; obj['2'] = 'b'; obj['x'] = 'x'; console.log(Object.keys(obj)); // ['1', '2', '3', 'x'],整数键被升序重排了前端的对象遍历一般推荐Object.keys()、Object.values()、Object.entries(),或者for...in。但注意for...in会把原型链上的可枚举属性也遍历进来,比如所有对象继承来的toString方法,虽然默认不可枚举,但如果你给对象原型添加过自定义属性,遍历就出乱子了。所以遍历对象自己的属性,标准化做法永远是Object.keys()组合Object.entries()。
5.3 深拷贝与浅拷贝:拷贝不是复制地址
既然引用类型存的是地址,真正困扰业务的问题就是"我不想改原对象,但操作之后原对象被改了"。解决思路分浅拷贝和深拷贝两个层次。
浅拷贝只复制最外层:
const obj = { name: '张三', info: { age: 18 } }; const copy = { ...obj }; copy.name = '李四'; console.log(obj.name); // 张三,最外层属性互不影响 copy.info.age = 20; console.log(obj.info.age); // 20,深层对象其实还是同一个{ ...obj }和Object.assign({}, obj)都属于浅拷贝,内层嵌套对象仍然是引用。想要完整的深拷贝,业界有个"交学费"级别的操作:JSON.stringify后再JSON.parse。
const obj = { name: '张三', info: { age: 18 }, tags: ['a', 'b'] }; const deepCopy = JSON.parse(JSON.stringify(obj));这个方法之所以容易翻车,是因为它有几个坎:undefined、函数、Symbol会直接消失;Date会变成字符串;NaN、Infinity会变成null;遇到循环引用直接报错。如果数据里含上述任何一种特殊值,这个方法就不安全了。
如果需要深拷贝,而且项目里已经引用了现代前端框架(如 React、Vue)或者 lodash 这类公共库,直接使用内置的方案。框架里如果带了响应式或不可变数据机制,往往不用你手动拷贝。没有这些条件时,可以自己写一个递归版本的核心逻辑来应对大多数场景。
6. 高频运行时报错与排查路径
6.1 TypeError 家族:不是函数、不能读取属性
JavaScript 运行时报错里,TypeError见得最多。打开控制台看到一串xxx is not a function通常意味着你调用的那个变量,当前并不是函数类型。常见原因包括:变量被重新赋值为非函数值、对象属性名拼写错误、事件回调在注册时就被立即执行了。
// 例子:手误把 options 写成 option const config = { options: [] }; config.option.map(...); // TypeError: config.option.map is not a function另一类高频报错是Cannot read properties of undefined (reading 'xxx')。这种基本就是链式访问时,某个环节返回了undefined或null。最典型的场景是接口返回的数据结构因为各种原因缺失了某个层级:
// 假设接口返回 data: { list: [...] },但 list 可能未返回 const list = response.data.list.ite ms.map(...); // 如果 data.list 为 undefined,直接报错安全的写法是加可选链操作符?.:
const items = response?.data?.list?.items?.map(...) ?? [];?.是 ES2020 引入的,之前在项目里最常见的就是一级级&&短路判断,现在有了可选链,代码整洁程度完全不是一个级别。
6.2 数组与对象的空值防护模式
我在做递归遍历或者处理深层次接口嵌套时,有个心得:宁可多写一层防护,不要在运行时报错后再回来补。常见的防护模式有兜底默认值、提前判空、封装数据筛选器。
// 兜底默认值 const list = response?.data?.list ?? []; // 判空函数 function safeGetArray(data) { return Array.isArray(data) ? data : []; } // 接口数据的统一清洗层 function normalizeList(apiData) { const rawList = apiData?.items; return Array.isArray(rawList) ? rawList.map(item => ({ id: item.id, name: item.name, })) : []; }这套"清洗层"的思路在协作项目中特别重要。后端返什么不可控,前端在边界层做一次数据类型收敛,后面的业务代码直接当作规范数据来写,不用到处判断。
6.3 类型感知的调试习惯
最后分享一个调试习惯:每逢运行时报错,先打印类型,再打印值。很多问题不是变量没有值,是因为你脑子里假定的值和实际跑出来的值类型对不上。比如console.log(typeof value, value),这一行能直接告诉你"哦,原来它是字符串,不是数字",比你盯着复杂的报错堆栈定位更快。
我用这个方法排查过一个很隐蔽的问题:某个列表数据渲染不出来,看数据里有值,循环也写了,找半天发现后端返回的是一个对象而不是数组,map方法根本不在对象上,所以渲染过程中静默失败了。如果一开始就console.log(typeof data),一眼就能发现类型问题。
这个习惯建议所有初学者从第一天就开始养成,收益在后期的项目维护中特别大。