写在前面
JavaScript 是浏览器的原生语言,也是当下 Web 开发不可绕过的地基。它的语法表面简单,却在"变量提升""引用传递""类型转换"这些底层机制上埋着大量坑------很多写了两三年的人,面对 typeof null === "object"、0.1 + 0.2 !== 0.3 依然会答错。
本文将讲明白以下三件事:
- 怎么把 JS 代码跑起来(三种引入方式与执行时机);
- 怎么声明变量 (
var/let/const及背后的作用域与提升机制); - 数据分几类、有什么区别(原始类型与引用类型,这是理解后续一切的核心)。
第三点是本文的重头戏。只有真正分清"值"和"引用",你才能解释为什么 const 声明的对象还能被修改、为什么两个看似相等的对象 === 却为 false。
一、引入方式:让浏览器执行 JS 的三种途径
1.1 内联脚本:<script> 标签
在 HTML 中直接写 JS,最直接的方式:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>内联脚本</title>
</head>
<body>
<h1>打开控制台(F12)查看输出</h1>
<script>
// 浏览器逐行执行这里的代码
console.log("Hello, JavaScript!");
let a = 10;
console.log(a * 2); // 20
</script>
</body>
</html>
- 代码写在
</body>之前,可以确保 DOM 已解析完成; - 脚本中的语法错误会导致整段脚本中断(后面的语句不执行)。
1.2 外部文件:<script src>
把 JS 独立成 .js 文件,用 src 引入,是工程化的标准做法:
html
<script src="./js/main.js"></script>
js
// js/main.js
console.log("外部脚本执行了");
推荐理由:结构分离、可缓存、可复用。代码量一大,内联脚本就是灾难。
1.3 内联事件属性(了解即可)
把 JS 直接写在 HTML 属性里(如 onclick)。能跑,但不推荐------HTML 与逻辑耦合,难以维护:
html
<button onclick="alert('点击了')">点我</button>
1.4 加载时机:普通脚本、defer 与 async
普通 <script> 是同步阻塞的:浏览器遇到它必须下载并执行完,才继续渲染后面的 HTML。这导致:
- 脚本放
<head>会阻塞首屏渲染; - 脚本放
<body>末尾可以避免阻塞,但脚本会等 DOM 解析完才执行。
defer 和 async 解决外部脚本的加载问题:
html
<!-- defer:下载不阻塞,DOM 解析完成后按顺序执行 -->
<script defer src="./js/a.js"></script>
<script defer src="./js/b.js"></script>
<!-- async:下载不阻塞,下载完立刻执行(不保证顺序) -->
<script async src="./js/c.js"></script>
| 属性 | 下载是否阻塞 | 执行时机 | 顺序保证 |
|---|---|---|---|
| 无 | 阻塞 | 立即执行 | 按文档顺序 |
defer |
不阻塞 | DOM 解析完成后 | 按文档顺序 |
async |
不阻塞 | 下载完成立即执行 | 不保证 |
现代工程实践:脚本一律放
<body>末尾或加defer;模块化项目则用<script type="module">(自带 defer 语义)。
二、变量声明:var / let / const
2.1 三种声明方式
js
var a = 1; // ES5 时代的声明
let b = 2; // ES6:块级作用域,可重新赋值
const c = 3; // ES6:块级作用域,不可重新赋值
2.2 作用域:这是 var 与 let/const 最本质的区别
var:函数作用域(或全局),没有块级概念;let/const:块级作用域 ({}内有效)。
js
if (true) {
var x = 1; // var 无视块级
let y = 2; // let 只在块内有效
}
console.log(x); // 1 ------ 变量泄漏到了外面
console.log(y); // ReferenceError: y is not defined
2.3 变量提升与暂时性死区
var 会提升声明,不会提升赋值:
js
console.log(a); // undefined(不报错!)
var a = 10;
等价于:
js
var a; // 声明被提升到顶部,值为 undefined
console.log(a); // undefined
a = 10;
let / const 存在暂时性死区(TDZ, Temporal Dead Zone):声明之前访问直接报错:
js
console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;
从进入作用域到 let b = 10 执行完之前的这段区域就是 TDZ,期间访问变量会抛错。这比 var 的静默 undefined 更安全,能尽早暴露错误。
2.4 赋值规则:const 不变量,只防"重新绑定"
const 禁止的是重新赋值,而不是禁止修改值本身------这一点是高频考点:
js
const obj = { name: "Tom" };
obj.name = "Jerry"; // ✅ 允许:修改对象的属性
obj = {}; // ❌ TypeError: Assignment to constant variable
因为 obj 变量保存的是对象的引用 (内存地址),const 锁定的是"地址不能变",对象内部怎么改都管不着。
2.5 实践原则
- 默认用
const:变量一旦初始化就不要再重新绑定; - 确定需要重新赋值时才用
let; - 不要用
var(除非在兼容 ES5 的老代码里)。
三、数据类型总览:七种原始 + 一种引用
ECMAScript 定义了 8 种数据类型:
| 类型 | 分类 | 说明 |
|---|---|---|
string |
原始 | 字符串 |
number |
原始 | 数值(含 NaN、Infinity) |
boolean |
原始 | true / false |
undefined |
原始 | 声明未赋值 |
null |
原始 | 显式空值 |
symbol |
原始 | 唯一标识(ES6) |
bigint |
原始 | 任意精度整数(ES2020) |
object |
引用 | 对象(含数组、函数等) |
原始类型(Primitive) :直接存值,复制即拷贝值。 引用类型(Reference):存内存地址,复制拷贝的是引用。
用 typeof 判断类型:
js
typeof "hi"; // "string"
typeof 42; // "number"
typeof true; // "boolean"
typeof undefined; // "undefined"
typeof null; // "object" ← 历史 bug,注意!
typeof Symbol(); // "symbol"
typeof 10n; // "bigint"
typeof {}; // "object"
typeof []; // "object" ← 数组也是 object
typeof function() {}; // "function"
两个著名的坑:typeof null 返回 "object"(ES 规范遗留 bug,无法修复);数组用 typeof 也返回 "object"。
四、原始类型详解
4.1 string(字符串)
三种写法,模板字符串支持插值与换行:
js
const s1 = "单引号";
const s2 = '双引号';
const name = "Tom";
const s3 = `你好,${name},这是模板字符串`;
常用操作:
js
const str = "hello world";
str.length; // 11
str.toUpperCase(); // "HELLO WORLD"
str.includes("llo"); // true
str.slice(0, 5); // "hello"
str.split(" "); // ["hello", "world"]
4.2 number(数值)
包含三个特殊值:NaN、Infinity、-Infinity。
浮点精度问题------必考:
js
0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3 // false
原因:小数在内存中以二进制存储,0.1 无法被二进制精确表示。解决方案:换算成整数运算,或用 toFixed 处理展示:
js
// 方案一:转整数计算
(0.1 * 10 + 0.2 * 10) / 10; // 0.3
// 方案二:只处理展示
(0.1 + 0.2).toFixed(2); // "0.30"
NaN 是"不是数字的数字",NaN 不等于任何值,包括它自己:
js
NaN === NaN; // false
isNaN("abc"); // true(会先做类型转换)
Number.isNaN("abc");// false(严格判断,推荐)
4.3 boolean 与真值判断
布尔只有 true / false,但任何值都可以转成布尔。假值(falsy)只有 6 个:
js
false
0
"" // 空字符串
null
undefined
NaN
其余全是真值(truthy),包括 "0"、" "、[]、{}:
js
if ([] && {}) { console.log("都是真值"); } // 会执行
4.4 null 与 undefined 的区别
undefined |
null |
|
|---|---|---|
| 语义 | 声明了但没赋值(系统给的"未定义") | 主动声明"这里没有值" |
| typeof | "undefined" |
"object" |
| 出现场景 | 变量未赋值、函数无返回、访问不存在的属性 | 手动清空/占位 |
js
let a;
console.log(a); // undefined
const user = null; // 开发者主动表示"暂无用户"
4.5 symbol 与 bigint(了解)
symbol 用于生成唯一标识(常用于对象私有属性 / 消除命名冲突):
js
const s1 = Symbol("id");
const s2 = Symbol("id");
s1 === s2; // false,即使描述相同也不相等
bigint 解决大整数精度问题:
js
const big = 9007199254740993n; // 尾部 n 表示 bigint
typeof big; // "bigint"
五、引用类型详解
引用类型统称 object,常见形态:普通对象、数组、函数、日期、正则等。它们的共同点:变量保存的是堆内存中的地址。
5.1 对象(Object)
js
const user = {
name: "Tom",
age: 18,
"爱好": "coding", // 属性名可以是中文
};
user.age = 19; // 修改属性
user.email = "t@e.com"; // 动态添加属性
delete user.age; // 删除属性
访问属性两种方式:user.name(点)与 user["name"](方括号,可接变量)。
5.2 数组(Array)
数组本质是"特殊对象",下标即属性名:
js
const arr = [1, 2, 3];
arr.push(4); // [1, 2, 3, 4] 尾部追加
arr.pop(); // 删除尾部
arr.map(x => x * 2); // [2, 4, 6] 不修改原数组
arr.filter(x => x > 1); // [2, 3]
5.3 函数(Function)
函数也是对象,可以被赋值、传参、作为返回值(即"一等公民"):
js
const add = function (a, b) {
return a + b;
};
const result = add(1, 2); // 3
箭头函数是 ES6 的简洁写法:
js
const add = (a, b) => a + b;
六、原始 vs 引用:核心区别与传值机制
这是全篇最重要的部分。
6.1 赋值行为:拷贝值 vs 拷贝引用
原始类型赋值 = 值拷贝,两个变量互不影响:
js
let a = 10;
let b = a; // 把 a 的值复制一份给 b
b = 20;
console.log(a); // 10 ------ a 没变
引用类型赋值 = 引用拷贝,两个变量指向同一对象:
js
const obj1 = { count: 1 };
const obj2 = obj1; // 拷贝的是地址,不是对象
obj2.count = 999;
console.log(obj1.count); // 999 ------ obj1 也变了!
内存示意:对象本身在堆内存,变量只存堆地址。obj2 = obj1 让两个变量持有同一个地址。
6.2 函数参数:也是"值传递",但引用类型传的是"地址值"
js
function change(x) {
x = 100; // 重新绑定参数,不影响外部
}
let n = 1;
change(n);
console.log(n); // 1
function modify(o) {
o.name = "Jerry"; // 修改对象内部,外部可见
}
const u = { name: "Tom" };
modify(u);
console.log(u.name); // "Jerry"
结论:参数永远是值传递------原始类型传值,引用类型传"地址值"。所以"改了对象的属性,函数外也能看到",但"把参数重新赋值成别的对象"不影响外部。
6.3 相等性比较:== / === / Object.is
==:比较前会类型转换;===:类型和值都相同才相等(推荐默认使用);Object.is:类似===,但能正确区分NaN和±0。
js
1 == "1"; // true(类型转换后相等)
1 === "1"; // false(类型不同)
null == undefined; // true(== 的经典特例)
null === undefined; // false
NaN === NaN; // false
Object.is(NaN, NaN); // true
0 === -0; // true
Object.is(0, -0); // false
引用类型的 === 比较的是地址:
js
const o1 = { a: 1 };
const o2 = { a: 1 };
o1 === o2; // false ------ 内容一样,但不是同一个对象
const o3 = o1;
o1 === o3; // true ------ 同一地址
6.4 深拷贝与浅拷贝
引用类型赋值(=)只拷贝引用,这叫"浅"到极点。所谓浅拷贝:只复制一层,嵌套对象仍是共享的:
js
const original = { a: 1, nested: { b: 2 } };
// 浅拷贝
const shallow = { ...original };
shallow.nested.b = 999;
console.log(original.nested.b); // 999 ------ 嵌套对象仍共享
// 深拷贝(结构化克隆,可处理 JSON 数据)
const deep = JSON.parse(JSON.stringify(original));
deep.nested.b = 888;
console.log(original.nested.b); // 2 ------ 完全独立
JSON.parse(JSON.stringify())是最常用的深拷贝手段,但有局限(丢失undefined、函数、Date等)。生产环境可用structuredClone()(浏览器原生支持)或第三方库。
七、类型转换
7.1 显式转换
js
String(123); // "123"
Number("42"); // 42
Number("abc"); // NaN
Boolean(0); // false
Boolean("hi"); // true
parseInt("12px"); // 12
parseFloat("3.14"); // 3.14
7.2 隐式转换(最常见的坑)
js
"1" + 2; // "12" ------ 字符串拼接优先
"1" - 0; // 1 ------ 减法触发数值转换
"5" * "2"; // 10
+ "3"; // 3 ------ 一元加号转数值
[] + []; // "" ------ 空数组转字符串
{} + []; // 0 或 "[object Object]"(看上下文)
规避策略:类型不确定时先显式转换 ,比较一律用 ===。
八、实战练习
实战 1:可靠的类型判断函数
typeof 对 null 和数组不可靠,封装一个更准确的判断:
js
function getType(value) {
// Object.prototype.toString 返回 "[object Array]" 之类
return Object.prototype.toString.call(value).slice(8, -1);
}
getType(null); // "Null"
getType([]); // "Array"
getType({}); // "Object"
getType(42); // "Number"
getType("hi"); // "String"
getType(undefined); // "Undefined"
实战 2:手写深拷贝(递归版)
js
function deepClone(source) {
if (source === null || typeof source !== "object") {
return source; // 原始类型直接返回
}
const result = Array.isArray(source) ? [] : {};
for (const key in source) {
result[key] = deepClone(source[key]); // 递归处理嵌套
}
return result;
}
const a = { list: [1, 2, { x: 3 }] };
const b = deepClone(a);
b.list[2].x = 99;
console.log(a.list[2].x); // 3 ------ 互不影响
实战 3:变量作用域与提升演示
js
console.log(v); // undefined(var 提升)
var v = 1;
if (true) {
var leak = "leak";
let safe = "safe";
}
console.log(leak); // "leak" ------ var 泄漏
// console.log(safe); // ReferenceError
for (var i = 0; i < 3; i++) { /* ... */ }
console.log(i); // 3 ------ var 泄漏到循环外
for (let j = 0; j < 3; j++) { /* ... */ }
// console.log(j); // ReferenceError
九、高频误区清单
| 误区 | 正解 |
|---|---|
typeof null 是 "object" |
ES 历史遗留 bug,判断空值用 value === null |
var 提升后值为 undefined |
是"声明提升、赋值不提升" |
0.1 + 0.2 === 0.3 |
false,浮点精度问题,整数化运算 |
const 对象不能改属性 |
能改,const 只禁止重新绑定变量 |
两个 {a:1} 用 === 相等 |
false,比较的是内存地址 |
== 比较"值" |
会先做隐式类型转换,默认用 === |
NaN === NaN |
false,用 Number.isNaN 或 Object.is |
十、总结
用一张表收束本文核心:
| 维度 | 原始类型 | 引用类型 |
|---|---|---|
| 包含 | string / number / boolean / null / undefined / symbol / bigint | object(数组、函数等) |
| 存储 | 栈,直接存值 | 堆,变量存地址 |
| 赋值 | 拷贝值,互不影响 | 拷贝引用,共享对象 |
| 比较 | 比较值 | 比较地址 |
| 传参 | 传值 | 传地址值(改内部可见) |
| 常见坑 | 浮点精度、NaN |
浅拷贝、意外共享 |
三条实践心法收尾:
- 声明 :默认
const,需要重绑才用let,放弃var; - 比较 :一律
===,判空用value === null,判 NaN 用Number.isNaN; - 拷贝:知道"等号只拷引用",需要独立数据时主动做深拷贝。