0%

在 javascript 中,this 关键字是一个特殊的对象标识符,它指向当前执行上下文的对象。与其他编程语言不同,javascript 的 this 不是在编写时绑定,而是在运行时根据函数的调用方式动态绑定的。理解 this 的指向是掌握 javascript 的关键,也是许多开发者容易混淆的地方。

全局上下文中的 this

在全局执行环境(浏览器中是 window,Node.js 中是 global)中,this 指向全局对象。

1
2
3
4
5
// 浏览器环境
console.log(this === window); // true

var name = 'globalName';
console.log(this.name); // 'globalName'

函数调用中的 this

非严格模式

在普通函数调用中,this 指向全局对象。

1
2
3
4
function showThis() {
console.log(this);
}
showThis(); // 浏览器中输出 window

严格模式

如果函数处于严格模式(‘use strict’),this 会保持为 undefined,因为严格模式下禁止默认指向全局。

1
2
3
4
5
'use strict';
function showThisStrict() {
console.log(this);
}
showThisStrict(); // undefined

方法调用中的 this

当函数作为对象的方法被调用时,this 指向调用该方法的对象。

1
2
3
4
5
6
7
8
9
10
11
const person = {
name: 'Alice',
greet: function() {
console.log('Hello, ' + this.name);
}
};
person.greet(); // Hello, Alice

// 如果将方法赋值给变量再调用,this 会丢失
const greetFn = person.greet;
greetFn(); // Hello, undefined (非严格模式下 this 指向 window)

构造函数中的 this

使用 new 关键字调用函数时,会创建一个新对象,并将该对象绑定到函数的 this 上。构造函数通常首字母大写。

1
2
3
4
5
function Person(name) {
this.name = name;
}
const alice = new Person('Alice');
console.log(alice.name); // Alice

如果构造函数显式返回一个对象,则 this 会被替换;如果返回基本类型,则忽略,依然返回 this 指向的新对象。

箭头函数中的 this

箭头函数没有自己的 this,它捕获其所在(定义时)上下文的 this 值。箭头函数的 this 在定义时就确定了,之后不会改变。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
name: 'obj',
normalFunc: function() {
console.log(this.name);
},
arrowFunc: () => {
console.log(this.name);
}
};

obj.normalFunc(); // 'obj'
obj.arrowFunc(); // undefined(箭头函数的 this 继承自全局/外层,此处外层是全局,所以 this 指向 window)

箭头函数的 this 不能被 call、apply、bind 改变。

事件处理中的 this

在 DOM 事件处理函数中,this 通常指向绑定事件的元素。

1
2
3
4
5
6
7
<button id="btn">Click me</button>
<script>
const btn = document.getElementById('btn');
btn.addEventListener('click', function() {
console.log(this); // <button id="btn">Click me</button>
});
</script>

如果使用箭头函数,this 会继承外层上下文(比如 window),可能不是期望的元素。

改变 this 指向:call、apply、bind

我们可以使用 call、apply 或 bind 显式指定函数调用时的 this。

  1. call(thisArg, arg1, arg2, …):立即调用函数,并指定 this。
  2. apply(thisArg, [argsArray]):与 call 类似,但参数以数组形式传递。
  3. bind(thisArg, arg1, arg2, …):返回一个新函数,其 this 永久绑定到指定对象,不会立即执行。
1
2
3
4
5
6
7
8
9
10
11
function introduce(age, city) {
console.log(`I'm ${this.name}, ${age} years old, from ${city}.`);
}

const user = { name: 'Bob' };

introduce.call(user, 25, 'New York'); // I'm Bob, 25 years old, from New York.
introduce.apply(user, [30, 'London']); // I'm Bob, 30 years old, from London.

const boundIntroduce = introduce.bind(user, 28);
boundIntroduce('Paris'); // I'm Bob, 28 years old, from Paris.

常见陷阱与注意事项

  1. 回调函数中的 this 丢失:将对象方法作为回调传递时,常常丢失 this。
1
2
3
4
5
6
7
8
9
10
const counter = {
count: 0,
increment: function() {
console.log(this.count);
}
};
setTimeout(counter.increment, 1000); // 输出 undefined(this 指向全局)
// 解决方法:使用箭头函数、bind 或包装函数
setTimeout(() => counter.increment(), 1000); // 0
setTimeout(counter.increment.bind(counter), 1000); // 0
  1. 嵌套函数中的 this:在方法内部定义普通函数时,该函数的 this 会指向全局(非严格模式)或 undefined(严格模式)。
1
2
3
4
5
6
7
8
9
10
11
12
const obj = {
name: 'obj',
outer: function() {
console.log('outer this:', this.name); // 'obj'
function inner() {
console.log('inner this:', this.name); // undefined(非严格模式 window.name)
}
inner();
}
};
obj.outer();
// 解决:使用箭头函数、保存 this 变量(const self = this)、bind

总结

  1. this 的指向取决于函数的调用方式,而不是定义位置。
  2. 全局函数调用 → 指向全局对象(严格模式下 undefined)。
  3. 对象方法调用 → 指向调用该方法的对象。
  4. 构造函数调用(new)→ 指向新创建的实例。
  5. 箭头函数 → 继承定义时外层上下文的 this。
  6. 事件处理 → 指向绑定事件的元素。
  7. 可以使用 call、apply、bind 显式绑定 this。

随着前端应用日益复杂,javascript 模块化成为组织代码的必备手段。目前最主流的两种模块规范是 CommonJS 和 ES6 模块(ES Module)。CommonJS 主要用于 Node.js 服务端,而 ES6 模块则是 ECMAScript 官方标准,支持浏览器和现代 Node.js。虽然二者目标相似,但在语法、加载机制、导出值等方面存在显著差异。

语法差异

CommonJS 语法

  1. 导出:使用 module.exports 或 exports 对象。
  2. 导入:使用 require() 函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
// CommonJS 导出 (a.js)
const name = 'CommonJS';
const greet = () => console.log('Hello');
module.exports = { name, greet };

// 或者
exports.name = 'CommonJS';
exports.greet = () => console.log('Hello');

// CommonJS 导入 (b.js)
const mod = require('./a.js');
console.log(mod.name); // 'CommonJS'
mod.greet(); // 'Hello'

ES6 模块语法

  1. 导出:使用 export 关键字(命名导出或默认导出)。
  2. 导入:使用 import 关键字。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ES6 导出 (a.js)
export const name = 'ES6 Module';
export function greet() {
console.log('Hello');
}
// 默认导出
export default function() {
console.log('Default export');
}

// ES6 导入 (b.js)
import defaultFn, { name, greet } from './a.js';
console.log(name); // 'ES6 Module'
greet(); // 'Hello'
defaultFn(); // 'Default export'

加载机制:静态 vs 动态

CommonJS 动态加载

  1. require 可以在代码任意位置调用,可以动态拼接路径、条件加载。
  2. 模块在运行时同步加载。
1
2
3
// 动态加载示例
const moduleName = './module-' + Math.random() + '.js';
const mod = require(moduleName); // 运行时确定路径

ES6 模块静态加载

  1. import 和 export 必须位于模块顶层,不能嵌套在条件块内。
  2. 模块依赖在编译时(静态分析)确定,有利于 tree shaking 和优化。
1
2
3
4
5
6
7
// 错误:import 不能在代码块中
if (condition) {
import mod from './mod.js'; // 语法错误
}

// 正确:静态导入
import mod from './mod.js';

虽然 ES6 也提供了动态导入 import() 函数(返回 Promise),但那是运行时的异步加载,属于例外。

导出值的绑定:值的拷贝 vs 值的引用

CommonJS:导出值的拷贝

CommonJS 模块导出的是值的浅拷贝,一旦模块执行完毕,导出值就固定了;模块内部的后续变化不会影响已导入的值(除非导出的是引用类型,修改引用内部属性会体现)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// counter.js
let count = 0;
module.exports = {
count,
increment() {
count++;
console.log('内部 count:', count);
}
};

// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 0
increment(); // 内部 count: 1
console.log(count); // 0 (count 仍为 0,并未改变)

ES6 模块:动态只读绑定

ES6 模块导出的是值的动态只读引用,导入的变量与原始变量绑定,原始值变化时,导入值也会相应变化(但导入方不能修改导入的变量,除非它本身是对象)。

1
2
3
4
5
6
7
8
9
10
11
12
// counter.js
export let count = 0;
export function increment() {
count++;
console.log('内部 count:', count);
}

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment(); // 内部 count: 1
console.log(count); // 1 (count 随内部变化)

this 指向

  1. CommonJS 中,模块顶层的 this 指向当前模块的 exports 对象。
  2. ES6 模块 中,模块顶层的 this 是 undefined。
1
2
3
4
5
// CommonJS 模块
console.log(this === module.exports); // true

// ES6 模块
console.log(this); // undefined

循环依赖处理

CommonJS 的循环依赖

CommonJS 遇到循环依赖时,会返回当前已执行的导出部分(未完成的导出可能是不完整的对象),容易导致问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// a.js
exports.done = false;
const b = require('./b.js');
console.log('a 中 b.done =', b.done);
exports.done = true;
console.log('a 执行完毕');

// b.js
exports.done = false;
const a = require('./a.js');
console.log('b 中 a.done =', a.done);
exports.done = true;
console.log('b 执行完毕');

// main.js
const a = require('./a.js');
// 输出顺序:
// b 中 a.done = false
// b 执行完毕
// a 中 b.done = true
// a 执行完毕

ES6 模块的循环依赖

由于 ES6 模块是动态绑定,导入的是值的引用,即使在循环依赖中也能获取到正确的值(但需要依赖顺序,且变量提升机制使函数声明等可提前访问)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// a.mjs
import { b } from './b.mjs';
export const a = 'a';
console.log('a 模块执行');

// b.mjs
import { a } from './a.mjs';
export const b = 'b';
console.log('b 模块执行, a =', a);

// 运行 a.mjs,输出:
// b 模块执行, a = undefined (因为 a 尚未初始化)
// a 模块执行

ES6 模块的循环依赖通常比 CommonJS 更可预测,但若导入未初始化的变量会得到 undefined,需注意编码顺序。

同步 vs 异步

  1. CommonJS 模块加载是同步的,因为 Node.js 早期服务端读取本地文件很快,且无需考虑网络。
  2. ES6 模块 设计为异步加载,适合浏览器环境,可以与 <script type="module"> 配合,支持按需加载。静态 import 是异步的,但语法上看起来是静态声明;动态 import() 返回 Promise。

静态分析与优化

  1. ES6 模块 支持静态分析,可以在编译阶段确定导入导出关系,从而实现 tree shaking(摇树优化)、死代码消除等。
  2. CommonJS 的模块结构是动态的,难以静态分析,通常打包工具需要额外处理才能实现优化。

使用环境

  1. CommonJS 主要运行在 Node.js 环境(通过 require),浏览器中需要打包工具(如 Browserify、Webpack)才能使用。
  2. ES6 模块 现代浏览器原生支持 <script type="module">,Node.js 从 12.x 开始逐步稳定支持(需将文件后缀改为 .mjs 或 package.json 中设置 “type”: “module”)。目前二者在 Node.js 中可以共存,但互操作有一些限制。

总结

特性 CommonJS ES6 模块
语法 require / module.exports import / export
加载时机 运行时同步 编译时静态(静态导入)+ 运行时异步(动态导入)
导出绑定 值的拷贝(基本类型) 值的动态只读引用
顶层this exports 对象 undefined
循环依赖 返回已执行部分的拷贝 动态绑定,但可能遇到暂时性死区
静态分析 不支持 支持,可实现 tree shaking
适用环境 Node.js(原生)、浏览器(打包后) 现代浏览器、Node.js(需配置)

javascript 是一门单线程语言,为了协调同步任务与异步任务,它引入了事件循环(Event Loop)的机制。然而,javascript 的运行环境主要有两种——浏览器和 Node.js,它们的事件循环实现方式有显著差异。理解这些差异对于编写可靠的跨环境代码至关重要。

宏观架构差异

浏览器事件循环

浏览器的事件循环由宿主环境(浏览器)实现,主要协调以下组成部分:

  • 调用栈(Call Stack):执行同步代码。
  • 宏任务队列(MacroTask Queue):存放 setTimeout、setInterval、I/O、UI 渲染等回调 。
  • 微任务队列(MicroTask Queue):存放 Promise.then、MutationObserver 等回调 。
  • 渲染引擎:负责页面绘制,与事件循环协同工作 。

Node.js 事件循环

Node.js 的事件循环由 libuv 库 实现,它包含多个阶段(phase),每个阶段都有自己的任务队列 。Node.js 的事件循环结构更复杂,因为它需要处理底层操作系统的 I/O 操作。

维度 浏览器事件循环 Node.js 事件循环
核心实现 浏览器宿主环境 libuv 库
阶段划分 简化为宏任务队列 + 微任务队列 细分为 6 个阶段(timers、I/O、poll、check 等)
任务粒度 每个宏任务队列执行一个任务就切换 每个阶段会清空当前阶段的整个队列
微任务执行时机 每个宏任务执行后清空微任务 每个阶段之后清空微任务(Node 11+ 与浏览器对齐)

Node.js 事件循环的六个阶段

Node.js 的事件循环按照以下顺序循环执行 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   ┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout、setInterval 的回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ I/O callbacks │ 执行延迟到下一轮的 I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ idle, prepare │ 系统内部使用
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ poll │ 检索新的 I/O 事件,执行 I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ check │ 执行 setImmediate 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ close callbacks │ 执行 socket 等关闭事件的回调
│ └───────────────────────────┘
└──────────────┘

每个阶段结束后,Node.js 会先清空微任务队列(process.nextTick 和 Promise.then),然后进入下一阶段 。

核心差异详解

任务队列结构

  • 浏览器:只有两个任务队列(宏任务、微任务)。
  • Node.js:每个阶段都有独立的队列,外加两个微任务队列(nextTick 队列和 Promise 队列)。

任务执行粒度

  • 浏览器:从宏任务队列中取出第一个任务执行,然后清空微任务队列,接着检查是否需要渲染,再取下一个宏任务 。
  • Node.js:进入一个阶段后,会清空该阶段队列中的所有任务,然后才执行微任务,再进入下一阶段 。

微任务执行时机

  • 浏览器:每个宏任务执行完毕后清空微任务 。
  • Node.js:
    – Node 11 之前:在阶段切换时才清空微任务,可能导致一个阶段内积累大量微任务 。
    – Node 11 及之后:在每个宏任务执行后也会清空微任务,行为与浏览器基本对齐 。

特殊 API

  • 浏览器独有:requestAnimationFrame、MutationObserver 。
  • Node.js 独有:setImmediate、process.nextTick 。
    – setImmediate 在 check 阶段执行 。
    – process.nextTick 不属于事件循环的任何阶段,而是在每个阶段结束后立即执行,优先级高于 Promise 。

代码示例验证差异

示例 1:setTimeout 与 setImmediate 的执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- Node.js 环境 -->
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

--> 执行结果可能不确定 :
--> 1. 如果事件循环进入 timers 阶段时,定时器时间已到 → setTimeout 先执行。
--> 2. 如果准备时间较短,定时器还没到期 → 跳过 timers,进入 poll → check → setImmediate 先执行。

<!-- 固定顺序的技巧:将代码放在 I/O 回调内 -->
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate')); // 总是先于 setTimeout
}); // 输出:setImmediate → setTimeout

--> 因为在 I/O 回调中,事件循环当前处于 poll 阶段,下一阶段是 check,所以 setImmediate 总是先执行 。

示例 2:process.nextTick 与 Promise 的优先级

1
2
3
4
5
6
7
8
9
10
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('setTimeout'), 0);

<!-- Node.js 输出顺序: -->
nextTick
promise
setTimeout

--> process.nextTick 优先级高于 Promise,且在所有阶段切换时都会执行 。

示例 3:Node 11 前后的行为差异

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);

setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(() => console.log('promise2'));
}, 0);

--> Node 10:timeout1 → timeout2 → promise1 → promise2(每个阶段内先清空所有宏任务,再执行微任务)
--> Node 11+ 及浏览器:timeout1 → promise1 → timeout2 → promise2(每个宏任务后执行微任务)

总结与建议

对比维度 浏览器事件循环 Node.js 事件循环
核心模型 宏任务 + 微任务 多阶段 + 微任务
微任务时机 每个宏任务后 每个阶段后(Node 11+ 也支持每个宏任务后)
特殊 API requestAnimationFrame setImmediate, process.nextTick
复杂程度 相对简单 更复杂,涉及系统 I/O

编写跨环境代码的建议

  1. 避免依赖执行顺序:尽量不要编写依赖 setTimeout 与 setImmediate 执行顺序的业务代码。
  2. 使用 queueMicrotask:如果需要微任务,优先使用标准 API queueMicrotask 而不是 process.nextTick。
  3. 了解版本差异:Node 11+ 的事件循环与浏览器行为更接近,但仍需注意 nextTick 等特有 API 的存在 。
  4. 利用 I/O 回调稳定顺序:在 Node.js 中,如果需要在 I/O 之后立即执行任务,setImmediate 是最可靠的选择 。

在 javascript 中,拷贝对象或数组是常见的操作。然而,由于引用类型的存在,拷贝分为浅拷贝和深拷贝两种方式。理解它们的区别对于避免意外的数据共享和修改至关重要。

基本概念

在 javascript 中,数据类型分为基本类型(如 string、number、boolean、null、undefined、symbol、bigint)和引用类型(如 Object、Array、Function、Date、RegExp 等)。

  1. 基本类型:存储的是实际值,拷贝时直接复制值,互不影响。
  2. 引用类型:存储的是内存地址,拷贝时复制的是地址引用,多个变量可能指向同一个对象。

浅拷贝

创建一个新对象,但这个新对象会复制原对象的所有属性值。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是引用类型,拷贝的就是内存地址,因此新旧对象会共享同一块内存数据,修改其中一个会影响另一个。

深拷贝

创建一个全新的对象,递归地复制原对象的所有属性。新对象和原对象完全隔离,修改互不影响。

浅拷贝的实现方式

  1. 手动赋值(只适用于简单对象)
1
2
3
4
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: obj1.a, b: obj1.b }; // 浅拷贝
obj2.b.c = 100;
console.log(obj1.b.c); // 100 (相互影响)
  1. Object.assign()

Object.assign(target, …sources) 将源对象的可枚举属性复制到目标对象,返回目标对象。它是浅拷贝。

1
2
3
4
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = Object.assign({}, obj1);
obj2.b.c = 100;
console.log(obj1.b.c); // 100
  1. 展开运算符 …
1
2
3
4
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { ...obj1 };
obj2.b.c = 100;
console.log(obj1.b.c); // 100
  1. 数组的浅拷贝方法

数组也是对象,许多数组方法返回的是浅拷贝。

  • slice():
1
2
3
4
const arr1 = [1, 2, { a: 3 }];
const arr2 = arr1.slice();
arr2[2].a = 100;
console.log(arr1[2].a); // 100
  • concat():
1
const arr2 = arr1.concat();
  • 展开运算符:
1
const arr2 = [...arr1];
  • Array.from():
1
const arr2 = Array.from(arr1);

深拷贝的实现方式

JSON.parse(JSON.stringify(obj))

这是最常用的简单深拷贝方法,但存在一些限制:

  • 无法拷贝函数、undefined、Symbol、BigInt。
  • 无法处理循环引用(会抛出错误)。
  • 特殊对象如 Date、RegExp、Map、Set 等会被序列化为字符串或空对象。
  • 会丢弃对象的 constructor,原型链不再保留。
1
2
3
4
5
6
7
8
9
10
11
const obj1 = {
a: 1,
b: { c: 2 },
d: new Date(),
e: undefined,
f: function() {},
g: Symbol('g')
};
const obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj2);
// { a: 1, b: { c: 2 }, d: "2024-01-25T..." } // 函数、undefined、Symbol 丢失,Date 变成了字符串

递归实现深拷贝(基础版)

手动编写递归函数,处理基本类型和对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function deepClone(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 处理数组和普通对象
const cloneObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
cloneObj[key] = deepClone(obj[key]);
}
}
return cloneObj;
}

const obj1 = { a: 1, b: { c: 2 }, d: [1, 2, { e: 3 }] };
const obj2 = deepClone(obj1);
obj2.b.c = 100;
console.log(obj1.b.c); // 2

该基础版未处理循环引用、Map、Set、Symbol 属性等,但已满足大部分场景。

处理循环引用

使用 WeakMap 记录已拷贝的对象,遇到循环引用直接返回记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function deepClone(obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (hash.has(obj)) return hash.get(obj); // 处理循环引用

const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);

// 处理 Symbol 属性
const symKeys = Object.getOwnPropertySymbols(obj);
if (symKeys.length) {
symKeys.forEach(symKey => {
clone[symKey] = deepClone(obj[symKey], hash);
});
}

// 处理普通字符串属性
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], hash);
}
}
return clone;
}

// 测试循环引用
const obj = { a: 1 };
obj.self = obj;
const cloned = deepClone(obj);
console.log(cloned.self === cloned); // true

使用第三方库

lodash 的 _.cloneDeep():功能完善,处理了各种边界情况。

1
2
const _ = require('lodash');
const obj2 = _.cloneDeep(obj1);

jQuery 的 $.extend(true, {}, obj)。

结构化克隆(Structured Clone)

现代浏览器提供 structuredClone() 全局函数,用于深拷贝,支持大部分内置类型(如 Date、RegExp、Map、Set、ArrayBuffer 等),但仍不能拷贝函数、Symbol、DOM 节点等。

1
2
3
const obj1 = { a: 1, b: { c: 2 }, d: new Date() };
const obj2 = structuredClone(obj1);
console.log(obj2); // 正确深拷贝

浅拷贝与深拷贝对比示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 原始对象
const original = {
name: 'Alice',
age: 25,
address: {
city: 'New York',
zip: 10001
},
hobbies: ['reading', 'gaming']
};

// 浅拷贝
const shallowCopy = { ...original };

// 深拷贝(JSON 方法)
const deepCopy = JSON.parse(JSON.stringify(original));

// 修改浅拷贝的嵌套对象
shallowCopy.address.city = 'Los Angeles';
shallowCopy.hobbies.push('swimming');
console.log(original.address.city); // 'Los Angeles' (原对象被改变)
console.log(original.hobbies); // ['reading', 'gaming', 'swimming'] (原对象被改变)

// 修改深拷贝的嵌套对象
deepCopy.address.city = 'Chicago';
deepCopy.hobbies.push('running');
console.log(original.address.city); // 仍为 'Los Angeles' (不受影响)
console.log(original.hobbies); // 仍为 ['reading', 'gaming', 'swimming'] (不受影响)

特殊场景与注意事项

  1. 函数和 undefined 属性:JSON.stringify 会丢失函数和 undefined,递归实现可以保留函数(拷贝函数引用),但通常函数是无状态的,浅拷贝即可。
  2. Symbol 作为属性名:JSON.stringify 会忽略 Symbol 属性,递归拷贝时可借助 Object.getOwnPropertySymbols 处理。
  3. Date、RegExp、Map、Set 等:JSON.stringify 会将其转换为字符串或普通对象,导致信息丢失。递归拷贝时应针对这些类型特殊处理。
  4. 原型链:浅拷贝和大多数深拷贝方法都不会复制原型链上的属性,只拷贝对象自身的可枚举属性。如果需要保留原型,可使用 Object.create(Object.getPrototypeOf(obj)) 并结合 Reflect.ownKeys 等方式。
  5. 性能:深拷贝递归遍历对象,可能影响性能,对于大型对象应谨慎使用,或考虑 immutable 数据结构。

总结

特性 浅拷贝 深拷贝
是否创建新对象
复制基本类型值 复制值 复制值
复制引用类型 复制引用(共享内存) 递归复制新对象
修改嵌套属性影响原对象 不会
实现复杂度 简单(内置方法) 较复杂(需递归)
常见实现 Object.assign、…、Array.slice() JSON.parse(JSON.stringify())、递归、structuredClone、lodash

选择浅拷贝还是深拷贝取决于需求:

  1. 如果对象只有一层属性(所有属性都是基本类型),浅拷贝即可。
  2. 如果对象包含嵌套结构,且希望完全隔离,必须使用深拷贝。
  3. 注意深拷贝的性能和边界情况,必要时选择成熟库或原生 structuredClone。

闭包是 javascript 中一个核心概念,但它并非孤立存在,而是与作用域、模块化、回调函数等紧密相连。

闭包与作用域

作用域决定了变量的可见性

javascript 采用词法作用域(静态作用域),即函数的作用域在定义时就已确定,而不是在执行时确定。这意味着,一个函数可以访问其外部函数中声明的变量,这种访问规则由作用域链(scope chain)实现。

闭包是作用域链的动态延续

当一个内部函数引用了外部函数的变量,并且这个内部函数在外部函数执行完毕后仍然可用(例如被返回或传递给其他函数),就形成了闭包。此时,即使外部函数已执行结束,其变量对象仍被内部函数引用,不会被垃圾回收。这正是闭包对作用域的“延长”效果。

1
2
3
4
5
6
7
8
9
function outer() {
let x = 10;
function inner() {
console.log(x); // 内部函数引用外部变量
}
return inner;
}
const closure = outer();
closure(); // 10 // 闭包保留了 outer 的作用域

关系总结

闭包是作用域链的具体体现,它让函数能够“记住”并访问其词法作用域,即使函数在当前作用域之外执行。

闭包与模块化

模块化的核心需求:封装与隐藏

模块化旨在将代码分割成独立、可复用的单元,同时隐藏内部实现细节,只暴露公共接口。在 ES6 模块出现之前,javascript 没有原生的模块系统,开发者利用闭包实现“模块模式”。

闭包实现模块化:IIFE 与返回值

通过立即执行函数表达式(IIFE)创建私有作用域,返回一个包含公共方法的对象。这些公共方法通过闭包访问私有变量,从而实现信息隐藏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const counterModule = (function() {
let count = 0; // 私有变量
function changeBy(val) {
count += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
getCount() {
return count;
}
};
})();

counterModule.increment();
console.log(counterModule.getCount()); // 1
console.log(counterModule.count); // undefined,无法直接访问私有变量

在这个例子中,count 和 changeBy 被封装在闭包中,外部只能通过返回的对象操作它们。这正是闭包对模块化的支持。

ES6 模块与闭包

ES6 模块(import/export)虽然语法不同,但底层仍然利用了闭包的特性。每个模块都有自己的作用域,导出内容相当于暴露了公共接口,而模块内部的变量对外部不可见,本质也是闭包的一种应用。

关系总结

闭包是实现模块化的重要工具,它通过函数作用域和变量引用来创建私有空间,为模块化提供了语言层面的基础。

闭包与回调函数

回调函数需要记住上下文

回调函数经常在异步操作(如事件监听、定时器、网络请求)中使用。当回调执行时,原始的执行上下文可能已经结束,但回调往往需要访问当时的数据。闭包正好解决了这个问题——回调函数定义时捕获的外部变量会被保留。

1
2
3
4
5
6
7
8
9
function fetchData(url) {
const requestId = Math.random(); // 模拟请求ID
setTimeout(function callback() {
console.log(`请求 ${url} 完成,ID: ${requestId}`);
}, 1000);
}

fetchData('/api/data');
// 一秒后输出:请求 /api/data 完成,ID: 0.123456...

这里的 callback 是一个闭包,它记住了 url 和 requestId,即使 fetchData 早已执行完毕。

高阶函数与闭包

许多高阶函数(如 map、filter、forEach)接受回调函数作为参数,这些回调同样可以形成闭包。

1
2
3
4
5
6
7
8
9
function createMultiplier(factor) {
return function (number) {
return number * factor; // factor 被闭包捕获
};
}

const numbers = [1, 2, 3];
const doubled = numbers.map(createMultiplier(2));
console.log(doubled); // [2, 4, 6]

createMultiplier(2) 返回的函数作为回调传给 map,它捕获了 factor 变量。

事件处理与闭包

在事件监听中,闭包常用于保存循环变量等状态。

1
2
3
4
5
6
7
const buttons = document.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
buttons[i].addEventListener('click', function() {
console.log(`按钮 ${i} 被点击`); // 闭包捕获 i
});
}
// 但输出总是 '按钮 3 被点击'(如果使用 var)

这就是著名的循环陷阱,因为 var 没有块级作用域,所有回调共享同一个 i。利用闭包可以修复:

1
2
3
4
5
6
7
for (var i = 0; i < buttons.length; i++) {
(function(j) {
buttons[j].addEventListener('click', function() {
console.log(`按钮 ${j} 被点击`); // 闭包捕获 j
});
})(i);
}

关系总结

回调函数常常以闭包的形式存在,因为它需要在未来某个时刻访问定义时的环境。闭包让回调变得“有记忆”,是异步编程的基础。

三者交汇:一个综合示例

考虑一个简单的计数器模块,它支持异步增量操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const counter = (function() {
let count = 0; // 闭包私有变量(模块化)

function asyncIncrement(delay) {
setTimeout(function callback() {
count++; // 回调闭包访问 count
console.log(`当前计数: ${count}`);
}, delay);
}

function getCount() {
return count;
}

return {
asyncIncrement,
getCount
};
})();

counter.asyncIncrement(1000);
counter.asyncIncrement(1000);
// 一秒后依次输出:
// 当前计数: 1
// 当前计数: 2

作用域:count 存在于模块的闭包作用域中,callback 通过作用域链访问它。
模块化:IIFE 封装了私有变量 count,只暴露 asyncIncrement 和 getCount。
回调函数:setTimeout 的回调 callback 形成了闭包,捕获了 count 和 delay 变量,实现了异步递增。

五、总结

概念 与闭包的关系
作用域 闭包是基于词法作用域产生的,它让函数可以持续访问定义时的作用域。
模块化 闭包是实现模块模式的关键技术,通过私有变量和公共接口实现封装。
回调函数 回调函数常作为闭包出现,以便在异步执行时保留所需的环境变量。

闭包不仅是 javascript 的特性,更是连接这些编程范式的重要桥梁。

闭包是指函数与其词法环境的组合,也就是内部函数可以访问外部函数作用域中的变量,即使外部函数已经执行完毕。

什么是闭包?

在 javascript 中,每当创建一个函数,就会形成闭包。更准确地说,闭包是由函数以及该函数被声明时所在的作用域共同组成的。这个作用域包含了函数内部引用的所有外部变量。

看一个最简单的闭包示例:

1
2
3
4
5
6
7
8
9
10
function outer() {
const message = 'Hello, Closure!';
function inner() {
console.log(message);
}
return inner;
}

const closureFn = outer();
closureFn(); // 输出 'Hello, Closure!'

在这个例子中,inner 函数使用了外部函数 outer 的变量 message。当 outer 执行完毕返回 inner 后,按照常理 outer 的作用域应该被销毁,但由于 inner 仍然持有对 message 的引用,javascript 引擎会保留这个变量,使得 inner 可以在外部继续访问它。这个 inner 函数及其引用的环境就构成了一个闭包。

闭包的原理

闭包的形成依赖于 javascript 的词法作用域(静态作用域)和垃圾回收机制。

  1. 词法作用域:函数的作用域在定义时就已经确定,而不是在执行时确定。因此,内部函数总能访问外部函数中声明的变量。
  2. 垃圾回收:通常,当函数执行完后,其局部变量会被标记为可回收。但如果还有别的函数(如内部函数)仍然引用这些变量,它们就不会被回收,从而继续存在。

闭包常见的应用场景

创建私有变量

javascript 没有真正的私有成员,但可以利用闭包模拟私有变量。通过函数作用域隐藏变量,只暴露特定方法进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function createCounter() {
let count = 0;
return {
increment() {
count++;
console.log(count);
},
decrement() {
count--;
console.log(count);
},
getCount() {
return count;
}
};
}

const counter = createCounter();
counter.increment(); // 1
counter.increment(); // 2
console.log(counter.getCount()); // 2
// 无法直接访问 count
console.log(counter.count); // undefined

函数工厂

闭包可以用于生成具有特定行为的函数,例如根据参数创建不同的处理函数。

1
2
3
4
5
6
7
8
9
10
function multiplyBy(factor) {
return function(number) {
return number * factor;
};
}

const double = multiplyBy(2);
const triple = multiplyBy(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15

回调函数与事件处理

在异步操作或事件监听中,闭包常用于保留状态。

1
2
3
4
5
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出 3 3 3
}, 100);
}

上面的例子中,由于 var 没有块级作用域,所有回调共享同一个 i,循环结束后 i 变为 3,因此输出三个 3。利用闭包可以解决这个问题:

1
2
3
4
5
6
7
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 输出 0 1 2
}, 100);
})(i);
}

或者使用 let 创建块级作用域:

1
2
3
4
5
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 输出 0 1 2
}, 100);
}

模块化

在 ES6 模块出现之前,闭包常用于实现模块模式,隔离内部实现,只暴露公共 API。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var myModule = (function() {
var privateVar = 'I am private';
function privateMethod() {
console.log('private method');
}
return {
publicMethod: function() {
console.log(privateVar);
privateMethod();
}
};
})();

myModule.publicMethod(); // I am private \n private method

闭包的内存管理

由于闭包会持续引用外部函数的变量,这些变量无法被垃圾回收,如果使用不当可能导致内存泄漏。例如,在不需要闭包时,仍然保持引用:

1
2
3
4
5
6
7
function heavyProcess() {
const largeData = new Array(1000000).fill('data');
return function() {
console.log(largeData.length);
};
}
const leak = heavyProcess(); // largeData 一直存在

上例中 largeData 无法被回收,因为闭包一直引用它。如果不再需要闭包,应该将引用设为 null:

1
leak = null; // 解除引用,允许垃圾回收

闭包与性能

闭包虽然强大,但过度使用可能带来性能问题,因为每个闭包都额外维护自己的作用域链。在频繁创建大量闭包的场景(如循环中创建函数)时需谨慎,尽量重用函数。

总结

  1. 闭包 = 函数 + 函数定义时的词法环境。
  2. 内部函数可以访问外部函数的变量,即使外部函数已经返回。
  3. 常见用途:私有变量、函数工厂、回调、模块化。
  4. 注意内存管理,及时释放不再需要的闭包。
  5. 现代 javascript 中,let/const 和箭头函数可以简化某些闭包场景,但闭包的核心思想依然不变。

在 CSS 布局中,我们经常会遇到 margin 折叠、浮动元素覆盖、高度塌陷等问题。这些问题的背后,往往与一个核心概念有关——BFC(块级格式化上下文)。

什么是 BFC?

BFC(Block Formatting Context),即块级格式化上下文,是 Web 页面中一块独立的渲染区域,它规定了内部块级盒子的布局方式,并且与区域外部互不影响。

简单来说,BFC 就是一个隔离的容器,容器内的元素不会在布局上影响到外面的元素,反之亦然。

BFC 的布局规则

  1. 内部的块级盒子会在垂直方向上一个接一个地放置。
  2. 盒子在垂直方向上的距离由 margin 决定,属于同一个 BFC 的两个相邻块级盒子的上下 margin 会发生折叠。
  3. 每个元素的左外边距与包含块的左边界相接触(对于从左到右的格式化),即使存在浮动也是如此。
  4. BFC 的区域不会与浮动盒子重叠。
  5. 计算 BFC 的高度时,浮动元素也参与计算(即清除浮动的原理)。
  6. BFC 是一个独立的容器,内部元素不会影响外部元素。

如何触发 BFC?

满足以下任一条件即可创建 BFC:

  1. 根元素 <html> 本身就是最大的 BFC。
  2. 浮动:float 不为 none(即 left、right)。
  3. 绝对定位:position 为 absolute 或 fixed。
  4. 行内块:display: inline-block。
  5. 表格单元格:display: table-cell(HTML 表格单元格默认值)。
  6. 表格标题:display: table-caption(HTML 表格标题默认值)。
  7. 匿名表格单元格元素:display: table、table-row、table-row-group 等。
  8. 块级盒子 带有 overflow 且值不为 visible(即 auto、scroll、hidden)。
  9. 弹性盒子:display: flex 或 inline-flex 的直接子元素。
  10. 网格盒子:display: grid 或 inline-grid 的直接子元素。
  11. 多列容器:column-count 或 column-width 不为 auto。
  12. contain 值为 layout、content 或 paint 的元素。
  13. display: flow-root:一个专门用于创建无副作用 BFC 的属性。

其中最常用的触发方式有:

1
2
3
4
5
6
overflow: hidden
display: flex / inline-flex
display: grid / inline-grid
display: flow-root
position: absolute / fixed
float: left / right

BFC 的常见应用场景

清除浮动(防止高度塌陷)

当子元素浮动,父元素没有设置高度时,父元素会失去高度(高度塌陷)。此时如果父元素触发 BFC,那么计算高度时浮动子元素也会被计入,从而解决塌陷问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

<div class="parent">
<div class="child float">浮动元素</div>
</div>

.parent {
border: 2px solid red;
/* 触发 BFC */
overflow: hidden;
}
.child {
float: left;
width: 100px;
height: 100px;
background: lightblue;
}

避免 margin 折叠

相邻的两个块级盒子,如果它们的上下 margin 相遇,会发生折叠(取较大值)。如果希望它们不折叠,可以将其中一个放入新的 BFC 中。

1
2
3
<div class="box" style="margin-bottom: 20px;">A</div>
<div class="box" style="margin-top: 30px;">B</div>
<!-- 实际间距为 30px(折叠) -->

避免折叠:

1
2
3
4
5
<div class="box" style="margin-bottom: 20px;">A</div>
<div style="overflow: hidden;"> <!-- 触发 BFC -->
<div class="box" style="margin-top: 30px;">B</div>
</div>
<!-- 间距变为 20 + 30 = 50px -->

自适应两栏布局(防止文字环绕)

经典的两栏布局:左侧固定宽度浮动,右侧自适应。如果不做处理,右侧内容会环绕左侧浮动元素。将右侧容器触发 BFC,即可阻止环绕,实现自适应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<div class="left">左侧固定</div>
<div class="right">右侧自适应,不会环绕左侧浮动</div>

.left {
float: left;
width: 200px;
height: 100px;
background: lightcoral;
}
.right {
overflow: hidden; /* 触发 BFC */
height: 150px;
background: lightgreen;
}

防止浮动元素覆盖文本

有时浮动元素会覆盖后面的文本内容,给文本容器触发 BFC 可以避免覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
<div class="float-box">浮动</div>
<p class="text">这是一段很长的文字,如果不触发 BFC,它可能会环绕浮动元素。但如果我们让这段文字所在的容器成为 BFC,它就不会被浮动覆盖,而是独立排列。</p>

.float-box {
float: left;
width: 100px;
height: 100px;
background: lightblue;
}
.text {
overflow: hidden; /* 触发 BFC */
}

布局中的独立容器

使用 display: flow-root 可以创建一个无副作用的 BFC,非常适合现代布局。

1
2
3
4
.container {
display: flow-root;
border: 1px solid #ccc;
}

注意事项

  1. overflow: hidden 会裁剪溢出内容,使用时需注意是否会导致内容不可见。
  2. display: flow-root 是专门为创建 BFC 设计的属性,不会产生其他副作用,推荐使用。
  3. BFC 内部 margin 折叠规则仍然适用,只是 BFC 与外部 margin 不会折叠。
  4. 触发 BFC 的方法很多,选择最适合场景且副作用最小的即可。

总结

BFC 是 CSS 中一个重要的概念,它帮助我们理解布局中的许多“异常”行为,并提供有效的解决方案。掌握 BFC 的触发条件和应用场景,能够让你在面对复杂布局时更加游刃有余。

场景 问题 BFC 解决方案
浮动子元素导致父容器高度塌陷 父容器高度为 0 父容器触发 BFC(overflow: hidden 等)
相邻块级元素 margin 折叠 间距小于预期 将一个元素放入独立 BFC 中
浮动元素覆盖自适应区域 右侧内容环绕 右侧容器触发 BFC
文字环绕浮动元素 排版不符合设计 文本容器触发 BFC

在 CSS 布局中,Flexbox(弹性盒布局)和 Grid(网格布局)是两大核心工具。它们解决了传统布局方式(浮动、定位、表格)的诸多痛点,让开发者能够更高效、更灵活地构建页面结构

Flex 布局(一维布局)

Flexbox 是一种一维布局模型,它专注于在一个方向(水平或垂直)上排列元素。你可以控制元素在主轴上的对齐、分布以及换行行为。

Flex 容器属性

在父元素上设置 display: flex 或 display: inline-flex 即可创建 Flex 容器。

  • flex-direction:定义主轴方向。
    – row(默认):水平从左到右
    – row-reverse:水平从右到左
    – column:垂直从上到下
    – column-reverse:垂直从下到上
  • flex-wrap:控制是否换行。
    – nowrap(默认):不换行,可能溢出
    – wrap:换行,第一行在上方
    – wrap-reverse:换行,第一行在下方
  • justify-content:主轴对齐方式。
    – flex-start(默认):起始对齐
    – flex-end:末尾对齐
    – center:居中对齐
    – space-between:两端对齐,项目之间间距相等
    – space-around:每个项目两侧间距相等
    – space-evenly:项目之间和两端间距相等
  • align-items:交叉轴对齐方式(单行)。
    – stretch(默认):拉伸填满
    – flex-start:交叉轴起始对齐
    – flex-end:交叉轴末尾对齐
    – center:交叉轴居中对齐
    – baseline:基线对齐
  • align-content:多行情况下交叉轴对齐方式(类似 justify-content,但作用于交叉轴)。

Flex 项目属性

  • flex-grow:定义项目的放大比例,默认为 0(不放大)。
  • flex-shrink:定义项目的缩小比例,默认为 1(空间不足时缩小)。
  • flex-basis:定义在分配多余空间之前项目占据的主轴空间,默认 auto(即项目本来的大小)。
  • flex 是 flex-grow、flex-shrink、flex-basis 的简写,例如 flex: 1 等价于 1 1 0%。
  • align-self:允许单个项目覆盖容器的 align-items 设置。
  • order:定义项目的排列顺序,数值越小越靠前,默认为 0。

Flex 示例:导航栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<nav class="navbar">
<div class="logo">Logo</div>
<ul class="nav-links">
<li>首页</li>
<li>产品</li>
<li>关于</li>
<li>联系</li>
</ul>
<div class="user">登录</div>
</nav>

.navbar {
display: flex;
align-items: center;
justify-content: space-between;
background: #333;
color: #fff;
padding: 1rem;
}
.nav-links {
display: flex;
list-style: none;
gap: 1.5rem;
}

Grid 布局(二维布局)

Grid 是一种二维布局模型,它可以同时处理行和列,非常适合构建复杂的页面结构。

Grid 容器属性

在父元素上设置 display: grid 或 display: inline-grid 即可创建 Grid 容器。

  • grid-template-rows / grid-template-columns:定义行和列的轨道大小。
    – 可以使用固定长度、百分比、fr 单位(剩余空间分配)、auto、minmax()、repeat() 等。
1
2
3
4
5
.container {
display: grid;
grid-template-columns: 1fr 2fr 1fr; /* 三列,比例 1:2:1 */
grid-template-rows: 100px auto 200px;
}
  • gap:行列之间的间距(简写 row-gap 和 column-gap)。
  • justify-items / align-items:控制单元格内容在单元格内的水平/垂直对齐方式(start、end、center、stretch)。
  • justify-content / align-content:控制整个网格在容器内的对齐方式(当网格总大小小于容器时)。
  • grid-template-areas:通过命名区域来定义布局,非常直观。
1
2
3
4
5
6
7
8
9
10
11
12
.container {
display: grid;
grid-template-areas:
"header header header"
"sidebar main main"
"footer footer footer";
grid-template-columns: 1fr 2fr 2fr;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.footer { grid-area: footer; }

Grid 项目属性

  • grid-column / grid-row:指定项目占据的列/行范围。例如 grid-column: 1 / 3 表示从第 1 列线到第 3 列线(跨越 2 列)。也可以使用 span 关键字,如 grid-column: span 2。
  • grid-area:如果定义了 grid-template-areas,直接指定区域名;否则可以作为 grid-row-start / grid-column-start / grid-row-end / grid-column-end 的简写。
  • justify-self / align-self:单个项目的对齐方式,覆盖容器的 justify-items / align-items。

Grid 示例:卡片网格

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<div class="grid-container">
<div class="card">卡片1</div>
<div class="card">卡片2</div>
<div class="card">卡片3</div>
<div class="card">卡片4</div>
<div class="card">卡片5</div>
<div class="card">卡片6</div>
</div>
css
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
padding: 1rem;
}
.card {
background: #f4f4f4;
padding: 1rem;
border-radius: 4px;
text-align: center;
}

Flex 与 Grid 的区别

维度 Flexbox Grid
布局维度 一维(行或列) 二维(同时控制行和列)
设计思想 内容驱动(项目大小影响布局) 布局驱动(先定义网格,再放置项目)
适用场景 组件内部排列、导航栏、小规模布局 整体页面架构、复杂二维布局
对齐能力 强大的主轴/交叉轴对齐 同样强大,且支持单元格内对齐
重叠控制 无法控制项目重叠(除非定位) 可以轻松让项目占据相同单元格实现重叠
响应式 通过 flex-wrap 和 flex 属性适应 通过 repeat(auto-fit, minmax()) 轻松实现

Flex 与 Grid 的结合使用

在实际项目中,Grid 和 Flex 并非互斥,而是相辅相成。通常可以用 Grid 搭建页面的宏观框架(如头部、侧边栏、主要内容区、底部),然后在每个区域内用 Flex 处理内部元素的排列。

示例:Grid 宏观布局 + Flex 内部微调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<div class="page">
<header class="header">
<div class="logo">Logo</div>
<nav class="nav">导航链接...</nav>
</header>
<aside class="sidebar">侧边栏</aside>
<main class="main">主要内容</main>
<footer class="footer">页脚</footer>
</div>

.page {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 200px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
.header {
grid-area: header;
display: flex; /* 内部使用 Flex */
justify-content: space-between;
align-items: center;
background: #333;
color: #fff;
padding: 1rem;
}
.sidebar { grid-area: sidebar; background: #f0f0f0; }
.main { grid-area: main; padding: 1rem; }
.footer { grid-area: footer; background: #333; color: #fff; text-align: center; padding: 1rem; }

总结

  1. Flexbox 擅长处理一维排列,适合小范围的组件布局,例如导航栏、列表、卡片内的元素分布。
  2. Grid 擅长处理二维布局,适合构建整个页面的骨架,例如整体布局、网格相册、仪表盘。
  3. 两者可以无缝结合,用 Grid 做宏观布局,用 Flex 做微观调整,让代码既清晰又灵活。
  4. 掌握 Flex 和 Grid 是现代 CSS 布局的必修课,它们让响应式设计变得更加简单和可控。

在编写 CSS 时,经常遇到多个规则作用于同一个元素的情况,最终哪个规则生效由选择器的优先级决定。

什么是选择器优先级?

当多个 CSS 规则同时匹配一个元素时,浏览器需要根据一定的规则来决定哪个样式生效。这个规则就是优先级(Specificity)。优先级是一个由四部分组成的权重体系,权重高的规则胜出。如果权重相同,则后定义的规则覆盖先定义的规则(层叠顺序)。

优先级权重计算规则

CSS 选择器的优先级可以表示为四部分:(内联样式,ID 选择器,类/伪类/属性选择器,元素/伪元素选择器)。通常用 (a, b, c, d) 来表示:

  • a:内联样式(inline style),即在 HTML 元素的 style 属性中定义的样式。它的优先级最高,记作 1,0,0,0。
  • b:ID 选择器的数量,例如 #header,记作 0,1,0,0。
  • c:类选择器(如 .box)、伪类选择器(如 :hover)、属性选择器(如 [type=“text”])的数量,记作 0,0,1,0。
  • d:元素选择器(如 div)、伪元素选择器(如 ::before)的数量,记作 0,0,0,1。
    此外:
  • 通配符选择器 *、组合器(+、>、~、空格)和否定伪类 :not() 本身对优先级没有贡献,但 :not() 内部的选择器会正常计算权重。
  • !important 规则会覆盖任何普通优先级声明,但同属 !important 时仍按优先级比较。

计算示例

示例 1:简单选择器

1
2
3
4
5
6
7
8
/* 权重 (0,0,0,1) */
div { color: red; }

/* 权重 (0,0,1,0) */
.my-class { color: blue; }

/* 权重 (0,1,0,0) */
#my-id { color: green; }

对于 <div id="my-id" class="my-class"> 元素,三个规则都匹配,优先级最高的 #my-id 生效,文字为绿色。

示例 2:组合选择器

1
2
3
4
5
6
7
8
/* 权重 (0,0,1,1) */
div.my-class { color: red; }

/* 权重 (0,1,0,1) */
div#my-id { color: blue; }

/* 权重 (0,1,1,0) */
#my-id.my-class { color: green; }

对于 <div id="my-id" class="my-class">,第三个规则权重最高(一个 ID + 一个类),生效为绿色。

示例 3:复杂选择器

1
2
3
4
/* 权重 (0,1,2,1) */
#nav .list > li:hover a::before {
color: red;
}

计算过程:

  • ID 选择器:#nav → 1 个 → b=1
  • 类/伪类/属性:.list 和 :hover → 2 个 → c=2
  • 元素/伪元素:li、a、::before → 3 个 → d=3
  • 内联样式:0 → a=0

最终权重 (0,1,2,3)。

示例 4:内联样式

1
2
3
4
5
<div id="box" class="box" style="color: black;">文本</div>

#box { color: red; } /* (0,1,0,0) */
.box { color: blue; } /* (0,0,1,0) */
div { color: green; } /* (0,0,0,1) */

内联样式权重为 (1,0,0,0),最高,最终文字为黑色。

!important 规则

  • !important 写在属性值后面,表示该声明具有最高优先级。
  • 它会覆盖任何普通声明(包括内联样式),但如果有多个 !important 规则,则仍然按常规优先级比较。
1
2
p { color: red !important; }      /* 权重极高 */
#text { color: blue; } /* 普通优先级 */

对于 <p id="text">,虽然 ID 选择器权重更高,但 !important 使红色生效。

1
2
p#text { color: red !important; }   /* (0,1,0,1) + !important */
p { color: blue !important; } /* (0,0,0,1) + !important */

两者都是 !important,比较权重:第一个有一个 ID,第二个没有,所以第一个生效。

特殊情况的处理

通配符和组合器

通配符 * 以及组合器(+、>、~、空格)本身不计入权重,但组合器连接的选择器分别计算。

1
2
* { color: black; }           /* (0,0,0,0) */
div * p { color: red; } /* (0,0,0,2) (两个元素选择器) */

:not() 伪类

:not() 本身不计入权重,但其内部的选择器正常计算。

1
div:not(.hidden) { color: red; }   /* (0,0,1,1)(一个元素 + 一个类) */

继承的样式

继承的样式没有优先级,任何直接作用于元素的规则都会覆盖继承的值。

优先级比较的常见误区

  • 误区一:认为类选择器比元素选择器权重高,这是正确的,但需要精确计算数量。
  • 误区二:认为 !important 可以无视任何规则,但实际上多个 !important 仍要比较权重。
  • 误区三:认为行内样式权重无限大,但实际上它只是 (1,0,0,0),可以被 !important 覆盖。
  • 误区四:认为权重可以进位,比如 10 个类选择器可以超过 1 个 ID 选择器。权重不进位,无论多少个类选择器,都无法超过一个 ID 选择器。例如 (0,0,11,0) 仍然小于 (0,1,0,0)。

总结

CSS 优先级规则可以概括为以下层级(从高到低):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌───────────────────────────┐
│ !important 规则 │ 相同 !important 再比较内联等
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ 内联样式(style 属性) │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ ID 选择器数量 │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ 类、伪类、属性选择器数量 │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ 元素、伪元素选择器数 │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ 通配符、组合器(无贡献) │
└───────────┬───────────────┘
┌───────────▼───────────────┐
│ 继承样式(最低) │
└───────────────────────────┘

权重计算口诀:内联最高,ID 次之,类第三,元素最后。!important 权倾天下,但内斗仍按上述规则。

Vue 3 的发布带来了一个重要的新特性——Composition API,它与 Vue 2 经典的 Options API 并存,为开发者提供了更多灵活性。Options API 以“选项”为中心组织代码,而 Composition API 则基于函数式思想,将逻辑按功能聚合。

Options API 回顾

Options API 是 Vue 2 及 Vue 3 中仍然支持的传统写法。它将组件的不同部分(数据、方法、计算属性、侦听器、生命周期钩子)归类到不同的选项(data、methods、computed、watch、created 等)中。

Options API 示例:计数器组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<p>Double: {{ doubleCount }}</p>
</div>
</template>

<script>
export default {
data() {
return {
count: 0
};
},
computed: {
doubleCount() {
return this.count * 2;
}
},
methods: {
increment() {
this.count++;
},
decrement() {
this.count--;
}
},
mounted() {
console.log('组件已挂载,初始值:', this.count);
}
};
</script>

特点

  1. 代码按选项类型(数据、方法、生命周期)自然分割,结构清晰,易于上手。
  2. 对于简单组件,逻辑一目了然。
  3. 但当组件变得复杂时,同一功能的代码可能分散在多个选项中,导致维护困难。

Composition API 介绍

Composition API 是 Vue 3 中新增的一组 API,它允许开发者使用函数(setup 函数)来组织组件逻辑。通过 ref、reactive、computed、watch、生命周期钩子(如 onMounted)等函数,将相关逻辑聚合在一起,并支持提取可复用的逻辑单元(组合式函数)。

Composition API 示例:计数器组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<p>Double: {{ doubleCount }}</p>
</div>
</template>

<script>
import { ref, computed, onMounted } from 'vue';

export default {
setup() {
const count = ref(0);

const doubleCount = computed(() => count.value * 2);

function increment() {
count.value++;
}
function decrement() {
count.value--;
}

onMounted(() => {
console.log('组件已挂载,初始值:', count.value);
});

return {
count,
doubleCount,
increment,
decrement
};
}
};
</script>

特点:

  1. 所有逻辑都集中在 setup 函数内,可以按功能模块分组,而不是按选项类型。
  2. 更容易提取和复用逻辑(使用组合式函数)。
  3. 对 TypeScript 类型推导更友好。

核心区别

逻辑组织方式

  • Options API:按照选项类型强制分组。当组件功能增多时,同一功能的逻辑可能散落在 data、methods、computed 等不同选项中,难以快速理解整体功能。
  • Composition API:允许开发者按照功能模块自由组织代码,可以将相关变量、函数、计算属性写在一起,就像编写普通 JavaScript 函数一样。

代码复用

  • Options API:通常使用 mixins 复用逻辑,但 mixins 存在命名冲突、来源不清晰等缺点。
  • Composition API:通过自定义组合式函数(useXxx)实现逻辑复用,类似于 React Hooks,清晰且无副作用。

TypeScript 支持

  • Options API:虽然可以通过一些技巧实现类型推导,但不够自然,且 this 的上下文让类型推断复杂。
  • Composition API:基于函数和变量,天然支持 TypeScript 类型推导,IDE 提示更友好。

学习曲线

  • Options API:对新手非常友好,概念简单,文档分类清晰。
  • Composition API:需要理解响应式原理(ref、reactive)和 setup 的执行时机,有一定学习成本。

优缺点对比

特性 Options API Composition API
易学性 ⭐⭐⭐⭐⭐ 直观易学 ⭐⭐⭐ 需理解新概念
代码组织 按选项类型分组,简单组件清晰 按功能聚合,复杂组件更易维护
逻辑复用 Mixins(有缺陷) 组合式函数(强大清晰)
TypeScript 支持 一般 优秀
Tree-shaking 选项对象整体引入,不利于摇树 按需引入 API,更利于优化
适用场景 小型组件、新手项目、快速原型 大型项目、复杂组件、需要高度复用的场景

如何选择

  • 如果你是新项目,并且团队成员熟悉 Composition API,推荐使用 Composition API,它更灵活,更适合大型应用。
  • 如果你维护现有 Options API 项目,无需重构,Vue 3 完全兼容 Options API。
  • 对于简单组件,两者皆可,Options API 可能更简洁。
  • 对于需要高度复用逻辑的场景,Composition API 的组合式函数是首选。
  • 如果团队 Vue 2 经验丰富,且项目复杂度不高,继续使用 Options API 也没有问题。
    实际上,Vue 3 允许两种 API 混用,你可以在一个组件中同时使用 setup 和传统选项,但通常不建议这样做,以免造成混乱。

进阶对比:逻辑提取示例

假设我们需要在多个组件中复用鼠标追踪功能。

Options API 使用 mixin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// mouseMixin.js
export default {
data() {
return { x: 0, y: 0 };
},
mounted() {
window.addEventListener('mousemove', this.update);
},
beforeUnmount() {
window.removeEventListener('mousemove', this.update);
},
methods: {
update(e) {
this.x = e.pageX;
this.y = e.pageY;
}
}
};

使用 mixin 的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script>
import mouseMixin from './mouseMixin';
export default {
mixins: [mouseMixin],
mounted() {
console.log('鼠标位置:', this.x, this.y);
}
};
</script>
Composition API 使用组合式函数
js
// useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
const x = ref(0);
const y = ref(0);

function update(e) {
x.value = e.pageX;
y.value = e.pageY;
}

onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));

return { x, y };
}

使用组合式函数的组件:

1
2
3
4
5
6
7
8
9
10
<script>
import { useMouse } from './useMouse';
export default {
setup() {
const { x, y } = useMouse();
// 可以直接使用 x, y
return { x, y };
}
};
</script>

对比可见,组合式函数更加清晰,没有隐藏的依赖,且返回值明确。

总结

Options API 和 Composition API 各有千秋。Options API 以其直观的结构赢得了大量 Vue 2 开发者的青睐,而 Composition API 则为复杂应用提供了更好的逻辑组织能力和复用性。Vue 3 同时支持两者,让开发者可以根据项目需求和团队偏好灵活选择。

在实际开发中,建议:

  1. 小型项目或原型:可以使用 Options API 快速搭建。
  2. 中大型项目、组件库、需要长期维护的应用:推荐 Composition API,它更能应对复杂度的增长。
  3. 混合使用也是可行的,但应保持风格一致。
  4. 无论选择哪种 API,Vue 的核心响应式系统和组件化思想始终不变。