魂
第 4 章 JavaScript(ES2024):网页的灵魂
JavaScript — The Language of the Web
HTML 是骨架,CSS 是衣服,JavaScript 才是灵魂。它让页面能响应用户:点按钮发请求、滚动加载更多、表单校验。JS 是门弱类型、动态、函数一等公民的语言,单线程、异步非阻塞。后端同学熟悉 Java/Go,学 JS 最大的坎不是语法,是异步模型和原型。
变量与数据类型
// var 是老古董,函数作用域还会变量提升,别用了
// let / const 是现代写法,块级作用域
let count = 0; // 会变的用 let
const name = "小明"; // 不会重新赋值的用 const,默认就选它
// 注意:const 不是"不可变",是"引用不能变"
const obj = { a: 1 };
obj.a = 2; // 合法!改的是对象内部,不是引用
// obj = {}; // 报错!不能重新赋值
// 原始类型(存在栈):string number boolean null undefined symbol bigint
// 引用类型(存在堆,变量存的是地址):Object Array Function Date Map Set
typeof "hi"; // "string"
typeof null; // "object",这是历史 bug,别慌
== vs ===:别再用 ==
== 会偷偷转换类型:0 == "" 是 true,null == undefined 是 true,"1" == 1 是 true。坑多到数不清。一律用 ===,类型不一样直接 false,省心。
数组方法:JS 最常用的十八般兵器
后端同学写 SQL 会 select/where/map,前端就是这些数组方法。必须熟练:
const nums = [1, 2, 3, 4, 5];
nums.map(n => n * 2); // [2,4,6,8,10] 每个元素变一下
nums.filter(n => n % 2 === 0); // [2,4] 挑符合条件的
nums.reduce((sum, n) => sum + n, 0); // 15 归约成一个值
nums.find(n => n > 3); // 4 找第一个
nums.some(n => n > 10); // false 有没有满足的
nums.every(n => n > 0); // true 是不是都满足
// 注意:sort 默认按字符串排!数字数组要传比较函数
[10, 2, 20].sort(); // [10, 2, 20] 坑!按字符串排的
[10, 2, 20].sort((a, b) => a - b); // [2, 10, 20] 正确
函数、箭头函数与 this
// 函数声明
function add(a, b) { return a + b; }
// 箭头函数:没有自己的 this,继承外层 this,写回调更短
const add2 = (a, b) => a + b;
// 默认参数 / 剩余参数
const greet = (name = "朋友", ...titles) => {
return `你好,${name},头衔:${titles.join("/")}`;
};
// 解构赋值 + 展开运算符
const user = { name: "小明", age: 18 };
const { name, age } = user; // 直接拿出来
const copy = { ...user, age: 19 }; // 浅拷贝并改 age
this this 到底指谁
新手最大的坎。this 不是定义函数时决定的,是调用函数时决定的。普通函数谁调用它,它就是谁;obj.foo() 里 this 是 obj。
箭头函数没有自己的 this,它直接用定义时外层的 this。所以在回调里用箭头函数,this 不会乱跳——这就是 React 里为什么到处是箭头函数。
想强行改 this:fn.call(thisArg, ...args) / apply / fn.bind(thisArg)。日常用得不多,知道就行。
闭包:函数记住了它出生时的环境
// 外层函数执行完了,但 inner 还能访问 count
function counter() {
let count = 0;
return function () {
count++;
return count;
};
}
const inc = counter();
console.log(inc(), inc(), inc()); // 1 2 3 —— count 没被回收
闭包有用(做私有变量、防抖),但用多了会内存泄漏:不用的闭包记得断开引用。React 的 useEffect 清理函数就是在帮你干这事。
异步编程:Promise 与 async/await
JS 单线程,不能傻等网络请求。早期靠回调,结果写出"回调地狱"——嵌套十几层。Promise 把它拉平,async/await 让异步代码读起来像同步。
// 老写法:回调地狱,三层以上就没法看了
getUser(id, function(user) {
getOrders(user.id, function(orders) {
getDetail(orders[0].id, function(detail) { /* ... */ });
});
});
// 现代写法:async/await,错误用 try/catch 抓
async function load(id) {
try {
const user = await fetch(`/api/users/${id}`).then(r => r.json());
const orders = await fetch(`/api/users/${id}/orders`).then(r => r.json());
return { user, orders };
} catch (e) {
console.error("加载失败", e);
}
}
// 并发:Promise.all 等所有都完成;race 谁先完成用谁
const [a, b] = await Promise.all([fetch("/a"), fetch("/b")]);
环事件循环:为什么 Promise.then 比 setTimeout 先跑
JS 是单线程,但浏览器可以"挂起"任务。宏任务(setTimeout、setInterval、I/O)和微任务(Promise.then、queueMicrotask)不一样:每执行完一个宏任务,立刻把所有微任务清空,再去渲染,再取下一个宏任务。
所以:setTimeout(() => console.log(1)) 排在宏任务,Promise.resolve().then(() => console.log(2)) 排在微任务,结果 2 先打印。面试爱考,懂了就不慌。
DOM 与事件
// 选元素
const btn = document.querySelector("#submit");
const items = document.querySelectorAll(".item");
// 改内容和样式
btn.textContent = "提交中...";
btn.classList.add("loading");
// 绑定事件
btn.addEventListener("click", async (e) => {
e.preventDefault(); // 阻止表单默认提交
const res = await fetch("/api/foo", { method: "POST" });
});
React 这种框架出来后,手写 DOM 操作的场景少了很多——框架帮你做。但原理要懂,不然看 DevTools 都不知道发生了啥。事件委托(把子元素的事件绑在父元素上)是性能优化常用招。
模块化:import / export
// utils.js —— 导出
export const PI = 3.14159;
export function double(x) { return x * 2; }
export default function () { /* 默认导出,一个文件只能一个 */ }
// main.js —— 导入
import App, { PI, double } from "./utils.js";
import * as utils from "./utils.js";
浏览器原生支持 ESM(<script type="module">),Node 也从 ESM 起步。后端同学熟悉的 require 是 CommonJS,是 Node 的老模块系统,新项目尽量用 ESM。
JavaScript 深化:类型、闭包、this、原型链、异步与 DOM
前面会用了,这里讲"为什么"。JS 就是网页的大脑——大脑的接线方式(闭包、this、原型、事件循环)不懂,写多了就是糊里糊涂堆代码。
数据类型与类型转换
// 原始类型(按值传递,存在栈):string number boolean null undefined symbol bigint
// 引用类型(按引用传递,存在堆):Object Array Function Date RegExp Map Set ...
// 判断类型三种武器
typeof []; // "object" —— typeof 只能粗判,数组/null 都中招
[1,2] instanceof Array; // true —— 沿原型链找
Object.prototype.toString.call(new Map()); // "[object Map]" —— 最准
// 假值 falsy(!!x 为 false):false 0 '' null undefined NaN 0n document.all
!![]; // true —— 空数组是真值!这是经典坑
!!{}; // true
[1] == true; // true —— 别用 ==,说过多少遍了
// 显式转换:Number('12') parseInt('12px') String(1) Boolean(1)
作用域与闭包:函数记住了它出生的地方
// 作用域:全局 / 函数 / 块级(let/const 有块级,var 只有函数级)
// 经典面试题:循环里用 var,输出全是 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3
}
// 为什么?var 没有块级作用域,三个回调共享同一个 i,等它们跑时 i 已经是 3
// 修法一:把 var 换成 let(每轮循环一个新绑定)
// 修法二:IIFE 立即执行函数,立刻把 i 存成局部参数
for (var j = 0; j < 3; j++) {
(function(k) {
setTimeout(() => console.log(k), 0); // 0 1 2
})(j);
}
// 闭包的典型用途:私有计数器
function createStore() {
let state = 0; // 外部访问不到,只能通过返回的函数
return {
get: () => state,
inc: () => ++state,
};
}
const s = createStore();
s.inc(); s.inc();
s.get(); // 2
内存泄漏注意:闭包会长期持有外部变量。不用的 DOM 元素若被闭包引用,记得置 null。
this:谁调用我,我就是谁
| 调用方式 | this 是谁 |
foo() 直接调用 | 默认绑定:严格模式是 undefined,非严格是 window(全局)。 |
obj.foo() | 隐式绑定:this 是 obj。 |
foo.call(x)/.apply(x)/.bind(x) | 显式绑定:this 强制为 x。 |
new Foo() | new 绑定:this 是新建的实例。 |
() => {} 箭头函数 | 没有自己的 this,捕获定义时外层的 this。 |
优先级:new > call/apply/bind > obj.foo() > 直接调用。React 回调里用箭头函数就是为了让 this 不乱跳。
原型与原型链
// 每个对象有个 __proto__,指向它的构造函数的 prototype
// 访问 obj.x 时:obj 自己没有 → 沿 __proto__ 往上找 → 直到 null
function Person(name) { this.name = name; }
Person.prototype.sayHi = function() { return "我是" + this.name; };
const p = new Person("小明");
p.sayHi(); // "我是小明" —— sayHi 不在 p 上,在 p.__proto__ 上
p.__proto__ === Person.prototype; // true
p.constructor === Person; // true
// ES6 class 就是上面这套的语法糖
class Student extends Person {
constructor(name, score) { super(name); this.score = score; }
greet() { return super.sayHi() + ",考了" + this.score; }
}
const s = new Student("小红", 95);
s.greet(); // "我是小红,考了95"
s instanceof Student; // true —— 沿原型链能找到 Student.prototype
原型污染
原型污染(prototype pollution):如果用户输入 {"__proto__.isAdmin": true},代码若用 Object.assign 或递归合并对象,可能把属性塞进 Object.prototype,导致所有对象都"继承"到恶意属性。防御:合并时跳过 __proto__/constructor/prototype 键,或用 Object.create(null) 做无原型字典。
异步编程与事件循环
// Promise 三个状态:pending → fulfilled / rejected(一旦落定不可逆)
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve("数据"), 1000);
});
p.then(x => console.log(x)).catch(e => console.error(e)).finally(() => {});
// 并发家族
Promise.all([a, b]); // 都成功才成功,一个失败全失败
Promise.allSettled([a, b]); // 等全部落定,不管成败(批量上报用)
Promise.race([a, b]); // 谁先落定用谁(含失败)
Promise.any([a, b]); // 谁先成功用谁,全失败才失败
Promise.withResolvers(); // ES2024:不用包在 new 里,直接拿 resolve/reject
// async/await:await 就是"暂停这里,等 Promise 落定"
// 并行:await Promise.all([fetchA(), fetchB()]),别顺序 await 浪费时间
// 顶层 await:ESM 模块里直接 await,不用再套 async main()
环事件循环:JS 的排队叫号系统
规则一句话:同步代码先跑完 → 清空所有微任务 → 浏览器渲染 → 取下一个宏任务。如此循环。
微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver。宏任务:setTimeout、setInterval、I/O、UI 事件、setImmediate(Node)。
process.nextTick(Node)比微任务更早;Node 每个宏任务后清空 nextTick + 微任务队列,和浏览器模型略有差异。
经典输出题,先自己猜再对答案
console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1 4 3 2
// 解析:同步 1→4;微任务 3;宏任务 setTimeout 2
ES6+ 速查
const obj = { a: 1 };
const { a, b = 2 } = obj; // 解构 + 默认值
const merged = { ...obj, b: 9 }; // 展开,浅拷贝
const tail = [1, 2, 3].at(-1); // 3 负索引
[1,[2,[3]]].flat(2); // [1,2,3]
"a,b,c".replaceAll(",", "-"); // "a-b-c"
user && user.profile && user.profile.name; // 老写法
user?.profile?.name; // 可选链:上面的现代写法
const name = input ?? "默认"; // 空值合并:只有 null/undefined 才取默认
x ||= 0; x &&= 1; x ??= 2; // 逻辑赋值
const map = new Map(); map.set(obj, "键可以是对象");
const s = new Set([1,1,2]); // 去重:[1,2]
const clone = structuredClone(deepObj); // 深拷贝(ES2022)
// 模块:import/export/export default;动态 import() 返回 Promise 做代码分割
DOM 操作与事件
// 选元素
document.querySelector(".card"); // 第一个
document.querySelectorAll(".item"); // 所有,NodeList
el.closest(".card"); // 往上找最近的匹配祖先
el.matches(".active"); // 自己是否匹配选择器
// 增删改
const frag = document.createDocumentFragment(); // 批量插节点不触发多次重排
const li = document.createElement("li");
li.textContent = "hi"; // 安全:自动转义,别用 innerHTML 塞用户输入
parent.append(li);
li.remove();
// classList:比 className 字符串操作香
el.classList.add("on");
el.classList.toggle("on");
el.classList.contains("on");
// 事件流:捕获(外→内)→ 目标 → 冒泡(内→外)
btn.addEventListener("click", (e) => {
e.target; // 实际触发的元素
e.currentTarget; // 绑定监听的元素(即 btn)
e.preventDefault(); // 阻止默认行为(链接跳转、表单提交)
e.stopPropagation(); // 阻止继续冒泡
}, { passive: true }); // scroll/touch 监听加 passive,告诉浏览器不会 preventDefault,滚动更顺
// 事件委托:把子元素的点击绑在父元素上,动态加的子元素也能响应
list.addEventListener("click", (e) => {
const item = e.target.closest("li");
if (item) console.log(item.dataset.id); // data-id 属性
});
防抖与节流:性能优化必备
// 防抖 debounce:停止触发 wait 毫秒后才执行一次(搜索输入、resize)
function debounce(fn, wait = 300) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
// 节流 throttle:每 wait 毫秒最多执行一次(滚动、mousemove)
function throttle(fn, wait = 200) {
let last = 0, timer;
return function (...args) {
const now = Date.now();
const remaining = wait - (now - last);
if (remaining <= 0) {
clearTimeout(timer); last = now; fn.apply(this, args);
} else {
clearTimeout(timer);
timer = setTimeout(() => { last = Date.now(); fn.apply(this, args); }, remaining);
}
};
}
window.addEventListener("resize", debounce(onResize));
完整案例:原生 JS Todo List(含本地存储 + 事件委托)
<input id="inp" placeholder="输入任务,回车添加">
<ul id="list"></ul>
<script>
const list = document.querySelector("#list");
const inp = document.querySelector("#inp");
let todos = JSON.parse(localStorage.getItem("todos") || "[]");
function save() { localStorage.setItem("todos", JSON.stringify(todos)); }
function render() {
list.innerHTML = "";
const frag = document.createDocumentFragment();
todos.forEach((t, i) => {
const li = document.createElement("li");
li.dataset.index = i;
li.textContent = (t.done ? "[x] " : "[ ] ") + t.text + " [删]";
if (t.done) li.style.textDecoration = "line-through";
frag.append(li);
});
list.append(frag);
}
inp.addEventListener("keydown", (e) => {
if (e.key === "Enter" && inp.value.trim()) {
todos.push({ text: inp.value, done: false });
inp.value = ""; save(); render();
}
});
list.addEventListener("click", (e) => {
const li = e.target.closest("li");
if (!li) return;
const i = +li.dataset.index;
if (e.target.textContent.includes("删")) {
todos.splice(i, 1); // 删除
} else {
todos[i].done = !todos[i].done; // 切换完成
}
save(); render();
});
render(); // 初始渲染
</script>
// 效果:刷新页面任务还在(localStorage 持久化);点击文字切换完成,点"删"删除。
本章面试题(JS)
1. 什么是闭包?举一个有用和一个有害的场景。
看答案
闭包是函数 + 其定义时所在的词法环境,内层函数能访问外层变量。有用:私有变量(计数器/模块模式)、防抖、React Hooks。有害:闭包长期持有大对象导致内存泄漏;循环里 var 共享同一变量导致经典输出 3 3 3。
2. 解释 this 的判断规则。为什么 React 回调推荐箭头函数?
看答案
this 在调用时确定:obj.foo() 中是 obj;call/apply/bind 强制指定;new 绑定实例;箭头函数不绑定 this,捕获定义处外层 this。React 类组件/回调里普通函数 this 会随调用环境丢失,箭头函数固定外层 this,最省心。
3. 画出 new Foo() 的执行过程,说明原型链查找。
看答案
new 四步:① 创建空对象;② 该对象 __proto__ 指向 Foo.prototype;③ 把 this 绑到新对象执行构造函数;④ 返回 this(除非构造函数自己 return 对象)。访问属性先查对象自身,没有就沿 __proto__ 向上,直到 null。
4. 宏任务和微任务区别?给出那段 setTimeout/Promise 输出题答案。
看答案
每轮事件循环执行完同步栈后,先清空所有微任务(Promise.then、queueMicrotask),再渲染,再取下一个宏任务(setTimeout、I/O)。所以 setTimeout(fn,0) 的回调排到本轮宏任务之后,Promise.then 先跑:输出 1 4 3 2。
5. 防抖和节流区别?分别用在什么场景?
看答案
防抖:连续触发只在停止后执行最后一次(搜索联想、resize 重排布局)。节流:按固定频率执行(滚动加载、鼠标移动、按钮防连点)。本质都是减少回调执行次数。
Proxy 与 Reflect:给对象装拦截器
前面说过 Object.defineProperty 能拦单个属性。Proxy 更进一步:把整个对象包一层"代理",读、写、删、遍历、函数调用,每一步都能被你拦截。Vue 3 响应式、数据校验、打日志、只读视图,全靠它。
Proxy:监听对象的读写,自动打日志 + 校验
const target = { name: "小明", age: 3 };
const person = new Proxy(target, {
get(obj, key) { // 读属性时触发
console.log("读取了", key);
return Reflect.get(obj, key); // Reflect 把默认操作照做一遍,别自己写 obj[key]
},
set(obj, key, value) { // 写属性时触发
if (key === "age" && typeof value !== "number") {
throw new TypeError("age 必须是数字");
}
return Reflect.set(obj, key, value); // 返回 true 表示写入成功
},
});
person.age = 4; // 写入成功
person.age = "很大"; // 抛 TypeError
Reflect 是啥?它是一组和 Proxy 拦截器"同名同参"的默认操作函数:Reflect.get、Reflect.set、Reflect.has、Reflect.deleteProperty。在 Proxy 里用它完成"默认行为",比手写 obj[key] 更规范、返回值统一(布尔)。
Proxy vs Object.defineProperty
defineProperty 只能拦预先定义的某几个属性,新增属性、数组下标改动要额外 hack,Vue 2 因此要 Vue.set;Proxy 是整对象拦截,增删属性、数组成员变化全自动,Vue 3 因此换成 Proxy 响应式。新项目用 Proxy,别再背 Vue 2 那套。
生成器 Generator 与迭代器协议
普通函数一调到底,停不下来;生成器函数(function* + yield)能"跑到一半暂停,下次接着跑"。这是写可暂停逻辑、异步流程、惰性序列的利器。
function* 每次 yield 产出一个值,调用 .next() 往下走
function* countDown(from) {
while (from > 0) {
yield from--; // 暂停,把 from 交出去;下次 .next() 从这行后面继续
}
}
const g = countDown(3);
g.next(); // { value: 3, done: false }
g.next(); // { value: 2, done: false }
g.next(); // { value: 1, done: false }
g.next(); // { value: undefined, done: true } —— 结束
// 生成器天然可迭代:for...of 直接消费
for (const n of countDown(3)) console.log(n); // 3 2 1
和异步的关系:曾经 co 库用生成器把 async/await 提前实现了一遍;现在有了 async/await,生成器更多用在惰性数据流、分页拉取、自定义可迭代对象上。理解它就理解了 for...of 背后的迭代器协议(对象带 [Symbol.iterator] 方法即可被遍历)。
错误处理进阶:throw 与自定义 Error
try/catch/finally + 自定义错误类型,让错误可分类可分辨
// 自定义错误:继承 Error,加一个 code 字段方便上层区分
class ApiError extends Error {
constructor(status, msg) {
super(msg); // 必须调父类构造,否则 this 不对
this.name = "ApiError"; // 让 err.name 一看便知
this.status = status;
}
}
async function fetchUser(id) {
const r = await fetch(`/api/users/${id}`);
if (!r.ok) throw new ApiError(r.status, "接口挂了");
return r.json();
}
try {
await fetchUser(1);
} catch (err) {
if (err instanceof ApiError) console.warn("接口错误", err.status);
else console.error("未知错误", err);
} finally {
// 不管成败都执行:关 loading、关连接
}
防坑:① 自定义 Error 后一定设 this.name 并 Error.captureStackTrace(this, ApiError)(老引擎)以拿到干净堆栈;② 异步里 throw 会变成 rejected Promise,await 必须包在 try/catch 里,否则未捕获拒绝会崩页面;③ 别吞错误——catch(e) {} 空着不写日志是线上最大的坑。