楼层: 首页/ 软件技术/ 前端基础/ 第 4 章 JavaScript(ES2024):网页的灵魂
魂

第 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) {} 空着不写日志是线上最大的坑。