JS变量与数据类型通关指南:从内存模型到类型转换
2026/9/14 19:32:13 网站建设 项目流程

1. 从一道“送命题”开始:你真的了解变量吗

能把“变量”这个词讲清楚的人,ta 对 JavaScript 的理解肯定差不到哪里去。我面试过不少人,问数组和对象的区别,答得头头是道;再问一句“那const obj = {}为什么还能往 obj 里加属性”,十个人里有八个会愣一下。

这就是 JS 变量最迷惑人的地方:表面上是“存数据”,实际上背后藏着一整套内存模型、引用传递、类型转换的规则。很多新人写 bug,最后排查下来,十有八九不是语法写错了,而是对变量和数据类型的基本功不扎实。

这篇就是 JS 入门通关手册的第三篇,核心就两件事:变量怎么存,类型怎么变。把这个“万能盒子”的原理摸透了,后面再学函数、闭包、原型链,你会觉得一路绿灯。

如果你正处于刚学完 HTML 和 CSS、准备啃 JS 却到处碰壁的阶段,这篇内容就是为你准备的。哪怕你已经工作一两年,我也建议你把文中的数据装箱、类型判断、强制转换这几节重新捋一遍,踩坑概率会小很多。

2. 变量到底是什么:内存里的“盒子”与“引用纸条”

2.1 用“租房”理解变量赋值

很多人一开始背概念:“变量是用于存储数据的容器”。对,但太抽象了。我自己上课的时候喜欢用租房来比喻,学生反馈特别好。

一个变量声明,比如let age = 18,等于你租了一间房子,门牌号叫 age,屋子里放着值 18。往变量里重新赋值,比如age = 20,就是把这间屋子里的旧家具扔了,换一套新的。

原始类型(primitive type)的赋值,好比你把家具复制了一份送到新房子里。你改新房子的家具,老房子一点不受影响。但对象、数组这类引用类型不一样——变量里存的不是“家具本身”,而是一张写着“家具在仓库哪一排哪一格”的纸条。你把这个变量赋给另一个变量,只是把纸条复印了一份,两个人手拿纸条去仓库改的还是同一套家具。

这就是我在开头说的那道送命题的答案。const obj = {}限制的只是 obj 这个名字不能重新指向别的对象,但 obj 指向的那个对象本身,你想怎么改就怎么改,因为它不是常量,是“常量引用”。

2.2 内存里的真实样子:栈与堆的默契分工

要再往深挖一层,就得说内存了。JS 的引擎(比如 V8)在运行时会把内存主要分成栈(stack)和堆(heap)两块。

原始类型的值小,放在栈里,存取速度极快,按值访问。你复制变量,就是在栈里又新压入了一个值。

引用类型的对象本体比较大,放在堆里。栈里的变量保存的只是一个指向堆内存的地址编号。为什么这么做?因为栈的内存空间有限,而且入栈出栈讲究顺序,如果把一个超大对象直接塞进栈里,性能要崩。

画个简易示意:

栈(Stack) 堆(Heap) +-----------+ +-----------+ | age = 18 | | | | obj = 0x7 | --------------> | { name: '张三' } | +-----------+ +-----------+

这里面有个很重要的推论:当你做“浅拷贝”的时候,比如直接const obj2 = obj,你只是把地址编号复制了一份,obj 和 obj2 指向同一个对象,改任意一个,另一个都会跟着变。想要真正“独立”,得做深拷贝,把堆里的内容重新复制出一份来。

2.3 词法环境与作用域链

很多教程讲变量会跳过词法环境,但我建议新人至少知道这个概念。JS 在执行一段代码时,会先创建执行上下文,每个上下文里都有一个词法环境(Lexical Environment),用来记录当前作用域内声明的变量。

这是一个内部记录,不是给你 get 用的。但你写的代码能不能访问某个变量,访问到的是哪个值,全看词法环境怎么记录。函数内部能访问外部变量,是因为作用域链把内外层的词法环境串了起来。

新人常见的误区是:“我在函数里定义了一个变量,外面怎么访问不到?”这就正常了。函数内部的let变量只存在于该函数对应的词法环境,函数执行完,环境销毁,变量跟着没了。

3. 数据类型全家桶:七个成员,两种分类

3.1 原始类型:六块“乐高积木”

JS 有七种内置数据类型:六种原始类型,一种引用类型(Object)。原始类型包括:

类型示例说明
number423.14NaN整数和小数不分家,还包括Infinity
string'hi'"hi"`hi`文本,单双引号反引号都行
booleantruefalse逻辑判断
nullnull空值,注意 typeof 有坑
undefinedundefined声明了但没赋值
symbolSymbol('id')独一无二的值,对象属性名利器
bigint9007199254740993n超大的整数

这里我说句大实话:symbol 和 bigint 这两个新成员,新手日常写业务代码基本用不到,但面试爱问。知道它们是原始类型、按值传递、不可变,就够了。

拿 bigint 举例:JS 的 number 类型能安全表示的整数范围是-2^53 + 12^53 - 1,超出这个范围,精度就出问题了。比如9007199254740992 + 1得到的结果和9007199254740992一样,因为后一位被取舍掉了。bigint 就是为解决这个痛点出现的,末尾加一个n就是 bigint 类型。

3.2 引用类型:对象家族一脉相承

再说对象。数组、函数、日期、正则,甚至你自定义的 class,它们底层都是 Object。区别只是“长相”和内置方法不同。

  • 数组:有序列表,用[]包裹,适合放同一类数据
  • 对象:键值对集合,用{}包裹,适合描述某个实体的属性
  • 函数:可以执行的子程序,但本质上也是对象,所以你可以给函数挂属性

这个“万物皆对象”的特性,是 JS 灵活的一大源头。但也带来一个副作用:引用传递导致的数据联动,经常让新手心态崩溃。

我见过最经典的一个场景:把一个数组用const newArr = arr赋值给另一个变量,然后 sort 了一下 newArr,结果原数组也被排序了。原因就是两者指向同一个数组对象。要复制数组,得用[...arr]或者arr.slice()

3.3 字面量类型和“类型”不是一回事

网络热词里有人搜“字面量类型和数据类型的区别”,这个值得展开说说。字面量(literal)就是你直接写在代码里的那个具体值,比如let num = 5里的 5,let str = 'abc'里的'abc',它们都是字面量。

字面量是“字面意思”的值,而类型是“这个值属于哪一类”的标签。5 是 number 类型,'abc'是 string 类型。

但在 TypeScript 里这个说法有特例。TS 中有字面量类型,比如type Direction = 'up' | 'down',这里的字面量类型指的是“值只能等于某个具体字面量”的一种类型约束。如果你搜到这个热词,大概率是在学 TS,这里顺手帮你区分一下。

4. 变量声明的“三国演义”:var、let、const

4.1 三兄弟能力对比

ES6 之后,JS 有了三种变量声明方式,很多新手傻傻分不清。我直接给一张对照表:

声明方式作用域变量提升能否重复声明能否重新赋值
var函数作用域会提升并初始化为 undefined可以可以
let块级作用域会提升但不初始化(暂时性死区)不可以可以
const块级作用域会提升但不初始化不可以不可以

4.2 为什么建议彻底告别 var

我知道很多老教程还在教你用 var,但现实是:现代 JS 开发中,var 基本可以躺进博物馆了。

var 最大的问题有两个。第一是函数作用域会漏,比如:

if (true) { var leak = '我跑出来了'; } console.log(leak); // 输出:我跑出来了

第二是变量提升带来的诡异表现:

console.log(a); // undefined,不是报错 var a = 1;

因为你以为自己在用 a,实际拿到的是 undefined,这种 bug 一旦出现在业务里,特别难排查。let 和 const 把这个问题修掉了:在声明之前访问变量,直接抛 ReferenceError,帮你把隐患暴露在第一时间。

4.3 const 不是“不可变”,是“不可换名”

这一点新手最容易搞混。我反复跟学员强调:const 的意思是绑定关系不可变,不是内容不可变。

const person = { name: '张三' }; person.name = '李四'; // 允许,改的是对象内容 person = { name: '王五' }; // TypeError: Assignment to constant variable

那什么时候该选 const?我的习惯是:变量声明后不再重新赋值的一律用 const,比如从接口拿到的配置对象、函数引用。需要重新赋值的才用 let。这样代码读起来很清晰:看到 const 就知道这个变量从头到尾指向同一个东西,看到 let 就知道它可能变。

5. 类型判断:你不知道的 typeof 与 instanceof

5.1 typeof 的功能与局限

判断数据类型,最常用的工具是typeof操作符。它返回一个字符串。

typeof 42; // 'number' typeof 'hi'; // 'string' typeof true; // 'boolean' typeof undefined; // 'undefined' typeof Symbol(); // 'symbol' typeof 10n; // 'bigint' typeof function() {}; // 'function' typeof []; // 'object' typeof {}; // 'object' typeof null; // 'object'(经典 bug)

看到没?typeof null返回'object',这是个从 JavaScript 诞生起就存在的历史遗留 bug。你没法用 typeof 判断一个值是不是 null,只能这样写:

const value = null; if (value === null) { console.log('这是 null'); }

另外还有个大坑:typeof []返回的是'object',所以 typeof 也分不清数组和对象。要判断数组,用Array.isArray()

5.2 instanceof 的正确打开方式

如果 typeof 是判断“原始值”的利器,那 instanceof 就是判断“对象到底是谁家的孩子”的放大镜。

const arr = []; arr instanceof Array; // true arr instanceof Object; // true(数组也是对象) const date = new Date(); date instanceof Date; // true date instanceof Object; // true

instanceof 的底层机制是沿着原型链找:右边构造函数的 prototype 对象在不在左边对象的原型链上。在就是 true,不在就是 false。这也解释了为什么数组 instanceof Object 也是 true——数组的原型链上确实有 Object.prototype。

但 instanceof 也有失灵的时候。最典型的是 iframe 场景:一个 iframe 里创建的数组,拿到外层用arr instanceof Array判断,会得到 false。因为这个数组的原型链是 iframe 内部那个 Realm 的 Array.prototype,和外层的 Array 不是一个东西。高版本的 JS 提供了Array.isArray()来处理这种跨领域判断,这也侧面说明了为什么框架源码里到处都在用 isArray。

5.3 Object.prototype.toString:终极判断大法

如果你想问“这些方法都不完美,那最靠谱的判断方式是什么?”答案是借用 Object.prototype.toString。

Object.prototype.toString.call(42); // '[object Number]' Object.prototype.toString.call('hi'); // '[object String]' 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(new Date()); // '[object Date]'

直接返回一个统一的[object 类型]格式,无差别识别。我在写通用工具函数时,基本都用这个方式做类型判断,稳得一批。

6. 类型转换:V8 引擎的“自动翻译”与“手动翻译”

6.1 为什么需要强制转换

数据类型的话题里,最让人头大又最防不胜防的就是转换。前端从输入框拿到的值永远是字符串,哪怕用户输入的是数字。你要做加法,就得先把字符串变成数字,不然就会得到字符串拼接的结果。

const inputValue = '5'; const total = inputValue + 3; console.log(total); // '53',不是 8

这就是为什么“数据类型强制转换”会出现在热词里,整个开发过程到处都要用。

6.2 常用的显式转换套路

三个全能的构造函数:

String()转字符串,最省心:

String(123); // '123' String(true); // 'true' String(null); // 'null'

Number()转数字,细节很多:

Number('42'); // 42 Number('3.14'); // 3.14 Number(''); // 0,这个容易忘 Number(' '); // 0,纯空格也是 0 Number('12px'); // NaN,不是 12 Number(true); // 1 Number(false); // 0 Number(null); // 0 Number(undefined); // NaN

Boolean()转布尔:

Boolean(0); // false Boolean(''); // false Boolean(null); // false Boolean(undefined); // false Boolean(NaN); // false Boolean('0'); // true(字符串 '0' 是真值!)

这里有个高频考查点:字符串'0'会被转成 true。因为真值判断只看“这个值是不是那六个假值”,六个假值是:0NaN''nullundefinedfalse。字符串'0'不是空字符串,所以它是真值。

6.3 隐式转换才是真正的坑王

比显式转换更折磨人的是隐式转换。JS 的“自动翻译”规则非常多,而且前后版本还不一致,我挑几个高频场景讲讲。

加法运算符的特殊之处:只要有一边是字符串,结果就是字符串拼接:

1 + '2'; // '12' '1' + 2; // '12' 1 + 2 + '3'; // '33',先算 1+2=3,再拼上一个字符串 '1' + 2 + 3; // '123',从左到右全部变成字符串拼接 '3' - 1; // 2,减号没有拼接语义,强制转数字 '3' * '2'; // 6,两边都能转成数字,就成功乘法

相等运算符的模糊地带:

null == undefined; // true null == 0; // false,诡异吧 '' == 0; // true(字符串转数字后比较) [] == false; // true(数组转成空字符串,再转成 0,false 也转成 0)

看到这里你应该明白了,为什么我在日常开发中不用==,一律用=====设计初衷是方便,实际带来的坑远远大于便利。

6.4 解析 parseInt 与 Number 的本质差别

处理“带单位的字符串”,比如'12px',用 Number 会得到 NaN,用 parseInt 却能拿到 12:

parseInt('12px'); // 12 parseInt('px12'); // NaN parseInt('12.7'); // 12(只取整数部分) parseFloat('12.7'); // 12.7

parseInt 的规则是:从字符串头部开始解析,直到遇到不能转换的字符就停。所以'12px'能解析出 12。而 Number 要求整个字符串都必须是合法的数字格式,有一个字符不行就直接 NaN。

关于 parseInt,还有一个全网都讨论过的问题:不传第二个参数怎么办?parseInt('0x10')在旧引擎里会被当成十六进制解析,得到 16。为了不模糊,我建议永远传第二个参数:parseInt(str, 10),明确告诉引擎按十进制解析。

7. 深度实战:写一个靠谱的工具函数

7.1 需求:实现一个真正的类型判断函数

前面铺垫了那么多,现在来个综合实战。很多人网上搜“js判断字符串是否包含”,搜“js 数据类型判断”,其实底层原理就是前面这些。咱们来写一个真正能打的 deepType 函数。

function deepType(value) { return Object.prototype.toString.call(value).slice(8, -1).toLowerCase(); } deepType(42); // 'number' deepType('hello'); // 'string' deepType([]); // 'array' deepType({}); // 'object' deepType(null); // 'null' deepType(new Date()); // 'date' deepType(/regex/); // 'regexp' deepType(new Map()); // 'map' deepType(function() {}); // 'function'

这个函数把'[object Array]'这种字符串里的Array部分切出来并转成小写,返回一个干净的标识。跨 iframe、跨窗口也不会有问题,因为它用的是最原始的 Object.prototype.toString,而不是某个具体环境里的构造函数。

7.2 实战:安全的深拷贝

有了类型判断能力,再结合前面讲的栈堆原理,咱们来实现一个经典工具——深拷贝。冷知识:很多候选人能说出深拷贝的浅层实现,但一遇到循环引用就当场翻车。

function deepClone(value, map = new WeakMap()) { if (value === null || typeof value !== 'object') { return value; } if (map.has(value)) { return map.get(value); } if (value instanceof Date) { return new Date(value.getTime()); } if (value instanceof RegExp) { return new RegExp(value.source, value.flags); } const result = Array.isArray(value) ? [] : {}; map.set(value, result); Object.keys(value).forEach((key) => { result[key] = deepClone(value[key], map); }); return result; }

这里用 WeakMap 来记录已经克隆过的对象,就是为了对付循环引用:如果对象 A 里引用了对象 B,B 又引用回 A,没有 map 记录的话就会无限递归,直到栈溢出。用 WeakMap 还有一个好处:它的键是弱引用,不会阻止垃圾回收,所以不用担心内存泄漏。

7.3 前端业务里的类型转换自动化

再分享一个小场景:封装接口请求的时候,后端返回的数据经常要把“字符串的数字”转成真正的数字,比如订单金额、数量、状态码。你可以写一个自动转换的递归函数:

function convertTypes(obj) { if (typeof obj === 'string' && obj.trim() !== '' && !isNaN(Number(obj))) { return Number(obj); } if (Array.isArray(obj)) { return obj.map(convertTypes); } if (obj !== null && typeof obj === 'object') { const result = {}; for (const key of Object.keys(obj)) { result[key] = convertTypes(obj[key]); } return result; } return obj; }

这种工具在对接老接口时特别有用。但我不建议在正式项目中无差别使用,因为有些字段可能需要保留字符串格式,比如编号'001',转成数字就变成 1 了,会导致数据失真。所以这类工具一定要配合白名单或黑名单字段使用。

8. 常见问题与排坑手册

8.1 变量作用域相关的疑难杂症

现象:在 for 循环里用 var 定义 i,循环结束 i 值串了。

for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // 输出 3、3、3,不是 0、1、2

原因不复杂:var 是函数作用域,循环里每次迭代用的都是同一个 i,等到定时器回调执行时,i 早就已经变成 3 了。解决方式有三个:换成 let(块级作用域,每次迭代生成新的绑定)、用闭包(造个立即执行函数),或者用 setTimeout 的第三个参数传值。在我的代码规范里,直接用 let 是最优先选项。

现象:用 const 定义基础类型,之后想改值,报错。

这不是 bug,是你的变量声明选错了。const 只适合“绑定关系不允许改变”的变量,如果需要重新赋值,这种场景从一开始就应该用 let。报错是 JS 保护你的方式,不要拿const当“对象专用声明”。

8.2 类型判断的边界场景

现象:判断一个值是否为整数,用typeof value === 'number'然后判断value === value不对。

如果 value 是 NaN,typeof NaN === 'number'为 true,但 NaN 不等于任何值,连自己都不等。正确做法是用Number.isInteger(value),它会比typeof更严格,还会排除掉小数。

Number.isInteger(42); // true Number.isInteger(42.5); // false Number.isInteger(NaN); // false Number.isInteger('42'); // false(不会先转类型)

现象:检查一个对象是否有某个属性,用obj.prop判断得到 undefined 就以为没有。

obj.prop等于 undefined,可能真的是没有这个属性,也可能是这个属性存在但值就是 undefined。想区分两种场景,用'prop' in obj检查属性是否存在,或者更严格地讲,用Object.prototype.hasOwnProperty.call(obj, 'prop')检查是不是自身属性(不包含原型链上的属性)。

8.3 内存泄漏:变量不该成为“常驻嘉宾”

前面提到词法环境和垃圾回收,这里展开来说一个实战问题。全局变量是最容易造成内存泄漏的,因为它一直在词法环境的根上挂着,垃圾回收器不会动它。

常见场景:

  • 忘了用 let/const 声明,直接someVar = 1,严格模式下会报错,非严格模式下就默默变成了全局变量。
  • 事件监听器注册了但从不移除,回调本身带着外层的引用,整个作用域链都回收不了。
  • 定时器清除不干净,setInterval 里引用的大对象一直留在内存里。

我在项目里的做法是:任何临时数据尽量在函数内用局部变量存,函数执行完,局部变量随执行上下文一起销毁;window 上不挂临时数据;使用完的定时器,该 clearInterval 就 clearInterval。

8.4 类型转换地狱的真实翻车案例

有一次我在处理表格导出功能,单元格数字是字符串,我顺手用了value + ''转字符串。结果有个字段是 null,拼出来的字符串变成了"null",导出后用户看到一列全是 null,还以为是程序出了 bug。

这就是典型的没考虑边界值的转换。

const value = null; const str = String(value); // 'null'

后来我统一改成:

function safeToString(value) { return value === null || value === undefined ? '' : String(value); }

遇到 null 和 undefined 直接返回空字符串,符合大多数业务预期。这种细节写不到官方文档里,但就是这些细节决定了你的工具函数好不好用。

9. 写在最后的个人心得

从最早的var时代走到let/const,从手动判断类型到各种 framework 自带的处理逻辑,JS 的变量和数据类型体系离“完美”还有距离,但它已经足够撑起复杂的现代应用。

我做前端这么多年,一个深切的体会是:凡是能把 null/undefined/NaN 这三个概念的边界梳理清楚、能在脑子里画出栈和堆结构的人,写代码几乎不会有低级 bug。究其原因,是这部分人对底层机制有敬畏心,不会抱着“反正能跑就行”的心态去写。

如果你正在学 JS,我建议你今天就把代码编辑器打开,把本文里那几张表挨个敲一遍,重点体验'3' - 1'3' + 1[] == falsenull == undefined这几个让人神经错乱的结果。别嫌这些题目简单,很多工作了三四年的人,遇到隐式转换照样栽跟头。

还有一个小习惯我要强调:写工具函数时,先把输入的类型当成“不诚实的”来防。用户传了一个'0',哪个分支会出问题?接口返回了null,哪个字段会崩?多问自己这两句话,能省下来的排查时间,远超你当初多写的那几行防御代码。

JS 的变量和数据类型,说到底是人与内存之间的“翻译层”。你把翻译规则吃透了,无论未来用 React、Vue 还是直接写原生 JS,底层逻辑都能一以贯之,不会因为换个框架就重新开始踩坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询