这篇文章汇总了前端与移动端方向的面试题,按 JavaScript、浏览器、CSS、HTML、Vue、React、工程化、跨端(Flutter / React Native)、移动端、场景题分板块整理,既作为面试前的复习清单,也作为平时查漏补缺的索引。
每个板块内先放语言与原理层面的题目,再放框架与实践层面的题目,最后是可以直接口述的场景方案。持续更新中。

JavaScript

JS数据类型

  • 原始类型:undefined, null, boolean, number, bigint, string, symbol

  • 引用类型:object, array, function, date, regexp

  • 原始类型:这些类型直接包含值,比较时是值的比较。

    • Undefined:一个变量声明了,但没有赋值时,它的值就是undefined
      let x;
      console.log(x); // undefined
    • Null:表示一个空的或不存在的对象。与undefined不同,null是一个赋值对象,表示变量没有值。
      let y = null;
      console.log(y); // null
    • Boolean:只有两个值:truefalse
      let isActive = true;
      console.log(isActive); // true
    • Number:包括整数和浮点数。
      let num = 42;
      let price = 9.99;
      console.log(num); // 42
      console.log(price); // 9.99
    • BigInt:可以表示大于Number类型范围的整数。
      let bigIntNum = 1234567890123456789012345678901234567890n;
      console.log(bigIntNum); // 1234567890123456789012345678901234567890n
    • String:用于表示文本数据。
      let name = "Alice";
      console.log(name); // "Alice"
    • Symbol:是一种唯一且不可变的数据类型,通常用于对象属性的唯一标识符。
      let sym = Symbol('description');
      console.log(sym); // Symbol(description)
  • 引用类型:引用类型的值是对象,比较时是引用的比较。

    • Object:用于存储集合数据或更复杂的实体。
      let person = {
      name: "Bob",
      age: 30
      };
      console.log(person); // { name: 'Bob', age: 30 }
    • Array:一种特殊类型的对象,用于存储有序集合。
      let numbers = [1, 2, 3, 4, 5];
      console.log(numbers); // [1, 2, 3, 4, 5]
    • Function:一种可调用的对象。
      function greet() {
      console.log("Hello, World!");
      }
      greet(); // Hello, World!
    • Date:用于表示日期和时间。
      let now = new Date();
      console.log(now); // 当前日期和时间
    • RegExp:正则表达式,用于模式匹配字符串。
      let pattern = /ab+c/;
      console.log(pattern); // /ab+c/

JavaScript中的数据类型可以通过typeof操作符来检查:

console.log(typeof 42); // "number"
console.log(typeof "hello"); // "string"
console.log(typeof true); // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (这是一个历史遗留问题)
console.log(typeof {}); // "object"
console.log(typeof []); // "object"
console.log(typeof function(){}); // "function"

堆和栈区别

在JavaScript中,堆和栈是两种不同的内存区域,它们各自负责不同类型数据的存储和管理。

  • 栈(Stack)
    • 存储内容:栈主要存储基本类型数据,如undefined, string, boolean, number等,以及函数调用的上下文和局部变量。
    • 内存分配与回收:栈内存是由系统自动管理的,当一个函数被调用时,其参数和局部变量会被压入栈中;当函数返回时,这些数据会被弹出并释放。
    • 访问方式:栈中的基本类型数据是按值访问的,这意味着你直接访问的是实际的值。
    • 优点:内存分配和释放快速,管理简单,不需要额外的垃圾回收。
    • 缺点:缺乏灵活性,数据的大小和生命周期需要在编译时确定。
  • 堆(Heap)
    • 存储内容:堆主要用于存储复杂类型或引用类型数据,如对象、数组和函数。这些数据的大小在创建时未知且可变。
    • 内存分配与回收:堆内存的分配是动态的,由程序员(或更准确地说,由JavaScript引擎的垃圾回收器)控制。当一个对象不再被引用时,垃圾回收器会自动回收这块内存。
    • 访问方式:堆中的引用类型数据是按引用访问的,即在栈中存储的是指向堆中实际对象的引用。
    • 优点:提供了更大的灵活性,可以在运行时动态地分配和调整内存。
    • 缺点:内存分配和访问相对缓慢,由于垃圾回收机制的介入,可能会导致不可预测的性能影响。
  • 总结
    • 栈内存适合于那些生命周期短、大小固定的变量,如函数内的局部变量。
    • 堆内存适合于那些大小未知或可变的数据结构,如对象和数组。

在JavaScript中,函数调用和基本类型的变量通常使用栈内存,而对象、数组和函数体则存储在堆内存中。垃圾回收机制负责监控堆内存中的对象,并在适当时候释放不再使用的内存,以防止内存泄漏。

JS是单线程还是多线程,什么情况下会新开一个线程

JavaScript 在设计上是单线程的,这意味着在任何给定的时间点,JavaScript 引擎只能执行一个任务。这种设计的主要原因在于JavaScript最初是为了在浏览器环境中操作DOM(文档对象模型)而设计的,而DOM必须保持一致性,避免多个线程同时修改DOM导致的潜在冲突和复杂性。

现代的JavaScript环境提供了几种机制来模拟多线程行为或并行处理能力:

  1. Web Workers:
    Web Workers允许JavaScript代码在后台线程中运行,从而不会阻塞UI线程。Web Workers提供了一种将计算密集型任务放到单独的线程中运行的方式,而不会影响到网页的响应速度。Web Workers和主线程之间通过消息传递进行通信。
  2. Service Workers:
    Service Workers是运行在浏览器后台的特殊类型的Worker,它们可以拦截网络请求,缓存资源,甚至在没有网络连接的情况下提供离线服务。Service Workers完全独立于主线程运行,可以处理推送通知和背景同步等功能。
  3. Shared Workers:
    Shared Workers类似于Web Workers,但它们可以被多个窗口、标签页或框架共享,允许这些上下文共享状态和数据。
  4. Worker Threads in Node.js:
    在Node.js环境中,虽然V8引擎本身是单线程的,但Node.js利用事件循环和异步I/O来处理并发。从Node.js v10开始,引入了Worker Threads模块,允许创建在独立线程中运行的JavaScript代码片段,可以用于CPU密集型任务。

web worker有哪些限制,与主线程怎么通信

  • 限制
    • DOM 访问限制:
      • Web Workers 不能直接访问或操作 DOM。这意味着它们不能修改页面的内容或样式,也不能监听或触发 UI 事件。
    • 脚本限制:
      • Web Workers 不能调用 alert()confirm()prompt() 函数,因为它们通常用于与用户交互,而 Web Workers 应用于后台任务。
      • 也不能使用 windowdocumentlocation 等全局对象,因为它们与 UI 相关。
    • 同源策略:
      • Web Workers 只能加载与创建它们的脚本同源的资源。这意味着如果 Web Worker 的脚本来自于不同的源,则会引发安全错误。
    • 资源限制:
      • Web Workers 的使用可能会受到资源限制,例如每个页面可以创建的 Web Workers 数量,以及每个 Worker 可以使用的内存量。
    • 网络请求:
      • 尽管 Web Workers 可以使用 XMLHttpRequestfetch API 发起网络请求,但它们不能接收 onloadonerror 等 UI 相关的事件回调。
  • 与主线程的通信:主要依赖于 postMessage 方法和 message 事件处理器
    • 主线程向 Worker 发送消息:
      • 主线程可以通过调用 Worker 对象上的 postMessage() 方法来向 Worker 发送消息。这个方法接受一个参数,可以是任意可序列化的 JavaScript 数据类型(如字符串、数字、数组、对象等)。
    • Worker 向主线程发送消息:
      • Worker 线程同样可以使用 self.postMessage() 方法(self 指的是 Worker 线程的全局作用域)向主线程发送消息。
    • 监听消息:
      • 在主线程和 Worker 中,都可以通过添加事件监听器来处理接收到的消息。在主线程中,这个事件监听器添加到 Worker 对象上;在 Worker 中,事件监听器添加到 self 上。
      • 事件处理器通常包含一个 event 参数,其中 event.data 包含了发送方通过 postMessage 发送的数据。

事件循环Event Loop

  • 任务队列:在 JS中存在不同类型的任务队列,其中包括:
  • 同步任务队列(Synchronous Task Queue):包含同步执行的任务,例如代码块、函数调用等。在执行完一个同步任务后,才会去处理下一个任务。
  • 异步任务队列(Asynchronous Task Queue):包含异步执行的任务,例如事件回调、定时器回调等。这些任务不会立即执行,而是在满足一定条件时被推入执行队列。
    • 异步队列又分为宏任务队列和微任务队列,因为宏任务队列的执行时间较长,所以微任务队列要优先于宏任务队列。微任务队列的代表就是,Promise.thenMutationObserver,宏任务的话就是setImmediate setTimeout setInterval
  • Event Loop 是一个持续运行的循环,负责处理任务队列中的任务。它的基本工作流程如下:
    1. 从同步任务队列中取出一个任务,执行该任务。
    2. 如果在执行同步任务的过程中产生了异步任务(例如定时器、事件回调等),则将这些异步任务添加到异步任务队列中,等待执行。
    3. 当同步任务队列为空时,Event Loop 会检查异步任务队列。
    4. 如果异步任务队列不为空,则按照一定的优先级顺序(通常是 FIFO)从队列中取出一个任务,执行该任务。
    5. 重复步骤 1 至步骤 4。
  • 举例:
    console.log('1. 同步任务开始');

    setTimeout(function() {
    console.log('2. 异步任务A');
    }, 0);

    Promise.resolve().then(function() {
    console.log('3. 异步任务B');
    });

    console.log('4. 同步任务结束');

    // 假设没有其他操作,输出顺序应该是:
    // 1. 同步任务开始
    // 4. 同步任务结束
    // 3. 异步任务B
    // 2. 异步任务A
    1. 首先,console.log('1. 同步任务开始'); 执行并打印 “1. 同步任务开始”。
    2. setTimeout 函数被调用,但是其回调函数不会立即执行,而是会被添加到微任务队列中(实际上,setTimeout 是宏任务,但在现代浏览器中,它的延迟可以非常短,这里我们假设它为0)。
    3. 接下来,Promise.resolve().then() 被调用,这是一个微任务,它会在当前宏任务结束时立即执行。
    4. console.log('4. 同步任务结束'); 执行并打印 “4. 同步任务结束”。
    5. 当前宏任务执行完毕,所有的微任务(这里是Promise的回调)将被执行。因此,console.log('3. 异步任务B'); 将会被执行并打印 “3. 异步任务B”。
    6. 最后,事件循环检查宏任务队列,发现有setTimeout的回调,所以它将被取出并执行,打印 “2. 异步任务A”。

需要注意的是,微任务(如Promise)在每个宏任务执行完后会立即执行,而宏任务(如setTimeout)则会等待所有微任务执行完后才执行下一个。这就是为什么在上述示例中,尽管setTimeout的延迟为0,但Promise的回调却先于setTimeout的回调执行的原因。

Promise函数

Promise 提供了一种更优雅的方式来处理异步操作,使得代码更易读、更易维护。通过链式调用、Promise.all 和 Promise.race 等方法,可以更灵活地组织和处理异步操作的结果。

  • 原理:Promise 是 JavaScript 中处理异步操作的一种机制,其内部原理涉及到状态、回调函数队列等概念。
  • Promise 内部有三种状态:
    • Pending(进行中):初始状态,表示异步操作尚未完成。
    • Fulfilled(已成功):表示异步操作成功完成。
    • Rejected(已失败):表示异步操作失败。
    • 状态一旦发生变化,就不会再变化。例如,当 Promise 对象从 Pending 状态变为 Fulfilled 状态时,它就不能再变为 Rejected 状态,反之亦然。
  • 回调函数队列:Promise 内部有两个队列,分别用于存储成功态和失败态时的回调函数。
    • 成功态回调函数队列:存储 then 方法中的成功回调函数。
    • 失败态回调函数队列:存储 then 方法中的失败回调函数。
  • then 方法:Promise 提供了 then 方法用于注册对异步操作结果的处理逻辑。then 方法接受两个参数:成功回调函数和失败回调函数,它们分别在异步操作成功和失败时被调用。
    • 当 Promise 的状态为 Fulfilled 时,会调用成功态回调函数队列中的回调函数;当 Promise 的状态为 Rejected 时,会调用失败态回调函数队列中的回调函数。
  • 异步操作的触发和状态改变:异步操作完成后,调用 resolve 方法将 Promise 的状态从 Pending 变为 Fulfilled,并将结果传递给成功态回调函数队列中的回调函数;调用 reject 方法将 Promise 的状态从 Pending 变为 Rejected,并将错误信息传递给失败态回调函数队列中的回调函数。

Promise.all

Promise.all 是一个静态方法,用于将多个 Promise 实例包装成一个新的 Promise 实例。当使用Promise.all时,通常会传入一个包含多个Promise对象的数组。Promise.all会返回一个新的Promise,这个新的Promise会在所有输入的Promise都成功(resolved)后解析,或者如果任何一个Promise失败(rejected),则立即失败。

const fetch = require('node-fetch'); // 如果在Node.js环境中使用fetch API,需要引入node-fetch
const fetchGoogle = () => fetch('https://www.google.com').then(res => res.text());
const fetchBing = () => fetch('https://www.bing.com').then(res => res.text());
Promise.all([fetchGoogle(), fetchBing()])
.then(values => {
console.log('Google:', values[0]);
console.log('Bing:', values[1]);
})
.catch(error => {
console.error('Error fetching data:', error);
});

例子中创建了两个函数fetchGooglefetchBing,它们各自返回一个Promise。然后我们使用Promise.all来等待这两个请求同时完成。当所有的请求都成功完成时,Promise.all返回的Promise将被解析,并将所有解析值组成的数组传递给.then方法中的回调函数。如果任何请求失败,Promise.all返回的Promise将被拒绝,并跳过.then方法直接调用.catch方法中的错误处理器。

ES6新特性有哪些

ES6(ECMAScript 2015)是 JavaScript 的一个重要版本,引入了许多新特性和语法糖,使得 JavaScript 更加现代化、功能强大和易用。以下是 ES6 中一些常见的新特性:

  • let 和 const:letconst 用于声明变量,相比 var 具有块级作用域。
    let x = 10;
    const PI = 3.14;
  • 箭头函数:箭头函数是一种更简洁的函数声明方式,省略了 function 关键字和 return 关键字。
    const add = (a, b) => a + b;
  • 解构赋值:解构赋值允许从数组或对象中提取数据,并赋值给变量。
    const [x, y] = [1, 2];
    const { name, age } = { name: 'Alice', age: 25 };
  • 默认参数值:函数参数可以设置默认值。
    function greet(name = 'World') {
    console.log(`Hello, ${name}!`);
    }
  • 模板字符串:模板字符串允许使用反引号(`)定义多行字符串,并在字符串中插入变量。
    const name = 'Alice';
    console.log(`Hello, ${name}!`);
  • 扩展运算符:扩展运算符(...)可以将数组展开成逗号分隔的参数序列,或将对象展开成键值对。
    const arr1 = [1, 2, 3];
    const arr2 = [...arr1, 4, 5, 6];
    const obj1 = { name: 'Alice' };
    const obj2 = { ...obj1, age: 25 };
  • 类和模块:ES6 引入了类和模块的概念,使得 JavaScript 更像一种传统的面向对象语言。
    class Person {
    constructor(name) {
    this.name = name;
    }
    greet() {
    console.log(`Hello, ${this.name}!`);
    }
    }
    export default Person;
  • Promise:Promise 是一种用于处理异步操作的对象,使得异步编程更加优雅和易读。
    const fetchData = () => {
    return new Promise((resolve, reject) => {
    // 异步操作
    if (success) {
    resolve(data);
    } else {
    reject(error);
    }
    });
    };
  • Map 和 Set:ES6 引入了新的数据结构 Map 和 Set,用于存储键值对和唯一值集合。
    const myMap = new Map();
    myMap.set('key', 'value');

    const mySet = new Set();
    mySet.add(1);
  • Symbol:Symbol 是一种新的原始数据类型,表示唯一标识符。
    const mySymbol = Symbol('description');

变量作用域和作用域链

  • 作用域(Scope)是指变量和函数在代码中可访问的范围。在 JavaScript 中,作用域分为以下几种类型:
    1. 全局作用域(Global Scope):
      • 在整个程序中都可以访问的变量。
      • 一般在函数外部定义。
    2. 局部作用域(Local Scope):
      • 变量仅在其定义的代码块或函数内部可见。
      • 函数参数和在函数内声明的变量都是局部变量。
    3. 块级作用域(Block Scope):
      • 在某些语言中(如JavaScript ES6+),在特定的代码块(如if语句、for循环)中定义的变量只在该块内可见。
      • 这有助于避免命名冲突和提升代码可读性。
    4. 函数作用域(Function Scope):
      • 在函数内部定义的所有变量都具有函数作用域,在函数调用时创建,调用结束时销毁。
    5. 闭包作用域(Closure Scope):
      • 当一个函数被另一个函数内部定义时,它可以访问其外部函数的变量,即使外部函数已经返回。这种现象称为闭包。
  • 作用链(Scope Chain)是解释器或编译器在查找变量时遵循的一系列作用域。当在一个函数中引用一个变量时,解释器首先在当前函数的作用域中查找,如果没有找到,则会向上一级作用域查找,直到全局作用域。如果仍然没有找到,那么就认为变量未定义。这个查找的过程形成了作用链。

原型和原型链

  • 原型关系:
    • 每个 class都有显式原型 prototype
    • 每个实例都有隐式原型 _ proto_
    • 实例的_ proto_指向对应 class 的 prototype
  • 原型(Prototype)
    • 在 JavaScript 中,每个对象都有一个关联的原型对象。原型对象是一个普通的对象,它包含共享的属性和方法。当你创建一个对象时,JavaScript 引擎会自动为该对象关联一个原型对象。
  • 原型链(Prototype Chain)
    • 原型链是 JavaScript 中对象之间的一种链接方式,它是由对象的原型组成的链式结构。
    • 函数的原型链对象constructor默认指向函数本身,原型对象除了有原型属性外,为了实现继承,还有一个原型链指针__proto__,该指针是指向上一层的原型对象,而上一层的原型对象的结构依然类似。因此可以利用__proto__一直指向Object的原型对象上,而Object原型对象用Object.prototype.__ proto__ = null表示原型链顶端。如此形成了js的原型链继承。
    • 当访问对象的属性或方法时,JavaScript 引擎会首先在对象本身查找,如果没有找到,则会继续在对象的原型上查找,直到找到相应的属性或方法或者到达原型链的顶端(即 Object.prototype)。
    • 特点: JavaScript对象是通过引用来传递的,我们创建的每个新对象实体中并没有一份属于自己的原型副本。当修改原型时,与之相关的对象也会继承这一改变。

闭包

闭包是指有权访问另一个函数作用域中的变量的函数。

  • 闭包的特性:
    1. 内部函数可以访问定义他们外部函数的参数和变量。(作用域链的向上查找,把外围的作用域中的变量值存储在内存中而不是在函数调用完毕后销毁)设计私有的方法和变量,避免全局变量的污染。
      1.1. 闭包是密闭的容器,,类似于set、map容器,存储数据的
      1.2. 闭包是一个对象,存放数据的格式为 key-value 形式
    2. 函数嵌套函数
    3. 本质是将函数内部和外部连接起来。优点是可以读取函数内部的变量,让这些变量的值始终保存在内存中,不会在函数被调用之后自动清除
  • 闭包形成的条件:
    1. 函数的嵌套
    2. 内部函数引用外部函数的局部变量,延长外部函数的变量生命周期
  • 闭包的用途:
    1. 模仿块级作用域
    2. 保护外部函数的变量 能够访问函数定义时所在的词法作用域(阻止其被回收)
    3. 封装私有化变量
    4. 创建模块
  • 闭包应用场景
    闭包的两个场景,闭包的两大作用:保存/保护。在开发中,其实我们随处可见闭包的身影,大部分前端JavaScript 代码都是“事件驱动”的,即一个事件绑定的回调方法;发送ajax请求成功|失败的回调;setTimeout的延时回调;或者一个函数内部返回另一个匿名函数,这些都是闭包的应用。
  • 闭包的优点:延长局部变量的生命周期
  • 闭包缺点:会导致函数的变量一直保存在内存中,过多的闭包可能会导致内存泄漏
  • 闭包的作用:
    • 保护:划分一个独立的代码执行区域,在这个区域中有自己私有变量存储的空间,保护自己的私有变量不受外界干扰(操作自己的私有变量和外界没有关系);
    • 保存:如果当前上下文不被释放【只要上下文中的某个东西被外部占用即可】,则存储的这些私有变量也不会被释放,可以供其下级上下文中调取使用,相当于把一些值保存起来了;

this指针的5种情况

  1. 作为普通函数执行时,this指向window
  2. 当函数作为对象的方法被调用时,this就会指向该对象
  3. 构造器调用,this指向返回的这个对象
  4. 箭头函数 箭头函数的this绑定看的是this所在函数定义在哪个对象下,就绑定哪个对象。如果有嵌套的情况,则this绑定到最近的一层对象上。
  5. 基于Function.prototype上的 apply 、 call 和 bind 调用模式,这三个方法都可以显示的指定调用函数的 this 指向。apply接收参数的是数组,call接受参数列表,bind方法通过传入一个对象,返回一个this绑定了传入对象的新函数。这个函数的 this指向除了使用new 时会被改变,其他情况下都不会改变。若为空默认是指向全局对象window。

new运算符实现机制

  1. 首先创建了一个新的空对象
  2. 设置原型,将对象的原型设置为函数的prototype对象。
  3. 让函数的this指向这个对象,执行构造函数的代码(为这个新对象添加属性)
  4. 判断函数的返回值类型,如果是值类型,返回创建的对象。如果是引用类型,就返回这个引用类型的对象。

如何判断数据类型?手写一个 instanceof

  • 结论:没有万能方案,按场景选。判断基本类型(除 null)用 typeof;判断实例与构造函数的原型链关系用 instanceof;需要精确区分内置对象用 Object.prototype.toString.call;判断数组直接用 Array.isArray
  • typeof 的坑:typeof null === 'object' 是历史遗留 bug (早期用低三位类型标签表示类型,对象是 000,而 null 的机器码全为 0);除函数外所有引用类型都返回 'object',无法细分。
  • instanceof 的坑:只能用于引用类型;跨 iframe / 跨 realm 时两个环境的 Array.prototype 不是同一个对象,会误判 (这正是 Array.isArray 存在的意义);可被 Symbol.hasInstance 改写。
  • Object.prototype.toString.call(x) 返回 [object Xxx],能区分 Null Undefined Array Date RegExp Error Map Set 等;但自定义类实例统一是 [object Object] (除非定义了 Symbol.toStringTag)。
// 通用类型判断:取 [object Xxx] 中的 Xxx 并转小写
function getType(value) {
return Object.prototype.toString.call(value).slice(8, -1).toLowerCase()
}
getType(null) // 'null'
getType([]) // 'array'
getType(new Date()) // 'date'
getType(NaN) // 'number',NaN 需用 Number.isNaN 单独判断
// 手写 instanceof:沿 left 的原型链向上查找,是否存在 right.prototype
function myInstanceof(left, right) {
if (typeof right !== 'function') throw new TypeError('Right-hand side must be callable')
// 基本类型没有原型链,直接 false
if (left === null || (typeof left !== 'object' && typeof left !== 'function')) return false
const proto = right.prototype
let cur = Object.getPrototypeOf(left)
while (cur !== null) {
if (cur === proto) return true
cur = Object.getPrototypeOf(cur) // 逐层上溯,终点是 null
}
return false
}

== 与 === 的区别,以及 [] == ![] 为什么是 true

  • === 严格相等:类型不同直接 false,不做任何转换。特例 NaN === NaN 为 false,+0 === -0 为 true。
  • == 抽象相等:类型不同时按规范做隐式转换后再比较,因此可读性差,业务代码只建议在 x == null (同时判断 null 和 undefined)时使用。
  • Object.is 修正了两个特例:Object.is(NaN, NaN) 为 true,Object.is(+0, -0) 为 false。

== 的转换规则(按规范顺序逐条匹配):

  1. 类型相同,退化为 ===
  2. null == undefined 为 true,且二者不与任何其他值相等 (null == 0 是 false)。
  3. Number 与 String 比较,String 转 Number。
  4. Boolean 与任意类型比较,Boolean 先转 Number (true → 1,false → 0)。
  5. Object 与原始类型(Number/String/BigInt/Symbol)比较,Object 先走 ToPrimitive (依次尝试 Symbol.toPrimitivevalueOftoString)。
  6. 以上都不匹配则返回 false。
// [] == ![] 的推导过程
// 1. ![] 先算:[] 是对象,ToBoolean 为 true,取反得 false
// => [] == false
// 2. 规则 4:Boolean 转 Number
// => [] == 0
// 3. 规则 5:[] 走 ToPrimitive,valueOf 返回自身 (非原始值),退回 toString 得 ''
// => '' == 0
// 4. 规则 3:'' 转 Number 得 0
// => 0 == 0 -> true
console.log([] == ![]) // true

console.log({} == !{}) // false,ToPrimitive({}) 得 '[object Object]',转 Number 是 NaN
console.log([] == '') // true
console.log([1] == 1) // true,'1' -> 1
console.log(null == 0) // false,null 只与 undefined 宽松相等
console.log(NaN == NaN) // false

浅拷贝与深拷贝,手写支持循环引用的 deepClone

  • 浅拷贝只复制第一层,嵌套的引用类型仍共享同一地址:Object.assign({}, obj)、展开运算符 {...obj}Array.prototype.slice/concat
  • JSON.parse(JSON.stringify(obj)) 的缺陷:undefined/函数/Symbol 作为对象属性会丢失、作为数组元素变成 nullDate 变字符串;RegExp/Map/Set 变成 {}NaN/InfinitynullBigInt 直接抛错;循环引用抛 TypeError;丢失原型链和不可枚举属性;会意外触发对象上的 toJSON
  • structuredClone (结构化克隆,Node 17+ / 现代浏览器原生)支持循环引用、DateRegExpMapSetArrayBufferBlob;但不支持函数、Symbol、DOM 节点 (抛 DataCloneError),且不保留原型链(class 实例会退化为普通对象)、不保留 getter/setter (只拷贝取值结果)。
  • 手写深拷贝的关键点:用 WeakMap 缓存“原对象 → 副本”来切断循环引用 (WeakMap 是弱引用,不会阻止垃圾回收)。
function deepClone(target, cache = new WeakMap()) {
// 基本类型和函数直接返回 (函数一般无需拷贝,共享即可)
if (target === null || typeof target !== 'object') return target
// 命中缓存说明出现循环引用,直接返回此前创建的副本
if (cache.has(target)) return cache.get(target)

if (target instanceof Date) return new Date(target)
if (target instanceof RegExp) return new RegExp(target.source, target.flags)

// 用同一个构造函数创建,保留原型链 (数组得到数组,class 实例得到同类实例)
const clone = new target.constructor()
cache.set(target, clone)

if (target instanceof Map) {
target.forEach((v, k) => clone.set(deepClone(k, cache), deepClone(v, cache)))
return clone
}
if (target instanceof Set) {
target.forEach(v => clone.add(deepClone(v, cache)))
return clone
}

// Reflect.ownKeys 可同时拿到 Symbol 键和不可枚举键
for (const key of Reflect.ownKeys(target)) {
clone[key] = deepClone(target[key], cache)
}
return clone
}

const a = { name: 'x', list: [1, 2] }
a.self = a // 制造循环引用
const b = deepClone(a)
console.log(b.self === b) // true,循环结构被正确还原

手写 call、apply、bind

  • 三者都用于改变函数执行时的 thiscallapply 立即执行,区别只是传参形式 (call 逐个传,apply 传数组);bind 返回一个 this 被永久绑定的新函数,且支持柯里化。
  • 实现核心思路:把目标函数临时挂到指定的上下文对象上,以“对象方法”的形式调用,利用隐式绑定规则让 this 指向该对象,调用完再删掉这个临时属性。
  • Symbol 作为临时属性名,避免覆盖上下文对象上的同名属性。
  • bind 的难点在于:绑定后的函数如果被 new 调用,this 必须指向新创建的实例而不是绑定的上下文,同时原型链要能继续起作用。
Function.prototype.myCall = function (context, ...args) {
if (typeof this !== 'function') throw new TypeError('myCall must be called on a function')
// 非严格模式下 null/undefined 会指向全局对象,原始值会被装箱
const ctx = context === null || context === undefined ? globalThis : Object(context)
const key = Symbol('fn')
ctx[key] = this
const result = ctx[key](...args)
delete ctx[key] // 还原上下文对象,避免副作用
return result
}

Function.prototype.myApply = function (context, argsArray) {
// 只是取参方式不同,其余与 call 一致
return this.myCall(context, ...(argsArray || []))
}
Function.prototype.myBind = function (context, ...preArgs) {
if (typeof this !== 'function') throw new TypeError('myBind must be called on a function')
const self = this
function bound(...args) {
// 被 new 调用时 this instanceof bound 为 true,此时忽略绑定的 context
const isNew = this instanceof bound
return self.apply(isNew ? this : context, preArgs.concat(args))
}
// 继承原型,使 new bound() 的实例仍然 instanceof self
if (self.prototype) bound.prototype = Object.create(self.prototype)
return bound
}

function Point(x, y) { this.x = x; this.y = y }
const P = Point.myBind(null, 1)
const p = new P(2)
console.log(p.x, p.y, p instanceof Point) // 1 2 true

防抖与节流的区别、场景与手写实现

  • 防抖 debounce:事件停止触发后等待 n 毫秒才执行;期间再次触发就重新计时。语义是“只关心最后一次”。
  • 节流 throttle:在 n 毫秒内最多执行一次,稀释执行频率。语义是“匀速执行”。
  • 防抖场景:搜索框输入联想、表单实时校验、按钮防重复提交、resize 后重新计算布局。
  • 节流场景:滚动加载 (监听 scroll 判断触底)、mousemove 拖拽、高频上报打点、动画帧率控制。
  • 工程上注意:需要返回 cancel 方法在组件卸载时清理定时器,否则会造成内存泄漏和“卸载后 setState”的报错。
// 防抖:immediate 为 true 时首次立即执行,后续静默期内不再触发
function debounce(fn, delay = 300, immediate = false) {
let timer = null
function debounced(...args) {
if (timer) clearTimeout(timer)
if (immediate && !timer) fn.apply(this, args) // 保留 this 与参数
timer = setTimeout(() => {
timer = null
if (!immediate) fn.apply(this, args)
}, delay)
}
debounced.cancel = () => { clearTimeout(timer); timer = null }
return debounced
}

// 节流:时间戳版首次立即执行,定时器版保证最后一次不丢失,这里做合并实现
function throttle(fn, interval = 300) {
let last = 0
let timer = null
function throttled(...args) {
const now = Date.now()
const remaining = interval - (now - last)
if (remaining <= 0) {
if (timer) { clearTimeout(timer); timer = null }
last = now
fn.apply(this, args)
} else if (!timer) {
// 补一次尾调用,避免最后一次触发被丢弃
timer = setTimeout(() => {
last = Date.now()
timer = null
fn.apply(this, args)
}, remaining)
}
}
throttled.cancel = () => { clearTimeout(timer); timer = null; last = 0 }
return throttled
}

数组常用操作:扁平化、去重、乱序,以及 forEach/map/filter/reduce 的差异

  • forEach 无返回值 (返回 undefined),只用于产生副作用,无法用 break 中断 (只能用 some/every/for...of 或抛异常中断)。
  • map 返回等长新数组,做一对一映射,不应在里面只做副作用。
  • filter 返回长度小于等于原数组的新数组,做筛选。
  • reduce 把数组归约为任意形态的单一值,是 map/filter/flat 的超集;不传初始值时以第一个元素为初值,空数组不传初值会抛 TypeError
  • 三者都会跳过稀疏数组的空位,且都不会遍历遍历过程中新增的元素。
// 1. 扁平化:原生 flat(depth),Infinity 表示彻底拉平
const arr = [1, [2, [3, [4]]]]
arr.flat(Infinity) // [1, 2, 3, 4]

// reduce 版手写 flat,depth 控制层级
function flat(list, depth = 1) {
if (depth < 1) return list.slice()
return list.reduce(
(acc, cur) => acc.concat(Array.isArray(cur) ? flat(cur, depth - 1) : cur),
[]
)
}

// 2. 去重
const raw = [1, 1, NaN, NaN, {}, {}]
;[...new Set(raw)] // [1, NaN, {}, {}]
// Set 用 SameValueZero,能去掉重复的 NaN;但两个不同引用的 {} 无法去重

// 需要按字段去重时用 Map,保留首次出现的项
function uniqueBy(list, key) {
const map = new Map()
for (const item of list) {
if (!map.has(item[key])) map.set(item[key], item)
}
return [...map.values()]
}

// 3. 乱序:Fisher-Yates 洗牌,O(n) 且严格均匀分布
function shuffle(list) {
const a = list.slice()
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)) // j 在 [0, i] 闭区间取值
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
// 注意 arr.sort(() => Math.random() - 0.5) 不可用:
// 比较函数不满足传递性,结果分布不均匀且依赖引擎的排序算法实现

JS 继承的几种方式与优缺点

  • 原型链继承:Child.prototype = new Parent()。缺点是父类实例的引用类型属性被所有子实例共享(一个改全都改),且无法在创建子实例时向父类传参。
  • 构造函数继承:在子构造函数里 Parent.call(this, ...args)。解决了属性共享和传参问题,但父类原型上的方法继承不到,每个实例都要重新创建一份方法,无法复用。
  • 组合继承:Parent.call(this) + Child.prototype = new Parent()。是 ES5 最常用的方案,但父构造函数被调用了两次,子原型上会残留一份多余的父类实例属性(被实例属性遮蔽)。
  • 寄生组合继承:用 Object.create(Parent.prototype) 替代 new Parent(),只调用一次父构造函数,是 ES5 下的最优解,也是 Babel 编译 class 的产物。
  • class extends:语法糖但不完全等价。子类没有自己的 this,必须先调 super() 才能使用 thisclass 不会变量提升(存在暂时性死区),方法不可枚举,且只能用 new 调用;extends 同时继承静态成员 (Child.__proto__ === Parent),因此可以继承 ArrayError 等内置类型。
// 寄生组合继承 (ES5 最优解)
function Parent(name) {
this.name = name
this.list = [1, 2] // 引用类型放在构造函数里,避免实例间共享
}
Parent.prototype.say = function () { console.log(this.name) }

function Child(name, age) {
Parent.call(this, name) // 第一步:继承属性,只调用一次父构造函数
this.age = age
}
// 第二步:继承方法,用一个空的中间对象桥接原型,避免执行父构造函数
Child.prototype = Object.create(Parent.prototype)
Child.prototype.constructor = Child // 修复 constructor 指向
// class extends 写法
class Parent2 {
constructor(name) { this.name = name }
say() { console.log(this.name) }
static create(name) { return new this(name) }
}
class Child2 extends Parent2 {
constructor(name, age) {
super(name) // 必须先调 super,否则 this 处于 TDZ
this.age = age
}
say() { super.say(); console.log(this.age) }
}
console.log(Object.getPrototypeOf(Child2) === Parent2) // true,静态成员也被继承

手写一个符合 Promise/A+ 基本语义的简版 Promise

  • 三个核心约束:状态机只能 pending → fulfilledpending → rejected,一旦改变不可逆;then 的回调必须异步执行(放进微任务);then 返回新 Promise 以支持链式调用。
  • then 的返回值 x 需要走一段“解析过程”:x 是当前返回的 Promise 本身则抛循环引用错误;x 是 thenable (带 then 方法的对象或函数)则递归展开;否则直接作为值 resolve。
  • 值穿透:then 参数不是函数时要透传,这样 p.then(null).then(v => ...) 才能拿到值。
const PENDING = 'pending', FULFILLED = 'fulfilled', REJECTED = 'rejected'
class MyPromise {
constructor(executor) {
this.status = PENDING
this.callbacks = [] // 缓存 pending 期间注册的回调
const settle = (status, value) => {
if (this.status !== PENDING) return // 状态不可逆,重复敲定直接忽略
this.status = status
this.value = value
this.callbacks.forEach(cb => cb())
this.callbacks = []
}
try { executor(v => settle(FULFILLED, v), r => settle(REJECTED, r)) }
catch (e) { settle(REJECTED, e) } // executor 同步抛错等价于 reject
}
then(onFulfilled, onRejected) {
const onOk = typeof onFulfilled === 'function' ? onFulfilled : v => v // 值穿透
const onErr = typeof onRejected === 'function' ? onRejected : r => { throw r }
const p2 = new MyPromise((resolve, reject) => {
const run = () => queueMicrotask(() => { // then 回调必须放进微任务异步执行
try { resolvePromise(p2, (this.status === FULFILLED ? onOk : onErr)(this.value), resolve, reject) }
catch (e) { reject(e) }
})
this.status === PENDING ? this.callbacks.push(run) : run()
})
return p2
}
catch(onRejected) { return this.then(null, onRejected) }
}
// 解析 then 的返回值 x:兼容 thenable,并防止返回自身导致的循环引用
function resolvePromise(p2, x, resolve, reject) {
if (x === p2) return reject(new TypeError('Chaining cycle detected for promise'))
if (x === null || (typeof x !== 'object' && typeof x !== 'function')) return resolve(x)
let called = false // 保证只被敲定一次
try {
const then = x.then
if (typeof then !== 'function') return resolve(x)
then.call(x, v => { if (!called) { called = true; resolvePromise(p2, v, resolve, reject) } },
r => { if (!called) { called = true; reject(r) } })
} catch (e) { if (!called) reject(e) }
}

Promise.all / race / allSettled / any 的区别,手写 Promise.all

  • Promise.all:全部 fulfilled 才 fulfilled,结果数组严格按传入顺序(不是完成顺序);任意一个 reject 就立即 reject,采用第一个失败的原因,且其余 Promise 仍会继续执行(只是结果被忽略)。
  • Promise.race:谁先敲定(settle)就用谁的结果,无论成功还是失败。常用于给请求加超时。
  • Promise.allSettled:等所有都敲定,永不 reject,返回 {status: 'fulfilled', value}{status: 'rejected', reason} 的数组。适合“批量请求,允许部分失败”。
  • Promise.any:任意一个 fulfilled 就 fulfilled;全部 reject 才 reject,抛出 AggregateError (其 errors 属性收集了所有原因)。是 all 的镜像。
  • 四者都接收可迭代对象,传入非 Promise 值会被 Promise.resolve 包装;传空数组时 all/allSettled 立即 fulfilled,any 立即 reject,race 永远 pending。
Promise.myAll = function (iterable) {
return new Promise((resolve, reject) => {
const list = Array.from(iterable)
const results = new Array(list.length)
let count = 0
if (list.length === 0) return resolve(results) // 空数组直接成功
list.forEach((item, index) => {
// 用 Promise.resolve 兼容普通值和 thenable
Promise.resolve(item).then(value => {
results[index] = value // 按下标写入,保证顺序
if (++count === list.length) resolve(results)
}, reject) // 任一失败立即整体失败
})
})
}
// 用 race 实现请求超时
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
)
return Promise.race([promise, timeout])
}

async/await 的原理,以及 await 在事件循环中的表现

  • async 函数本质是 Generator + 自动执行器(co)的语法糖:await 对应 yield,返回值恒为 Promise,函数内抛错等价于返回 rejected Promise。
  • 自动执行器的职责:把每次 yield 出的值用 Promise.resolve 包装,等它敲定后再把结果通过 gen.next(value) 回灌给生成器,失败则用 gen.throw(err) 抛回函数内部,从而让 try/catch 能捕获异步错误。
  • await x 在事件循环中的语义:暂停当前函数,把后续代码注册为微任务;await 之前的代码是同步执行的。V8 7.2 (Chrome 73 / Node 12)起做了优化,await 一个原生 Promise 只额外消耗 1 个微任务 tick,此前是 3 个。
  • 性能注意:串行 await 会让互不依赖的请求排队,应改用 Promise.all 并发。
// Generator + 自动执行器,等价于 async/await 的编译产物
function co(genFn) {
return new Promise((resolve, reject) => {
const gen = genFn()
function step(method, arg) {
let res
try {
res = gen[method](arg) // method 为 'next' 或 'throw'
} catch (e) {
return reject(e) // 函数体内未捕获的错误
}
const { value, done } = res
if (done) return resolve(value)
// 把 yield 出来的值包成 Promise,敲定后驱动下一步
Promise.resolve(value).then(
v => step('next', v),
e => step('throw', e) // 让 await 处能被 try/catch 捕获
)
}
step('next')
})
}

co(function* () {
const a = yield Promise.resolve(1) // 等价于 const a = await ...
const b = yield Promise.resolve(a + 1)
return b
}).then(console.log) // 2

// 经典输出题
async function async1() { console.log('async1 start'); await async2(); console.log('async1 end') }
async function async2() { console.log('async2') }
console.log('script start')
setTimeout(() => console.log('setTimeout'), 0)
async1()
new Promise(r => { console.log('promise1'); r() }).then(() => console.log('promise2'))
console.log('script end')
// script start -> async1 start -> async2 -> promise1 -> script end
// -> async1 end -> promise2 -> setTimeout

浏览器与 Node.js 事件循环的差异

  • 相同点:都是“执行一个宏任务 → 清空微任务队列”的循环,微任务优先级永远高于宏任务,且微任务队列会被彻底清空(微任务里再产生微任务也会在本轮执行完)。
  • 浏览器:只有一个宏任务队列概念(实现上有多个任务源,如定时器、UI 事件、网络),每执行完一个宏任务就清空微任务队列,然后视情况执行渲染 (requestAnimationFrame 回调在渲染前、微任务之后)。微任务源包括 Promise.thenqueueMicrotaskMutationObserver
  • Node.js:一轮 tick 分六个阶段,按序为 timers (setTimeout/setInterval) → pending callbacks (上一轮延迟的系统回调,如 TCP 错误) → idle/prepare (内部使用) → poll (取回 I/O 事件并执行回调,队列空时可能阻塞等待) → check (setImmediate) → close callbacks (socket.on('close'))。
  • Node 特有的两个队列优先于普通微任务:process.nextTick 队列 > Promise 微任务队列。二者在每个阶段切换之间被清空。process.nextTick 递归调用会饿死事件循环。
  • Node 11 之前,同一阶段的多个宏任务会连续执行完才清空微任务;Node 11 之后改为每执行一个宏任务就清空一次微任务,与浏览器行为对齐。这是很多“同一段代码新旧 Node 输出不同”的根因。
  • setTimeout(fn, 0)setImmediate 在主模块中的先后顺序不确定(取决于进程启动到进入 timers 阶段的耗时是否超过 1ms);但在 I/O 回调内部,setImmediate 一定先于 setTimeout (因为 poll 之后紧接 check 阶段)。
// Node 环境
setTimeout(() => console.log('timeout'), 0)
setImmediate(() => console.log('immediate'))
Promise.resolve().then(() => console.log('promise'))
process.nextTick(() => console.log('nextTick'))
console.log('sync')
// sync -> nextTick -> promise -> (timeout / immediate 顺序不确定)

require('fs').readFile(__filename, () => {
setTimeout(() => console.log('io-timeout'), 0)
setImmediate(() => console.log('io-immediate'))
// 在 I/O 回调中固定输出:io-immediate -> io-timeout
})

V8 垃圾回收机制与内存泄漏排查

  • V8 采用分代式回收:内存分为新生代(存活时间短的小对象,默认 16~32MB)与老生代(存活久、体积大的对象),两代用不同算法,符合“大部分对象朝生夕死”的弱分代假说。
  • 新生代 Scavenge (Cheney 算法):把新生代等分为 From 和 To 两个 semispace,只在 From 分配;回收时把 From 中存活对象复制到 To,然后二者角色互换。优点是只处理存活对象、速度快且无碎片,缺点是浪费一半空间。晋升到老生代的条件:对象已经历过一次 Scavenge 仍存活,或复制时 To 空间使用率超过 25%。
  • 老生代标记清除(Mark-Sweep):从 GC Roots (全局对象、执行栈、活动函数的闭包等)出发做可达性分析,标记存活对象,然后清除未标记者。缺点是产生内存碎片。
  • 老生代标记整理(Mark-Compact):在需要分配大对象而碎片不足时启用,把存活对象向一端移动后清理边界外内存,代价是耗时更长。
  • 增量标记(Incremental Marking)把一次长标记拆成多个小步骤穿插在 JS 执行之间,配合三色标记法(白=未访问、灰=已访问但子节点未处理、黑=已完全处理)与写屏障保证正确性,把单次停顿从上百毫秒降到几毫秒;并发标记(Concurrent Marking)与惰性/并行清理进一步把工作挪到辅助线程,这套方案统称 Orinoco。
  • 常见内存泄漏场景:未声明造成的意外全局变量;setInterval/setTimeoutclearaddEventListenerremoveEventListener;闭包长期持有大对象;脱离文档树的 DOM 节点仍被 JS 变量引用;全局 Map/数组当缓存只增不减 (改用 WeakMap/WeakRef 或加 LRU 淘汰);console.log 打印的对象在 DevTools 打开时无法回收。
  • 排查手段:Chrome DevTools 的 Memory 面板拍两次 Heap Snapshot 做 Comparison,看 Delta 为正且持续增长的构造函数,通过 Retainers 找到引用链;Allocation instrumentation on timeline 观察分配未释放的蓝条;Performance 面板勾选 Memory 看 JS Heap 曲线是否呈锯齿上升。Node 端用 node --inspect 配合 DevTools,或 process.memoryUsage()v8.writeHeapSnapshot() 定时打点。
// 用 WeakMap 存储 DOM 关联数据,节点被移除后条目可自动回收
const domCache = new WeakMap()
function setMeta(el, meta) { domCache.set(el, meta) }

// 组件卸载时务必清理,否则闭包和监听器会常驻内存
function mount(el) {
const timer = setInterval(() => el.textContent = Date.now(), 1000)
const onClick = () => console.log('clicked')
el.addEventListener('click', onClick)
return function unmount() {
clearInterval(timer)
el.removeEventListener('click', onClick)
}
}

事件流三阶段、事件委托与 stopPropagation / preventDefault

  • 一次事件的传播分三个阶段:捕获阶段 (从 window 向下传到目标元素的父级)、目标阶段(到达目标元素)、冒泡阶段 (从目标元素向上回传到 window)。这是 W3C 对 IE 冒泡模型和 Netscape 捕获模型的折中。
  • addEventListener(type, fn, options):第三个参数为 true{capture: true} 时在捕获阶段触发,默认 false 走冒泡阶段。其他常用选项:once 只触发一次后自动移除、passive: true 声明不会调用 preventDefault (让浏览器无需等待即可滚动,显著改善 touchmove/wheel 的滚动性能)、signalAbortController 批量解绑。
  • 注意 focusblurscroll(元素上)等事件不冒泡,对应的冒泡版本是 focusin/focusout
  • stopPropagation 阻止事件继续传播(捕获阶段调用可阻止到达目标),但不阻止同一元素上已绑定的其他同类监听器,要阻止后者需用 stopImmediatePropagation
  • preventDefault 阻止浏览器默认行为 (如 a 标签跳转、表单提交、右键菜单),与传播无关。二者互不影响,return false 在原生 DOM0 级写法中相当于同时调用两者。
  • 事件委托:利用冒泡把子元素的监听器统一绑到父容器上,用 event.target 判断真实触发源。好处是减少监听器数量降低内存占用、动态新增的子元素自动生效、避免手动解绑。局限是不冒泡的事件无法委托,且深层结构里要用 closest 而非直接比对 target
// 手写事件委托:在容器上代理匹配 selector 的子元素事件
function delegate(container, selector, type, handler) {
const listener = function (event) {
// closest 从 target 向上找最近的匹配祖先,兼容点击到子元素内部的情况
const el = event.target.closest(selector)
// 确保匹配到的元素在容器内部,避免越界
if (el && container.contains(el)) {
handler.call(el, event, el)
}
}
container.addEventListener(type, listener)
return () => container.removeEventListener(type, listener) // 返回解绑函数
}

const off = delegate(document.getElementById('list'), 'li', 'click', function (e) {
console.log('clicked item:', this.dataset.id)
})
// 三阶段验证:currentTarget 是绑定监听的元素,target 是真实触发源
parent.addEventListener('click', e => console.log('parent capture', e.eventPhase), true) // 1
child.addEventListener('click', e => console.log('target', e.eventPhase)) // 2
parent.addEventListener('click', e => console.log('parent bubble', e.eventPhase)) // 3
// 点击 child 输出顺序:parent capture -> target -> parent bubble

模块化演进:CommonJS 与 ES Module 的区别

  • 演进路径:全局变量污染 → 命名空间对象 → IIFE 闭包 → CommonJS (Node,服务端同步加载)与 AMD/CMD (浏览器,异步加载) → UMD (兼容层) → ES Module (ES6 语言级标准,浏览器与 Node 统一)。
  • 加载时机:CommonJS 是运行时加载,require 是一个可以出现在任意位置、接受变量路径的函数调用,模块在首次 require 时才执行并缓存;ESM 是编译期(解析阶段)就确定依赖关系,import 会被提升到模块顶部,路径必须是静态字符串字面量,动态加载需用返回 Promise 的 import()
  • 值的语义:CommonJS 导出的是 module.exports 对象的值拷贝 (基本类型拷贝后互不影响,引用类型拷贝的是同一地址);ESM 导出的是动态只读绑定 (live binding),导入方始终看到导出方的最新值,且不能对导入的变量赋值 (会抛 TypeError)。
  • 循环依赖:CommonJS 遇到循环时返回当前已执行部分的 module.exports (可能是不完整的对象),容易拿到 undefined;ESM 因为是绑定引用,只要在实际使用时变量已初始化就能正常工作,但若在初始化前访问 let/const 声明会触发 TDZ 报错。
  • Tree Shaking:ESM 的静态结构使打包器能在编译期做静态分析,识别并删除未被引用的导出;CommonJS 的动态特性 (可以条件 require、运行时改写 exports)导致无法可靠做 Tree Shaking。这也是库同时提供 main (CJS)和 module/exports (ESM)字段的原因。
  • 其他差异:ESM 模块顶层自动是严格模式且有独立作用域,thisundefined (CommonJS 中 this === module.exports);ESM 中没有 __dirname/__filename/require,需用 import.meta.url 替代;ESM 支持顶层 await
// CommonJS:值拷贝
// counter.js
let count = 0
module.exports = { count, inc: () => ++count }
// main.js
const m = require('./counter')
m.inc()
console.log(m.count) // 0,导出时已把 count 的值拷贝进对象,后续修改不同步
// ES Module:动态绑定
// counter.mjs
export let count = 0
export function inc() { count++ }
// main.mjs
import { count, inc } from './counter.mjs'
inc()
console.log(count) // 1,导入的是绑定引用,始终读取最新值
// count = 5 // TypeError: Assignment to constant variable
// 动态导入,用于路由懒加载与条件加载
const { default: heavy } = await import('./heavy-module.js')

浏览器

浏览器的同源策略

浏览器采用同源策略(Same-Origin Policy, SOP)来防止跨域访问,SOP限制了一个源(由协议、域名和端口号组成)上的网页脚本与另一个源上的网页进行交互的能力。同源策略要求以下三个部分完全相同:

跨域是什么及是为了防止什么

防止恶意网站从不同的源获取或者操作敏感数据。

跨域是指一种网页或应用程序通过浏览器访问不同域名、协议或端口上的资源或服务的行为。由于安全原因,浏览器通常会限制这种跨域访问,这是为了防止某些类型的安全威胁,如跨站脚本攻击(Cross-Site Scripting, XSS)和跨站请求伪造(Cross-Site Request Forgery, CSRF)。
跨域是为了防止如下安全威胁:

  1. 跨站脚本攻击(XSS):
    • 恶意脚本被注入到可信网站中,攻击者利用这些脚本来窃取用户信息、劫持用户会话等。
    • 通过限制跨域访问,可以防止恶意网站从可信网站中窃取敏感数据。
  2. 跨站请求伪造(CSRF):
    • 攻击者诱导用户在登录状态下访问攻击者构造的恶意网站,从而以用户的身份执行未授权的操作。
    • 跨域访问限制可以减少这种攻击的风险,因为恶意网站不能直接向受保护的资源发出请求。

解决跨域问题的方法

虽然跨域访问受到限制,但在某些情况下需要进行跨域请求,比如在前后端分离的应用中。以下是一些常见的解决跨域问题的方法:

  1. CORS (Cross-Origin Resource Sharing):最常见且推荐的解决方式,它允许服务器通过响应头中的特定字段来指定哪些源可以访问其资源。
    • 服务器在响应头中设置特定的CORS头信息,允许指定的跨域请求。
    • Access-Control-Allow-Origin:指定哪些源可以访问资源,可以是一个具体的域名,或通配符*表示允许所有源。
      • Access-Control-Allow-Origin: *Access-Control-Allow-Origin: http://example.com
    • Access-Control-Allow-Methods: 允许的HTTP方法列表,如GET, POST, PUT等。
    • Access-Control-Allow-Headers: 允许的请求头列表,这对于预检请求(preflight requests)非常重要。
    • Access-Control-Max-Age: 预检请求的有效期,单位是秒,在此期间,对于同样的请求,浏览器不会再次发送预检请求。
    • Access-Control-Allow-Credentials: 如果设置为true,则表示服务器允许包含身份验证信息(如cookies和HTTP认证)的跨域请求。
    • Access-Control-Expose-Headers: 指定客户端可以从响应中访问的额外头部信息。
    • 通常可以在服务端通过框架或中间件来配置这些头部。例如,在Node.js的Express框架中,可以使用cors中间件来轻松配置CORS。
    • 前端通常不需要直接处理CORS配置,因为它主要是在服务器端完成的。但需要确保API调用正确地设置credentials属性,以便在需要cookies或其他认证信息时正确处理请求。
  2. JSONP (JSON with Padding):一种古老的技巧,利用<script>标签没有同源策略限制的特点,但只适用于GET请求。
    • 通过动态生成<script>标签进行跨域请求,因为<script>标签不受同源策略限制。
    • 主要用于GET请求,但现代应用中使用较少。
  3. 服务器代理(Proxy):在服务器端设置代理,让所有跨域请求先通过同一源的服务器转发,这样浏览器就不会视为跨域请求。
    • 在同源服务器上设置一个代理,通过代理服务器转发请求到不同的域。
    • 客户端只与代理服务器通信,代理服务器与目标服务器通信。
  4. WebSocket
    • WebSocket协议允许跨域通信,适用于需要实时双向通信的应用。

浏览器缓存-强制缓存/协商缓存

浏览器缓存是一种用于存储和重复利用资源的机制,它可以减少网络请求次数,提高网页加载速度。浏览器的缓存机制主要分为两种类型:强制缓存和协商缓存。

  • 强制缓存

    • 强缓存是指客户端在接收到响应后,响应中的缓存控制指示允许,客户端可以无条件地使用缓存的副本,而不必再次询问服务器。这通常意味着在给定的时间内,资源不会改变,所以不需要再次验证资源的有效性。常见的响应头有Cache-ControlExpires
    • Cache-Control 字段中的常见值包括:
      • max-age=<seconds>:指示响应可以被缓存的最大时间(以秒为单位),在此期间可以被重复使用而无需向服务器验证。
      • no-store:指示响应不能被存储在任何缓存中。
      • no-cache:这可能有些令人困惑,因为名字暗示不允许缓存,但实际上它可以与其他指令结合使用,如 no-cache=Set-Cookie,意味着可以缓存响应,但每次使用前需要先验证。
      • must-revalidate:指示缓存必须在过期后向服务器验证,即使在网络中断的情况下也不可使用过期的缓存。
      • proxy-revalidate:类似于 must-revalidate,但只适用于代理服务器,终端用户浏览器可以使用过期的缓存直到收到新的响应。
      • public:指示响应可以被任何缓存存储,包括共享缓存。
      • private:指示响应只能被单个用户的缓存存储,不能在代理服务器上共享。
    • Expires:指定缓存过期时间,是一个绝对的时间点,直到过期前都可以直接使用缓存。
  • 协商缓存
    协商缓存是指客户端在使用缓存副本之前,需要先向服务器确认资源是否已经改变。可以通过比较客户端缓存的版本和服务器上的版本完成,通常使用 ETagLast-ModifiedIf-Modified-Since 这样的头部。

  • 在协商缓存中,服务器会在响应头中包含:

    • ETag:一个代表资源版本的唯一标识符。
    • Last-Modified:资源最后修改的日期和时间。
  • 客户端在后续请求中可以通过以下头部进行验证:

    • If-None-Match:与服务器返回的 ETag 对比,以确定资源是否相同。
    • If-Modified-Since:与服务器的 Last-Modified 时间对比,以判断资源是否已更新。

当服务器收到这些请求头时,如果资源没有变化,它会返回一个304状态码,表示“未修改”,这样客户端就可以继续使用缓存中的副本,从而避免了完整资源的下载。

介绍一下304过程

在HTTP协议中,状态码“304 Not Modified”是在客户端缓存和服务器之间进行缓存验证时使用的一种响应状态。这个过程涉及到了强制缓存和协商缓存的概念,以及HTTP头部如If-Modified-SinceETag的使用。

  • 当客户端首次请求一个资源时,服务器会返回该资源,并可能包含以下头部之一:
    • Last-Modified - 表示资源最后一次修改的时间。
    • ETag - 一个代表资源版本的唯一标识符。
    • 假设服务器返回了资源和Last-Modified头部,例如:
      HTTP/1.1 200 OK
      Last-Modified: Wed, 01 Jul 2020 01:23:45 GMT
      ...
  • 当客户端需要再次获取同一资源时,它会检查是否有缓存版本,并在请求中包含If-Modified-Since头部。
    • 其值为上次请求时服务器返回的Last-Modified时间:
      GET /resource HTTP/1.1
      Host: example.com
      If-Modified-Since: Wed, 01 Jul 2020 01:23:45 GMT
  • 服务器接收到请求后,会检查资源是否自上次修改以来有变化:
    • 如果资源没有变化,服务器将返回一个304状态码,表示“Not Modified”。这意味着客户端可以继续使用其缓存的副本,无需接收新的数据。
    • 如果资源有变化,服务器将返回一个新的200状态码,包含更新后的资源和新的Last-ModifiedETag头部。

304过程对于提高Web性能至关重要,因为它减少了不必要的网络传输。如果资源没有变化,服务器不需要发送完整的资源内容,客户端可以立即使用缓存版本,这显著加快了页面加载速度,同时也减轻了服务器的负载。

总之,304过程是HTTP缓存机制的核心部分,它优化了客户端与服务器之间的通信,提高了用户体验,同时降低了网络带宽消耗和服务器资源的使用。

重排和重绘

“重排”(Reflow)和“重绘”(Repaint)是浏览器渲染网页时发生的两个重要过程,它们对于页面性能有着直接的影响。

  • 重排发生在浏览器需要重新计算元素的几何属性时,比如位置、大小或形状。以下情况可能会触发重排:

    • 添加或删除可见的DOM元素。
    • 元素的尺寸发生变化(如通过JavaScript修改CSS样式)。
    • 触发布局依赖的属性变化,如widthheightpaddingmarginborder等。
    • 文档流中的内容变化,如文本内容的更改。
    • 影响:重排会导致浏览器重新计算页面布局,这是一个比较耗时的操作,因为它涉及到复杂的计算,并且可能涉及整个文档流中多个元素的重新定位。
  • 重绘则是在元素的外观发生变化,但不涉及尺寸或位置改变时发生的过程。例如:

    • 改变元素的颜色或背景。
    • 应用或移除透明度变化。
    • 更改元素的z-index值(只要不影响布局)。
    • 影响:重绘比重排要快一些,因为它不需要重新计算布局,仅需要更新像素颜色。然而,频繁的重绘也会对性能造成影响。
  • 性能优化,为了减少重排和重绘对性能的影响,开发者可以采取以下策略:

    • 减少布局依赖的样式更改,尽量使用不影响布局的属性。
    • 使用requestAnimationFrame来批量处理动画和DOM操作,减少浏览器的重排/重绘次数。
    • 避免使用window.getComputedStyleelement.offsetHeight等会触发重排的方法在循环中。
    • 尽量减少DOM树的深度和复杂性。
    • 利用CSS层叠上下文和独立容器,比如position: fixedtransform,可以让某些元素的改变只影响自身而不影响其他元素。
    • 使用硬件加速的CSS属性,让浏览器尽可能利用GPU进行渲染。

从输入 URL 到页面展示发生了什么

  • 整体分七步:URL 解析 → DNS 查询 → TCP 连接(含 TLS 握手) → 发送 HTTP 请求 → 服务端响应 → 浏览器解析渲染 → 断开连接。回答时要按阶段讲,并在每个阶段点出可优化项。
  • URL 解析与预处理:浏览器判断输入是搜索词还是 URL,补全协议,做 IDN 编码与非法字符转义;随后查 HSTS 列表决定是否强制升级到 HTTPS。此阶段还会命中 Service Worker 与强制缓存,命中则直接返回,不发起网络请求。
  • DNS 查询:依次查浏览器 DNS 缓存 → 操作系统缓存 → hosts 文件 → 本地 DNS 服务器(LDNS);未命中则由 LDNS 迭代查询根域名服务器 → 顶级域服务器 → 权威域名服务器。优化点:dns-prefetch 预解析、减少域名数量以复用连接、使用 HTTPDNS 规避运营商劫持。
  • TCP 三次握手建立连接;HTTPS 还要做 TLS 握手协商对称密钥并校验证书链,TLS 1.2 需要 2 个 RTT,TLS 1.3 降到 1 个 RTT,会话复用可做到 0-RTT。优化点:preconnect 提前建连、开启 Keep-Alive 复用连接、升级 HTTP/2 (多路复用,解决 HTTP/1.1 队头阻塞和 6 个并发连接的限制)或 HTTP/3 (基于 QUIC/UDP,彻底消除 TCP 层队头阻塞)。
  • 发送请求与服务端响应:请求行 + 请求头 (含 Cookie、协商缓存的 If-None-Match/If-Modified-Since) + 请求体;服务端可能返回 304 让浏览器读本地缓存,或 301/302 重定向。优化点:CDN 就近接入降低 TTFB、Brotli/Gzip 压缩、精简 Cookie 体积、合理设置缓存头、服务端渲染或流式输出。
  • 浏览器解析渲染:解析 HTML 构建 DOM、解析 CSS 构建 CSSOM、合成 Render Tree、Layout、Paint、Composite。优化点:CSS 放头部、scriptdefer/async 避免阻塞解析、内联首屏关键 CSS、图片指定宽高避免布局抖动。
  • 断开连接:HTTP/1.1 默认 Keep-Alive,空闲超时后走 TCP 四次挥手;HTTP/2 下一个域名通常只保持一条连接。
# 排查各阶段耗时的常用手段
curl -w "dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" -o /dev/null -s https://example.com

浏览器渲染流程与关键渲染路径

  • 关键渲染路径(Critical Rendering Path)指从收到 HTML 字节到首屏像素上屏的最短链路:字节 → 字符 → Token → Node → DOM,同时 CSS 走 字节 → CSSOM;两者合成 Render Tree (只包含可见节点,display: none 的节点被排除,而 visibility: hidden 仍在树中占位) → Layout 计算几何位置 → Layer 分层 → Paint 生成绘制指令列表 → Composite 合成上屏。
  • 阻塞关系:CSS 不阻塞 DOM 解析,但阻塞渲染(Render Tree 需要 CSSOM),且会阻塞其后的 JS 执行(JS 可能读取样式);同步 script 会阻塞 DOM 解析,因为脚本可能通过 document.write 改变文档结构。因此 CSS 放 head、JS 放 body 尾部或加 defer
  • deferasync 都异步下载:defer 在 DOM 解析完成后、DOMContentLoaded 之前按文档顺序执行,适合有依赖关系的脚本;async 下载完立即执行、顺序不确定,适合独立的统计脚本。
  • 分层与合成:Blink 会把满足条件的元素提升为独立的合成层(Compositing Layer),交由合成线程配合 GPU 进程做分块(Tiling)、光栅化(Raster)和最终的 Draw Quad。合成层的变换不需要主线程参与,因此即使 JS 阻塞主线程,合成层动画依然流畅。
  • 触发合成层的常见条件:3D transform 或 translateZ(0)will-change: transform / opacityvideo/canvas/WebGL 元素、CSS 滤镜动画、position: fixed、层叠在已有合成层之上的元素(层压导致的隐式提升)。
  • 性能要点:transformopacity 的动画只触发 Composite,跳过 Layout 与 Paint,是最高效的动画属性;改 width/top/margin 会触发 Layout (重排),改 color/background 触发 Paint (重绘)。滥用 will-change 会导致层爆炸,每个合成层都占用显存,反而拖慢渲染,应在动画开始前添加、结束后移除。
/* 推荐:只走合成,不触发重排重绘 */
.good { transition: transform .3s, opacity .3s; }
.good:hover { transform: translateX(20px) scale(1.05); opacity: .8; }

/* 不推荐:每帧都触发 Layout */
.bad { transition: left .3s, width .3s; }
// 读写分离,避免强制同步布局 (Layout Thrashing)
// 错误写法:循环内先读后写,每次读都强制刷新布局
items.forEach(el => { el.style.width = el.offsetWidth + 10 + 'px' })
// 正确写法:先批量读,再批量写
const widths = items.map(el => el.offsetWidth)
items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px' })

浏览器的多进程多线程架构,为什么 GUI 与 JS 线程互斥

  • Chrome 采用多进程架构,核心进程有:浏览器主进程(负责界面显示、用户交互、子进程管理、存储)、渲染进程(负责把 HTML/CSS/JS 变成页面,运行在沙箱中,通常每个站点一个,即 Site Isolation)、GPU 进程(负责 3D 绘制与合成上屏)、网络进程(统一处理网络请求)、插件进程与工具进程(如音频、存储服务)。
  • 多进程的收益:一个页面崩溃或插件崩溃不会拖垮整个浏览器;渲染进程运行在沙箱中,即使被恶意代码攻破也难以访问系统资源;多核并行提升响应速度。代价是内存占用高(每个进程都有独立的 V8 实例和资源副本),Chrome 因此有进程合并策略。
  • 渲染进程内部的经典线程模型(面试常考):GUI 渲染线程(解析 HTML/CSS、构建渲染树、布局绘制)、JS 引擎线程(执行 JS,同一时刻只有一个)、事件触发线程(把满足条件的回调放入任务队列)、定时器触发线程 (setTimeout/setInterval 的计时由它单独完成,因为 JS 线程阻塞会导致计时不准)、异步 HTTP 请求线程(发起请求,状态变更时把回调推入任务队列)。
  • 现代 Blink 的实际实现补充:渲染进程内还有合成线程(Compositor Thread)处理滚动与合成层动画、光栅化线程池(Raster Threads)把绘制指令转成位图、Worker 线程运行 Web Worker。合成线程独立于主线程,这就是为什么 JS 卡死时页面仍能滚动一部分已光栅化的内容。
  • GUI 渲染线程与 JS 引擎线程互斥的原因:JS 可以随时通过 DOM API 修改文档结构和样式,如果二者并行执行,就会出现渲染线程读到中间态、或同一个节点被两个线程同时修改的数据竞争,导致渲染结果不可预测。互斥后语义变成“JS 执行时渲染挂起,JS 执行完再统一渲染”,实现简单且结果确定。
  • 直接后果:长任务(超过 50ms 的同步 JS)会阻塞渲染造成掉帧和交互延迟。解决方案是把计算任务放进 Web Worker (无法访问 DOM,通过 postMessage 结构化克隆通信)、用时间切片把长任务拆成多个小任务、或用 requestIdleCallback 在空闲时段执行。
// 时间切片:把长任务拆成不超过 5ms 的分片,把主线程还给渲染
function chunkedRun(tasks) {
let i = 0
function work(deadline) {
while (i < tasks.length && deadline.timeRemaining() > 1) tasks[i++]()
if (i < tasks.length) requestIdleCallback(work)
}
requestIdleCallback(work)
}
// 重计算放进 Worker,避免阻塞主线程渲染
const worker = new Worker('calc.js')
worker.postMessage({ type: 'sum', payload: bigArray })
worker.onmessage = e => console.log('结果', e.data)
  • Cookie:容量约 4KB 且每个域名下条数有限;生命周期由 Expires/Max-Age 决定,不设置则为会话级;作用域是同域名 (可用 Domain/Path 放宽或收窄),跨标签页共享;每次同域请求都会自动携带,会增加带宽开销;API 是 document.cookie 字符串,需自行解析。
  • localStorage:容量约 5~10MB;永久有效,需手动清除;作用域是同源(协议 + 域名 + 端口),跨标签页共享;不随请求发送;同步键值对 API,只能存字符串。
  • sessionStorage:容量约 5~10MB;标签页关闭即失效;作用域是同源且仅限当前标签页,同一页面新开的标签也不共享;不随请求发送;同步键值对 API,只能存字符串。
  • IndexedDB:容量受磁盘配额限制,通常数百 MB 以上;永久有效,需手动清除;作用域是同源,跨标签页共享;不随请求发送;异步事务型 API,支持索引、游标和二进制数据。
  • 选型建议:身份凭证用 Cookie (配合 HttpOnly 防脚本窃取);跨标签页共享的用户偏好用 localStorage;单次流程的临时态(如表单草稿、支付流水号)用 sessionStorage;大量结构化数据或离线缓存用 IndexedDB。localStorage 是同步 API,存取大字符串会阻塞主线程,需要注意。
  • Cookie 的安全属性:HttpOnly 禁止 JS 通过 document.cookie 读取,是防御 XSS 窃取会话的关键;Secure 只在 HTTPS 下发送;SameSite 控制跨站携带策略,Strict 完全禁止跨站携带,Lax (Chrome 80+ 的默认值)只允许顶级导航的安全方法(GET)携带,None 允许跨站携带但必须同时设置 Secure
  • 另外 Domain 决定作用域,设为父域可被子域共享,这是单点登录的常见做法,但也扩大了泄露面;__Host- 前缀可强制要求 Secure、Path 为 / 且不带 Domain,进一步收紧。
// 服务端下发安全 Cookie 的推荐配置
// Set-Cookie: sid=xxx; Max-Age=7200; Path=/; HttpOnly; Secure; SameSite=Lax

// localStorage 存对象需自行序列化,并做异常兜底 (隐私模式或超配额会抛错)
const store = {
set(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)) } catch (e) { console.warn('存储失败', e) }
},
get(key, fallback = null) {
try { return JSON.parse(localStorage.getItem(key)) ?? fallback } catch { return fallback }
}
}
// 跨标签页同步:storage 事件只在"其他"标签页触发,当前页修改不会收到
window.addEventListener('storage', e => console.log(e.key, e.oldValue, e.newValue))

XSS 的三种类型与防御手段

  • XSS (Cross-Site Scripting)的本质是攻击者让恶意脚本在受害者的浏览器中、以受害者所在站点的身份执行,从而窃取 Cookie、伪造请求、篡改页面。根因是“把用户输入当作代码执行”。
  • 存储型(持久型):恶意脚本被写入服务端数据库,任何访问该页面的用户都会中招。典型场景是评论区、个人签名、富文本内容。危害最大、影响面最广。
  • 反射型(非持久型):恶意脚本藏在 URL 参数中,服务端未经转义就把参数拼进响应页面返回。需要诱导用户点击特制链接,常见于搜索结果页、错误提示页。
  • DOM 型:漏洞完全发生在前端,服务端不参与。前端 JS 从 location.hashdocument.referrer 等来源取值后,通过 innerHTMLdocument.writeevalnew FunctionsetTimeout(字符串) 等 sink 执行。特点是攻击载荷可以放在 # 后面,不会发送到服务端,服务端日志查不到。
  • 防御手段按优先级:
    1. 输出编码。根据插入位置选择对应的转义:HTML 文本用实体编码 (< > & " '),HTML 属性额外处理引号,URL 上下文用 encodeURIComponent,JS 上下文用 JSON 序列化。原则是“输入时不改动,输出时按上下文转义”。
    2. 优先使用 textContent/innerText 而非 innerHTML;确需渲染富文本时用 DOMPurify 等成熟库做白名单过滤,不要自己写正则黑名单。
    3. CSP (Content-Security-Policy)响应头,限制脚本只能从白名单来源加载并禁用内联脚本与 eval,即使存在注入点也无法执行。可先用 Content-Security-Policy-Report-Only 灰度观察。
    4. Cookie 加 HttpOnly,即便脚本被执行也拿不到会话凭证;配合 SecureSameSite
    5. 输入校验做长度、格式、类型的白名单限制;富文本存储时保留原文,渲染时净化。
    6. 框架层面 React/Vue 默认对插值做转义,风险集中在 dangerouslySetInnerHTMLv-html、以及把用户输入拼进 href/src (javascript: 伪协议)的地方。
// HTML 文本上下文的转义
const escapeHtml = str => String(str).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]))

// 危险写法:用户输入直接进 innerHTML
el.innerHTML = userInput
// 安全写法一:纯文本渲染
el.textContent = userInput
// 安全写法二:确需 HTML 时做白名单净化
el.innerHTML = DOMPurify.sanitize(userInput)

// 防御 javascript: 伪协议
const safeUrl = url => /^(https?:|\/|\.\/)/i.test(url) ? url : '#'
# CSP 示例:脚本仅允许同源与带 nonce 的内联脚本,禁止 eval 与内联事件
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'self'; report-uri /csp-report

CSRF 的原理与防御

  • CSRF (Cross-Site Request Forgery)是攻击者诱导已登录用户的浏览器,向目标站点发起非本意的请求。浏览器会自动携带目标站点的 Cookie,服务端因此误认为是用户本人的操作。
  • 与 XSS 的本质区别:XSS 是“盗取凭证并直接执行脚本”,攻击者能读到响应;CSRF 是“借用凭证发请求”,攻击者通常读不到响应(受同源策略限制),只能完成写操作,比如转账、改密码、发帖。
  • 常见攻击载体:imgsrc 触发 GET、自动提交的隐藏 form 触发 POST、iframe 加载恶意页面。因此接口的写操作绝不能设计成 GET。
  • 防御手段:
    1. SameSite Cookie。设为 Lax 可拦截绝大多数跨站 POST 与子资源请求,Strict 更严格但会影响从外链跳转回站点时的登录态。这是成本最低、收益最大的一层。
    2. CSRF Token。服务端为每个会话生成随机 token,下发到页面(放在隐藏字段或 meta 标签),前端在写请求中通过请求头或表单字段回传,服务端比对。攻击者因同源策略读不到页面内容,无法获取 token。要点是 token 必须与会话绑定、足够随机、且不能放在 Cookie 里被自动携带。
    3. 双重 Cookie 校验。把随机值同时写入 Cookie 和请求参数,服务端校验二者一致。无需服务端存储状态,但如果存在子域被攻破,子域可写父域 Cookie,防御会失效。
    4. 校验 OriginReferer 请求头。实现简单,但 Referer 可能因隐私策略被裁剪或为空,需要设计好为空时的兜底策略,只能作为辅助手段。
    5. 关键操作二次验证:短信验证码、图形验证码、密码确认。体验成本高,用于转账等高危操作。
    6. 严格遵循 RESTful 语义,GET 只读不写。
// 前端从 meta 中读取服务端下发的 CSRF Token 并统一注入请求头
const csrfToken = document.querySelector('meta[name="csrf-token"]').content
fetch('/api/transfer', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ to: 'bob', amount: 100 })
})
# 服务端下发:SameSite 拦截跨站携带,CSRF Token 不放在 Cookie 中自动发送
Set-Cookie: sid=xxx; HttpOnly; Secure; SameSite=Lax; Path=/

前端性能指标与优化清单

  • 加载类指标:TTFB (Time To First Byte,首字节时间,反映网络与服务端处理耗时,良好阈值 < 800ms)、FP (First Paint,首次绘制任意像素)、FCP (First Contentful Paint,首次绘制文本或图片等内容,良好 < 1.8s)。
  • 体验类核心指标(Core Web Vitals):LCP (Largest Contentful Paint,最大内容元素渲染完成时间,衡量加载体验,良好 < 2.5s,差 > 4s)、CLS (Cumulative Layout Shift,累积布局偏移,衡量视觉稳定性,良好 < 0.1,差 > 0.25)、INP (Interaction to Next Paint,衡量整体交互响应,2024 年 3 月起正式取代 FID 成为核心指标,良好 < 200ms,差 > 500ms)。
  • 加载层优化:走 CDN 与 HTTP/2;开启 Brotli/Gzip;路由级与组件级代码分割 + 懒加载;图片用 WebP/AVIF、响应式 srcset、非首屏 loading="lazy";字体用 font-display: swap 并子集化;合理的强缓存 + 内容哈希文件名;关键资源 preload,次要资源 prefetch
  • 渲染层优化:内联首屏关键 CSS,其余 CSS 异步加载;scriptdefer;给图片和广告位预留固定宽高或 aspect-ratio 以避免 CLS;首屏内容考虑 SSR/SSG;长列表用虚拟滚动;动画只改 transform/opacity
  • 运行时优化:拆分长任务做时间切片,重计算放 Web Worker;高频事件加防抖节流;滚动监听改用 IntersectionObserverscroll/touchmove 监听加 passive: true;避免强制同步布局(读写分离);及时清理定时器与事件监听防止内存泄漏。
  • 采集方式:实验室数据用 Lighthouse / WebPageTest,真实用户数据(RUM)用 PerformanceObserver 上报。注意 LCP 和 CLS 需要持续观察直到页面隐藏才能取到最终值,因此要在 visibilitychange 时统一上报。
// 用 PerformanceObserver 采集核心指标 (生产环境建议直接用 web-vitals 库)
const metrics = {}

new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') metrics.FCP = entry.startTime
}
}).observe({ type: 'paint', buffered: true })

// LCP 会多次触发,取最后一次的值
new PerformanceObserver(list => {
const entries = list.getEntries()
metrics.LCP = entries[entries.length - 1].startTime
}).observe({ type: 'largest-contentful-paint', buffered: true })

// CLS 需累加非用户输入引起的偏移
new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) metrics.CLS = (metrics.CLS || 0) + entry.value
}
}).observe({ type: 'layout-shift', buffered: true })

// 导航阶段耗时拆解:DNS / TCP / TTFB / DOM 解析
const nav = performance.getEntriesByType('navigation')[0]
metrics.DNS = nav.domainLookupEnd - nav.domainLookupStart
metrics.TCP = nav.connectEnd - nav.connectStart
metrics.TTFB = nav.responseStart - nav.requestStart
metrics.DOMReady = nav.domContentLoadedEventEnd - nav.startTime

// 页面隐藏时统一上报,sendBeacon 不会因页面卸载而被中断
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/rum', JSON.stringify(metrics))
}
}, { once: true })

requestAnimationFrame、requestIdleCallback 与 setTimeout 的区别

  • setTimeout 是宏任务,只保证“不早于”指定时间执行,实际时机受主线程繁忙程度影响;嵌套层级超过 5 层后最小间隔被钳制为 4ms;页面切到后台时被节流(通常降到 1s 一次甚至冻结)。它与屏幕刷新完全无关,用它做动画会因为周期和刷新率对不齐而产生丢帧或跳帧。
  • requestAnimationFrame 由浏览器在每次重绘之前调用,频率自动与显示器刷新率对齐(60Hz 约 16.7ms 一帧,120Hz 屏则约 8.3ms),因此动画天然平滑;页面不可见或标签页在后台时自动暂停,节省 CPU 与电量。回调接收一个高精度时间戳 (与 performance.now() 同源),应基于时间差而非帧数计算位移,以适配不同刷新率。同一帧内的多次 DOM 修改会被合并,避免布局抖动。
  • requestIdleCallback 在浏览器每帧的空闲时段执行,回调接收 IdleDeadline,通过 deadline.timeRemaining() 查询剩余可用时间(单帧空闲期上限约 50ms);可传 { timeout } 强制在超时后执行,避免任务被无限期推迟。它的优先级最低,页面繁忙时可能长时间不触发,因此不适合有时效要求的任务。
  • 执行顺序:一帧内大致为 处理输入事件 → 执行定时器等宏任务与微任务 → requestAnimationFrame 回调 → 样式计算与布局 → 绘制与合成 → 若有剩余时间则执行 requestIdleCallback
  • 选型:视觉动画和需要读取布局信息的操作用 rAF;埋点上报、日志压缩、预计算、非关键的资源预取用 rIC;定时轮询、延迟触发用 setTimeoutrIC 中不建议做 DOM 修改,因为此时样式和布局已完成,改动会导致下一帧额外重排,必要时应把 DOM 操作转交给 rAF
  • 兼容性:rIC 在部分旧版浏览器(含较早的 Safari)不支持,需要用 setTimeout 做降级;React 的调度器出于精度与一致性考虑,也是用 MessageChannel 自研实现而非直接使用 rIC
// rAF 动画:基于时间差计算进度,保证不同刷新率下速度一致
function animate(el, distance, duration = 300) {
let start = null
function step(timestamp) {
if (start === null) start = timestamp
const progress = Math.min((timestamp - start) / duration, 1)
el.style.transform = `translateX(${distance * progress}px)` // 只改 transform,走合成
if (progress < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}

// rIC 降级封装
const onIdle = window.requestIdleCallback || (cb =>
setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1))

onIdle(deadline => {
while (deadline.timeRemaining() > 0 && queue.length) report(queue.shift())
}, { timeout: 2000 })

浏览器的资源加载优先级与预加载

  • 浏览器会给每个请求分配优先级(Highest / High / Medium / Low / Lowest),并据此调度。大致规律:HTML 文档与 CSS 最高;同步 script 高(在首屏图片之前的更高);字体高;async/defer 脚本为 Low;视口内的图片为 High、视口外图片为 Low;prefetch 与埋点请求最低。DevTools 的 Network 面板可以打开 Priority 列直接查看。
  • preload:声明当前页面一定会用到的高优先级资源,提前发起请求但不执行,把发现时机从“解析到引用处”提前到“解析 head 时”。必须指定 as 属性,否则可能重复下载且优先级判断错误;跨源字体必须加 crossorigin,否则会下载两次。若预加载后 3 秒内未被使用,控制台会给出警告。
  • prefetch:声明未来导航可能用到的资源,以最低优先级在空闲时下载并存入 HTTP 缓存,供下一个页面使用。典型场景是预取下一页路由的 chunk。
  • dns-prefetch:仅提前做 DNS 解析,开销极小,对第三方域名收益明显,兼容性最好,常与 preconnect 一起写作为降级。
  • preconnect:提前完成 DNS 解析 + TCP 握手 + TLS 协商,节省一个完整的建连往返。代价是会占用连接资源,建议只对 2~4 个最关键的第三方域名使用,且要确保连接会在 10 秒内被用到,否则浏览器会关闭它。
  • modulepreload:专用于 ES Module,不仅下载还会解析模块并递归发现其依赖,一并预加载,同时把模块放入模块图(module map)。相比 preload as="script" 更适合原生 ESM 场景。
  • 相关的还有 fetchpriority 属性 (可对单个 img/link/script 手动提升或降低优先级,常用于给 LCP 图片设 high)、loading="lazy" (原生图片与 iframe 懒加载)以及 Speculation Rules API 的 prerender (整页预渲染,成本最高,需谨慎)。
<!-- 提前解析并建连关键第三方域名,dns-prefetch 作为降级 -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>

<!-- 预加载首屏关键资源:字体必须带 crossorigin -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/img/hero.webp" as="image" fetchpriority="high">

<!-- 预取下一个页面的资源,最低优先级,空闲时下载 -->
<link rel="prefetch" href="/js/next-page.chunk.js" as="script">

<!-- ESM 场景:连同依赖图一起预加载 -->
<link rel="modulepreload" href="/js/app.mjs">

<!-- LCP 图片提优先级,非首屏图片懒加载 -->
<img src="/img/hero.webp" fetchpriority="high" width="1200" height="600" alt="hero">
<img src="/img/footer.webp" loading="lazy" width="800" height="400" alt="footer">
// 运行时按需预取:在空闲时段预加载下一步可能用到的路由 chunk
const prefetch = href => {
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = href
document.head.appendChild(link)
}
requestIdleCallback(() => prefetch('/js/detail-page.chunk.js'))

CSS

盒模型与怪异盒模型

每个元素都被渲染成一个矩形盒子,由内到外是 content、padding、border、margin 四层。两种盒模型的区别只在于 width / height 到底描述哪一部分。

  • box-sizing: content-box 是 W3C 标准盒模型,也是默认值。此时 width 只表示内容区宽度,元素实际占位宽度 = width + padding 左右 + border 左右。
  • box-sizing: border-box 是 IE 怪异盒模型。width 表示 content + padding + border 的总和,加 padding 不会把盒子撑大,而是压缩内容区。
  • 怪异模式(quirks mode)指的是文档缺少 <!DOCTYPE html> 声明时浏览器为兼容老站点启用的渲染模式,此时 IE 会强制使用 border-box。可以用 document.compatMode 判断,返回 CSS1Compat 是标准模式,BackCompat 是怪异模式。
  • 实践中一般全局设置为 border-box,布局时宽度可控,尤其是百分比宽度加 padding 的场景不会溢出。
  • 获取尺寸的几种 API:el.clientWidth = content + padding - 纵向滚动条;el.offsetWidth = content + padding + border + 纵向滚动条,不含 margin;el.scrollWidth 是内容的完整宽度含溢出部分;el.getBoundingClientRect() 返回的是视觉尺寸,会受 transform 缩放影响。
  • window.getComputedStyle(el).width 返回的值取决于 box-sizing:content-box 下是内容宽度,border-box 下是含 padding 和 border 的宽度。
  • margin 不属于盒子本身尺寸,且相邻块级元素的垂直 margin 会发生合并。
/* 推荐的全局重置:继承式写法便于组件内局部覆盖 */
html {
box-sizing: border-box;
}
*,
*::before,
*::after {
box-sizing: inherit;
}

.box {
width: 200px;
padding: 20px;
border: 5px solid #333;
}
/* content-box: 实际占位 200 + 40 + 10 = 250px,内容区 200px */
/* border-box: 实际占位 200px,内容区 200 - 40 - 10 = 150px */
// 判断当前文档渲染模式
console.log(document.compatMode) // 'CSS1Compat' 表示标准模式

const el = document.querySelector('.box')
console.log(el.clientWidth) // 内容 + padding
console.log(el.offsetWidth) // 内容 + padding + border
console.log(el.getBoundingClientRect()) // 受 transform 影响的视觉矩形

BFC 是什么、触发条件与应用场景

BFC(Block Formatting Context,块级格式化上下文)是页面中一块独立的渲染区域,内部元素的布局不会影响外部,外部也不会侵入内部。它是很多“玄学”CSS 现象的统一解释。

  • 触发条件:根元素 <html>float 不为 nonepositionabsolutefixeddisplayinline-blocktable-celltable-captionflexinline-flexgridinline-gridflow-rootoverflow 不为 visible(hidden、auto、scroll);containlayoutcontentpaint
  • display: flow-root 是专门为创建 BFC 设计的值,没有 overflow: hidden 会裁剪溢出内容、float 会脱流这些副作用,是当前最干净的写法。
  • 应用一,阻止外边距合并:同一个 BFC 内相邻的兄弟块级元素、以及父子元素之间的垂直 margin 会合并取较大值。给其中一个元素套上独立 BFC 容器即可隔离。
  • 应用二,清除浮动/解决高度塌陷:BFC 在计算高度时会把内部的浮动元素也算进去,因此父元素触发 BFC 后就能包裹住浮动子元素。
  • 应用三,自适应两栏布局:BFC 区域不会与外部的浮动元素重叠,所以左侧 float: left 固定宽,右侧元素触发 BFC 后会自动占据剩余宽度且不被浮动元素覆盖。
  • BFC 只影响块级布局,不影响行内格式化上下文;Flex 和 Grid 容器创建的其实是 FFC 和 GFC,但同样具有隔离特性。
/* 场景一:阻止父子 margin 合并 */
.parent {
display: flow-root; /* 不加这行,子元素的 margin-top 会"传递"给父元素 */
background: #eee;
}
.child {
margin-top: 30px;
}

/* 场景二:父元素包裹浮动子元素,避免高度塌陷 */
.container {
display: flow-root; /* 或 overflow: hidden */
}
.container > .item {
float: left;
width: 100px;
height: 100px;
}

/* 场景三:自适应两栏,右侧不需要写死 margin-left */
.aside {
float: left;
width: 200px;
}
.main {
overflow: hidden; /* 触发 BFC,自动避开左侧浮动并填满剩余空间 */
}

CSS 选择器优先级与权重计算

浏览器决定用哪条声明时,先比较来源和重要性,再比较特异性(specificity),最后比较书写顺序。

  • 整体优先级从高到低:!important 声明 > 内联 style > ID 选择器 > 类/属性/伪类选择器 > 元素/伪元素选择器 > 通配符与组合符 > 继承而来的样式 > 浏览器默认样式。
  • 特异性用四元组(a, b, c, d)表示:a 是内联样式,b 是 ID 数量,c 是类、属性选择器和伪类数量,d 是元素和伪元素数量。逐位从左往右比较,高位大就直接胜出。
  • 权重不会进位。.a.b.c.d.e.f.g.h.i.j.k(11 个类)依然输给一个 #id,因为比较的是 b 位而不是总和。
  • 通配符 *、组合符 > + ~ 和空格的权重都是 0;:not() :is() :has() 本身不计权重,但括号内的参数参与计算,其中 :is():has() 取参数列表中最高的那一个。
  • :where() 的特异性恒为 0,非常适合写组件库的默认样式,便于使用方用最简单的选择器覆盖。
  • 相同特异性时后定义的覆盖先定义的,因此引入顺序和打包顺序会影响结果。
  • !important 之间也比特异性;作者样式表的 !important 会被用户样式表的 !important 覆盖(可访问性设计)。@layer 级联层的优先级高于特异性判断:后声明的层整体覆盖先声明的层,未分层样式优先级最高。
  • 继承的属性权重最低,任何直接命中该元素的规则都能覆盖它。
/* (0,0,0,1) 元素选择器 */
p { color: black; }

/* (0,0,1,0) 类选择器,胜出 */
.text { color: blue; }

/* (0,0,1,1) 类 + 元素 */
p.text { color: green; }

/* (0,1,0,0) ID,胜出 */
#main { color: red; }

/* (0,0,2,2) :not 本身不计权重,但内部的 .disabled 计入 */
ul li:not(.disabled).active { color: orange; }

/* :where 权重为 0,组件库默认样式推荐写法 */
:where(.btn) { padding: 8px 16px; }
.btn { padding: 12px 24px; } /* 只用一个类就能覆盖上面的默认值 */

/* 级联层:后声明的层整体胜出,与特异性无关 */
@layer base, theme;
@layer theme { p { color: purple; } } /* 生效 */
@layer base { #main p { color: gray; } } /* 特异性更高但层级更低,不生效 */

水平垂直居中的多种方案

居中方案的选择取决于三个条件:元素是否定宽定高、是否允许脱离文档流、以及目标浏览器范围。

  • Flex 方案:display: flex; justify-content: center; align-items: center。不需要知道子元素尺寸,是当前最通用的做法,适合绝大多数场景。
  • Grid 方案:display: grid; place-items: center,写法最简短。也可以用 place-content: center 或子元素 margin: auto
  • 绝对定位 + transform:top: 50%; left: 50%; transform: translate(-50%, -50%)。不需要知道尺寸,兼容性好。缺点是元素尺寸为奇数时位移出现半像素可能导致文字模糊,可用 will-change: transform 或改为偶数尺寸缓解。
  • 绝对定位 + margin auto:四边定位全设 0margin: auto。需要元素有确定的宽高,好处是不涉及 transform,不会模糊。
  • 绝对定位 + 负 margin:top: 50%; left: 50%; margin-top: -h/2; margin-left: -w/2。必须写死宽高,兼容性最好,老项目常见。
  • 行高方案:单行文本用 line-height 等于容器 heighttext-align: center,只适用于单行文本,多行会溢出。
  • table-cell 方案:display: table-cell; vertical-align: middle; text-align: center,可以让不定高的行内内容垂直居中,兼容 IE8。
  • 内联块方案:容器 text-align: center,子元素 display: inline-block; vertical-align: middle,再配合一个 100% 高度的伪元素做基线参照,适合居中未知尺寸的图片。
/* 一、Flex:最推荐,父元素三行搞定 */
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}

/* 二、Grid:写法最短 */
.grid-center {
display: grid;
place-items: center;
}

/* 三、absolute + transform:无需知道子元素尺寸 */
.abs-transform {
position: relative;
}
.abs-transform > .box {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

/* 四、absolute + margin auto:需要定宽定高,不会有半像素模糊 */
.abs-margin > .box {
position: absolute;
inset: 0; /* 等价 top/right/bottom/left 全为 0 */
width: 200px;
height: 100px;
margin: auto;
}

Flex 布局详解

Flex 是一维布局模型,沿主轴排列项目并在交叉轴上对齐。容器决定整体排布规则,项目决定自身的伸缩行为。

  • 容器属性:flex-direction 主轴方向(row / row-reverse / column / column-reverse);flex-wrap 是否换行;flex-flow 是前两者简写;justify-content 主轴对齐;align-items 交叉轴单行对齐;align-content 多行整体对齐(只在换行且有剩余空间时生效);gap / row-gap / column-gap 设置间距,比 margin 更清爽。
  • 项目属性:order 排列顺序(视觉顺序改变不影响 DOM 顺序和读屏顺序,慎用);flex-grow 剩余空间放大比例;flex-shrink 空间不足时的收缩比例;flex-basis 分配前的基准尺寸;flex 三者简写;align-self 覆盖容器的 align-items。
  • flex: 1 等价于 flex: 1 1 0%,basis 是 0 意味着完全忽略内容自身宽度,多个 flex: 1 的项目会等分容器宽度。
  • flex: auto 等价于 1 1 auto,会先按内容尺寸分配再瓜分剩余空间,因此内容多的项目更宽,不是等分。
  • flex: none 等价于 0 0 auto,不伸不缩;默认值是 flex: 0 1 auto,即不放大但会收缩。
  • flex-basiswidth 的关系:在主轴方向上 flex-basis 优先级高于 widthflex-basis: auto 时回退到 width;两者最终都受 min-width / max-width 约束。
  • 最常见的坑:flex 项目的 min-width 默认是 auto,导致内容(长文本、图片、嵌套 flex)无法收缩而撑破容器。解决办法是给项目加 min-width: 0overflow: hidden,纵向布局同理用 min-height: 0
  • margin: auto 在 flex 容器中会吸收剩余空间,margin-left: auto 可以把某个项目推到最右侧,比额外加占位元素更简洁。
.container {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
}

.fixed {
flex: 0 0 200px; /* 固定 200px,不放大不收缩 */
}

.fluid {
flex: 1; /* = 1 1 0%,与其他 flex:1 项目等分剩余空间 */
min-width: 0; /* 关键:允许内部长文本收缩,否则会撑破容器 */
}

.fluid .ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.push-right {
margin-left: auto; /* 吸收剩余空间,把自己顶到最右边 */
}

Grid 布局常用能力

Grid 是二维布局模型,能同时控制行和列,适合处理页面骨架和网格化内容。

  • 定义轨道:grid-template-columns / grid-template-rows 指定各轨道尺寸,可以混用 px、%、frautomin-contentmax-content
  • fr 表示剩余空间的份数,1fr 2fr 就是按 1:2 分配剩余空间。注意 fr 轨道的最小尺寸默认是 auto,内容过长时不会收缩,需要写成 minmax(0, 1fr)
  • repeat(3, 1fr) 简化重复轨道;repeat(auto-fill, minmax(200px, 1fr)) 实现响应式网格,无需媒体查询。
  • auto-fillauto-fit 的区别:容器足够宽而项目不够时,auto-fill 会保留空轨道占位,项目维持原宽度靠左排;auto-fit 会把空轨道折叠为 0,现有项目被拉伸填满整行。
  • 区域命名:grid-template-areas 用字符串画出布局,配合项目的 grid-area 命名,可读性极高,. 表示空单元格。
  • 项目定位:grid-column: 1 / 3grid-column: span 2 指定跨越范围;grid-auto-flow: row dense 可以让小项目回填空隙。
  • 对齐能力:justify-items / align-items 控制单元格内项目对齐,justify-content / align-content 控制整个网格在容器中的对齐,place-items / place-content 是简写。
  • 何时优于 Flex:需要行列同时对齐(表格型布局、卡片瀑布网格)、需要项目重叠、需要精确的整体骨架划分时用 Grid;一维排列(导航条、按钮组、标签流、内容居中)用 Flex 更简单。二者可以嵌套使用,外层 Grid 划分区域,内层 Flex 排内容。
/* 响应式卡片网格,无需任何媒体查询 */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}

/* 区域命名画出经典页面骨架 */
.layout {
display: grid;
grid-template-columns: 200px 1fr 240px;
grid-template-rows: 60px 1fr 48px;
grid-template-areas:
'header header header'
'aside main extra'
'footer footer footer';
min-height: 100vh;
gap: 8px;
}
.header { grid-area: header; }
.aside { grid-area: aside; }
.main { grid-area: main; min-width: 0; } /* 防止内容撑破 fr 轨道 */
.extra { grid-area: extra; }
.footer { grid-area: footer; }

/* 项目跨列与整体对齐 */
.banner {
grid-column: 1 / -1; /* 从第一条线跨到最后一条线,即整行 */
}

position 的取值与包含块规则

position 决定元素如何参与文档流以及相对什么进行偏移。偏移的参照物就是“包含块”。

  • static:默认值,正常流中,top / left / z-index 全部无效。
  • relative:相对自身原本位置偏移,不脱离文档流,原位置仍然占位,后续元素不会重排。常用来给绝对定位子元素做参照物。
  • absolute:脱离文档流,不占位;相对最近的“已定位祖先”(position 不为 static)定位,若没有则相对初始包含块(视口大小的区域)。宽高会收缩为内容宽度。
  • fixed:脱离文档流,相对视口定位,滚动不动。
  • sticky:粘性定位,跨越阈值前表现为 relative(占位、随流滚动),之后表现为 fixed(固定在阈值位置),超出父容器范围后又跟随父容器滚出。
  • 包含块的确定规则:staticrelative 元素的包含块是最近块级祖先的 content box;absolute 元素的包含块是最近已定位祖先的 padding box;fixed 元素的包含块是视口。
  • 一个重要例外:祖先元素设置了 transformfilterperspectivebackdrop-filterwill-change 指向这些属性、或 contain: paint / layout / strict / content 时,会成为 fixedabsolute 后代的包含块,这就是“给父元素加了动画后 fixed 弹窗不再固定在视口”的原因。
  • sticky 生效条件:必须至少指定 top / right / bottom / left 中的一个作为阈值;任何祖先容器都不能设置 overflow: hidden / auto / scroll(否则粘性会失效或立刻消失);父容器高度必须大于 sticky 元素高度,否则没有可粘住的滚动区间;sticky 元素只在其直接父容器的范围内粘住。
.wrapper {
position: relative; /* 给绝对定位子元素提供参照 */
}
.badge {
position: absolute;
top: 8px;
right: 8px; /* 相对 .wrapper 的 padding box */
}

/* sticky 表头 */
.scroll-area {
height: 400px;
overflow-y: auto; /* 滚动容器本身可以有 overflow */
}
.scroll-area thead th {
position: sticky;
top: 0; /* 必须指定阈值,否则完全不生效 */
z-index: 1; /* 避免被后面的内容盖住 */
background: #fff; /* sticky 元素背景必须不透明,否则会透出下方内容 */
}

/* 反例:中间层级设置 overflow 会让 sticky 失效 */
.broken-parent {
overflow: hidden; /* 其内部的 sticky 子元素将无法粘住 */
}

层叠上下文与 z-index 为什么有时不生效

z-index 只在同一个层叠上下文内部比较大小。跨层叠上下文时,先比较各自祖先上下文的层级,子元素再大的 z-index 也无法翻越父级的限制。

  • 创建层叠上下文的条件:根元素 <html>position 为 relative / absolute 且 z-index 不为 auto;position 为 fixed / sticky(无论 z-index);flex 或 grid 容器的直接子项且 z-index 不为 auto;opacity 小于 1;transformfilterperspectiveclip-pathmaskbackdrop-filtermix-blend-mode 不为默认值;will-change 指定了上述任一属性;isolation: isolatecontain: layout / paint / content / strict
  • 同一层叠上下文内的层叠顺序由低到高是:背景和边框 → 负 z-index 的子上下文 → 块级盒 → 浮动盒 → 行内/行内块盒 → z-index: 0auto 的定位元素 → 正 z-index 的定位元素。相同层级时按 DOM 顺序,后者在上。
  • z-index 不生效的常见原因一:元素既不是定位元素,也不是 flex/grid 子项。普通静态元素的 z-index 完全被忽略。
  • 原因二:被父级层叠上下文“封印”。父元素处在低层级上下文中,其子元素写 z-index: 9999 也只能在父容器内部排序。这是弹窗被遮挡最常见的原因。
  • 原因三:祖先元素意外创建了层叠上下文。比如为了做动画加了 transform、为了做遮罩加了 opacity: 0.99、为了性能加了 will-change,都会让内部元素被限制住。
  • 排查方法:从目标元素向上逐层检查是否有创建层叠上下文的属性,定位到那个“封印层”,把它的 z-index 提上去,或者把弹窗通过 Portal / Teleport 挂到 body 下彻底脱离该上下文。
  • isolation: isolate 可以在不引入其他视觉副作用的前提下主动创建层叠上下文,用于隔离第三方组件。
/* 反例:子元素 z-index 再大也盖不过 .other */
.parent {
position: relative;
z-index: 1; /* 创建了层叠上下文,等级为 1 */
}
.child {
position: absolute;
z-index: 9999; /* 只在 .parent 内部有效 */
}
.other {
position: relative;
z-index: 2; /* 整体高于 .parent,覆盖其所有子元素 */
}

/* 隐蔽的陷阱:这些属性都会创建层叠上下文 */
.animated { transform: translateZ(0); }
.faded { opacity: 0.99; }
.perf { will-change: transform; }

/* 主动隔离,避免内部 z-index 影响外部 */
.widget { isolation: isolate; }

元素隐藏的几种方式对比

不同隐藏方式在“是否占位、是否触发回流、能否交互、能否被读屏软件识别、能否过渡”这几个维度上差异很大,需要按场景选择。

  • display: none:不生成渲染盒,不占位,切换会触发回流重排;无法响应任何事件;子元素无法通过覆盖显示出来;屏幕阅读器完全忽略;不能做 transition 过渡(除非配合 transition-behavior: allow-discrete@starting-style 等新特性)。
  • visibility: hidden:占位,只触发重绘不触发回流;不响应事件;子元素可以设置 visibility: visible 单独显示出来;屏幕阅读器忽略;属于可过渡属性,配合 transition-delay 能实现淡出后再隐藏。
  • opacity: 0:占位,只触发重绘且能走合成层动画,性能最好;仍然可以响应点击等事件,这是最容易出 bug 的一点;屏幕阅读器仍会读出内容;会创建层叠上下文。
  • opacity: 0 + pointer-events: none:在保留过渡动画的同时禁掉交互,是做淡入淡出弹层的常用组合,但仍需配合 aria-hidden="true" 才能对读屏隐藏。
  • 绝对定位移出屏幕(left: -9999px):仍参与布局计算与绘制,元素可被 Tab 聚焦,聚焦时可能引发页面意外横向滚动,不推荐单独使用。
  • .sr-only 视觉隐藏方案:用 clip-path 加 1px 尺寸把元素裁剪掉,视觉上不可见但保留在无障碍树中,是给读屏用户提供补充说明的标准做法。
  • hidden 属性:HTML 原生属性,等价于 display: none,但会被任何 display 声明覆盖,写全局样式时要注意。
  • content-visibility: hidden:跳过内容渲染但保留渲染状态,再次显示时恢复很快,适合大量折叠面板的性能优化。
/* 淡出且不可点击,动画结束后彻底移除渲染 */
.modal {
opacity: 1;
visibility: visible;
transition: opacity 0.3s, visibility 0s;
}
.modal.is-hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.3s, visibility 0s 0.3s; /* 延迟隐藏,等淡出播完 */
}

/* 无障碍视觉隐藏:读屏可读,视觉不可见 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

/* 保证 hidden 属性不被组件样式意外覆盖 */
[hidden] {
display: none !important;
}

浮动的问题与清除浮动

float 最初是为了实现文字环绕图片。浮动元素会脱离普通流但仍然占据行框空间,这个“半脱离”特性带来了两类经典问题。

  • 问题一,父元素高度塌陷:父元素计算高度时不包含浮动子元素,如果所有子元素都浮动,父元素高度会变成 0,背景和边框随之消失。
  • 问题二,后续元素布局错乱:非浮动的后续块级元素会钻到浮动元素下方,只有其中的行内内容会被挤开形成环绕效果。
  • 方案一,clearfix 伪元素:在父元素末尾插入一个 clear: both 的块级伪元素。这是兼容性与副作用平衡最好的经典方案,不产生多余 DOM。
  • 方案二,触发父元素 BFC:overflow: hidden 最常用但会裁剪溢出内容(如下拉菜单、阴影);display: flow-root 无副作用,是现代首选;floatposition: absolutedisplay: inline-block 也能触发,但会改变父元素自身的布局特性。
  • 方案三,末尾插入空元素 <div style="clear: both"></div>:有效但引入无语义节点,不推荐。
  • 需要注意 clear 属性只对块级元素生效,且只能清除同一 BFC 内在它之前的浮动。
  • 现代布局中已很少使用浮动,Flex 和 Grid 能更好地表达布局意图;浮动目前主要保留在文字环绕和一些老项目兼容场景。
/* 经典 clearfix,无副作用、无多余 DOM */
.clearfix::after {
content: '';
display: block;
clear: both;
height: 0;
visibility: hidden;
}

/* 现代写法:一行触发 BFC,不裁剪溢出内容 */
.container {
display: flow-root;
}

/* overflow 方案的副作用:子元素的下拉菜单会被裁掉 */
.legacy {
overflow: hidden;
}
<div class="clearfix">
<div style="float: left; width: 100px; height: 80px"></div>
<div style="float: left; width: 100px; height: 80px"></div>
<!-- 父元素高度正常包裹两个浮动子元素 -->
</div>

两栏与三栏布局

三栏布局的经典命题是“中间列自适应、两侧固定宽,且中间列的 HTML 写在最前面以便优先渲染”。圣杯与双飞翼就是在没有 Flex 的年代解决这个问题的两种思路。

  • 圣杯布局:父容器用 padding-left / padding-right 预留两侧空间,三列都 float: left,中间列 width: 100%。左列用 margin-left: -100% 拉到行首,再用 position: relative; left: -左宽 挪进 padding 区;右列用 margin-right: -右宽right: -右宽。缺点是依赖相对定位,且当中间栏宽度小于左栏时布局会错乱。
  • 双飞翼布局:思路相同但去掉了父容器 padding 和相对定位,改为在中间列内部再嵌一层 div,用它的 margin-left / margin-right 给两侧让位。多一层 DOM,但不需要 relative,容错性更好。
  • Flex 现代写法:中间列 flex: 1,两侧固定 flex: 0 0 宽度,用 order 调整视觉顺序即可让中间列的 DOM 写在最前。
  • Grid 现代写法:一行 grid-template-columns: 200px 1fr 240px 即可,配合 grid-template-areas 可读性最好。
  • 两栏自适应的几种写法:左侧 float 加右侧触发 BFC;Flex 中右侧 flex: 1;Grid 用 auto 1fr;左侧 absolute 加右侧 margin-left
  • 现代项目直接用 Flex 或 Grid,圣杯与双飞翼主要作为理解负 margin 和文档流的面试考点。
/* 双飞翼布局:HTML 顺序为 main -> left -> right */
.container { overflow: hidden; } /* 包裹浮动 */
.main, .left, .right { float: left; }

.main { width: 100%; }
.main-inner {
margin: 0 240px 0 200px; /* 给两侧让出空间,内容不会被遮挡 */
}
.left {
width: 200px;
margin-left: -100%; /* 负 margin 拉回到上一行行首 */
}
.right {
width: 240px;
margin-left: -240px; /* 负 margin 拉回到行尾 */
}
/* Flex 写法:DOM 中 main 在前,视觉上仍居中 */
.flex-layout {
display: flex;
}
.flex-layout .main { flex: 1; order: 2; min-width: 0; }
.flex-layout .left { flex: 0 0 200px; order: 1; }
.flex-layout .right { flex: 0 0 240px; order: 3; }

/* Grid 写法:一行搞定,可读性最好 */
.grid-layout {
display: grid;
grid-template-columns: 200px minmax(0, 1fr) 240px;
gap: 16px;
}

CSS 单位对比与移动端适配方案

单位的核心区别是“参照物是谁”。选对参照物,适配问题就解决了一大半。

  • px:CSS 像素,与设备物理像素通过 DPR 换算,最直观但不随字号或视口变化。
  • em:相对于自身的 font-size;但当它用于 font-size 属性本身时,参照的是父元素的 font-size,因此多层嵌套会产生复利式放大,需谨慎。适合做与字号绑定的内边距、圆角。
  • rem:相对于根元素 <html>font-size,不受嵌套影响,是移动端等比缩放方案的基础。
  • %:相对包含块的对应属性。要特别注意 paddingmargin 的百分比在水平和垂直方向上都相对包含块的宽度计算,可以用这个特性做固定宽高比;height: 100% 要求父元素有确定高度。
  • vw / vh:视口宽高的 1%。vw 包含滚动条宽度,在 PC 端可能导致横向滚动;移动端浏览器地址栏伸缩会让 vh 跳变,可改用 dvh(动态)、svh(最小)、lvh(最大)。
  • vmin / vmax:取视口宽高中较小/较大的一方的 1%,常用于保证元素在横竖屏下都不溢出。
  • rpx:小程序专有单位,规定 750rpx 等于屏幕宽度,本质就是内置了 vw 换算。
  • 适配方案一,viewport 缩放:通过 initial-scale = 1 / dpr 让 1 CSS 像素对应 1 物理像素,可以直接按设计稿标注写 px 并顺带解决 1px 问题,但会让所有内容整体缩放,字体也跟着变小。
  • 适配方案二,rem + flexible:用 JS 按屏幕宽度动态设置 htmlfont-size(如设为屏宽的 1/10),配合 postcss-pxtorem 自动转换。缺点是依赖 JS、有闪烁风险、大屏上元素会过大,需要配合 max-width 限制。
  • 适配方案三,vw + postcss-px-to-viewport:纯 CSS 方案,无需 JS,按设计稿宽度直接换算,是目前的主流做法。同样建议给根容器加最大宽度以适配平板和折叠屏。
  • 实践组合:布局尺寸用 vw 或 rem 等比缩放,字号用 px 加媒体查询分档(避免小屏字太小、大屏字太大),1px 边框单独处理。
/* vw 方案:设计稿 750px,1px = 100/750 vw */
.title {
width: 100vw;
font-size: 16px; /* 字号保持固定,避免过度缩放 */
padding: 2.6667vw; /* 20 / 750 * 100 */
}

/* 移动端视口高度跳变,用 dvh 兜底 */
.full-screen {
height: 100vh;
height: 100dvh;
}

/* 大屏限制最大宽度,避免 vw 方案下元素被拉得过大 */
#app {
max-width: 750px;
margin: 0 auto;
}
// rem + flexible 核心逻辑
function setRem() {
const width = Math.min(document.documentElement.clientWidth, 750)
// 把屏幕宽度十等分,1rem = 屏宽 / 10
document.documentElement.style.fontSize = width / 10 + 'px'
}
setRem()
window.addEventListener('resize', setRem)

移动端 1px 边框问题

问题根源是设备像素比 DPR。DPR 等于设备物理像素数与 CSS 像素数之比,iPhone 等高清屏 DPR 为 2 或 3,此时 CSS 中写的 1px 会被渲染成 2 到 3 个物理像素,视觉上比设计师期望的“1 物理像素细线”粗一倍以上。

  • 可以通过 window.devicePixelRatio 读取 DPR,注意它会随页面缩放而变化。
  • 方案一,伪元素 + transform scale:给元素加绝对定位的伪元素,把它的边框按 1 / dpr 比例缩放。这是兼容性和可控性最好的通用方案,单边可以直接 scaleY(0.5),四边需要把伪元素放大到 200% 再整体 scale(0.5),并设置 transform-origin: 0 0。圆角场景要把 border-radius 同步放大一倍。
  • 方案二,直接写 0.5px:iOS 8 及以上可以正确渲染,但 Android 上很多机型会把小于 1px 的值抹为 0 导致边框消失,需要做特性检测后再降级。
  • 方案三,border-imagebackground-image 渐变:用一张 2 倍图或线性渐变模拟细线,兼容性好但不支持圆角,且修改颜色不方便。
  • 方案四,viewport 缩放:把 initial-scale 设为 1 / dpr,整个页面按物理像素渲染,此时写 1px 就是 1 物理像素。属于整站级方案,必须配合 rem 或 vw 使用,且要在页面渲染前动态写入 meta 标签。
  • 方案五,box-shadow: 0 0 0 0.5px color:写法简单支持圆角,但在部分安卓机上颜色会偏淡。
  • 选择建议:单个组件用伪元素 scale 方案;整站从零搭建可以考虑 viewport 缩放方案;不要为了 1px 引入过多全局副作用。
/* 单边细线:最常用 */
.border-bottom-1px { position: relative; }
.border-bottom-1px::after {
content: '';
position: absolute;
inset: auto auto 0 0;
width: 100%;
height: 1px;
background: #e5e5e5;
transform: scaleY(0.5); /* DPR 为 3 时改成 scaleY(0.333) */
transform-origin: 0 100%; /* 从底部缩放,避免线条位置偏移 */
}

/* 四边细线 + 圆角:伪元素放大一倍再整体缩小 */
.border-all-1px { position: relative; border-radius: 8px; }
.border-all-1px::after {
content: '';
position: absolute;
inset: 0 auto auto 0;
width: 200%;
height: 200%;
border: 1px solid #e5e5e5;
border-radius: 16px; /* 圆角也要放大一倍 */
transform: scale(0.5);
transform-origin: 0 0;
box-sizing: border-box;
pointer-events: none; /* 避免遮挡内部点击 */
}
// viewport 缩放方案:必须在首屏渲染前执行
const scale = 1 / (window.devicePixelRatio || 1)
const content = `width=device-width, initial-scale=${scale}, maximum-scale=${scale}, user-scalable=no`
document.querySelector('meta[name="viewport"]').setAttribute('content', content)

transition、animation、transform 与 GPU 加速

transition 描述“状态变化时如何平滑过渡”,animation 描述“一段可自主播放的时间线”,transform 则是最适合做动画的属性之一。

  • 主要区别:transition 必须由状态改变触发(hover、类名切换、JS 修改样式),只有起点和终点两个状态,默认不能循环也不能自动播放;animation 通过 @keyframes 定义任意多个关键帧,可以自动播放、指定循环次数 animation-iteration-count、交替方向 animation-direction、用 animation-fill-mode 保留首尾状态、用 animation-play-state 暂停。
  • transition 只能作用于可插值的属性。displayheight: auto 这类离散属性传统上无法过渡,新特性 transition-behavior: allow-discrete 配合 @starting-style 才能实现。
  • 常用 transform 函数:translate / translate3d 位移、scale 缩放、rotate 旋转、skew 倾斜、matrix 矩阵。多个函数按从左到右依次应用,顺序不同结果不同;现代 CSS 也支持 translate / rotate / scale 独立属性便于分别控制。transform 不影响文档流,不会引起周围元素重排,同时会创建层叠上下文并成为后代 fixed 元素的包含块。
  • 浏览器渲染管线是 layout(布局) → paint(绘制) → composite(合成)。修改 widthtopmargin 会触发完整回流;修改 background-color 会触发重绘;而 transformopacity 可以只在合成阶段处理,由 GPU 完成,因此动画应优先使用这两个属性。
  • 提升为合成层的常见触发条件:3D transform、will-change: transform / opacityvideo / canvas / iframe、有合成层后代且自身有重叠、position: fixed 等。
  • will-change 的正确用法:只在即将发生变化前添加,动画结束后移除;不要在初始 CSS 中长期声明;不要对大量元素统一加。每个合成层都会占用显存,层数过多反而会导致内存暴涨和滚动卡顿。translateZ(0) 是同样目的的老 hack,现代项目优先用 will-change
  • 无障碍方面应尊重 prefers-reduced-motion,为对动效敏感的用户关闭动画。
.card {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
}
.card:hover {
transform: translateY(-4px) scale(1.02); /* 只走合成,不触发回流 */
}

@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.6; }
100% { transform: scale(1); opacity: 1; }
}
.dot {
animation: pulse 1.2s ease-in-out infinite both;
}

/* 反例:动画 left 会每帧触发布局计算;正例:改用 transform 只走合成 */
.bad { transition: left 0.3s; }
.good { transition: transform 0.3s; }

/* 尊重系统的减少动效设置 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
// will-change 应动态增删,避免长期占用合成层内存
const el = document.querySelector('.card')
el.addEventListener('mouseenter', () => (el.style.willChange = 'transform'))
el.addEventListener('transitionend', () => (el.style.willChange = 'auto'))

响应式设计与媒体查询、容器查询、图片响应式

响应式的目标是同一份代码在不同视口和不同设备能力下都有合理表现,手段包括弹性布局、媒体查询、容器查询和响应式资源。

  • 基础三件套:<meta name="viewport" content="width=device-width, initial-scale=1">、弹性单位(%、fr、vw、rem)、以及 max-width: 100% 让图片不溢出。
  • 媒体查询建议采用移动优先,用 min-width 逐级增强,样式覆盖方向单一更好维护。常用断点参考 640 / 768 / 1024 / 1280 / 1536。
  • 媒体查询不只能查宽度,还能查 orientation 横竖屏、prefers-color-scheme 深色模式、prefers-reduced-motion 减少动效、hoverpointer 判断是否为触摸设备、-webkit-min-device-pixel-ratio 判断高清屏。
  • 媒体查询范围语法(Media Queries Level 4)允许写成 @media (400px <= width <= 700px),比 min-widthmax-width 更直观。
  • 容器查询解决的是组件级响应式:同一个卡片组件放在侧边栏和主区域应该有不同布局,而这与视口宽度无关。用 container-type: inline-size 声明查询容器,再用 @container 按容器宽度写样式,还可以配合 cqw / cqi 等容器单位。
  • 图片响应式:srcset 配合 w 描述符和 sizes 让浏览器根据 DPR 和实际显示宽度自动挑选合适尺寸,节省流量;<picture><source> 可以按 media 做艺术方向裁剪,按 type 做格式降级(AVIF → WebP → JPEG)。
  • 图片还应加 loading="lazy" 延迟加载、decoding="async" 异步解码,并显式写 width / heightaspect-ratio 以预留空间,避免累积布局偏移。
/* 移动优先,逐级增强 */
.grid { display: grid; grid-template-columns: 1fr; gap: 12px; }

@media (min-width: 768px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}

/* 容器查询:组件根据自身容器宽度调整,而非视口 */
.card-wrap {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card { display: flex; gap: 16px; }
.card__thumb { width: 40cqw; } /* cqw 是容器宽度的 1% */
}
<!-- 按 DPR 和显示宽度自动选图 -->
<img src="photo-800.jpg"
srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
sizes="(min-width: 1024px) 50vw, 100vw"
width="800" height="450" loading="lazy" decoding="async" alt="示例图片" />

<!-- 格式降级 + 艺术方向裁剪 -->
<picture>
<source srcset="banner.avif" type="image/avif" />
<source srcset="banner.webp" type="image/webp" />
<source srcset="banner-mobile.jpg" media="(max-width: 767px)" />
<img src="banner.jpg" alt="横幅" />
</picture>

单行与多行文本溢出省略

省略号的本质是“内容超出容器时截断并追加提示符”。单行有标准属性支持,多行只能依赖 WebKit 私有属性或人工模拟。

  • 单行省略需要三个属性同时生效:overflow: hidden 裁剪、text-overflow: ellipsis 追加省略号、white-space: nowrap 禁止换行。
  • 单行省略失效的常见原因:元素没有确定宽度(比如是 display: inline 或在 flex 项目中未设 min-width: 0);父级 flex/grid 容器的默认 min-width: auto 让子项不收缩;display: flex 的元素自身不能应用 text-overflow,需要作用在内部的块级元素上。
  • 多行省略主流方案是 display: -webkit-box-webkit-box-orient: vertical-webkit-line-clamp: noverflow: hidden。虽然是 WebKit 私有前缀,但 Chrome、Safari、Firefox、Edge 均已支持,移动端可放心使用。标准的 line-clamp 属性正在推进中,可以两个都写。
  • -webkit-line-clamp 的注意点:不能与 text-overflow: ellipsis 混用;给元素加 padding-bottom 会露出被裁剪行的一部分,应改用外层容器加 padding;元素的 display 会被强制为 -webkit-box,与 flex 布局需求冲突时要多包一层。
  • 兼容或需要自定义省略符时,可用伪元素方案:容器 max-height 设为 line-height 的整数倍并 overflow: hidden::after 绝对定位到右下角放省略号,配合 linear-gradient 背景做渐隐过渡,避免省略号压住文字。缺点是文字刚好占满整行时也会显示省略号。
  • 还需要“展开收起”或精确判断是否溢出时,用 JS 比较 scrollHeightclientHeight,或用 Range API 二分查找截断位置。
  • 无论哪种方案,都建议加上 title 属性或 tooltip,保证被截断的内容可以完整查看。
/* 单行省略:三个属性必须同时生效 */
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 作为 flex 子项时必须补 min-width: 0,否则不会收缩也就不会出现省略号 */
.flex-row { display: flex; }
.flex-row .ellipsis { flex: 1; min-width: 0; }

/* 多行省略:主流方案 */
.line-clamp-2 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}

/* 伪元素兼容方案:渐隐 + 自定义省略符 */
.clamp-fallback {
position: relative;
line-height: 22px;
max-height: 66px; /* 22 * 3 行 */
overflow: hidden;
}
.clamp-fallback::after {
content: '...';
position: absolute;
right: 0; bottom: 0;
padding-left: 24px;
background: linear-gradient(to right, transparent, #fff 40%);
}

CSS 工程化方案对比选型

CSS 工程化要解决四个核心问题:全局作用域污染、代码复用、浏览器兼容、产物体积。不同方案侧重点不同。

  • 预处理器 Sass / Less / Stylus:提供变量、嵌套、mixin、函数、@use 模块化和循环判断,属于编译期能力,运行时零成本。Sass 生态最完善,Less 语法门槛低。缺点是嵌套过深容易产出冗长选择器,且不解决作用域问题。随着原生 CSS 变量、嵌套语法普及,预处理器的必要性在下降,但函数与 mixin 仍有价值。
  • PostCSS:基于 AST 的后处理平台,本身不定义语法,靠插件工作。常用插件有 autoprefixer 自动补前缀、postcss-preset-env 让新语法降级、postcss-px-to-viewport 做移动端适配、cssnano 压缩。它通常与预处理器或原子化方案配合而非替代。
  • CSS Modules:构建期把类名编译成带 hash 的唯一名称,天然局部作用域、零运行时开销、产物就是普通 CSS,配合 composes 复用、:global 声明全局。缺点是动态样式仍需借助内联 style 或 CSS 变量。Vue 的 scoped 是同类思路,通过 data-v-hash 属性选择器隔离,穿透子组件需要 :deep()
  • CSS-in-JS(styled-components、Emotion):样式与组件同文件、可直接使用 props 做动态样式、天然作用域和死代码消除。代价是运行时注入带来的性能开销、SSR 需要额外处理样式收集、与 React Server Components 兼容性差。趋势转向 vanilla-extract、Linaria 这类零运行时方案。
  • 原子化 CSS(Tailwind、UnoCSS):用组合小类替代自定义类名,彻底绕开命名难题;按需生成使得样式体积随项目增长趋于收敛而非线性膨胀;设计约束(间距、色板)天然统一。缺点是 HTML 类名冗长、可读性有争议、需要配合编辑器插件。UnoCSS 编译更快且规则完全可自定义,支持 attributify 等模式。
  • BEM 命名规范:block__element--modifier 纯约定,零工具成本,任何项目都能用,但类名冗长且依赖团队自觉,无法真正隔离作用域。
  • 选型建议:中小型 Vue 项目用 scoped 加 CSS 变量即可;组件库倾向 CSS Modules 或预处理器加 BEM,产物干净可被使用方覆盖;业务迭代快的中后台项目用 Tailwind 或 UnoCSS 效率最高;React 项目若重度依赖运行时动态主题可考虑 CSS-in-JS,否则优先零运行时方案。无论选哪种,PostCSS 加 autoprefixer 和主题用原生 CSS 变量都是通用的基础设施。
// Sass:变量、嵌套、mixin,配合 BEM 命名
$primary: #3b82f6;

@mixin clamp($line) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
overflow: hidden;
}

.card {
&__title { @include clamp(2); color: $primary; } // 编译为 .card__title
&--active { border-color: $primary; }
}
/* 原生 CSS 变量做主题,运行时可切换,无需重新编译 */
:root {
--color-primary: #3b82f6;
--radius: 8px;
}
[data-theme='dark'] { --color-primary: #60a5fa; }
.btn {
background: var(--color-primary);
border-radius: var(--radius);
}
<!-- 原子化 CSS:不需要写任何自定义类名 -->
<div class="flex items-center gap-3 rounded-lg bg-white p-4 shadow hover:shadow-md">
<img class="h-10 w-10 rounded-full object-cover" src="avatar.png" alt="头像" />
<span class="truncate text-sm font-medium text-gray-900">用户名</span>
</div>

HTML

script标签如何加载js文件

在HTML中,可以通过<script>标签来加载JavaScript文件。

  • 内联脚本:直接在<script>标签中编写JavaScript代码。
    <script>
    alert("Hello, World!");
    </script>
  • 外部脚本:通过src属性指定要加载的JavaScript文件的URL。
    <script src="path/to/my/script.js"></script>
  • 异步加载:<script> 标签的 async 属性用于指定脚本的异步加载。当指定了 async 属性时,脚本将会在加载时不阻塞 HTML 解析,并在加载完成后立即执行。多个异步脚本的执行顺序是不确定的。
    <script src="path/to/my/script.js" async></script>
  • 延迟加载:<script> 标签的 defer 属性用于指定脚本的延迟加载。当指定了 defer 属性时,脚本将会在 HTML 解析完成后再执行,但在 DOMContentLoaded 事件之前执行。多个延迟脚本的执行顺序是按照它们在文档中出现的顺序执行的。
    <script src="path/to/my/script.js" defer></script>
  • 动态加载脚本:通过 JavaScript 动态创建 <script> 标签,然后将其插入到文档中。动态加载脚本可以在任何时候进行,例如在页面加载后、用户操作后或其他事件触发时。这种方式可以控制脚本的加载时机。
    var script = document.createElement('script');
    script.src = 'script.js';
    document.body.appendChild(script);

    注意,JavaScript 脚本默认是同步加载的,即阻塞 HTML 解析并立即执行。async、defer、动态加载脚本方法可以调整脚本的加载方式,使其异步或延迟加载,提高页面加载性能或控制脚本执行时机。

DOCTYPE 的作用与标准模式、怪异模式

<!DOCTYPE html> 位于文档最前面,它不是 HTML 标签,而是一条文档类型声明,作用是告诉浏览器用哪一套规则解析和渲染这份文档。HTML5 的声明是最简形式,不再引用 DTD (HTML4 必须写完整的 DTD URL,因为它基于 SGML; HTML5 不再基于 SGML)。

浏览器据此进入三种渲染模式之一:

  • 标准模式(Standards Mode):存在正确的 DOCTYPE,按 W3C/WHATWG 规范渲染
  • 怪异模式(Quirks Mode): DOCTYPE 缺失、拼写错误或前面有其他内容,浏览器退回到模拟 IE5 等老浏览器的行为,目的是让上世纪的老页面不至于彻底错乱
  • 准标准模式(Almost Standards Mode):某些过渡型 DTD 会触发,绝大多数行为与标准模式一致,只有表格单元格中图片的行内布局(基线间隙)沿用怪异模式的处理

主要差异:

  1. 盒模型:怪异模式下 width/height 包含 padding 与 border,等同于 box-sizing: border-box;标准模式下是 content-box
  2. 行内元素:怪异模式下给 inline 元素设置 width/height 会生效,标准模式下无效
  3. 图片基线间隙:标准模式下 img 是行内元素,坐在基线上,下方会留出几像素空白;怪异模式下表格单元格内的图片不产生这段空隙
  4. 度量属性:怪异模式下 document.body.clientHeight、scrollTop 等属性的取值和归属元素与标准模式不同,老代码常见的 document.documentElement.scrollTop || document.body.scrollTop 兼容写法就来源于此
  5. 其他:百分比高度的解析、margin: 0 auto 水平居中、font-size 的继承、颜色值的容错解析在两种模式下都有差异

实践上只需记住一条:永远把 <!DOCTYPE html> 写在文件第一行,前面不要有注释、空行以外的内容或 BOM 以外的字节,否则部分浏览器会直接掉进怪异模式。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
</head>
</html>
// 运行时检测当前处于哪种渲染模式
console.log(document.compatMode);
// "CSS1Compat" 表示标准模式 (含准标准模式), "BackCompat" 表示怪异模式

HTML 语义化的意义与常用语义标签

语义化指用含义恰当的标签去描述内容的角色,而不是仅仅为了视觉效果而选标签。核心判断标准是“标签的语义与内容的角色是否一致”,而不是“有没有少用 div”。

意义:

  1. SEO:搜索引擎爬虫无法做复杂的视觉判断,主要依靠标签层级理解页面结构。h1-h6 构成文档大纲, main/article/nav 标明主体内容与导航区域,有助于提升抓取质量和搜索结果摘要
  2. 可访问性:屏幕阅读器依据语义标签构建无障碍树(Accessibility Tree),用户可以按标题或地标(landmark)快速跳转。button、a、input 等原生元素自带键盘可聚焦、Enter/Space 触发、状态播报等能力,用 div 模拟则必须手动补 role、tabindex 和键盘事件,极易做漏
  3. 可维护性:结构层次清晰,去掉 CSS 后文档依然可读;团队协作时不必靠 class 名猜结构
  4. 多端解析:阅读模式、打印样式、爬虫、语音助手、内容聚合等非视觉场景都能正确提取内容

常用标签:

  • 结构类: header、nav、main (整页只应有一个)、article (可独立分发的完整内容)、section (有主题的分区,通常应包含标题)、aside、footer
  • 内容类: h1-h6、p、ul/ol/li、dl/dt/dd、figure/figcaption、blockquote、time、address、details/summary
  • 文本类: strong (内容重要)与 b (仅视觉加粗)、em (语气强调)与 i、mark、code、abbr、small、sub/sup
  • 表单表格类: label 通过 for 与控件关联、fieldset/legend 分组、table 的 caption/thead/tbody/tfoot 与 th 的 scope 属性
<article>
<header>
<h1>文章标题</h1>
<time datetime="2024-05-01">2024 年 5 月 1 日</time>
</header>
<p>正文中的<strong>关键结论</strong>用 strong 而不是 b。</p>
<figure>
<img src="chart.png" alt="近三年月度访问量呈上升趋势" />
<figcaption>图 1 访问量趋势</figcaption>
</figure>
<footer><address>作者联系方式</address></footer>
</article>

几个常见误区: section 不是 div 的替代品,纯粹为了样式而存在的容器仍然应该用 div;一个页面允许多个 h1 (每个 sectioning content 内部),但为了兼容性通常仍建议只用一个;图片必须写 alt,纯装饰图写 alt=”” 让读屏软件跳过,而不是省略该属性。

src 与 href 的区别

结论: href 建立当前文档与外部资源的引用关系(hypertext reference),两者是平等的关联; src 是把外部资源下载下来嵌入并替换当前元素的内容(source),资源是当前文档不可分割的一部分。

具体区别:

  1. 语义: href 表示“指向”,浏览器知道那里有个资源但不一定现在就要; src 表示“替换”,元素本身只是一个占位符,内容完全由下载到的资源决定
  2. 加载与阻塞:遇到没有 async/defer 的 script src,浏览器会暂停 HTML 解析,下载并执行完脚本再继续;遇到 href 则并行下载,不阻塞 HTML 解析(但 link rel=”stylesheet” 会阻塞渲染,并间接阻塞后续脚本的执行,因为脚本可能读取样式)
  3. 适用元素: href 用于 a、link、area、base; src 用于 script、img、iframe、video、audio、embed、source、input type=”image”
  4. 降级处理:带 src 的元素通常配套降级方案,例如 img 的 alt、iframe 与 video 的内部回退内容,因为嵌入失败会直接导致内容缺失
<!-- href: 引用关系 -->
<link rel="stylesheet" href="/style.css" />
<link rel="preload" href="/font.woff2" as="font" type="font/woff2" crossorigin />
<a href="/about">关于我们</a>
<base href="https://cdn.example.com/" />

<!-- src: 内容替换 -->
<script src="/app.js" defer></script>
<img src="/logo.png" alt="站点 logo" />
<iframe src="/widget" title="嵌入组件">当前浏览器不支持 iframe</iframe>

补充两点常见追问:

  • CSS 中的 @import 与 link href 的区别: @import 必须等外层 CSS 下载解析后才发起请求,造成串行加载,性能明显更差,且不能通过 media 属性做条件加载的优化,生产环境应优先用 link
  • 为什么 link 用 href 而 script 用 src:样式表是对文档的补充描述,与文档是关联关系;脚本则是要被拉进来执行、成为文档一部分的内容

行内、块级、行内块元素与空白间隙

这三类由 display 属性决定,不是标签固有的不可变属性,任何元素都可以通过 CSS 改变其表现。

块级元素(display: block):

  • 独占一行,默认宽度撑满父容器的内容区
  • width、height、四个方向的 margin 和 padding 全部生效
  • 可以包含块级元素和行内元素
  • 常见: div、p、h1-h6、ul、ol、li、dl、table、form、hr、pre、blockquote、section、article、header、footer、main、nav、aside

行内元素(display: inline):

  • 不独占一行,宽高由内容撑开
  • width、height 无效; margin-top/bottom 无效; padding-top/bottom 视觉上会溢出但不占据布局空间,不会把行撑高
  • margin-left/right、padding-left/right 有效
  • 通常只能包含文本和其他行内元素(a 是例外, HTML5 允许它包裹块级元素)
  • 常见: span、a、em、strong、b、i、label、code、small、sub、sup、br

行内块元素(display: inline-block):

  • 与其他元素同行排列,同时 width、height、margin、padding 全部生效,兼具两者特点
  • 默认以元素内最后一行文本的基线作为自身基线;若元素内无文本或 overflow 不为 visible,则以下外边距边界为基线,这是很多对齐问题的根源
  • 常见: img、input、button、select、textarea 等可替换元素在默认样式下表现为行内块

空白间隙问题:

成因是 HTML 源码中标签之间的换行和缩进被解析为文本节点,在行内格式化上下文中渲染为一个空格,于是相邻的行内或行内块元素之间出现约一个空格宽度的缝隙,宽度随父元素字号变化。

<!-- 方案一: 去掉标签之间的空白 (可读性差) -->
<li>A</li><li>B</li>

<!-- 方案二: 用注释吃掉换行 (保留缩进的同时消除空白节点) -->
<li>A</li><!--
--><li>B</li>
/* 方案三: 父元素 font-size: 0, 子元素重新设置字号 (部分安卓最小字号限制下会失效) */
.list { font-size: 0; }
.list > li { font-size: 14px; display: inline-block; }

/* 方案四 (推荐): 改用 flex 或 grid, 空白文本节点不参与布局, 间距用 gap 精确控制 */
.list { display: flex; gap: 8px; }

另一个高频问题是 img 下方的几像素空隙,它与上面不同: img 作为行内元素坐在基线上,基线到底线之间的空间形成留白。解决方式是给 img 设置 display: block,或 vertical-align: bottom / middle,或给父元素设置 line-height: 0。

HTML5 新增特性汇总

HTML5 不只是新增了一批标签,而是把浏览器从“文档阅读器”扩展成“应用运行平台”的一整套规范集合。

  1. 语义化标签: header、nav、main、article、section、aside、footer、figure/figcaption、time、mark、details/summary、dialog、template
  2. 表单增强
    • 新 type: email、url、tel、number、range、date、time、datetime-local、month、week、color、search
    • 新属性: placeholder、required、pattern、min/max/step、autofocus、autocomplete、multiple、novalidate、form (让控件关联到页面上任意表单)
    • datalist 提供输入建议;内置约束校验 API (checkValidity、reportValidity、setCustomValidity)与 :valid / :invalid 伪类
    • 新元素: output、progress、meter
  3. 音视频: audio 与 video 原生播放,配合 source 做多格式回退、track 提供字幕,配合 MSE (Media Source Extensions)支撑流媒体
  4. 图形绘制: Canvas (位图,提供 2d 与 webgl 上下文)与 SVG (矢量,基于 XML,可内联进 HTML)
  5. Web Storage: localStorage 持久保存、sessionStorage 限于当前标签页,同源约束,容量约 5MB,同步 API;存储结构化大数据用 IndexedDB
  6. 多线程: Web Worker 在独立线程执行 JS 不阻塞 UI,通过 postMessage 通信但无法访问 DOM; SharedWorker 可跨同源页面共享; Service Worker 承担离线缓存、请求代理与消息推送
  7. WebSocket:基于 TCP 的全双工通信, ws/wss 协议,一次 HTTP 握手升级后保持长连接双向收发,取代低效的轮询
  8. 拖放: draggable 属性配合 dragstart、dragover、drop 等事件,通过 DataTransfer 对象传递数据
  9. 地理定位: navigator.geolocation 的 getCurrentPosition 与 watchPosition,需要 HTTPS 环境和用户授权
  10. 其他常用能力: History API 的 pushState/replaceState (前端路由的基础)、requestAnimationFrame、Fetch、FileReader、Notification、全屏 API、Page Visibility、Intersection Observer

同时 HTML5 移除了纯表现性的标签(font、center、big、strike、tt)和 frame/frameset。

<video controls preload="metadata" poster="cover.jpg" width="480">
<source src="movie.webm" type="video/webm" />
<source src="movie.mp4" type="video/mp4" />
<track kind="subtitles" src="zh.vtt" srclang="zh" label="中文" default />
当前浏览器不支持 video 标签, 请<a href="movie.mp4">下载视频</a>
</video>

<details>
<summary>点击展开</summary>
<p>无需任何 JS 的原生折叠面板。</p>
</details>

meta 标签的常见用法

meta 用于提供文档的元数据,不会显示在页面上,由浏览器、搜索引擎、社交平台等消费。它有四种基本写法: charset、name + content、http-equiv + content,以及 property + content (Open Graph 等扩展协议)。

charset:

  • <meta charset="utf-8"> 声明字符编码。必须放在 head 最前面且位于文档前 1024 字节内,否则浏览器可能已按猜测的编码解析出乱码,需要丢弃重新解析

viewport 各参数:

  • width=device-width:让布局视口宽度等于设备的理想视口宽度(以 CSS 像素计),移动端适配的基础
  • initial-scale=1.0:页面初始缩放比例
  • minimum-scale / maximum-scale:允许的缩放范围
  • user-scalable=no:禁止用户手动缩放。会严重损害可访问性(低视力用户无法放大), iOS 10 起 Safari 已忽略该设置,不建议使用
  • viewport-fit=cover:让页面内容延伸到刘海屏的整个物理区域,需配合 CSS 的 env(safe-area-inset-*)处理安全区

其他常用 name:

  • description:页面摘要,通常控制在 160 字以内,直接影响搜索结果的展示文案
  • robots:用逗号组合 index/noindex、follow/nofollow、noarchive、nosnippet、max-image-preview 等指令,控制爬虫的收录与快照行为。keywords 已被主流搜索引擎忽略
  • referrer:控制发出请求时 Referer 头的携带策略,取值包括 no-referrer、origin、same-origin、strict-origin、strict-origin-when-cross-origin (现代浏览器默认值)、unsafe-url,用于防止 URL 中的敏感参数泄漏给第三方
  • theme-color:定义浏览器 UI 的主题色(Android Chrome 地址栏、PWA 状态栏),可用 media 属性区分深浅色模式

http-equiv (模拟 HTTP 响应头):

  • content-type:已被 charset 取代
  • X-UA-Compatible:指定 IE 渲染内核版本,现代浏览器无意义
  • refresh:定时刷新或跳转,会打断屏幕阅读器和用户操作,应优先用服务端 3xx 重定向
  • Content-Security-Policy:可在 meta 中下发 CSP,但 frame-ancestors、report-uri、sandbox 等指令只能通过真实响应头生效
  • cache-control / expires:可靠性远不如响应头,代理和 CDN 通常不识别
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="description" content="控制在 160 字以内的页面摘要" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#111111" media="(prefers-color-scheme: dark)" />
<meta property="og:title" content="分享到社交平台时展示的标题" />
<meta property="og:image" content="https://example.com/cover.png" />

iframe 的适用场景、缺点与跨域通信

iframe 在当前页面中嵌入一个独立的浏览上下文,它拥有自己的 window、document、事件循环和 JS 执行环境,与父页面之间受同源策略约束。

适用场景:嵌入第三方内容(地图、视频、支付收银台、验证码、广告)、隔离不受信任的代码、老系统集成与微前端的过渡方案、富文本编辑器的编辑区、作为下载文件或提交表单的隐藏载体、在线代码运行沙箱。

缺点:

  • 性能:创建完整的浏览上下文,内存与进程开销大;内部资源加载完毕前会推迟父页面的 load 事件;与父页面竞争同域名的连接数
  • SEO:内部内容通常不计入父页面权重,爬虫抓取不完整
  • 布局:尺寸需要显式指定,跨域时父页面无法读取内部内容高度做自适应,只能由内部主动 postMessage 上报
  • 体验:移动端存在滚动异常、iOS 上宽高塌陷、键盘弹起遮挡等问题;内部页面无法弹出覆盖父页面的浮层;会污染浏览器的前进后退历史
  • 可访问性:必须提供 title 属性说明其内容,否则读屏软件只会读出“框架”
  • 安全:存在点击劫持风险,需要通过 X-Frame-Options 或 CSP 的 frame-ancestors 控制自身页面能否被嵌套

跨域通信用 postMessage:

// 父页面 -> iframe
const child = document.querySelector('#child').contentWindow;
child.postMessage({ type: 'init', theme: 'dark' }, 'https://child.example.com');
// 第二个参数是目标源, 必须显式指定, 用 '*' 会把数据发给任何占据该窗口的页面

// iframe 内接收并回传
window.addEventListener('message', (e) => {
if (e.origin !== 'https://parent.example.com') return; // 必须校验来源, 否则任何页面都能发消息
if (e.data.type === 'init') applyTheme(e.data.theme);
// 把内容高度回传给父页面做自适应
e.source.postMessage({ type: 'resize', height: document.body.scrollHeight }, e.origin);
});

同源时可以直接通过 contentWindow / contentDocument 与 window.parent、window.top 互相访问 DOM 和方法。主域相同、子域不同的老方案是双方都设置 document.domain,该特性已被现代浏览器废弃,不应再使用。

sandbox 属性:

  • 只要写上 sandbox 就启用最严格的限制:内容被视为独立源(即使同源也当作跨域)、禁止脚本执行、禁止表单提交、禁止弹窗、禁止顶层导航、禁止插件
  • 通过空格分隔的 token 按需放开: allow-scripts、allow-forms、allow-same-origin、allow-popups、allow-modals、allow-downloads、allow-top-navigation-by-user-activation、allow-presentation
  • 安全提示:同时给出 allow-scripts 和 allow-same-origin 会让沙箱形同虚设,因为内部脚本可以直接移除自身的 sandbox 属性再重新加载
<iframe
src="https://third-party.example.com/widget"
title="第三方组件"
sandbox="allow-scripts allow-forms"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>

Canvas 与 SVG 的区别与选型

Canvas 是位图(光栅)画布,通过 JS 命令式地在像素缓冲区上作画。绘制完成后画布上只剩像素,图形本身不作为对象存在。SVG 是矢量图形,用 XML 描述形状,每个图形都是真实的 DOM 节点。

对比维度:

  1. 分辨率: Canvas 依赖分辨率,放大会模糊,高 DPI 屏必须按 devicePixelRatio 放大画布再用 CSS 缩回去; SVG 与分辨率无关,任意缩放都清晰
  2. DOM 与交互: SVG 元素可以直接绑定事件、用 CSS 选择器设置样式、被读屏软件识别; Canvas 整体只有一个元素,交互需要自己做命中检测(isPointInPath 或维护一份图形数据结构反查)
  3. 性能特征: Canvas 的开销主要与画布像素数量相关,与图形数量关系不大,适合对象数量极多的场景; SVG 的开销与节点数量近似线性增长,节点上千后明显卡顿
  4. 重绘方式: Canvas 需要 clearRect 后手动重绘整帧; SVG 修改属性后浏览器自动完成局部重绘
  5. 文本与可访问性: SVG 中的文本可选中、可搜索、可被爬虫读取,便于国际化; Canvas 中的文字只是像素
  6. 动画: SVG 可用 CSS 动画和 SMIL,交给合成线程处理; Canvas 用 requestAnimationFrame 逐帧绘制,完全占用主线程(可用 OffscreenCanvas 移到 Worker)
  7. 导出与生成: Canvas 用 toDataURL / toBlob 直接导出图片; SVG 本身是文本,便于版本控制、服务端生成和内联进 HTML

选型建议:

  • 统计图表、图标、地图矢量层、需要交互与可访问性、需要打印或无损缩放:选 SVG (ECharts、AntV 等库同时提供两种渲染器可切换)
  • 游戏、图像处理、视频滤镜、粒子效果、上万级数据点的散点图、需要像素级操作:选 Canvas;三维或超大规模渲染进一步用 WebGL / WebGPU
// Canvas 高清屏适配: 把物理像素与 CSS 逻辑尺寸分开
const canvas = document.querySelector('#c');
const dpr = window.devicePixelRatio || 1;
canvas.width = 300 * dpr; // 实际像素缓冲区
canvas.height = 150 * dpr;
canvas.style.width = '300px'; // CSS 显示尺寸
canvas.style.height = '150px';

const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr); // 之后按逻辑像素坐标绘制即可
ctx.fillStyle = '#3b82f6';
ctx.fillRect(10, 10, 100, 50);
<!-- SVG: 每个图形都是可绑定事件、可用 CSS 选中的 DOM 节点 -->
<svg viewBox="0 0 120 70" width="300" height="150" role="img" aria-label="蓝色矩形">
<rect class="bar" x="10" y="10" width="100" height="50" fill="#3b82f6" />
</svg>

DOMContentLoaded 与 load 的区别

DOMContentLoaded 在 HTML 被完全解析、DOM 树构建完成时触发,不等待图片、iframe、字体等子资源; load 在页面全部资源(包括图片、样式表、iframe、字体)加载完成后触发。

影响触发时机的细节:

  1. 同步脚本阻塞:没有 async/defer 的 script 会暂停 HTML 解析,因此推迟 DOMContentLoaded
  2. 样式表的间接影响:由于脚本可能读取样式(getComputedStyle),浏览器会等待位于脚本之前的 CSS 加载完再执行脚本,于是 CSS 也间接推迟了 DOMContentLoaded
  3. defer 脚本:在 DOMContentLoaded 之前按文档顺序执行,所有 defer 脚本跑完才触发该事件
  4. async 脚本:下载完立即执行,与 DOMContentLoaded 的先后顺序不确定
  5. 动态创建插入的 script:默认是 async 行为,不阻塞 DOMContentLoaded
  6. 图片和 iframe:不影响 DOMContentLoaded,只影响 load
  7. 对应关系: jQuery 的 $(document).ready()对应 DOMContentLoaded, window.onload 对应 load

顺序与状态: document.readyState 依次经历 loading -> interactive (此时触发 DOMContentLoaded) -> complete (此时触发 load),之后离开页面时依次是 beforeunload、pagehide/unload。

实践建议:需要操作 DOM 的初始化逻辑绑定 DOMContentLoaded,或直接把脚本写成 defer,效果更好;只有确实需要图片真实尺寸或统计完整加载耗时时才用 load;现代性能统计更推荐用 PerformanceObserver 监听 FCP/LCP 等指标;页面离开时的埋点上报用 visibilitychange 配合 navigator.sendBeacon,而不是 unload (它在移动端和 bfcache 场景下并不可靠)。

document.addEventListener('DOMContentLoaded', () => {
// DOM 可用, 但图片可能还没加载完, 此时读到的图片尺寸是 0
document.querySelector('#app').classList.add('ready');
});

window.addEventListener('load', () => {
const img = document.querySelector('img');
console.log(img.naturalWidth); // 资源就绪, 可以拿到真实尺寸
});

// 脚本可能在事件已经触发之后才执行, 需要兜底判断
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}

响应式图片与图片格式选型

目标是让不同 DPR、不同视口宽度的设备各自下载最合适的那一张图,既避免小屏下载大图浪费带宽,也避免高清屏上图片发虚。

srcset + sizes (分辨率切换,图片内容相同只是尺寸不同):

  • srcset 用 w 描述符声明每个候选图的固有宽度像素数,例如 hero-800.jpg 800w
  • sizes 声明该图片在不同媒体条件下的显示宽度,浏览器据此结合当前 DPR 算出需要的像素密度,从候选中挑出最小的够用的那张
  • 关键点:浏览器要在 CSS 解析完成之前就做出选择,无法自己得知布局宽度,所以必须由开发者提供 sizes,且它必须与实际 CSS 布局一致,写错就会选错图
  • x 描述符(hero@2x.jpg 2x)是简化写法,只按 DPR 选择,不考虑视口宽度,适合固定尺寸的图标和头像

picture + source (艺术方向切换与格式回退):

  • 用 media 属性按断点切换不同裁剪比例的图片,例如移动端用方图、桌面端用宽幅图
  • 用 type 属性提供新格式并自动回退,浏览器选中第一个它支持的 source
  • picture 内必须有一个 img 作为兜底和真正的渲染元素, alt、width、height、loading 等属性都写在 img 上

loading=”lazy”:

  • 原生懒加载,浏览器在图片接近视口时才发起请求,可替代手写的 IntersectionObserver 方案
  • 首屏图片不要加 lazy,那会推迟 LCP;首屏关键图应加 fetchpriority=”high” 或用 link rel=”preload” 预加载
  • 务必同时写 width/height 或用 CSS 的 aspect-ratio,让浏览器提前占位,避免累积布局偏移(CLS)
  • decoding=”async” 让图片解码不阻塞主线程

格式选型:

  • WebP:支持有损、无损、透明和动图,同画质下比 JPEG 小约 25%-35%,现代浏览器全面支持,是目前的默认选择
  • AVIF:基于 AV1 编码,压缩率比 WebP 再高一截,支持 HDR 与宽色域;代价是编码耗时长,极低码率下细节容易被抹平,兼容性略逊于 WebP
  • JPEG:照片类内容的兜底格式; PNG:需要无损或复杂透明时使用; SVG:图标、插画、logo; GIF 已应被 WebP/AVIF 动图或静音自动播放的 video 替代
<picture>
<source type="image/avif" srcset="hero-800.avif 800w, hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 50vw" />
<source type="image/webp" srcset="hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 50vw" />
<img
src="hero-800.jpg"
srcset="hero-800.jpg 800w, hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
width="1600" height="900"
alt="产品主视觉图"
loading="lazy" decoding="async"
/>
</picture>

文件上传与拖拽上传

涉及三个核心对象: input[type=file] 触发系统文件选择并产生 FileList; File 继承自 Blob,携带 name、size、type、lastModified; FormData 负责把文件按 multipart/form-data 编码后提交。

input file 要点:

  • accept 限定可选类型(MIME 类型或扩展名),它只是选择框的过滤提示,服务端必须重新校验; multiple 允许多选, capture 在移动端可直接调起摄像头
  • 连续选择同一个文件不会触发 change,因为 value 没变,需要在处理完后执行 e.target.value = ‘’ 重置
  • 原生样式无法定制,通用做法是隐藏 input,用 label 的 for 关联或按钮触发 input.click()

FormData 提交:

  • 不要手动设置 Content-Type,浏览器会自动生成带 boundary 的 multipart/form-data,手动设置反而会让 boundary 丢失导致后端解析失败
  • 需要上传进度时用 XMLHttpRequest 的 upload.onprogress, fetch 目前无法直接监听上传进度

拖拽上传:

  • 必须在 dragover 事件中调用 e.preventDefault(),否则浏览器的默认行为是直接打开该文件, drop 事件根本不会触发;设置 e.dataTransfer.dropEffect = ‘copy’ 可以改变鼠标指针的视觉反馈
  • e.dataTransfer.files 拿到拖入的文件; e.dataTransfer.items 配合 webkitGetAsEntry()可以递归读取拖入的文件夹
  • dragenter/dragleave 在鼠标经过子元素时会反复触发,高亮状态需要用进入计数器或判断 e.relatedTarget 是否仍在容器内来维护

其他相关能力:监听 paste 事件读取 e.clipboardData.files 实现粘贴上传;大文件用 file.slice()分片并发上传,对分片算 hash 实现断点续传与秒传;上传前用 URL.createObjectURL(file)生成本地预览,组件卸载时记得 URL.revokeObjectURL 释放内存。

// <input id="picker" type="file" accept="image/*" multiple hidden />
// <div id="zone">把文件拖到这里, 或点击选择</div>
const zone = document.querySelector('#zone');
const picker = document.querySelector('#picker');
zone.addEventListener('click', () => picker.click());
picker.addEventListener('change', (e) => {
upload([...e.target.files]);
e.target.value = ''; // 重置, 保证再次选择同一文件也能触发 change
});
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // 必须阻止默认行为, 否则 drop 不会触发
e.dataTransfer.dropEffect = 'copy';
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
upload([...e.dataTransfer.files]);
});
function upload(files) {
const fd = new FormData();
files.forEach((f) => fd.append('files', f, f.name));
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
// fetch 无法监听上传进度, 需要进度条时只能用 XHR
xhr.upload.onprogress = (e) => e.lengthComputable && console.log(e.loaded / e.total);
xhr.send(fd); // 不要手动设置 Content-Type
}

Vue

Vue 的响应式原理

响应式的本质是“读取时记录谁在用我,修改时通知它们重新执行”。Vue2 用 Object.defineProperty 劫持属性的 getter/setter,Vue3 换成 Proxy + Reflect 劫持整个对象。

  • Vue2 在初始化时递归遍历 data,把每个属性都改写成访问器属性,这是一次性的深度遍历,属性越多初始化开销越大。
  • Vue2 的缺陷:无法侦测对象属性的新增和删除(需要 Vue.set / Vue.delete);无法侦测数组索引赋值 arr[0] = 1arr.length = 0;不支持 Map/Set。对数组的妥协做法是重写原型上 7 个变更方法(push pop shift unshift splice sort reverse),在方法内部手动触发更新。
  • Vue3 的 Proxy 可以拦截 get set has deleteProperty ownKeys 等操作,因此新增/删除属性、数组索引与 length 变化都能被感知,Map/Set 通过单独的 collectionHandlers 处理。
  • Vue3 是惰性递归:只有真正读取到嵌套对象时才把它转成 reactive,初始化成本显著降低。
  • Reflect 而不是直接 target[key],是为了把 receiver 传下去,保证原型链继承场景下 getter 内部的 this 指向代理对象,依赖才能被正确收集。Proxy 的代价是无法被 polyfill,Vue3 因此不再支持 IE11。
// Vue2 精简实现
function defineReactive(obj, key, val) {
observe(val) // 递归处理嵌套对象
const dep = new Dep()
Object.defineProperty(obj, key, {
get() {
if (Dep.target) dep.depend() // 收集当前正在求值的 watcher
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
observe(newVal) // 新值也要变成响应式
dep.notify()
}
})
}

// Vue3 精简实现
function reactive(target) {
return new Proxy(target, {
get(t, key, receiver) {
track(t, key)
const res = Reflect.get(t, key, receiver)
// 惰性递归:读到对象才继续代理
return typeof res === 'object' && res !== null ? reactive(res) : res
},
set(t, key, value, receiver) {
const oldVal = t[key]
const hadKey = Object.prototype.hasOwnProperty.call(t, key)
const res = Reflect.set(t, key, value, receiver)
// 区分新增和修改,避免重复触发;删除走 deleteProperty 拦截同理
if (!hadKey || oldVal !== value) trigger(t, key, hadKey ? 'set' : 'add')
return res
}
})
}

依赖收集与派发更新

依赖收集解决的是“数据和副作用之间建立多对多映射”的问题。Vue2 用 Observer/Dep/Watcher 三个类,Vue3 用 targetMap/effect/track/trigger 一组函数。

  • Vue2 三者关系:Observer 负责把对象变成响应式;每个响应式属性对应一个 Dep(发布者),内部维护 subs 数组;Watcher 是订阅者,分渲染 Watcher、计算 Watcher、用户 Watcher($watch)三种。
  • 收集时机:Watcher 求值前把自己挂到全局 Dep.target,触发 getter 时 dep.depend() 建立双向引用(dep 记住 watcher,watcher 也记住 dep,便于后续清理),求值结束出栈。
  • 嵌套组件渲染会有嵌套 Watcher,Vue2 用 targetStack 栈来保存和恢复 Dep.target
  • 派发更新:dep.notify() 遍历 subs 调用 watcher.update(),默认走 queueWatcher 放入去重队列,在下一个微任务里按 id 从小到大(父先于子)批量执行。
  • Vue3 的核心数据结构是 targetMap: WeakMap<target, Map<key, Set<ReactiveEffect>>>,用 WeakMap 保证对象销毁后依赖可被 GC。
  • Vue3 的 activeEffect 相当于 Dep.targetReactiveEffect 上的 scheduler 是关键扩展点,computed 用它实现惰性重算,watch 用它控制 flush 时机。
  • 每次 effect 重新执行前会做 cleanupEffect,把上一轮收集的依赖清掉再重新收集,这样分支切换(v-if 两个分支读不同数据)后不会残留无效依赖。
const targetMap = new WeakMap()
let activeEffect = null

function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, (dep = new Set()))
dep.add(activeEffect)
activeEffect.deps.push(dep) // 反向记录,便于 cleanup
}

function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (!dep) return
// 复制一份再遍历,避免 cleanup 过程中集合被修改导致死循环
;[...dep].forEach(effect => {
if (effect === activeEffect) return // 防止自身触发自身
effect.scheduler ? effect.scheduler() : effect.run()
})
}

Vue 的虚拟 DOM 与 diff 算法

虚拟 DOM 是用 JS 对象描述真实 DOM 的轻量结构,它的价值不是“比原生 DOM 快”,而是提供跨平台能力和一个可做批量对比的中间层。diff 的前提是同层比较、不跨层移动,把复杂度从 O(n^3)降到 O(n)。

  • 判断两个节点是否值得复用:Vue2 的 sameVnode 比较 keytag、是否注释节点、data 是否同时存在、input 的 type 是否一致。
  • Vue2 的核心是双端比较,四个指针 oldStart/oldEnd/newStart/newEnd,依次尝试四种命中:头头、尾尾、头尾(节点右移)、尾头(节点左移)。
  • 四种都不命中时,用旧children 的 key 建立 oldKeyToIdx 映射表去查找,找到就复用并移动,找不到就新建。循环结束后处理剩余的新增或删除。
  • Vue3 的最大变化是把优化前移到编译期,运行时只处理“确实会变的部分”。
  • 静态提升(hoistStatic):完全静态的节点被提升到 render 函数外部,只创建一次,后续渲染直接复用引用;连续静态节点还会被预字符串化为 innerHTML。
  • PatchFlag:编译器给动态节点打标记(1 TEXT、2 CLASS、4 STYLE、8 PROPS、16 FULL_PROPS、32 HYDRATE_EVENTS、64 STABLE_FRAGMENT、128 KEYED_FRAGMENT 等),patch 时按位判断只对比标记过的部分。
  • Block Tree:带结构化指令以外的区域被划为 Block,Block 上的 dynamicChildren 收集了所有动态后代,diff 时直接线性遍历这个扁平数组,跳过整棵静态子树。
  • cacheHandlers:内联事件处理函数被缓存,避免每次渲染生成新函数导致的 props 变更。
// Vue3 patchKeyedChildren 的五个阶段
// 1. 从头同步:i 从 0 开始,遇到不同则停
// 2. 从尾同步:e1/e2 从末尾开始,遇到不同则停
// 3/4. 旧节点遍历完但新节点有剩余 -> 全部 mount;反之旧节点有剩余则全部 unmount
// 5. 中间乱序区间:
// a. 为新节点建立 keyToNewIndexMap
// b. 遍历旧节点填充 newIndexToOldIndexMap,顺便 unmount 找不到的旧节点
// c. 对 newIndexToOldIndexMap 求最长递增子序列(LIS)
// d. 从后往前倒序遍历,在 LIS 中的节点保持不动,其余 insertBefore 移动
function getSequence(arr) {
const p = arr.slice()
const result = [0]
for (let i = 0; i < arr.length; i++) {
if (arr[i] === 0) continue // 0 表示新增节点,不参与 LIS
let last = result[result.length - 1]
if (arr[last] < arr[i]) {
p[i] = last
result.push(i)
continue
}
let lo = 0, hi = result.length - 1
while (lo < hi) { // 二分找第一个大于等于 arr[i] 的位置替换掉
const mid = (lo + hi) >> 1
arr[result[mid]] < arr[i] ? (lo = mid + 1) : (hi = mid)
}
if (arr[i] < arr[result[lo]]) {
if (lo > 0) p[i] = result[lo - 1]
result[lo] = i
}
}
let u = result.length, v = result[u - 1]
while (u-- > 0) { result[u] = v; v = p[v] } // 回溯还原真实序列
return result
}

Vue 生命周期

生命周期是组件实例从创建到销毁过程中暴露的一组钩子。理解顺序的关键是:初始化是“父先开始、子先结束”,销毁同理,因为父组件的挂载必须等所有子组件挂载完成。

  • Vue2 完整顺序:beforeCreatecreatedbeforeMountmountedbeforeUpdateupdatedbeforeDestroydestroyed,另有 activated / deactivated(keep-alive)和 errorCaptured
  • beforeCreate 时 data、methods、computed 都还未初始化;created 后可以访问数据但拿不到 $el 和真实 DOM;mounted 后 DOM 可用但不保证子组件全部渲染完(需要 $nextTick)。
  • 父子挂载顺序:父 beforeCreate → 父 created → 父 beforeMount → 子 beforeCreate → 子 created → 子 beforeMount → 子 mounted → 父 mounted。
  • 父子更新顺序:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated。
  • 父子销毁顺序:父 beforeDestroy → 子 beforeDestroy → 子 destroyed → 父 destroyed。
  • 各阶段适合做的事:created 发起请求、初始化非 DOM 数据;mounted 操作 DOM、初始化第三方图表实例、绑定全局事件;beforeUpdate 获取更新前的 DOM 状态(如滚动位置);updated 里改数据要防止死循环;beforeDestroy / onBeforeUnmount 清定时器、解绑全局事件、取消未完成请求。
  • Vue3 的 setupbeforeCreate 之前执行,因此 setup 内部没有 this
// Vue2 选项式 -> Vue3 组合式 对应关系
// beforeCreate -> setup() 本身
// created -> setup() 本身
// beforeMount -> onBeforeMount
// mounted -> onMounted
// beforeUpdate -> onBeforeUpdate
// updated -> onUpdated
// beforeDestroy -> onBeforeUnmount (名称变更)
// destroyed -> onUnmounted (名称变更)
// activated -> onActivated
// deactivated -> onDeactivated
// errorCaptured -> onErrorCaptured
// 新增: onRenderTracked / onRenderTriggered (仅开发环境,调试依赖)
import { onMounted, onBeforeUnmount } from 'vue'

export default {
setup() {
let timer = null
onMounted(() => {
timer = setInterval(() => console.log('tick'), 1000)
})
onBeforeUnmount(() => {
clearInterval(timer) // 必须清理,否则组件销毁后定时器仍在跑
})
}
}

computed、watch、watchEffect、methods 的区别与选型

四者都能“由数据推导行为”,区别在于是否缓存、是否惰性、以及关注点是“求值”还是“副作用”。

  • methods:每次模板重新渲染都会重新调用,没有任何缓存,适合事件处理和需要传参的场景。
  • computed:基于依赖缓存,内部有 dirty 标记,依赖没变时直接返回上次结果;惰性求值,没有人读取就不会计算;必须有返回值;支持 get/set 写法。适合从已有状态派生新状态。
  • watch:惰性执行,默认数据变化才触发,能同时拿到新值和旧值,支持 deepimmediateflush,适合“数据变化后做异步或开销较大的操作”,比如请求接口、写 localStorage。
  • watchEffect:立即执行一次并自动收集回调内用到的响应式依赖,拿不到旧值,依赖来源隐式。适合依赖较多且不关心旧值的副作用。
  • Vue3 的 flush 决定回调时机:pre(默认,组件更新前)、post(组件更新后,能读到最新 DOM)、sync(同步触发,慎用)。watchPostEffect / watchSyncEffect 是对应快捷方式。
  • Vue3 中直接侦听一个 reactive 对象会隐式开启深度侦听;侦听 getter 返回的对象则需要显式 deep: true
  • 选型口诀:能用 computed 就不用 watch;需要副作用才用 watch;依赖多且不需要旧值用 watchEffect;纯交互逻辑放 methods。
import { ref, reactive, computed, watch, watchEffect } from 'vue'

const price = ref(100)
const count = ref(2)
const state = reactive({ user: { name: 'a' } })

// computed 有缓存,多次读取只计算一次
const total = computed(() => price.value * count.value)

// 可写 computed
const discount = computed({
get: () => total.value * 0.8,
set: v => { price.value = v / 0.8 / count.value }
})

// watch 惰性,能拿到新旧值
watch(price, (val, oldVal) => {
console.log(oldVal, '->', val)
}, { immediate: false })

// 侦听 getter 返回的对象要显式 deep
watch(() => state.user, () => {}, { deep: true })

// watchEffect 立即执行 + 自动收集依赖,onCleanup 处理竞态
watchEffect(onCleanup => {
const controller = new AbortController()
fetch(`/api/goods?count=${count.value}`, { signal: controller.signal })
onCleanup(() => controller.abort()) // 依赖再次变化前取消上一次请求
})

v-if 与 v-show 的区别,v-for 中 key 的作用

v-if 控制“要不要生成”,v-show 控制“看不看得见”。key 则是 diff 阶段判定节点身份的依据。

  • v-if 是真正的条件渲染,编译成三元表达式,条件为假时对应节点根本不出现在 vnode 树里;切换会销毁并重建组件实例、事件监听和子组件状态,因此切换开销高、初始渲染开销低。
  • v-show 始终渲染真实 DOM,只切换 display: none;初始渲染开销高、切换开销低;不支持 <template>,也不能与 v-else 配对。
  • 判断标准:切换频繁用 v-show(如 Tab 面板),条件基本不变或需要延迟初始化用 v-if(如权限区块、弹窗内容)。
  • key 的作用:让 diff 能准确判断“这两个节点是不是同一个”,从而正确复用、移动而不是就地覆盖。
  • 不写 key 时 Vue 采用“就地复用”策略,只更新节点内容不移动节点。对纯文本列表没问题,但列表项含有内部状态(输入框的值、勾选状态、子组件 data、CSS 过渡)时会串位。
  • 不要用数组 index 当 key:在头部插入或删除时所有 index 都会变,等价于没有 key,还可能让 Vue 做出更多无效 patch。key 应该用业务上稳定唯一的 id。
  • v-ifv-for 不建议同用的原因在两个版本里正好相反:Vue2 中 v-for 优先级更高,会先遍历再逐个判断,即使 99% 都被过滤掉也要走一遍循环并创建 vnode 上下文;Vue3 中 v-if 优先级更高,它执行时 v-for 的迭代变量还不存在,直接抛出变量未定义的错误。
  • 正确写法是用 computed 预先过滤数据,或者把 v-if 提到外层 <template> 上。
<template>
<!-- 反例:Vue2 性能浪费,Vue3 直接报错 item is not defined -->
<li v-for="item in list" v-if="item.active" :key="item.id">{{ item.name }}</li>

<!-- 正例一:computed 过滤,只遍历真正需要渲染的数据 -->
<li v-for="item in activeList" :key="item.id">{{ item.name }}</li>

<!-- 正例二:条件与循环无关时,把 v-if 提到外层 template -->
<template v-if="showList">
<li v-for="item in list" :key="item.id">{{ item.name }}</li>
</template>
</template>

<script setup>
import { computed, ref } from 'vue'
const list = ref([{ id: 1, name: 'a', active: true }])
const showList = ref(true)
const activeList = computed(() => list.value.filter(i => i.active))
</script>

Vue 组件通信的所有方式

选择通信方式的核心依据是组件之间的关系距离:父子、跨层级、还是完全无关联。

  • props / emit:父子通信的标准方式,数据单向流动。Vue3 用 defineProps / defineEmits,且 emits 需要显式声明才能从 $attrs 中剥离。
  • v-model:props 加 emit 的语法糖,适合表单类组件的双向绑定。
  • ref 直接调用子组件方法:适合命令式操作(如让子组件 validate()reset())。Vue3 的 <script setup> 默认封闭,必须 defineExpose 暴露成员。
  • provide / inject:跨多层级传递,适合主题、国际化、表单上下文这类“祖先给后代的环境配置”。传响应式值时建议配合 readonly 避免后代直接改,同时提供修改方法。
  • $attrs:接收父组件传入但未被 props 声明的属性,用于二次封装组件时透传。Vue3 中 $listeners 被合并进 $attrs,并支持多根节点手动 v-bind="$attrs"(需 inheritAttrs: false)。
  • EventBus / mitt:任意组件间通信。Vue2 用 new Vue() 当总线,Vue3 移除了实例上的 $on / $off / $once,改用 mitttiny-emitter。缺点是数据流向不可追踪、必须手动解绑,只建议用于少量全局事件。
  • Vuex / Pinia:多个不相关组件共享同一份状态、且需要持久化和调试追踪时使用。
  • 作用域插槽:本质上是“子组件把数据传给父组件的渲染函数”,适合父组件要定制子组件内部某块 UI 的场景。
  • $parent / $root:耦合严重,只在组件库内部有限使用;Vue3 已移除 $children
<!-- 父组件 -->
<template>
<Child ref="childRef" v-model="msg" :title="title" @done="onDone" />
</template>

<script setup>
import { ref, provide, readonly } from 'vue'
const msg = ref('hello')
const title = ref('标题')
const childRef = ref(null)

const theme = ref('dark')
// 跨层级注入:只读值 + 修改方法,保证数据流可追踪
provide('theme', readonly(theme))
provide('setTheme', v => (theme.value = v))

const onDone = payload => console.log(payload)
const callChild = () => childRef.value.validate() // 通过 ref 命令式调用
</script>

<!-- 子组件 -->
<script setup>
import { inject } from 'vue'
defineProps(['title', 'modelValue'])
const emit = defineEmits(['update:modelValue', 'done'])
const theme = inject('theme', 'light') // 第二个参数是默认值
const validate = () => true
defineExpose({ validate }) // script setup 下必须显式暴露
</script>

nextTick 的原理与使用场景

Vue 的 DOM 更新是异步批量的:同一个 tick 内多次修改数据只会触发一次渲染。nextTick 的作用就是把回调排到“本轮 DOM 更新完成之后”执行。

  • 数据变化触发 dep.notify() 后,watcher 并不立即重新渲染,而是被推入一个去重队列 queue,同时通过 nextTick(flushSchedulerQueue) 注册一次刷新任务。
  • 刷新前队列会按 watcher id 升序排序,保证父组件先于子组件更新、用户 watcher 先于渲染 watcher。
  • nextTick 内部维护 callbacks 数组,同一 tick 内多次调用只注册一个异步任务,最后一次性遍历执行全部回调。
  • Vue2 的降级策略依次是:Promise.thenMutationObserversetImmediatesetTimeout(fn, 0),前两者是微任务,后两者是宏任务。Vue 2.5 曾因宏任务导致事件冒泡等问题,2.6 起统一优先微任务。
  • Vue3 直接使用 Promise.resolve().then(),不再需要降级,并且 nextTick() 返回 Promise,可以配合 await
  • 使用场景:修改数据后立刻读取更新后的 DOM 尺寸或内容;v-if 显示元素后立即 focus();列表渲染完成后初始化第三方插件;在 created 中需要操作 DOM 时。
  • 注意 mounted 中子组件不一定全部渲染完成,需要 this.$nextTick 包裹。
// Vue2 写法
export default {
data: () => ({ show: false }),
methods: {
async openInput() {
this.show = true
// 此时 DOM 还未更新,直接取会是 undefined
console.log(this.$refs.input) // undefined
await this.$nextTick()
this.$refs.input.focus() // DOM 已更新,可以安全操作
}
}
}
// Vue3 写法
import { ref, nextTick } from 'vue'

const show = ref(false)
const inputRef = ref(null)

async function openInput() {
show.value = true
await nextTick() // 等待渲染队列刷新完成
inputRef.value.focus()
}

v-model 双向绑定的实现原理

v-model 不是真正的双向绑定,而是“属性下发 + 事件上抛”的语法糖,本质仍然是单向数据流。

  • Vue2 在原生表单元素上:text/textarea 展开为 :value + @inputcheckbox/radio 展开为 :checked + @changeselect 展开为 :value + @change。修饰符 .lazyinput 换成 change.numberparseFloat 转换,.trim 去掉首尾空格。
  • 中文输入法场景下,Vue 会监听 compositionstart / compositionend,输入过程中不触发更新,避免拼音阶段的中间值污染数据。
  • Vue2 在自定义组件上:默认 prop 名是 value,事件名是 input,可以通过组件的 model 选项改成别的名字;一个组件只能写一个 v-model
  • Vue2 的 .sync 修饰符补充了多个双向属性的能力::title.sync="t" 展开为 :title="t" + @update:title="t = $event"
  • Vue3 统一了这两套机制:组件 v-model 默认 prop 是 modelValue,事件是 update:modelValuev-model:title 直接对应 title + update:title;因此 .syncmodel 选项都被移除。
  • Vue3 支持在同一组件上写多个 v-model,并支持自定义修饰符,通过 modelModifiers prop 读取。
  • Vue 3.4+ 提供 defineModel() 宏,自动声明 prop 与 emit,写法更简洁。
<!-- Vue2 自定义组件 -->
<script>
export default {
model: { prop: 'checked', event: 'change' }, // 自定义 v-model 的名字
props: { checked: Boolean },
methods: { toggle() { this.$emit('change', !this.checked) } }
}
</script>
<!-- Vue3 多个 v-model + 自定义修饰符 -->
<!-- 父组件: <UserName v-model:first.capitalize="a" v-model:last="b" /> -->
<script setup>
const props = defineProps({
first: String,
last: String,
firstModifiers: { default: () => ({}) } // 修饰符对应 xxxModifiers
})
const emit = defineEmits(['update:first', 'update:last'])

function onFirstInput(e) {
let val = e.target.value
if (props.firstModifiers.capitalize) {
val = val.charAt(0).toUpperCase() + val.slice(1)
}
emit('update:first', val)
}
</script>

<template>
<input :value="first" @input="onFirstInput" />
<input :value="last" @input="emit('update:last', $event.target.value)" />
</template>

Composition API 与 Options API 的对比,ref 与 reactive

Options API 按“选项类型”组织代码,Composition API 按“逻辑关注点”组织代码。前者上手快,后者在复杂组件和逻辑复用上更有优势。

  • Options API 的问题:一个功能的 data、methods、computed、watch 分散在四处,组件超过几百行后阅读需要反复跳转;逻辑复用依赖 mixin,存在命名冲突、来源不清晰、隐式依赖三大问题。
  • Composition API 把同一功能的状态和逻辑收拢在一起,可以抽成 useXxx 组合式函数复用,来源显式、支持完整的 TS 类型推导、对 Tree Shaking 友好。
  • 代价是需要理解响应式原语,ref.value 也有一定心智负担;小组件用 Options API 依然清晰。
  • refreactive 的区别:ref 接受任意类型,通过 RefImplget value / set value 做依赖收集,因此必须写 .valuereactive 只接受对象、数组、Map、Set,基于 Proxy 直接代理。
  • ref 传入对象时内部会调用 reactive 转换,所以 ref({a:1}).value 本身也是一个 Proxy。
  • 模板中只有顶层 ref 会自动解包;写在普通对象里作为属性的 ref 不会自动解包,但作为 reactive 对象的属性时会自动解包(数组和 Map 中的 ref 例外,不解包)。
  • 常见坑一:解构 reactive 对象会丢失响应式,因为解构出来的是普通值,解决办法是 toRefstoRef
  • 常见坑二:整体替换 reactive 变量会断开代理引用,只能逐字段赋值或用 Object.assignref 则可以直接整体替换 .value
  • 实践建议:统一优先用 ref,只在明确是一组关联状态时用 reactive,避免两种风格混用带来的困惑。
import { ref, reactive, toRefs, isRef } from 'vue'

const state = reactive({ count: 0, name: 'vue' })

// 坑一:解构后 count 只是普通数字,改它不会触发更新
let { count } = state
count++ // 无效

// 解法:toRefs 把每个属性包成 ref,保持响应式连接
const { count: countRef, name } = toRefs(state)
countRef.value++ // 生效,等价于 state.count++

// 坑二:整体替换会丢失代理
let obj = reactive({ a: 1 })
obj = { a: 2 } // 丢失响应式
Object.assign(obj, { a: 2 }) // 正确做法

// ref 可以整体替换,组合式函数返回 ref 更安全
const user = ref({ id: 1 })
user.value = { id: 2 } // 依然响应式

// 组合式函数封装
function useCounter(initial = 0) {
const count = ref(initial)
const inc = () => count.value++
return { count, inc } // 返回 ref,调用方解构后仍然响应式
}

keep-alive 的原理

keep-alive 是内置的抽象组件,作用是把被包裹组件的实例缓存起来,切换时不销毁而是移出/移入 DOM,从而保留组件状态并避免重复渲染。

  • 它本身不渲染任何 DOM,Vue2 中标记为 abstract: true,因此不会出现在父组件链 $parent 上。
  • 缓存结构是 cache 对象加 keys 数组,keys 记录访问顺序实现 LRU:命中缓存时把该 key 从数组中删除再 push 到末尾;未命中时写入缓存并 push,若 keys.length > max 就删除 keys[0] 对应的最久未使用实例。
  • 缓存 key 的生成规则:优先用 vnode 的 key,没有则用组件的 cid 加 tag 组合。Vue3 中若未指定 key 则直接用 vnode.type 作为 key。
  • 被缓存的组件不会触发 destroyed / unmounted,而是触发 deactivated;重新进入触发 activated。因此依赖“每次进入都刷新”的逻辑要从 mounted 挪到 activated
  • Vue3 的实现是把失活组件的 DOM 移动到一个内存中的 storageContainer 隐藏容器,激活时再 move 回来,避免了销毁重建。
  • include / exclude 支持字符串、逗号分隔字符串、正则和数组,匹配的是组件的 name 选项。Vue3 的 <script setup> 没有隐式 name,需要 defineOptions({ name }) 或额外写一个普通 <script> 块。
  • 配合路由使用时,注意 keep-alive 要包在 router-view 外层;Vue3 中需要用 router-view 的插槽写法。
<!-- Vue3 配合路由的写法 -->
<template>
<router-view v-slot="{ Component }">
<keep-alive :include="['Home', 'List']" :max="10">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
// LRU 核心逻辑简化版
function cacheVNode(cache, keys, key, vnode, max) {
if (cache[key]) {
vnode.componentInstance = cache[key].componentInstance // 直接复用实例
keys.splice(keys.indexOf(key), 1)
keys.push(key) // 更新为最近使用
} else {
cache[key] = vnode
keys.push(key)
if (max && keys.length > max) {
pruneCacheEntry(cache, keys[0], keys) // 淘汰最久未使用
}
}
vnode.data.keepAlive = true
}
// 被缓存组件里刷新数据要用 activated
export default {
name: 'List',
activated() { this.fetchList() }, // 每次重新进入都执行
deactivated() { this.timer && clearInterval(this.timer) }
}

插槽 slot:默认插槽、具名插槽、作用域插槽

插槽是组件的内容分发机制。理解插槽的关键是:插槽内容在父组件作用域中编译,但在子组件中被调用渲染。

  • 默认插槽:子组件写 <slot></slot>,父组件传入的内容会替换它;<slot> 内部可以写默认内容,父组件未传时展示。
  • 具名插槽:子组件 <slot name="header">,父组件 <template #header>。Vue 2.6 起 slot="xxx"v-slot 取代。
  • 作用域插槽:子组件把内部数据通过 <slot :row="item"> 传出去,父组件用 <template #default="{ row }"> 接收。它解决的是“父组件想自定义 UI,但需要的数据在子组件内部”这个矛盾,是表格、列表类组件的核心扩展点。
  • 编译原理:插槽内容会被编译成函数而不是静态 vnode 数组。Vue3 中所有插槽统一为 { name: (props) => VNode[] } 的形式,子组件渲染时调用 this.$slots.default(props) 拿到 vnode,从而实现“数据由子给、渲染由父定”。
  • 因为插槽内容在父作用域编译,所以插槽里不能直接访问子组件的 data,必须通过作用域插槽的参数拿。
  • 动态插槽名写作 <template #[dynamicName]>;判断插槽是否存在用 $slots.footer。Vue3 移除了 $scopedSlots 统一到 $slots,其中每一项都是函数,渲染函数中以对象形式传入插槽。
<!-- 子组件 MyTable.vue -->
<template>
<div class="table">
<div class="header">
<!-- 具名插槽,带默认内容 -->
<slot name="header">默认表头</slot>
</div>
<div v-for="(row, index) in data" :key="row.id">
<!-- 作用域插槽:把内部数据抛给父组件 -->
<slot :row="row" :index="index">{{ row.name }}</slot>
</div>
<!-- 条件插槽:没传就不渲染外层容器 -->
<div v-if="$slots.footer" class="footer">
<slot name="footer" />
</div>
</div>
</template>

<script setup>
defineProps({ data: { type: Array, default: () => [] } })
</script>
<!-- 父组件使用 -->
<template>
<MyTable :data="list">
<template #header>自定义表头</template>

<!-- 解构子组件抛出的插槽 props -->
<template #default="{ row, index }">
<span>{{ index + 1 }} - {{ row.name }}</span>
<button @click="remove(row)">删除</button>
</template>
</MyTable>
</template>

Vue Router 原理

前端路由的核心是“改变 URL 但不触发浏览器刷新,同时能感知变化并渲染对应组件”。两种模式分别依赖 hash 和 History API。

  • hash 模式:URL 中 # 后面的内容不会发送给服务器,改变 hash 不会引起页面刷新,通过 hashchange 事件监听变化。优点是无需任何服务端配置,兼容性最好;缺点是 URL 带 # 不美观、对 SEO 不友好、无法用于需要读取完整路径的场景。
  • history 模式:使用 history.pushState / replaceState 修改 URL 而不刷新,通过 popstate 监听前进后退。注意 pushState 本身不会触发 popstate,所以路由库要手动拦截。
  • history 模式的部署要点:直接访问或刷新子路径时浏览器会真的向服务器请求该路径,服务端必须配置 fallback 到 index.html,否则 404。Nginx 写 try_files $uri $uri/ /index.html;,同时要注意静态资源 publicPath 与真实部署路径一致。
  • 完整的导航守卫执行顺序:导航触发 → 失活组件 beforeRouteLeave → 全局 beforeEach → 重用组件 beforeRouteUpdate → 路由配置 beforeEnter → 解析异步路由组件 → 被激活组件 beforeRouteEnter → 全局 beforeResolve → 导航确认 → 全局 afterEach → DOM 更新 → beforeRouteEnternext 的回调(此时才能拿到组件实例)。
  • beforeRouteEnter 里没有 this,因为组件实例还未创建,需要通过 next(vm => {}) 访问。
  • 动态路由 /user/:id 通过 $route.params.id 取值;同一路由参数变化时组件会复用不重新创建,需要用 beforeRouteUpdatewatch 监听参数变化。addRoute 可在运行时动态添加路由,常用于登录后按权限注册。
  • 路由懒加载用动态 import(),打包工具会自动分包,配合注释 /* webpackChunkName: "group" */ 可以合并 chunk。
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'

const routes = [
{
path: '/user/:id',
name: 'User',
// 懒加载:返回 Promise,首屏不加载该 chunk
component: () => import('@/views/User.vue'),
meta: { requiresAuth: true },
beforeEnter: (to, from) => true
}
]

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // hash 模式换成 createWebHashHistory()
routes,
scrollBehavior: () => ({ top: 0 })
})

// 全局前置守卫:返回 false 中断,返回路由对象则重定向
router.beforeEach(to => {
if (to.meta.requiresAuth && !localStorage.token) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
})

// 动态添加路由,必须在守卫中 return to 重新触发一次导航
router.addRoute({ path: '/admin', component: () => import('@/views/Admin.vue') })

Vuex 与 Pinia 的区别与核心概念

两者都是集中式状态管理,Pinia 是 Vue 官方推荐的新一代方案,核心变化是去掉了 mutations 并放弃了嵌套模块。

  • Vuex 五个核心概念:state 单一状态树;getters 派生状态,相当于 store 的 computed;mutations 唯一允许同步修改 state 的地方,devtools 靠它做时间旅行;actions 处理异步,通过 commit 提交 mutation;modules 分割命名空间,namespaced: true 后调用要带模块路径字符串。
  • Vuex 的痛点:mutation 和 action 职责重复导致样板代码多;模块嵌套后类型推导困难;commit('user/setName') 这种魔法字符串无法被 TS 检查;this.$store 全局注入不利于 Tree Shaking。
  • Pinia 只有三个概念:state(函数返回对象)、gettersactions(同步异步都写这里)。修改 state 可以直接赋值、$patch 批量修改,或在 action 中修改。
  • Pinia 的 store 是扁平的,需要嵌套时直接在一个 store 里 import 另一个 store,天然规避了命名空间问题。
  • Pinia 支持 setup 风格定义 store,ref 对应 state、computed 对应 getters、function 对应 actions,心智模型与组合式 API 完全一致。
  • 其他差异:Pinia 体积约 1.5kb;完整 TS 类型推导无需额外声明;支持插件机制(持久化、日志);对 SSR 更友好;支持同时使用多个 store 实例。
  • 注意点:直接解构 Pinia store 会丢失响应式,必须用 storeToRefs(它只转换 state 和 getters,方法仍可直接解构)。
// Pinia 选项式写法
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
state: () => ({ name: '', list: [] }), // 必须是函数,避免多实例共享
getters: {
upperName: state => state.name.toUpperCase()
},
actions: {
async fetchUser(id) {
const res = await fetch(`/api/user/${id}`).then(r => r.json())
this.name = res.name // 直接修改,无需 mutation
}
}
})
// Pinia setup 式写法 + 组件中使用
import { ref, computed } from 'vue'
import { defineStore, storeToRefs } from 'pinia'

export const useCounter = defineStore('counter', () => {
const count = ref(0) // state
const double = computed(() => count.value * 2) // getter
function inc() { count.value++ } // action
return { count, double, inc }
})

// 组件中
const store = useCounter()
const { count, double } = storeToRefs(store) // 解构保持响应式
const { inc } = store // 方法直接解构即可
store.$patch({ count: 10 }) // 批量修改,只触发一次更新

Vue 模板编译流程

模板编译把声明式的 template 转成命令式的 render 函数,整个过程分三步:解析、优化、生成。

  • 第一步 parse:用正则和状态机扫描模板字符串,处理开始标签、结束标签、文本、注释,借助栈维护父子关系,产出 AST(抽象语法树)。每个 AST 节点记录 tag、attrsList、指令、children 等信息。
  • 第二步 optimize / transform:Vue2 中标记静态节点和静态根节点(static / staticRoot),patch 时直接跳过;Vue3 换成一系列 transform 插件,做静态提升、生成 PatchFlag、Block 树标记、事件缓存,优化力度大得多。
  • 第三步 generate:递归遍历 AST 拼接成渲染函数的代码字符串,如 _c('div',{attrs:{id:'app'}},[_v(_s(msg))]),最后通过 new Function 转成可执行函数。Vue3 生成的是带 _createElementVNode 等辅助函数的 ESM 代码,便于 Tree Shaking。
  • 编译时机分两种:构建时编译(vue-loader / @vitejs/plugin-vue 处理 .vue 单文件组件)和运行时编译(浏览器中把 template 字符串转成 render)。
  • 完整版 vs 运行时版:完整版包含编译器,体积大约多 30%,支持 template 选项和 DOM 内模板;运行时版只有运行时,体积更小、启动更快,但只能接受已编译好的 render 函数。
  • 生产环境应当使用运行时版本;如果控制台出现”You are using the runtime-only build”警告,说明代码里用了 template 选项或挂载了 DOM 内模板。
  • Vue3 中若确实需要运行时编译,要把别名指向 vue/dist/vue.esm-bundler.js
// 编译产物示意
// 模板: <div id="app">{{ msg }}<span class="s">静态</span></div>

// Vue2 生成
function render() {
with (this) {
return _c('div', { attrs: { id: 'app' } }, [
_v(_s(msg)),
_c('span', { staticClass: 's' }, [_v('静态')]) // 静态节点仅标记,仍会创建
])
}
}

// Vue3 生成:静态节点被提升到函数外,动态节点带 PatchFlag
const _hoisted_1 = /*#__PURE__*/ createElementVNode('span', { class: 's' }, '静态', -1)

function render(_ctx) {
return (openBlock(), createElementBlock('div', { id: 'app' }, [
createTextVNode(toDisplayString(_ctx.msg), 1 /* TEXT */), // 1 表示只有文本会变
_hoisted_1 // 直接复用,不参与 diff
]))
}
// vite 中指向含编译器的版本
export default {
resolve: {
alias: { vue: 'vue/dist/vue.esm-bundler.js' }
}
}

Vue 与 React 的核心区别

两者都是组件化的声明式 UI 框架,根本分歧在于“如何知道数据变了”以及“变了之后重算多少”。

  • 数据变更机制:Vue 是可变数据加依赖追踪,直接改属性即可,框架自动知道哪些地方用到了它;React 是不可变数据加显式调用,必须 setState / useState 的 setter 触发,且要返回新引用。
  • 更新粒度:Vue 的依赖精确到组件级别,只有真正依赖该数据的组件会重新渲染;React 默认从触发更新的组件向下重新执行整棵子树的渲染函数,需要 React.memouseMemouseCallback 手动裁剪。React 19 的编译器正在把这部分自动化。
  • 模板 vs JSX:Vue 的模板是静态可分析的,编译器能提取静态节点、生成 PatchFlag,做运行时之外的优化;JSX 本质是 JS,表达能力和动态性更强,但编译器难以做同等程度的静态分析。Vue 也支持 JSX。
  • diff 策略:Vue2 双端比较,Vue3 用 PatchFlag 加 Block 树跳过静态内容、用最长递增子序列最小化移动;React 采用单向遍历加 key 复用,重心放在 Fiber 架构上,把渲染拆成可中断的工作单元以实现并发特性和优先级调度。
  • 心智模型:Vue 更接近“数据驱动的响应式对象”,写法贴近直觉;React 更接近“UI 是状态的纯函数”,函数组件每次渲染都是全新闭包,容易踩闭包陷阱和依赖数组问题,但数据流更显式可预测。
  • 生态与约束:Vue 官方提供路由、状态管理、构建工具的统一方案,团队协作一致性好;React 保持核心精简,路由与状态方案由社区提供,灵活度高但选型成本大。
  • 副作用管理:Vue 的 watch / watchEffect 自动追踪依赖;React 的 useEffect 依赖数组需手写,遗漏或多写都会导致 bug。
// React:状态不可变,必须产生新引用
const [list, setList] = useState([])
setList(prev => [...prev, item]) // 直接 push 不会触发渲染

// Vue:可变数据,直接操作即可
const list = ref([])
list.value.push(item) // 数组方法被 Proxy 感知,自动触发更新

Vue 项目常见性能优化手段

性能优化分三个层面:减少渲染次数、减少单次渲染工作量、减少加载体积。

  • 渲染层面:用 v-show 替代频繁切换的 v-if;静态内容加 v-once 只渲染一次;Vue 3.2+ 的 v-memo 可以在依赖数组不变时跳过整个子树的更新,适合超大列表行。
  • 列表层面:为 v-for 提供稳定唯一的 key;长列表使用虚拟滚动(vue-virtual-scrollervxe-table)只渲染可视区;不需要响应式的大数组用 Object.freezeshallowRef 跳过代理开销。
  • 计算层面:模板里避免写复杂表达式和方法调用,改用 computed 利用缓存;高频事件(scroll、resize、input)加防抖节流。
  • 组件层面:路由和大组件用 defineAsyncComponent / 动态 import() 异步加载,配合 Suspense 做加载态;列表页与详情页切换用 keep-alive 保留状态;合理拆分组件缩小更新范围,但不要过度拆分。
  • 内存层面:beforeUnmount 中清理定时器、addEventListenerIntersectionObserver、第三方图表实例,否则组件销毁后仍占内存并可能报错。
  • 打包层面:路由级代码分割;build.rollupOptions.output.manualChunks 拆分大依赖;UI 库用 unplugin-vue-components 按需引入;开启 gzip/brotli 预压缩;用 rollup-plugin-visualizer 分析体积找出大包;生产环境去掉 sourcemap 和 console。
  • 首屏层面:关键资源 preload、图片懒加载与 WebP/AVIF、字体子集化、必要时上 SSR 或 SSG 预渲染。
<template>
<!-- v-once:只渲染一次,后续更新完全跳过 -->
<footer v-once>{{ copyright }}</footer>

<!-- v-memo:依赖数组不变时跳过该行的所有更新,大列表收益明显 -->
<div v-for="row in rows" :key="row.id" v-memo="[row.id, row.selected]">
<span>{{ row.name }}</span>
<span>{{ row.selected ? '已选' : '未选' }}</span>
</div>
</template>

<script setup>
import { shallowRef, defineAsyncComponent } from 'vue'

// 大数据只需要整体替换,用 shallowRef 避免深层代理开销
const bigList = shallowRef([])

// 异步组件 + 加载态与超时兜底
const Chart = defineAsyncComponent({
loader: () => import('@/components/Chart.vue'),
delay: 200,
timeout: 8000
})
</script>
// vite 分包:大依赖单独成 chunk,充分利用浏览器长期缓存
export default {
build: {
rollupOptions: {
output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], echarts: ['echarts'] } }
}
}
}

React

受控组件和非受控组件

React中受控组件和非受控组件主要用于描述如何处理表单元素(如<input>, <textarea>, <select>等)中的用户输入。两者的主要区别在于状态管理和数据流的方向。

  • 受控组件(Controlled Components)
    • 定义:在受控组件中,表单的值(value属性)是由React组件的状态(state)或属性(props)控制的。这意味着React组件负责维护表单元素的状态,并通过事件处理器(如onChange)更新状态,进而更新显示的值。
    • 数据流:数据流是单向的,从React组件的state或props流向表单元素。用户交互会触发事件处理器,导致state更新,然后state的变化会导致组件重新渲染,更新表单的值。
  • 非受控组件(Uncontrolled Components)
    • 定义:非受控组件的值不由React组件直接控制,而是由DOM元素自身管理。React不会通过value属性来设置表单元素的值,而是通过defaultValue属性设置初始值,之后的值变化由DOM自行处理。
    • 数据读取:由于React不直接控制表单元素的值,它通常使用ref来访问DOM节点,以便读取和设置值。
  • 选择使用哪种组件
    • 受控组件通常用于需要实时响应用户输入并执行某些逻辑(如验证)的情况,或是需要将表单值作为应用程序状态的一部分的情况。
    • 非受控组件可能在性能敏感或复杂的表单中更有效率,因为它们减少了状态更新的次数,特别是在大型应用中,这可能会带来性能优势。
    • 某些情况下可能需要混合使用两种类型,或使用“受控与非受控”的组合组件,这种组件可以在没有提供value属性时作为非受控组件工作,在提供了value时作为受控组件工作。这在一些库组件中比较常见,比如Ant Design的一些组件就支持这种模式。

useLayoutEffect和useEffect区别

useEffectuseLayoutEffect 都是 React Hooks 中用于处理副作用(例如数据获取、订阅或者手动修改 DOM)的钩子函数。它们的主要区别在于执行时机和对渲染的影响。

  1. 执行时机:
    • useEffect: 这个 Hook 是在 DOM 更新后异步执行的,即它不会阻塞浏览器的渲染。这意味着在执行 useEffect 的回调函数时,屏幕上的元素已经完成渲染,因此任何在 useEffect 中的操作都不会影响当前屏幕上的渲染输出。这使得 useEffect 更适合处理那些不需要立即反映到用户界面上的副作用,如网络请求、事件监听器的设置或清除等。
    • useLayoutEffect: 相比之下,useLayoutEffect 是同步执行的,它的回调函数会在所有 DOM 变更完成后但在浏览器绘制更新前执行。这意味着 useLayoutEffect 中的代码可以改变布局,并且这种变化会立即反映到用户界面上,不会造成视觉上的闪烁或抖动。这使得 useLayoutEffect 更适合处理那些需要立即反映到布局中的副作用,如测量 DOM 节点的尺寸或位置,或直接修改 DOM。
  2. 性能影响:
    • 因为 useEffect 是异步执行的,所以它通常对性能的影响较小,特别是在涉及到复杂的计算或大量的 DOM 操作时。这是因为浏览器可以优先完成渲染,然后再处理副作用,从而保持流畅的用户体验。
    • useLayoutEffect 的同步执行特性意味着如果它包含的逻辑过于复杂,可能会阻塞浏览器的渲染线程,导致页面卡顿。因此,在性能敏感的场景下,应当谨慎使用 useLayoutEffect
  3. 兼容性:
    • useEffect 在所有现代浏览器中都有良好的兼容性。
    • useLayoutEffect 在某些旧版浏览器中可能不被支持,尤其是那些不支持异步渲染的浏览器。

推荐首先尝试使用 useEffect,因为它的异步执行方式更加符合现代 Web 应用程序的性能要求。只有在特定场景下,比如需要在渲染之前进行精确的 DOM 测量或修改,才应该使用 useLayoutEffect

hooks为什么不能在if中使用

React Hooks(如 useState, useEffect 等)设计时有一些关键的规则需要遵守,以确保组件的正确渲染和生命周期的一致性。其中一条规则是,Hooks 必须在函数组件或自定义 Hook 的顶层调用,而不能在循环、条件或嵌套函数中调用。

不建议在 if 条件语句中调用 Hooks 的原因如下:

  1. 可预测性:
    React 需要知道何时调用 Hooks,以便能够追踪状态更新和副作用。如果在 if 语句中调用,React 将无法确定每次渲染时是否应该调用这些 Hooks,这会导致状态和副作用的混乱。
  2. 一致性和确定性:
    Hooks 被设计成在每次组件渲染时按照相同的顺序调用,这保证了状态和副作用的一致性。如果将 Hooks 放入条件逻辑中,那么每次渲染调用的 Hooks 可能不同,这违反了 Hooks 的这一核心原则。
  3. 避免内存泄漏:
    如果你在条件语句中使用了 useEffect,并且该条件不再满足,你可能忘记清理之前的副作用,从而导致内存泄漏。

为了遵循这些规则,应该在函数组件的主体内始终调用你的 Hooks,而不是在 if 语句中。如果需要根据条件执行某些操作,可以考虑使用 useStateuseEffect 的依赖数组来控制这些操作的发生,或者在组件内部创建逻辑分支来处理不同的情况,但要确保所有的 Hooks 调用都在组件的顶层。

memorizedState

在React中,memorizedState这个术语通常与React的Hooks机制相关联,尤其是useStateuseReducer等Hooks。当使用这些Hooks时,React内部会维护一个叫做“memorized state”的值,这是Hook当前状态的最新版本。

memorizedState并不是直接暴露给开发者的一个属性或API,而是React为了实现状态管理和更新逻辑的一部分。当组件重新渲染时,React会检查新的props或state是否与前一次渲染的不同。如果memorizedState发生变化,React将执行必要的更新逻辑,否则可能直接使用之前计算的输出,以优化性能。

对于React.memo来说,它是一个高阶组件(HOC),用于包装函数组件,提供类似于shouldComponentUpdate生命周期方法的功能,即它会比较传递给组件的新旧props,如果它们是浅相等的(即引用相等或值相等,取决于props的数据类型),那么React.memo会告诉React不要重新渲染组件,而是使用上一次渲染的结果。

需要注意的是,React.memo只比较props,并不会比较组件内部的状态(state)。如果需要根据组件内部状态的变化来决定是否重新渲染,将需要使用React的Hooks,如useStateuseReducer,以及可能的useCallbackuseMemo来确保某些函数或对象的引用保持不变,从而避免不必要的重新渲染。

setState是同步还是异步

React 中的 setState 方法的行为在不同情况下有所不同,它通常被视为异步的,但这种异步行为不是传统意义上的异步(如使用 Promiseasync/await),而是基于 React 的批处理更新机制。

  • 在大多数情况下:当在组件的生命周期方法(如 componentDidMount, componentDidUpdate)或 React 合成事件处理器中调用 setState 时,React 会将状态更新放入一个队列中,并在当前工作循环结束或在下一次重绘之前的一段时间内进行批处理。这意味着状态更新可能不会立即反映在组件的状态上,而是等待 React 完成当前的工作或等到下一次渲染周期。
  • 特殊情况:当在 setTimeout 或原生 DOM 事件(非 React 合成事件)中调用 setState 时,React 的批处理机制可能不会生效,导致 setState 表现得更像同步行为。这是因为这些环境下的调用通常不在 React 的控制流中,所以 React 可能会立即执行状态更新。
  • 如何确保访问最新状态:如果需要确保在 setState 后访问到最新的状态,你可以传递一个回调函数作为 setState 的第二个参数。这个回调函数将在状态更新并且组件完成重新渲染后被调用。
    this.setState({ someState: newValue }, () => {
    // 这里可以安全地访问最新的状态
    console.log(this.state.someState);
    });

虚拟 DOM 与 diff 算法, key 的作用

虚拟 DOM 就是用 JS 对象描述真实 DOM 结构。JSX 经过编译后变成 React.createElement 调用,产出形如 { type, props, key, ref } 的普通对象树。

为什么用虚拟 DOM,常见的误解是“它比直接操作 DOM 快”。准确的说法是:手写最优的命令式 DOM 操作一定比虚拟 DOM 快,虚拟 DOM 的真正价值在于:

  • 提供声明式编程模型,开发者只描述“UI 应该长什么样”,不用手动维护状态到 DOM 的增量同步
  • 把多次状态变更合并成一次批量 DOM 更新,减少回流与重绘
  • 与平台解耦,同一套描述可以渲染到 DOM、Native、Canvas、SSR 字符串

完整树对比的时间复杂度是 O(n^3),无法接受。React 用三条启发式策略把它降到 O(n):

  1. tree diff:只做同层比较,不跨层级移动节点。一个节点从层级 A 移到层级 B, React 会直接删除旧节点再新建,而不是移动。因此应避免频繁改变节点的层级结构
  2. component diff:同类型组件继续向下 diff;类型不同(例如 div 变成 span,或 ComponentA 变成 ComponentB)直接卸载整棵旧子树并重建,不再深入比较
  3. element diff:同层子节点通过 key 建立唯一标识,支持插入(INSERT)、移动(MOVE)、删除(REMOVE)三种操作,从而复用已有节点

key 的作用是在同一层级中标识节点身份,让 React 知道“新列表中的这个节点就是旧列表中的那个节点”,从而复用对应的 Fiber 节点、DOM 节点以及组件内部 state。

为什么不能用 index 作 key:

  • 列表发生头部插入、删除、排序时, index 与数据项的对应关系整体错位
  • React 认为 key 相同就是同一个节点,于是复用了错误的节点,导致非受控输入框的值、组件内部 state、CSS 动画状态串到别的行上
  • 反而增加 DOM 更新量:本来只需插入一个节点,现在变成后续 N 个节点全部更新 props
  • 只有列表是静态的、永不重排且不做增删时,用 index 才是无害的
// 错误: 头部插入时所有 index 后移, 输入框的值会错位到别的行
{list.map((item, index) => <Row key={index} item={item} />)}

// 正确: 使用数据本身稳定且唯一的标识
{list.map((item) => <Row key={item.id} item={item} />)}

key 只需在兄弟节点之间唯一,不需要全局唯一;也不要用 Math.random()生成 key,那会让每次渲染都完全重建节点。

Fiber 架构解决了什么问题

React 15 的 Stack Reconciler 用递归遍历组件树,递归一旦开始就无法中断。组件树较大时, JS 会长时间独占主线程,浏览器无法响应用户输入也无法绘制,表现为输入卡顿和掉帧。

Fiber 是 React 16 引入的重写版协调器,核心是把“不可中断的递归”改造成“可中断的循环”。

Fiber 节点本身是一种链表结构的虚拟 DOM 描述,用三个指针取代了树的递归关系:

  • return 指向父节点, child 指向第一个子节点, sibling 指向下一个兄弟节点
  • 每个 Fiber 节点是一个工作单元,处理完一个单元就检查剩余时间,需要时把主线程还给浏览器,之后从中断处继续

双缓冲 workInProgress 树:

  • 内存中同时存在两棵 Fiber 树, current 树对应当前屏幕上的内容, workInProgress 树是正在构建的下一帧
  • 两棵树的对应节点通过 alternate 指针互相引用,可以复用节点对象,减少内存分配
  • 构建完成后只需把 root.current 指针切到新树,一次性完成切换,用户不会看到构建中途的中间状态

两个阶段:

  • render 阶段(可中断): beginWork 向下遍历创建/复用 Fiber 并做 diff, completeWork 向上归并创建 DOM 实例、收集副作用。这个阶段是纯 JS 计算,不接触真实 DOM,因此可以被打断、丢弃、重做。这也是 StrictMode 下开发环境会双调用渲染函数和 useState 初始化函数的原因,用来暴露不纯的逻辑
  • commit 阶段(不可中断):一次性把副作用应用到真实 DOM,分 beforeMutation、mutation、layout 三个子阶段。getSnapshotBeforeUpdate 在 beforeMutation, DOM 变更在 mutation, useLayoutEffect 和 componentDidMount/DidUpdate 在 layout 子阶段同步执行, useEffect 则被异步调度到绘制之后

优先级调度:

  • React 18 使用 Lane 模型(31 位位掩码)表示优先级,取代了 React 16/17 的 expirationTime,位运算让“一批优先级”的表达和判断更灵活
  • 离散事件(click、keydown)优先级高于连续事件(scroll、mousemove),高于 transition,高于 Idle
  • 高优先级更新可以打断正在进行的低优先级 render 阶段,低优先级任务之后从头重新开始
  • 时间切片默认每 5ms 让出一次主线程,底层用 MessageChannel 产生宏任务来调度,没有用 requestIdleCallback (触发频率不稳定且兼容性差)

需要强调: Fiber 架构从 React 16 就有了,但“可中断”的能力在 React 16/17 中并未对外开放。只有 React 18 使用 createRoot,并配合 startTransition、useDeferredValue 等 API,并发渲染才真正生效。

React 18 的并发特性

React 18 的核心变化是引入并发渲染(Concurrent Rendering),让渲染可以被中断、暂停、恢复和放弃。它不是一个开关式的新模式,而是一组按需启用的能力。

createRoot 的变化:

// React 17 及以前
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, container);

// React 18: 入口从 react-dom 改为 react-dom/client
import { createRoot } from 'react-dom/client';
const root = createRoot(container);
root.render(<App />); // render 不再接收回调参数, 卸载改用 root.unmount()

继续用 ReactDOM.render 会有警告,且应用运行在兼容的旧行为下,拿不到任何并发能力。SSR 场景对应的新 API 是 hydrateRoot。

  1. Automatic Batching: React 17 只在合成事件处理函数中批处理, setTimeout、Promise.then、原生监听回调中的多次 setState 会分别触发渲染; React 18 用 createRoot 后所有场景默认批处理。需要立即读取更新后的 DOM 时用 flushSync 主动退出批处理,但它牺牲性能,应尽量少用
  2. useTransition:返回 [isPending, startTransition],把包裹其中的 setState 标记为非紧急更新。过渡期间旧 UI 保持可交互,更高优先级的更新可以打断它。注意它只能标记同步执行的 setState,写在 await 之后的 setState 需要再包一层。另有不带 isPending 的独立导出 React.startTransition,可在组件外使用
  3. useDeferredValue:接收一个值返回它的延迟版本,更新时先用旧值渲染保证响应,空闲时再用新值渲染。适用于无法控制 setState 调用点的场景,例如值来自 props 或第三方 store
  4. Suspense: React 18 支持 SSR 流式渲染(renderToPipeableStream)与选择性水合, Suspense 边界可以独立水合并优先水合用户正在交互的部分。但 React 18 本身没有提供通用的数据获取方案,用 Suspense 取数需要框架(Next.js、Relay)或 React 19 的 use()配合
function Search() {
const [query, setQuery] = useState('');
const [list, setList] = useState([]);
const [isPending, startTransition] = useTransition();

function onChange(e) {
setQuery(e.target.value); // 紧急更新: 输入框必须立即响应
startTransition(() => {
setList(filterHugeList(e.target.value)); // 非紧急: 可被后续输入打断
});
}

return (
<>
<input value={query} onChange={onChange} />
<HugeList data={list} dimmed={isPending} />
</>
);
}

其他新增: useId 生成 SSR 与 CSR 一致的稳定 id; useSyncExternalStore 供外部状态库在并发下安全订阅; useInsertionEffect 供 CSS-in-JS 库在 DOM 变更前注入样式。

类组件生命周期与 Hooks 等价写法

三个阶段的执行顺序:

  • 挂载: constructor -> getDerivedStateFromProps -> render -> componentDidMount
  • 更新: getDerivedStateFromProps -> shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate
  • 卸载: componentWillUnmount
  • 错误处理: getDerivedStateFromError -> render 降级 UI -> componentDidCatch

废弃的 will 系列(React 16.3 标记不安全, 17 起必须加 UNSAFE_ 前缀):

  • componentWillMount、componentWillReceiveProps、componentWillUpdate
  • 废弃原因:这三个钩子都处于 render 阶段。Fiber 的 render 阶段可中断、可丢弃、可重做,意味着它们可能被调用多次。而开发者习惯在其中发起请求、订阅事件、直接 setState,就会造成重复请求、内存泄漏和难以复现的 bug
  • 替代方案: componentWillMount 的逻辑放 constructor (初始化 state)或 componentDidMount (副作用); componentWillReceiveProps 换成静态的 getDerivedStateFromProps (没有 this,强制写成纯函数)或干脆在渲染中直接计算派生值; componentWillUpdate 换成 getSnapshotBeforeUpdate,它在 DOM 真正变更前调用,适合读取滚动位置等信息并把结果传给 componentDidUpdate

Hooks 等价写法对照:

  • constructor 中初始化 state -> useState / useReducer 的惰性初始化
  • componentDidMount -> useEffect(fn, [])
  • componentDidUpdate -> useEffect(fn, [deps])
  • componentWillUnmount -> useEffect 返回的清理函数
  • shouldComponentUpdate -> React.memo(Comp, areEqual)
  • getDerivedStateFromProps -> 渲染中直接计算,或“记录上一次 props”模式
  • getSnapshotBeforeUpdate + componentDidUpdate -> useLayoutEffect
  • 实例属性 this.xxx -> useRef
  • getDerivedStateFromError / componentDidCatch -> 无对应 Hook,必须用类组件
function Detail({ id }) {
useEffect(() => {
const timer = setInterval(tick, 1000); // 等价 componentDidMount
return () => clearInterval(timer); // 等价 componentWillUnmount
}, []);

useEffect(() => {
const controller = new AbortController();
fetchDetail(id, controller.signal); // 等价 componentDidUpdate 中比较 prevProps.id
return () => controller.abort(); // id 变化时先清理上一次
}, [id]);
}

两者语义并不完全等价,需要注意的差异:

  • useEffect 在依赖变化时会先执行上一次的清理函数再执行新的副作用,而 componentDidUpdate 没有对应的“先清理”环节
  • useEffect 是异步的(绘制之后), componentDidMount/DidUpdate 是同步的(绘制之前),需要同步行为时用 useLayoutEffect
  • 函数组件没有实例,每次渲染的 props 和 state 都是当次渲染的快照;类组件的 this.props 总是最新的

useState 的实现原理与常见坑

实现原理:

  • Hook 的状态不存在组件实例上,而是挂在对应 Fiber 节点的 memoizedState 字段上,多个 Hook 按调用顺序串成一条单向链表。这正是“不能在条件、循环、嵌套函数中调用 Hook”的根本原因
  • 每个 useState 对应一个 Hook 对象,内部有 queue 保存更新链表(环形链表)。调用 setState 只是把一个 update 对象入队并调度一次渲染,并不会立即改变变量
  • 重新渲染时按顺序遍历 Hook 链表,依次消费 queue 中的 update 计算出最新 state
  • useState 本质就是预置了 basicStateReducer 的 useReducer,后者的 reducer 由用户提供

常见坑:

  1. 函数式更新:依赖旧值计算新值时必须写 setCount(c => c + 1)。直接写 setCount(count + 1)时 count 是本次渲染的常量快照,连续调用只会生效一次
  2. 闭包陷阱:每次渲染都会产生一套全新的 state 和函数。setTimeout、事件订阅、防抖函数中捕获的是那次渲染的旧值。解法是用函数式更新、把最新值放进 useRef,或正确声明 effect 依赖让闭包随之更新
  3. 状态合并:类组件的 this.setState 会对对象做浅合并, useState 是整体替换。更新对象的部分字段必须自己展开
  4. 惰性初始化: useState(expensiveInit())每次渲染都会执行 expensiveInit,只是返回值被忽略。应写成 useState(expensiveInit)或 useState(() => expensiveInit())。反过来,若初始值本身就是一个函数,必须写成 useState(() => fn),否则会被当成初始化函数执行
  5. Bailout: React 用 Object.is 比较新旧 state,相同则跳过后续渲染。但可能仍会多渲染当前组件一次才 bailout,这是正常现象
  6. 直接修改引用类型不会触发更新: state.list.push(x)后再 setState(state.list),引用没变, React 认为没有变化
function Counter() {
// 惰性初始化: 只在首次渲染执行一次
const [count, setCount] = useState(() => Number(localStorage.getItem('c')) || 0);
const [form, setForm] = useState({ name: '', age: 0 });

function addTwiceWrong() {
setCount(count + 1); // 两次都基于同一个快照, 最终只 +1
setCount(count + 1);
}

function addTwiceRight() {
setCount((c) => c + 1); // 基于更新队列中的上一个结果, 最终 +2
setCount((c) => c + 1);
}

function changeName(name) {
setForm((prev) => ({ ...prev, name })); // 手动展开, 没有自动浅合并
}
}

useMemo、useCallback、React.memo 的区别与误用

三者都是缓存,但作用的层级不同:

  • useMemo(fn, deps)缓存计算结果,依赖不变时不重新执行 fn,返回上一次的值
  • useCallback(fn, deps)缓存函数引用,等价于 useMemo(() => fn, deps)
  • React.memo(Comp)是高阶组件,对 props 做浅比较,相等则跳过该组件的重新渲染(只拦截来自父组件的渲染,自身 state 或 context 变化仍会渲染)

三者是组合关系而不是替代关系: React.memo 只做浅比较,父组件每次渲染都会创建全新的函数和对象字面量,浅比较必然失败。所以传给 memo 子组件的引用类型 props 通常需要用 useCallback / useMemo 稳定下来, memo 才有意义。

什么时候是负优化:

  • 缓存本身有成本:需要保存依赖数组、每次渲染做依赖比较、额外占用内存。对 a + b、简单字符串拼接这类廉价计算包 useMemo 是净亏损
  • 依赖数组写漏会拿到过期闭包和陈旧数据,这比不优化更危险,排查成本也高得多
  • 子组件没有被 React.memo 包裹时,传 useCallback 完全无效,因为父组件渲染时子组件本来就会重新渲染
  • props 中只要有一个引用类型没被稳定住, memo 就永远失效,前面所有的 useCallback 全部白做
  • memo 对 children 无效: 中的 children 每次都是新创建的 element 对象
  • props 字段很多或对象很大时,浅比较本身的开销可能超过重新渲染的开销

真正值得使用的场景:计算开销明显(大数组的排序、过滤、聚合);该值要作为其他 Hook 的依赖项;该值要传给 React.memo 组件或作为 Context 的 value;渲染成本高的子树(大列表、图表)。

const Child = React.memo(function Child({ onClick, config }) {
return <button onClick={onClick}>{config.text}</button>;
});

function Parent({ list }) {
const [n, setN] = useState(0);
const sorted = useMemo(() => [...list].sort(compare), [list]); // 值得: 排序开销大
const onClick = useCallback(() => setN((v) => v + 1), []); // 值得: 传给 memo 子组件
const config = useMemo(() => ({ text: '点我' }), []); // 必须: 否则上面的 memo 失效
return <Child onClick={onClick} config={config} />;
}

React 19 的 React Compiler 可以在编译期自动完成这类记忆化,但在 React 18 中仍然需要手动处理。原则是先用 Profiler 找到真实瓶颈再优化,不要默认给所有东西套上缓存。

useRef 的三大用途与 forwardRef

useRef(initialValue)返回一个在组件整个生命周期内保持同一引用的对象 { current }。修改 current 不会触发重新渲染,且赋值立即生效,没有 state 的快照语义。可以把它理解为函数组件版的实例属性。

三大用途:

  1. 访问 DOM:把 ref 挂到宿主元素上,在事件回调或 effect 中通过 ref.current 操作焦点、选区、滚动位置、测量尺寸,或集成需要真实 DOM 的第三方库(地图、图表、播放器)
  2. 保存跨渲染的可变值:定时器 id、WebSocket 实例、AbortController、是否首次渲染的标记、最新的回调函数。这些值变化时不应该触发渲染,用 state 反而会造成无谓的重渲染甚至死循环
  3. 保存上一次的 props/state:在 useEffect 中把当前值写入 ref,由于 effect 在渲染之后执行,渲染期间读到的就是上一次的值

注意:不要在渲染期间读写 ref.current,那会破坏渲染的纯粹性,在并发渲染下可能得到不一致的结果。读写应放在事件回调或 effect 中(初始化惰性单例是可接受的例外)。

forwardRef 与 useImperativeHandle:

  • 函数组件没有实例,默认无法接收 ref。forwardRef 让组件接收第二个参数 ref,从而把它转发给内部的 DOM 节点
  • useImperativeHandle(ref, createHandle, deps)配合 forwardRef 自定义暴露给父组件的方法,而不是把整个 DOM 节点交出去,保持封装性
  • 命令式句柄应该是最后手段,优先用 props 驱动。它适合焦点管理、播放控制、表单校验触发这类无法用声明式表达的动作
  • React 19 起 ref 可以作为普通 prop 直接传给函数组件, forwardRef 不再必需; React 18 仍然需要它
const PasswordInput = forwardRef(function PasswordInput(props, ref) {
const inner = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inner.current.focus(),
clear: () => { inner.current.value = ''; },
}), []);
return <input type="password" ref={inner} {...props} />;
});

// 保存上一次的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; }, [value]); // 渲染完成后才写入
return ref.current; // 渲染期间读到的是上一次的值
}

自定义 Hook 的价值与编写规范

在 Hooks 之前,复用有状态逻辑只能靠 HOC 或 render props,两者都会引入额外的组件层级,造成嵌套地狱和 props 命名冲突。自定义 Hook 复用的是逻辑本身而不是 UI,不产生任何组件层级,且多个组件调用同一个 Hook 时状态完全隔离(每次调用都是独立的一份 state)。

编写规范:

  • 名字必须以 use 开头,否则 eslint-plugin-react-hooks 无法识别并校验 Hook 规则
  • 只能在函数组件或其他 Hook 的顶层调用,不能放在条件、循环、回调中
  • 返回值约定:两个值用数组返回(调用方便于重命名),多个值用对象返回
  • 返回的函数用 useCallback 包裹,返回的对象用 useMemo 包裹,否则调用方把它们放进依赖数组会每次都变
  • 依赖外部传入的回调时,用 ref 保存最新回调,避免把回调放进 effect 依赖导致订阅反复重建
  • 必须清理副作用:定时器、事件监听、订阅、未完成的请求
  • 异步请求要做竞态保护,快速切换参数时旧请求的响应可能后到达
// 防抖: 返回值的延迟版本
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // value 变化时取消上一次的定时器
}, [value, delay]);
return debounced;
}

// 请求: 统一处理 loading / error, 并防止竞态和卸载后更新
function useRequest(fetcher, deps = []) {
const [state, setState] = useState({ loading: true, data: null, error: null });
const fetcherRef = useRef(fetcher);
useEffect(() => { fetcherRef.current = fetcher; }); // 始终持有最新的 fetcher

useEffect(() => {
let cancelled = false;
setState((s) => ({ ...s, loading: true }));
fetcherRef.current()
.then((data) => { if (!cancelled) setState({ loading: false, data, error: null }); })
.catch((error) => { if (!cancelled) setState({ loading: false, data: null, error }); });
return () => { cancelled = true; }; // 竞态保护: 忽略过期请求的结果
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);

return state;
}

设计建议:一个 Hook 只做一件事,复杂逻辑由多个小 Hook 组合而成;不要把整个页面的逻辑塞进一个 useXxxPage,那只是把组件的混乱搬了个位置。

useReducer + Context 与 Context 的性能问题

useReducer 适用于状态逻辑复杂的场景:状态包含多个互相关联的子值、下一个状态依赖上一个状态、更新分支多。它把“怎么改”收敛到 reducer 纯函数中,组件只负责 dispatch 一个描述意图的 action,便于单元测试和行为追踪。

配合 Context 就能实现一个轻量的全局状态方案,关键做法是拆成两个 Context:一个提供 state,一个提供 dispatch。React 保证 dispatch 的引用在组件生命周期内稳定,因此只 dispatch 不读 state 的组件不会因为 state 变化而重新渲染。

Context 的性能问题:

  • Context 不做任何浅比较或字段级订阅。只要 Provider 的 value 引用发生变化,所有 useContext 了该 Context 的组件都会重新渲染
  • 这种渲染无法被 React.memo 拦截,因为 memo 只比较 props,而 context 值不是 props
  • value 写成对象字面量 value={{ state, dispatch }} 会导致每次 Provider 渲染都产生新引用
  • 消费者只用到 value 中的一个字段,其他字段变化也会连累它重新渲染

解决方案:

  1. 按变化频率拆分 Context: state 与 dispatch 分开,高频数据与低频数据分开,不同业务域分开
  2. Provider 的 value 用 useMemo 缓存
  3. 把 children 通过 props 传入 Provider 组件,这样 Provider 自身渲染时 children 的 element 引用不变,不会跟着重建
  4. 消费组件内部用 React.memo 包裹纯展示子组件,把重渲染范围限制在最小
  5. 数据量大、更新频繁时改用带选择器订阅的状态库(Redux + useSelector、Zustand、Jotai、Valtio),它们基于发布订阅,只通知真正依赖该切片的组件

结论: Context 的定位是依赖注入而非状态管理器,适合主题、语言、当前用户、路由等低频全局数据,不适合承载高频更新的业务状态。

const StateCtx = createContext(null);
const DispatchCtx = createContext(null);

function reducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, count: state.count + 1 };
default:
throw new Error(`未知 action: ${action.type}`);
}
}

// children 由外部传入, Provider 重新渲染时不会重建 children
export function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<DispatchCtx.Provider value={dispatch}>
<StateCtx.Provider value={state}>{children}</StateCtx.Provider>
</DispatchCtx.Provider>
);
}

export const useStoreState = () => useContext(StateCtx);
export const useDispatch = () => useContext(DispatchCtx);

Redux 核心思想、中间件与 Redux Toolkit

核心思想是单向数据流: view 派发 action -> reducer 依据(prevState, action)计算出新的 state -> store 通知订阅者 -> view 更新。状态变更的原因和结果都是显式且可追溯的,因此能实现时间旅行调试。

三大原则:

  1. 单一数据源:整个应用的状态存放在一个 store 的对象树中
  2. state 只读:唯一改变 state 的方式是 dispatch 一个 action,不能直接赋值
  3. 使用纯函数修改: reducer 必须是纯函数,相同输入产生相同输出,不产生副作用,返回新对象而不是修改原对象

中间件机制本质是对 dispatch 的层层包装,形成洋葱模型。applyMiddleware 用 compose 把中间件串成一条链,每个中间件拿到 next 并决定是否放行、放行前后做什么。

// 中间件签名: store => next => action => any
const logger = (store) => (next) => (action) => {
console.log('派发前', store.getState(), action);
const result = next(action); // 交给下一个中间件, 链尾是原始 dispatch
console.log('派发后', store.getState());
return result;
};

// 简易 thunk: action 是函数就注入 dispatch/getState 执行, 否则原样放行
const thunk = ({ dispatch, getState }) => (next) => (action) =>
typeof action === 'function' ? action(dispatch, getState) : next(action);

// 手写 applyMiddleware 的核心
function applyMiddleware(...middlewares) {
return (createStore) => (reducer) => {
const store = createStore(reducer);
let dispatch = store.dispatch;
const api = { getState: store.getState, dispatch: (a) => dispatch(a) };
const chain = middlewares.map((mw) => mw(api));
dispatch = chain.reduceRight((next, mw) => mw(next), store.dispatch);
return { ...store, dispatch };
};
}

redux-thunk 与 redux-saga 的区别:

  • thunk 只有几十行代码, action creator 返回一个函数,异步逻辑直接写在其中。上手快、心智负担低;但防抖、竞态取消、多任务编排都要手写,异步逻辑分散在组件和 action 中,测试时需要 mock 大量依赖
  • saga 基于 Generator,用 effect 对象声明式地描述副作用(call、put、take、fork、cancel、takeLatest、race)。effect 只是普通对象,由中间件负责执行,因此 saga 函数本身极易做单元测试;擅长复杂并发控制、任务取消和长流程编排。代价是概念多、包体积大、学习曲线陡

Redux Toolkit (RTK)是官方推荐的默认写法,主要解决模板代码过多的问题:

  • configureStore:默认集成 redux-thunk、Redux DevTools,并在开发环境加入不可变性检查和序列化检查
  • createSlice:一次性生成 action types、action creators 和 reducer,消除手写常量和 switch 的样板
  • 内置 Immer:可以在 reducer 中写 state.value += 1 这样的“可变”语法,底层通过 Proxy 生成不可变的新对象
  • createAsyncThunk:自动派发 pending / fulfilled / rejected 三个 action
  • createSelector 复用 reselect 做记忆化选择器, createEntityAdapter 管理范式化列表, RTK Query 提供请求、缓存、失效与轮询的完整数据层

React 合成事件机制

SyntheticEvent 是 React 对原生事件的跨浏览器包装层,抹平各浏览器差异,提供统一的 API (stopPropagation、preventDefault、target、currentTarget),并通过 nativeEvent 暴露原始事件对象。React 并不把事件直接绑在每个 DOM 元素上,而是统一委托到一个根节点,由自己的合成事件系统模拟捕获和冒泡,从而减少内存占用并支持事件的批处理与优先级调度。

事件委托挂载点的变化:

  • React 16 及以前:所有事件都委托到 document 上
  • React 17 起:改为委托到 createRoot / ReactDOM.render 传入的根容器 DOM 上
  • 变更原因:便于多个 React 版本在同一页面共存和渐进式升级(微前端场景下,旧版本的 document 级监听会与新版本互相干扰);也让 React 应用嵌入非 React 页面时,事件不会意外冒泡到 document 影响宿主逻辑

事件池: React 16 会复用 SyntheticEvent 对象,回调执行完属性即被置空,异步访问需要先调用 e.persist()。React 17 起移除了事件池,可以直接在异步代码中访问事件属性。

与原生事件混用的注意点:

  1. 执行顺序:原生事件先在真实 DOM 上按捕获-目标-冒泡传播,冒泡到根容器后 React 才分发合成事件。因此子元素上直接 addEventListener 绑定的冒泡阶段监听,会早于父级 React 事件回调执行
  2. 在原生监听中调用 e.stopPropagation(),事件到不了根容器,对应的 React 合成事件完全不会触发;反过来在 React 事件中 stopPropagation 只能阻止 React 体系内的传播,阻止不了已经执行过的原生监听,也阻止不了绑定在 document 上的原生监听
  3. 阻止默认行为必须显式调用 e.preventDefault(),在 React 事件中 return false 无效
  4. 部分事件不走委托, React 直接绑定在元素上,例如 scroll、媒体元素事件。React 17 起 onScroll 不再冒泡,与原生行为保持一致
  5. onChange 是 React 的语义化封装,底层基于原生 input 事件,输入过程中实时触发,而原生 change 是失焦时才触发
  6. 文档级或 window 级监听建议在 useEffect 中添加并在清理函数中移除;需要滚动性能时给原生监听加 { passive: true }
function Demo() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
const onNative = (e) => {
console.log('原生监听先执行');
// e.stopPropagation(); // 打开后 React 的 onClick 将完全不触发
};
el.addEventListener('click', onNative);
return () => el.removeEventListener('click', onNative);
}, []);

return <button ref={ref} onClick={() => console.log('React 合成事件后执行')}>点击</button>;
}

HOC、render props、Hooks 的对比与演进

三者本质上解决的是同一个问题:复用与 UI 无关的状态逻辑。

HOC (高阶组件):接收一个组件返回一个新组件的函数,通过 props 把能力注入进去,典型如 connect、withRouter。

  • 优点:可以包裹渲染、拦截 props、注入 Provider,适合横切关注点
  • 问题:组件层级嵌套过深, DevTools 中出现一长串包装层; props 来源不透明,多个 HOC 注入同名 props 会静默覆盖;静态方法需要 hoist-non-react-statics 手动拷贝; ref 需要 forwardRef 手动转发; TypeScript 类型推导写起来相当繁琐

render props:把渲染逻辑作为函数类型的 prop (通常是 children 或 render)传入,由使用方决定渲染什么。

  • 优点:数据来源清晰可见,不存在命名冲突,组合灵活
  • 问题:多个 render props 嵌套会形成 JSX 金字塔式的回调地狱;每次渲染都创建新的内联函数,不利于 shouldComponentUpdate 和 PureComponent 优化

Hooks:

  • 完全扁平,不产生额外组件层级;数据来源明确(谁调用谁声明变量);组合多个逻辑只是多写几行,不会嵌套; TypeScript 类型推导天然友好
  • 限制:只能在函数组件中使用;有调用顺序规则和 ESLint 依赖检查;不能复用 UI 结构;无法实现错误边界和 getSnapshotBeforeUpdate

演进结论:状态逻辑复用优先用 Hooks;需要包裹或劫持渲染时(权限拦截、埋点容器、注入 Provider、异常降级) HOC 依然合适;需要让调用方决定渲染内容时用 render props 或“children 作为函数”。三者不是互斥的,现代库常见的做法是内部用 Hooks 实现,同时导出一个 HOC 或组件形态兼容类组件用户。

// 同一份逻辑的三种封装形态
const withMouse = (Comp) => (props) => { // HOC
const pos = useMousePosition();
return <Comp {...props} mouse={pos} />;
};

const Mouse = ({ children }) => children(useMousePosition()); // render props
// 用法: <Mouse>{(pos) => <span>{pos.x}</span>}</Mouse>

function Demo() { // Hooks
const pos = useMousePosition();
return <span>{pos.x}</span>;
}

Error Boundary 能捕获什么

错误边界是实现了 getDerivedStateFromError 或 componentDidCatch 的类组件,用于捕获其子组件树在渲染过程中抛出的 JS 错误,记录日志并展示降级 UI。从 React 16 开始,未被捕获的渲染错误会导致整棵组件树被卸载(白屏),因此错误边界是生产应用的必备设施。

能捕获:

  • 子组件 render 阶段抛出的错误
  • 子组件生命周期方法中的错误
  • 子组件构造函数中的错误
  • 子树中函数组件渲染期间 throw 的错误

不能捕获:

  • 事件处理函数中的错误(事件回调不在渲染流程中,用 try/catch 处理)
  • 异步代码中的错误(setTimeout、Promise 回调、requestAnimationFrame)
  • 服务端渲染过程中的错误
  • 错误边界自身抛出的错误(需要更上层再包一个边界)

两个钩子的分工: getDerivedStateFromError 是静态方法,在 render 阶段调用,只能返回新的 state 用于渲染降级 UI,不能有副作用(render 阶段可能被重复执行); componentDidCatch 在 commit 阶段调用,可以安全地做日志上报,第二个参数 info.componentStack 给出组件调用栈。

函数组件目前没有等价的 Hook,必须写类组件,或使用社区的 react-error-boundary (它还提供 resetErrorBoundary 重试能力和 useErrorBoundary Hook)。函数组件自身处理错误的方式:事件回调用 try/catch;异步用 .catch();需要把异步错误交给边界时,在 catch 中把 error 存进 state,然后在渲染期间抛出它。

实践建议:分层布置边界,路由级边界防整页白屏,组件级边界隔离图表、富文本、第三方组件这类高风险区域;降级 UI 提供重试入口;配合 window.onerror 和 unhandledrejection 兜底非渲染错误。

class ErrorBoundary extends React.Component {
state = { error: null };

static getDerivedStateFromError(error) {
return { error }; // render 阶段: 只更新 state 用于渲染降级 UI
}

componentDidCatch(error, info) {
report(error, info.componentStack); // commit 阶段: 副作用放这里
}

render() {
return this.state.error
? <Fallback onRetry={() => this.setState({ error: null })} />
: this.props.children;
}
}

// 函数组件把异步错误抛给边界
function Submit() {
const [error, setError] = useState(null);
if (error) throw error; // 必须在渲染期间抛出才能被边界捕获
return <button onClick={() => submit().catch(setError)}>提交</button>;
}

React 性能优化清单

前提:先用 React DevTools Profiler 录制并定位真正的瓶颈组件,再配合浏览器 Performance 面板看主线程占用。凭感觉优化往往只增加复杂度。

渲染优化:

  • React.memo + useCallback / useMemo 切断不必要的重渲染,三者必须配套使用,漏一个就全部失效
  • 状态下沉:把只被局部使用的 state 移到最小的子组件中,避免顶层 state 变化触发全树渲染
  • 内容提升:把不依赖变化状态的 JSX 通过 children 传入, children 的 element 引用不变就不会重新渲染
  • 拆分 Context, Provider 的 value 用 useMemo 缓存
  • 避免在 JSX 中内联创建对象、数组、箭头函数作为 props 传给 memo 组件
  • 避免在渲染期间做重复的派生计算,派生数据不要存进 state

列表优化:

  • key 使用数据本身稳定唯一的 id,不用 index
  • 长列表使用虚拟滚动(react-window、TanStack Virtual)只渲染可视区域
  • 分页或无限滚动,配合 IntersectionObserver 触发加载
  • 高频输入用防抖节流,或用 useDeferredValue / useTransition 把过滤渲染降为低优先级

代码分割与加载:

  • React.lazy + Suspense 做路由级和重组件级懒加载
  • 在 hover、视口临近时提前发起 import 做预加载,避免点击后才开始下载
  • 第三方大库按需引入或替换为轻量方案,用 bundle 分析工具找出体积大户
  • 图片懒加载、骨架屏、首屏关键资源 preload

数据与状态:

  • 使用不可变数据,更新时创建新引用,浅比较才能生效;但不要滥用深拷贝
  • 请求做缓存、去重与竞态处理(SWR、TanStack Query)
  • 外部 store 通过 useSyncExternalStore 订阅,保证并发渲染下的一致性

其他:

  • useLayoutEffect 会阻塞绘制,只在需要同步测量或修改 DOM 避免闪烁时使用
  • StrictMode 的双调用只发生在开发环境,不要据此判断生产性能
  • 确认使用的是生产构建,开发构建包含大量额外检查和警告代码
  • 长任务拆分,非紧急计算放到 Web Worker 或 requestIdleCallback

CSR/SSR/SSG/ISR 与 hydration

四种渲染模式的区别在于 HTML 在哪里生成、什么时候生成:

  • CSR:服务端只返回空的 HTML 壳和 JS bundle,浏览器下载并执行 JS 后才渲染出内容。首屏白屏时间长,对 SEO 不友好;但服务器成本低,首屏之后的路由切换无需请求页面,交互体验流畅
  • SSR:每次请求由服务器执行 React 代码生成完整 HTML 返回(renderToString 或 React 18 的 renderToPipeableStream 流式输出)。首屏内容可直接被爬虫读取, FCP 快;代价是服务器有计算压力, TTFB 受后端接口耗时影响,且需要维护 Node 运行时
  • SSG:构建时预先生成静态 HTML,部署到 CDN。性能和成本都最优,但内容更新必须重新构建部署,适合文档、博客、营销落地页
  • ISR: SSG 的增量再生成,页面按 revalidate 设定的时间过期后,由服务端在后台静默重新生成并替换。兼顾静态页的性能与内容的新鲜度,是 Next.js 提供的能力而非 React 本身

hydration 水合的原理:

  • 服务端产出的 HTML 只有结构和样式,没有绑定任何事件。客户端调用 hydrateRoot 时不会重新创建 DOM,而是复用已有节点,构建 Fiber 树并把它与真实 DOM 关联起来,然后挂载事件
  • 前提是服务端与客户端的首次渲染结果必须一致,否则报 hydration mismatch。常见原因:使用 Date.now()、Math.random()、直接读取 window/localStorage、服务端与客户端时区或语言不同、浏览器插件改动了 DOM、HTML 标签嵌套不合法被浏览器自动修正
  • 规避方式:仅客户端才有的内容放到 useEffect 之后再渲染;时间格式化统一时区;确实无法一致的节点加 suppressHydrationWarning
  • 水合完成前页面处于“可见但不可交互”的状态,这段空窗直接影响 TTI。React 18 的选择性水合(Selective Hydration)允许每个 Suspense 边界独立水合,并优先水合用户正在交互的区域,缓解了必须整页水合完才能响应的问题

React Server Components (RSC)简介:

  • 组件在服务端运行并渲染,产出的不是 HTML 字符串,而是一段可序列化的组件描述。客户端接收后把它合并进现有的 React 树,因此更新时不会丢失客户端组件的状态
  • 服务端组件的代码不会打进客户端 bundle,可以直接访问数据库、文件系统和私有环境变量,减小首屏体积并省去一层接口
  • 服务端组件不能使用 state、effect、浏览器事件和只在浏览器存在的 API;需要交互的部分用 ‘use client’ 指令标记为客户端组件,两者可以互相嵌套组合(服务端组件可以作为 children 传给客户端组件)
  • RSC 与 SSR 是两件事: SSR 解决首屏 HTML, RSC 解决组件的执行位置与打包体积。二者通常配合使用
  • RSC 依赖构建工具和路由的深度配合,目前需要 Next.js App Router 这类框架支持, React 本身无法独立启用
// React 18 客户端入口
import { createRoot, hydrateRoot } from 'react-dom/client';

if (window.__SSR__) {
hydrateRoot(document.getElementById('root'), <App />); // SSR/SSG 页面: 复用已有 DOM
} else {
createRoot(document.getElementById('root')).render(<App />); // 纯 CSR
}

// 避免 hydration mismatch: 仅客户端可用的信息延后到 effect 之后渲染
function ClientOnlyTime() {
const [time, setTime] = useState(null);
useEffect(() => setTime(new Date().toLocaleString()), []);
return <span>{time ?? '--'}</span>;
}

npm 与前端工程化

npm包有哪些license,哪些允许商用

  1. MIT License
  2. Apache License 2.0
  3. BSD License (2-clause or 3-clause)
  4. GPL (General Public License)
  5. LGPL (Lesser General Public License)
  6. MPL (Mozilla Public License)
  7. ISC License
  8. Unlicense
  9. Creative Commons (CC0)

其中,允许商用的许可证包括:

  1. MIT License:允许几乎任何用途,包括商用。
  2. Apache License 2.0:允许商用,要求附带版权声明和免责条款。
  3. BSD License:允许商用,要求附带版权声明和免责条款。
  4. ISC License:类似于BSD许可证,允许商用。
  5. MPL (Mozilla Public License):允许商用,但对分发源代码有一定的要求。
  6. Unlicense:放弃版权,允许任何用途,包括商用。
  7. Creative Commons (CC0):类似于公有领域,允许任何用途,包括商用。

需要注意的是,GPL和LGPL虽然也是开源许可证,但对商业用途有严格的规定。GPL要求衍生作品也必须遵循GPL许可证(即所谓的“传染性”),这对于商业软件可能不太适用。LGPL则允许在商业软件中使用,但如果修改了LGPL库,修改后的部分仍需要开源。

package.json 核心字段详解

package.json 是包的清单文件,同时被三类角色消费:包管理器(安装与发布)、Node 运行时(模块解析)、构建工具与编辑器(打包与类型提示)。字段写错通常不会报错,只会表现为“打包体积异常”“类型丢失”“ESM 下引入报错”这类难排查的问题。

标识与入口:

  • name:包名,只能小写,不能有空格,长度不超过 214。带 scope 时写成 @scope/name
  • version:必须符合 semver, name + version 唯一确定 registry 上的一个包
  • main: CommonJS 入口,未指定时默认 index.js,是最老也是兼容性最好的字段
  • module:非官方约定字段,由打包器识别,指向 ESM 产物,目的是让 tree shaking 生效
  • exports: Node 12.7+ 的官方方案,优先级高于 main/module。它同时做两件事:按条件(import/require/types/node/browser/default)分发不同产物;封装内部结构,未在 exports 中声明的路径无法被深层引入
  • types / typings: TypeScript 声明文件入口。在 exports 中 types 条件必须写在最前面,否则部分解析器读不到

发布与运行约束:

  • bin:声明命令行入口,安装后被链接到 node_modules/.bin (全局安装则链到 PATH)。对应脚本文件首行需要 #!/usr/bin/env node
  • files:白名单,声明哪些文件会被打进 tarball。它比 .npmignore 更可控。package.json、README、LICENSE 总是包含, node_modules、.git 总是排除
  • engines:声明 node/npm 版本要求。默认只产生警告,只有在 .npmrc 中设置 engine-strict=true 才会安装失败
  • sideEffects:告诉打包器哪些文件有副作用。设为 false 表示整个包无副作用,未被引用的模块可以安全删除;有全局样式或 polyfill 时要写成数组保留它们,否则样式会被摇没
  • scripts:命令别名,执行时 node_modules/.bin 会被自动加入 PATH,因此可以直接写 tsc 而不必写完整路径
  • 其他常用: type (“module” 表示 .js 按 ESM 解析)、private (设为 true 可防止误发布)、workspaces、publishConfig、peerDependenciesMeta
{
"name": "@scope/utils",
"version": "1.2.3",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./styles.css": "./dist/styles.css",
"./package.json": "./package.json"
},
"bin": { "my-cli": "./bin/cli.js" },
"files": ["dist"],
"engines": { "node": ">=18" },
"sideEffects": ["*.css"],
"scripts": { "build": "tsup src/index.ts --format cjs,esm --dts" }
}

四种依赖的区别与使用场景

四类依赖的本质区别是“谁在什么时候需要它”,直接决定了使用者安装你的包时会连带装下什么。

dependencies:

  • 生产依赖,运行时真正被 require/import 的包
  • 使用者安装你的包时会被自动一并安装
  • npm install xxx 默认写入此处
  • 典型: react、axios、lodash-es、dayjs

devDependencies:

  • 只在开发和构建阶段需要,不进入运行时
  • 使用者安装你的包时不会被安装,但在本仓库执行 npm install 会装
  • npm install xxx -D 写入此处; npm install –omit=dev 可跳过安装
  • 典型: typescript、vite、eslint、vitest、@types/*
  • 常见误区:应用项目(不发布到 registry,自己打包部署)中两者的界限意义不大,但组件库、工具库必须严格区分,否则会让使用者装进一堆无用的构建工具

peerDependencies:

  • 声明“宿主环境必须提供的包”,表达的是兼容性约束而非安装指令
  • 用于插件类包:它必须和宿主共用同一个实例,装两份就会出问题(React 装两份会直接报 Invalid hook call, ESLint 插件装两份则规则失效)
  • npm 3-6 不自动安装,只警告; npm 7 起默认自动安装并在版本冲突时报 ERESOLVE 错误,需要用 –legacy-peer-deps 或 –force 绕过
  • 通常配合 devDependencies 一起写: peer 声明兼容范围供使用者校验, dev 装一个具体版本供本地开发和测试
  • peerDependenciesMeta 中标记 optional: true 可以把某个 peer 变成可选,缺失时不报警

optionalDependencies:

  • 安装失败也不会中断整个安装流程,代码中必须自行处理它不存在的情况
  • 典型场景是平台相关的原生模块(例如按 CPU 架构分发的二进制包、fsevents 这类只在 macOS 生效的包)
  • 与 dependencies 同名时 optionalDependencies 优先生效
{
"peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" },
"peerDependenciesMeta": { "react-dom": { "optional": true } },
"devDependencies": { "react": "^18.2.0", "typescript": "^5.4.0" },
"optionalDependencies": { "fsevents": "^2.3.3" }
}

语义化版本 semver 与范围规则

版本号格式是 主版本号.次版本号.修订号(major.minor.patch),递增规则是:不兼容的 API 变更升 major,向下兼容的新功能升 minor,向下兼容的问题修复升 patch。它是一份社区契约而非强制机制,因此仍需锁文件兜底。

预发布与构建元数据: 1.0.0-beta.2 中横线后是预发布标识,优先级低于 1.0.0;加号后的 +build.1 是构建元数据,不参与版本比较。默认的范围匹配不会命中预发布版本,除非范围本身也带预发布标识。

范围规则:

  • 插入号 ^ 允许“不改变最左侧非零数字”的更新,这是 npm install 的默认写法
  • 波浪号 ~ 通常只允许修订号更新,比 ^ 更保守
  • 星号 * 或空字符串匹配任意版本,等价于 >=0.0.0,生产项目不应使用
  • 0.x 版本被视为不稳定期, ^ 的行为会自动收紧
^1.2.3   # >=1.2.3 <2.0.0   允许 minor 和 patch
^0.2.3 # >=0.2.3 <0.3.0 0.x 时只允许 patch
^0.0.3 # >=0.0.3 <0.0.4 等同于精确锁定
~1.2.3 # >=1.2.3 <1.3.0 只允许 patch
~1.2 # >=1.2.0 <1.3.0
~1 # >=1.0.0 <2.0.0 只写 major 时等价于 1.x
1.2.x # >=1.2.0 <1.3.0
* # 任意版本
1.2.3 # 精确版本
>=1.2.3 <2.0.0 # 显式区间
1.2.3 - 1.5.0 # 闭区间
^1.0.0 || ^2.0.0 # 或

除版本号外还可以写 npm 别名(npm:pkg@1.0.0)、git 地址(user/repo#semver:^2.0.0)、本地路径(file:../pkg)、workspace 协议(workspace:*, 由 pnpm/yarn 支持)。

如何锁定版本:

  1. 提交锁文件,并在 CI 中用 npm ci 严格按锁文件安装,这是最主要的手段
  2. 需要写死语义时用精确版本: npm i lodash -E,或在 .npmrc 中设置 save-exact=true 让所有安装默认精确
  3. 用 overrides (npm) / resolutions (yarn) / pnpm.overrides 强制统一间接依赖的版本
  4. 关键依赖可以配合 npm shrinkwrap 把锁文件一并发布(仅适用于应用和 CLI,库不应这么做)
  5. 定期用 npm outdated 和自动化工具(Renovate、Dependabot)有节奏地升级,而不是长期不动

package-lock.json 与 npm ci

package.json 里写的是范围,锁文件记录的是这次安装实际落地的确定结果。它的作用是:

  • 锁定整棵依赖树(包括所有间接依赖)的确切版本、下载地址和完整性校验值 integrity,保证任何人任何机器任何时间装出的 node_modules 完全一致
  • 记录依赖树的物理结构,避免重新解析带来的差异,大幅加快后续安装
  • 作为依赖变更的 diff 载体, code review 时能看清楚一次升级到底引入了什么
  • 提供供应链校验: integrity 对不上会直接失败,防止包内容被篡改

锁文件必须提交到版本库。lockfileVersion 也需要留意: npm 6 产出 v1, npm 7 产出 v2 (同时保留兼容 v1 的字段), npm 9 起默认 v3,团队 npm 大版本不一致会导致锁文件反复大幅变动,应在 CI 与本地统一 Node/npm 版本。

npm install 与 npm ci 的区别:

  • install 会按 package.json 的范围重新求解,必要时更新锁文件; ci 完全以锁文件为准,且绝不写回锁文件
  • ci 要求锁文件必须存在,且与 package.json 一致,不一致直接报错退出(这正是它的价值:提前暴露“改了 package.json 忘记提交锁文件”)
  • ci 会先删除整个 node_modules 再全量安装,结果干净可复现; install 是增量的
  • ci 不能安装单个包, npm ci lodash 是非法用法
  • ci 通常更快,因为跳过了版本求解和逐个包的存在性检查
  • 结论:本地开发和新增依赖用 install, CI/CD 和构建镜像里一律用 ci

锁文件冲突的处理原则是不要手工合并那一大段 JSON,极容易产生一棵不自洽的依赖树:

# 方式一: npm 7+ 内置了锁文件冲突的自动处理
git checkout --theirs package-lock.json # 或 --ours, 先随便留一边
npm install # 由 npm 重新求解并生成正确的锁文件

# 方式二: 通用做法, 先手工解决 package.json 的冲突, 再重建锁文件
git checkout --ours package.json && vim package.json
rm -rf node_modules package-lock.json && npm install

# 提交前确认锁文件确实是干净重建的
git add package.json package-lock.json && npm ci && npm test

预防手段:尽量把依赖变更单独成一个 PR 并及时合入;团队约定统一的包管理器和版本(用 packageManager 字段 + corepack 固化);不要在同一个仓库里混用 npm 和 yarn,两份锁文件必然互相打架。

扁平化带来的幽灵依赖与依赖分身

npm 2 时代 node_modules 是严格的嵌套树,每个包把自己的依赖装进自己的 node_modules 里。问题是路径过深(Windows 曾因路径长度限制直接安装失败)且同一个包被重复安装无数份。npm 3 起改为扁平化(hoisting):尽可能把依赖提升到顶层 node_modules,只有版本冲突时才降级嵌套安装。

Node 的模块解析沿路径逐级向上查找 node_modules,因此提升到顶层的包可以被任何代码 require 到。这带来两个经典问题。

幽灵依赖(Phantom Dependency):

  • 定义:代码里 import 了一个没有写进自己 package.json 的包,只是因为它被某个间接依赖提升到了顶层才碰巧能用
  • 危害:上游依赖某天不再依赖它,或换了个大版本,你的代码就在没有任何改动的情况下崩掉;版本完全不受你控制;换用 pnpm 或改变安装顺序后立刻暴露
  • 排查:用 depcheck、eslint-plugin-import 的 no-extraneous-dependencies 规则做静态检查;用 pnpm 安装(它的 node_modules 默认不扁平,未声明的依赖根本找不到)

依赖分身(NPM Doppelgangers):

  • 定义:同一个包因为版本范围不兼容,在依赖树的不同位置被安装了多份
  • 危害:磁盘和安装时间浪费;打包产物中出现重复代码,体积膨胀;更严重的是实例不唯一带来的运行时错误,例如两份 React 导致 hooks 报错,两份 Vue 导致响应式系统失效, instanceof 判断失败,单例状态各存一份
  • 缓解:把这类包声明为 peerDependencies 交给宿主统一提供;用 overrides/resolutions 强制收敛到同一版本;用打包器的 alias 或 dedupe 配置(Vite 的 resolve.dedupe、webpack 的 resolve.alias)指向同一物理路径

扁平化还有一个副作用: node_modules 的最终结构受安装顺序影响,同样的 package.json 在不同时间安装可能得到不同的提升结果,这也是必须依赖锁文件的原因之一。

npm ls react                 # 查看 react 被装了几份, 分别在依赖树的什么位置
npm ls --depth=0 # 只看顶层
npm explain lodash # 解释某个包是被谁引入的 (npm 7+)
npm dedupe # 尝试重新提升以消除可去重的分身
npx depcheck # 找出未声明却被使用的包和已声明却没用的包

npm、yarn、pnpm 的区别

三者解决同一件事,差异集中在 node_modules 的组织方式与磁盘策略上。

npm:

  • Node 自带,生态兼容性最好,无需额外安装
  • 扁平化 node_modules,锁文件是 package-lock.json
  • npm 5 引入锁文件, npm 7 引入 workspaces 与 peer 依赖自动安装, npm 8.3 引入 overrides
  • 缺点:扁平化带来幽灵依赖,多项目之间不共享磁盘空间,大仓安装偏慢

yarn:

  • Yarn 1 (Classic)已进入维护状态,锁文件 yarn.lock 是自定义格式, node_modules 同样扁平化,主要优势(并行下载、离线缓存、锁文件)现已被 npm 追平
  • Yarn 2+ (Berry)引入 Plug’n’Play:不再生成 node_modules,而是用 .pnp.cjs 记录每个包的物理位置并接管 Node 的解析逻辑,依赖以 zip 形式存放。安装极快且彻底杜绝幽灵依赖,代价是部分工具链不兼容,常需要 nodeLinker: node-modules 回退
  • resolutions 字段可强制覆盖间接依赖版本

pnpm:

  • 内容寻址存储(content-addressable store):全局 store (默认 ~/.pnpm-store)以文件内容的 hash 为键存放,相同内容的文件全局只存一份。同一个包的不同版本之间只有变化的文件是新增的
  • 硬链接 + 符号链接:项目中的 node_modules/.pnpm 目录下放置每个包的真实副本(通过硬链接指向全局 store,不占额外磁盘),而 node_modules 根目录只为直接依赖创建符号链接
  • 非扁平结构从根本上消除幽灵依赖:没有写进 package.json 的包在 node_modules 根目录下压根不存在
  • 依赖树用平铺的 .pnpm 目录表达,既避免了深层嵌套,又保留了严格的依赖关系
  • 磁盘占用和安装速度通常是三者中最优的,对 monorepo 支持最好(内置 workspaces 与强大的 –filter)
  • 注意点:符号链接会让部分依赖真实路径的工具出错,可用 node-linker=hoisted 或 public-hoist-pattern 局部妥协; peer 依赖的处理比 npm 更严格

选型建议:新项目尤其是 monorepo 优先 pnpm;强调零配置和最大兼容性用 npm;已有 Yarn 生态的项目继续用 Yarn Berry。团队内务必统一,并用 packageManager 字段配合 corepack 固化版本。

corepack enable                      # Node 16.9+ 自带, 按 packageManager 字段自动切换
# package.json 中写 "packageManager": "pnpm@9.1.0"

pnpm store path && pnpm store prune # 查看与清理全局 store
pnpm why react # 追溯某个包被谁引入
pnpm import # 从 package-lock.json 迁移生成 pnpm-lock.yaml

npm scripts 生命周期、npx 与跨平台写法

npm scripts 执行时会把 node_modules/.bin 临时加入 PATH,所以能直接写 tsc、eslint 这类本地安装的命令。除此之外还有一套隐式的钩子机制。

pre/post 钩子:

  • 执行 npm run build 时, npm 会自动按 prebuild -> build -> postbuild 的顺序执行,无需手动串联。这对任意自定义脚本名都成立
  • 内置生命周期钩子有各自的触发时机,常用的几个: prepare 在 npm install (不带包名)、npm pack、npm publish 之前执行,也会在包作为 git 依赖被安装时执行,是安装 husky 和编译库产物的标准位置; prepublishOnly 只在 publish 前执行,适合放测试和构建; prepack/postpack 围绕打包; preversion/version/postversion 围绕 npm version
  • prepublish 语义混乱已被废弃,不要再用
  • 钩子中任一命令退出码非 0 会中断整条链路

npx 的作用与原理:

  • npm 5.2 起自带, npm 7 起是 npm exec 的别名
  • 查找顺序:当前项目的 node_modules/.bin -> PATH -> 都找不到时下载到 npx 缓存中临时执行(npm 7 起会先交互确认)
  • 价值在于免全局安装地执行一次性命令(脚手架、代码迁移工具),且执行的一定是当前项目锁定的版本,避免全局版本与项目版本不一致
  • 可以用 npx pkg@1.2.3 指定版本,用 -p 指定包名与命令名不同的情况,用 –no-install 强制只用本地已有的

跨平台注意点:

  • Windows 的 cmd 不支持 NODE_ENV=production cmd 这种前置赋值写法,用 cross-env 抹平
  • rm -rf、cp、mkdir -p 等 Unix 命令在 Windows 上不存在,用 rimraf、cpx、make-dir-cli 或直接写 node 脚本替代
  • 路径分隔符不要硬编码,在 node 脚本里用 path.join
  • 串行用 &&,并行不要用 & (Windows 行为不同),改用 npm-run-all 的 run-p 或 concurrently
  • 单引号在 cmd 中不会被当作引号,参数里尽量用双引号
  • 给脚本传参要加两个横线: npm run test – –watch
  • npm 会注入 npm_package_name、npm_package_version 等环境变量,以及 npm_config_* 形式的配置项; npm 7 起暴露的 package.json 字段有所收缩,不要依赖深层嵌套字段
  • 复杂逻辑不要堆在 scripts 字符串里,写成独立的 node 脚本,可读可调试可测试
{
"scripts": {
"prebuild": "rimraf dist",
"build": "cross-env NODE_ENV=production vite build",
"postbuild": "node scripts/report-size.mjs",
"dev": "run-p dev:*",
"test": "vitest run",
"prepublishOnly": "npm run test && npm run build",
"prepare": "husky"
}
}

如何发布一个 npm 包

发布前先确认产物清单是否正确,这是最容易出错也最容易验证的一步: npm pack –dry-run 会列出即将进入 tarball 的每个文件。多数“包能装但引不到”的问题都是 files 漏配或 exports 写错。

版本与标签:

  • npm version patch|minor|major|prerelease 会修改 package.json 的 version、生成一次 git commit 并打上 v1.2.3 形式的 tag,同时触发 preversion/version/postversion 钩子。手工改版本号容易漏 tag,应优先用它
  • dist-tag 是指向具体版本的可读别名。npm publish 默认打上 latest,用户 npm i pkg 装到的就是 latest 指向的版本
  • 发布预览版时必须加 –tag,例如 npm publish –tag beta,否则会把不稳定版本推给所有用户。用户通过 npm i pkg@beta 安装
  • 正式版就绪后用 npm dist-tag add 切换 latest 指针

scope 与私有源:

  • @scope/name 形式的包默认是私有的,发布公开包必须显式加 –access public 或在 publishConfig 中声明
  • 企业内部源通过 .npmrc 配置:全局 registry 指向私服镜像,再针对特定 scope 单独指定源和鉴权 token
  • scope + 明确的 registry 也是防范依赖混淆攻击(攻击者在公共源抢注同名内部包)的关键手段
  • CI 中用 NPM_TOKEN 环境变量注入凭据,不要把 token 提交进仓库

文件过滤与双端产物:

  • 优先用 package.json 的 files 白名单,语义更清晰。同时存在时 files 优先于 .npmignore;没有 .npmignore 时 npm 会退而使用 .gitignore,这常常导致 dist 被意外排除
  • 现代库通常同时提供 CJS 与 ESM 两份产物,通过 exports 的 require/import 条件分发,并各自附带类型声明
  • 需要警惕双包风险(dual package hazard):同一个包的 CJS 和 ESM 两份实例被同时加载时,模块级状态和 instanceof 判断会失效。规避方式是保持包本身无状态,或让其中一种格式只是薄壳
  • 发布前可用 publint 和 @arethetypeswrong/cli 校验 exports 与类型入口是否自洽
npm pack --dry-run                          # 检查产物清单, 发布前必做
npm version minor -m "chore: release v%s" # 改版本 + commit + tag
git push --follow-tags

npm publish --access public # scope 包发布为公开
npm publish --tag beta # 预览版, 不动 latest
npm dist-tag add @scope/utils@1.3.0 latest # 稳定后再切 latest
npm dist-tag ls @scope/utils

npm unpublish @scope/utils@1.3.0 # 仅在发布 72 小时内允许
npm deprecate @scope/utils@"<1.0.0" "请升级到 1.x"

# .npmrc 中按 scope 配置私有源
# @company:registry=https://npm.company.com/
# //npm.company.com/:_authToken=${NPM_TOKEN}

Monorepo 方案与适用场景

Monorepo 指用一个仓库管理多个相互关联的包。相对于 multi-repo,它的收益是:跨包改动可以在一个 PR 中原子完成;依赖本地直接链接,不需要发版才能联调;工具链、规范、CI 配置统一;代码复用与重构成本大幅降低。代价是仓库体积增长、CI 需要精细的增量策略、权限粒度变粗。

包管理器自带的 workspaces 负责最基础的两件事:把子包之间的依赖用符号链接指到本地目录,以及把所有子包的外部依赖统一安装和提升。

  • npm workspaces (npm 7+):在根 package.json 的 workspaces 字段写 glob,单锁文件,零额外依赖
  • yarn workspaces:生态成熟, Berry 版本配合 constraints 可以约束子包的依赖一致性
  • pnpm workspaces:需要单独的 pnpm-workspace.yaml,在 packages 字段下列出 packages/* 之类的 glob。配合 workspace:* 协议引用本地包,发布时会自动替换成真实版本号。–filter 的能力最强,支持按包名、按目录、按 git 变更范围筛选

任务编排与发布工具:

  • Lerna:早期事实标准,提供批量执行、版本管理和发布。现由 Nx 团队维护,新版本已把任务调度委托给 Nx,依赖安装则交给包管理器的 workspaces
  • Turborepo:专注任务编排,核心是任务依赖图(turbo.json 中声明 build 依赖上游的 ^build)、内容哈希驱动的本地与远程缓存、以及只跑受影响包的增量执行。配置简单,接入成本低
  • Nx:能力更全面,除缓存与依赖图外还提供代码生成器、模块边界约束、项目图可视化,适合大型多技术栈仓库
  • Changesets:专注版本与变更日志,开发者提交时写一份 changeset 描述变更类型,发布时自动计算各包版本、生成 CHANGELOG 并批量 publish,与上述任一方案都能配合

适用场景判断:

  • 适合:组件库与配套文档站、多端共享核心逻辑(web/小程序/Node SDK)、微前端的多个子应用、需要频繁跨包联动的产品线
  • 不适合:包之间完全没有共享代码、团队之间需要强隔离的权限边界、单个包体量已经极大且构建耗时长
# npm workspaces: 根 package.json 中 "workspaces": ["packages/*"]
npm i lodash -w @scope/utils # 给指定子包装依赖
npm run build -w @scope/utils # 在指定子包执行脚本
npm run build --workspaces --if-present # 所有子包都跑一遍

# pnpm
pnpm add lodash --filter @scope/utils
pnpm -r --parallel run dev # 递归并行
pnpm --filter "...[origin/main]" run test # 只测受本次改动影响的包及其下游

# turborepo
turbo run build --filter=web... # 构建 web 及其依赖, 命中缓存则直接复用

依赖安全与体积治理

安全和体积是两条不同的线,但都指向同一件事:对进入产物的每一行第三方代码保持可见和可控。

安全治理:

  • npm audit 基于公开漏洞库扫描依赖树。npm audit –omit=dev 只看生产依赖,避免被构建工具的低危告警淹没
  • npm audit fix 只在 semver 范围内升级;加 –force 会做破坏性升级,必须回归测试后才能合入
  • 间接依赖有漏洞而上游迟迟不升级时,用 overrides (npm 8.3+) / resolutions (yarn) / pnpm.overrides 强行指定版本,但要确认新版本确实兼容
  • 安装阶段的风险:恶意包常通过 postinstall 脚本执行代码。CI 中可以用 npm ci –ignore-scripts 关闭,再对确实需要构建的包单独放行
  • 供应链防护:严格提交并校验锁文件(integrity 能发现内容篡改)、发布账号开启 2FA、内部包统一加 scope 并绑定私服 registry 防依赖混淆、警惕与知名包仅差一个字母的仿冒包(typosquatting)
  • 引入新依赖前先看维护状态、下载量、最近更新时间、依赖数量和 license 是否允许商用

体积治理:

  • tree shaking 的前提是 ESM 的静态 import/export。CommonJS 是运行时动态求值的,无法被静态分析摇掉。因此库要提供 ESM 产物,应用要在生产模式下打包
  • package.json 的 sideEffects 是关键开关:打包器默认保守地认为每个模块都可能有副作用。正确声明后未使用的模块才会被真正删除,但样式文件和 polyfill 必须列进白名单
  • 按需引入: import _ from ‘lodash’ 会把整个库打进来,换成 lodash-es 配合具名引入,或直接引子路径 lodash/debounce。组件库优先使用官方支持的 ESM 入口,而不是依赖 babel-plugin-import 这类改写方案
  • 替换重量级依赖: moment 换 dayjs 或 date-fns,全量 echarts 换按需注册,大而全的工具库换原生 API
  • 动态 import 做代码分割,把低频路由和重组件推迟到用到时再加载
  • 用 npm ls 和 npm dedupe 消除重复安装,确认 React、Vue 这类必须单实例的包只有一份
npm audit --omit=dev --audit-level=high
npm outdated # 列出可升级的依赖及当前/期望/最新版本
npm explain lodash # 解释某个版本是被谁引入的
npx source-map-explorer dist/**/*.js # 基于 sourcemap 归因每个模块的体积
npx size-limit # 在 CI 中对产物体积设阈值, 超了直接失败
{
"overrides": {
"semver": "^7.5.4",
"some-pkg": { "minimist": "^1.2.8" }
},
"pnpm": { "overrides": { "semver": "^7.5.4" } },
"resolutions": { "semver": "^7.5.4" }
}

Flutter

Flutter原理

Flutter 是一个由 Google 开发的开源框架,用于构建高性能、高保真度的跨平台移动应用。其核心原理如下:

  1. Dart 编程语言:
    • Flutter 使用 Dart 语言作为开发应用的主要语言。Dart 是一种现代化的、面向对象的语言,具有即时编译特性,可以直接编译为本地代码。
  2. Skia 渲染引擎:
    • Flutter 使用 Skia 作为其渲染引擎,Skia 是一个跨平台的2D图形库,由 C++ 编写,能够提供高性能的绘图能力。
  3. Widget 树:
    • Flutter 的核心概念是 Widget。在 Flutter 中,一切都是 Widget,包括布局、样式、动画等。Flutter 使用基于组合的方式构建用户界面,每个 Widget 都是不可变的。
    • Widget 树描述了应用界面的结构,从根部的 Widget 开始,逐级构建出整个应用的 UI。
  4. Hot Reload:
    • Flutter 提供了热重载(Hot Reload)功能,这是一个非常强大的开发工具,可以在保持应用状态的同时快速更新代码和界面,大大加快开发迭代速度。
  5. 自绘:
    • Flutter 不依赖平台的原生控件,而是通过 Skia 直接绘制界面。这种方式使得 Flutter 的 UI 高度定制化,并且能够实现完全一致的跨平台体验。
  6. 平台通道:
    • Flutter 提供了平台通道(Platform Channels),用于在 Dart 代码和原生代码之间进行通信。通过平台通道,Flutter 可以调用平台特定的 API 和功能。
  7. 性能优化:
    • Flutter 通过使用 Skia 渲染引擎和自带的动画库来实现流畅的动画效果和高性能的绘制。它还利用 Dart 的优化能力,如快速的 JIT(即时编译)和 AOT(预编译)技术来提升运行时性能。
      通过这些原理和机制,Flutter 实现了高效、美观且跨平台的移动应用开发,为开发者提供了灵活且强大的工具集来构建现代应用。

StatelessWidget 与 StatefulWidget 的区别,以及 State 的完整生命周期

两者的本质区别是:StatelessWidget 没有可变状态,一旦构建完成,除非父级重新传入新的配置,否则不会自行重建;StatefulWidget 把可变状态外置到一个独立的 State 对象中,State 的生命周期比 Widget 长,Widget 被替换时 State 可以被复用。

  • StatelessWidget:只有 build 一个方法,输入(构造参数)决定输出(子树),适合纯展示型组件。
  • StatefulWidget:Widget 本身依然不可变,createState 创建的 State 对象挂在 Element 上,setState 只标记该 Element 为 dirty。
  • 判断标准:组件内部是否需要在生命周期内保存并主动改变数据。仅靠父级传参就能表达的,一律用 StatelessWidget。

State 的生命周期顺序:

  1. createState:StatefulWidget 被插入树时调用一次。
  2. initState:State 与 Element 关联后调用一次,适合初始化控制器、订阅流。此时不能使用 InheritedWidget 的依赖(如 MediaQuery.of),因为依赖关系尚未建立。
  3. didChangeDependencies:紧跟 initState 之后调用一次;之后每当依赖的 InheritedWidget 发生变化时会再次调用,适合做依赖变更后的数据重取。
  4. build:构建子树,可能被调用非常多次,必须保持纯粹、无副作用。
  5. didUpdateWidget(oldWidget):父级重建且新旧 Widget 可复用同一个 Element 时调用,用于对比 oldWidgetwidget 的差异并同步内部状态。
  6. deactivate:Element 从树中移除时调用,可能随后被重新插入(如 GlobalKey 移动子树)。
  7. dispose:Element 永久移除时调用,必须释放控制器、取消订阅与定时器,否则内存泄漏。
class _CounterState extends State<Counter> {
late final AnimationController _ctrl;

@override
void initState() {
super.initState();
// 只初始化一次,不能在这里访问 InheritedWidget
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 1));
}

@override
void didUpdateWidget(covariant Counter oldWidget) {
super.didUpdateWidget(oldWidget);
// 父级传入的配置变了,同步到内部状态
if (oldWidget.duration != widget.duration) {
_ctrl.duration = widget.duration;
}
}

@override
void dispose() {
_ctrl.dispose(); // 必须释放,否则泄漏
super.dispose();
}

@override
Widget build(BuildContext context) => Text('${widget.count}');
}

Widget、Element、RenderObject 三棵树的关系与 Element 复用机制

Flutter 用三棵树分离了“配置描述”“生命周期与状态管理”“实际布局绘制”三种职责,这是它能在频繁重建下依然保持高性能的核心设计。

  • Widget 树:不可变的配置描述,是轻量的、可随时丢弃重建的“蓝图”。它不参与真正的布局和绘制。
  • Element 树:Widget 的实例化产物,持有 BuildContext(Element 本身就是 BuildContext 的实现),负责维护父子关系、状态(State 挂在 StatefulElement 上)以及决定是否复用。
  • RenderObject 树:真正承担 layout、paint、hitTest 的对象,创建成本高,尽量复用而非重建。

Widget 不可变的原因:

  • 不可变对象可以安全地被缓存、共享和跨帧比较,const 构造的 Widget 甚至可以在编译期常量化,比较时直接判等即可跳过重建。
  • 每帧重建整棵 Widget 树的成本很低(只是分配一些轻量对象),而 Element 与 RenderObject 通过 diff 增量更新,把昂贵操作限制在真正变化的节点上。

Element 复用的判定逻辑集中在 Widget.canUpdate

static bool canUpdate(Widget oldWidget, Widget newWidget) {
return oldWidget.runtimeType == newWidget.runtimeType
&& oldWidget.key == newWidget.key;
}
  • 返回 true:复用旧 Element 与其 RenderObject,仅把新 Widget 赋给 Element 的 widget 字段,并触发 didUpdateWidget
  • 返回 false:卸载旧 Element(走 deactivate/dispose),新建 Element 与 RenderObject,State 随之丢失。
  • Key 的作用:在同级列表中区分身份。同类型 Widget 顺序调换时,若无 key,框架会按位置匹配导致状态错位;加上 ValueKey/ObjectKey 后可按身份匹配。GlobalKey 还能跨树查找并在移动时保留 State,但开销较大,不宜滥用。

setState 的更新流程与缩小重建范围的手段

setState 并不会立刻重建界面,它做的是:执行传入的回调修改状态,然后调用 Element.markNeedsBuild 把当前 Element 标记为 dirty 并加入 BuildOwner 的脏元素列表,最后请求一帧。下一帧的 drawFrame 阶段统一执行 buildScope,按深度从上到下重建脏节点,再进入 layout、paint。

关键结论:setState 的重建粒度等于调用它的那个 State 所在的整棵子树。State 越靠上、子树越大,浪费越多。优化的核心就是把状态下沉、把重建范围收窄。

  • 拆分组件:把会变化的部分抽成独立的小 StatefulWidget,让 setState 只影响这一小块,而不是整个页面。
  • 使用 const 构造:const Widget 在父级重建时是同一个实例,canUpdate 之后 Element 发现 widget 引用未变可直接跳过子树重建,这是零成本的优化。
  • 用 Builder 类组件做局部刷新:ValueListenableBuilderAnimatedBuilderStreamBuilder 只重建 builder 返回的部分,把不变的子树通过 child 参数传入并透传。
// 用 ValueNotifier 替代 setState,重建范围仅限 builder 内部
final counter = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
valueListenable: counter,
// child 只构建一次,不参与重建,通过参数透传给 builder
child: const ExpensiveStaticHeader(),
builder: (context, value, child) {
return Column(children: [child!, Text('计数: $value')]);
},
);
  • 避免在 build 中做耗时计算、创建控制器或发起网络请求,build 可能一帧内被多次调用。
  • 动画场景优先用 AnimatedBuilder 而非在监听器里 setState,前者只重建动画相关节点。

Flutter 状态管理方案对比与选型

状态管理要解决的是两个问题:状态放在哪里(存储与生命周期),以及状态变化后如何精准通知到需要它的 Widget(依赖追踪与重建粒度)。所有方案都是围绕这两点做取舍。

  • setState:内置、零依赖,重建范围是整个 State 子树。适合组件私有状态,跨组件共享时需要层层传参(prop drilling)。
  • InheritedWidget:Flutter 内置的自上而下数据传递机制,dependOnInheritedWidgetOfExactType 会登记依赖,数据变化时只重建依赖它的 Element。它是 Provider 等方案的底层基础,但直接手写样板代码多、不便于处理可变状态。
  • Provider:对 InheritedWidget 的封装,配合 ChangeNotifier 使用,提供 ConsumerSelectorcontext.watch/read。生态成熟、心智负担低,是官方文档长期推荐的入门方案。Selector 可按字段粒度过滤重建。
  • Riverpod:Provider 作者的重构版,不依赖 BuildContext,编译期安全(不会出现 ProviderNotFoundException),支持自动依赖追踪、缓存与销毁(autoDispose)、异步状态(AsyncValue)。适合中大型项目和需要在非 Widget 层访问状态的场景。
  • Bloc/flutter_bloc:基于事件驱动,Event 输入、State 输出,状态流转显式且可测试、可追溯(配合 BlocObserver 记录全链路)。样板代码相对多,适合业务流程复杂、多人协作、需要严格约束的团队。
  • GetX:把状态管理、路由、依赖注入打包在一起,API 极简(.obs + Obx),开发速度快;但侵入性强、隐式全局状态多、脱离 Flutter 官方约定,长期维护和调试成本较高,大型项目需谨慎。

选型建议:

  • 组件内部状态一律 setState,不要过度设计。
  • 小型应用或主题、语言等少量全局配置:ProviderInheritedWidget
  • 中大型应用、强调可测试性与类型安全:Riverpod
  • 业务状态机复杂、需要完整事件追溯:Bloc
  • 团队一致性优先于框架本身的优劣,避免在同一项目中混用多套方案。

平台通道的三种类型与 FFI 简介

平台通道(Platform Channel)是 Dart 侧与原生(iOS 的 Swift/OC、Android 的 Kotlin/Java)通信的标准机制。它是异步的、基于消息的:数据经过 codec 编解码为二进制后跨语言传递,调用必须在平台线程处理 UI 相关逻辑。

  • MethodChannel:一次请求对应一次响应,最常用。适合“调用一个原生方法并拿到结果”,如获取电量、调起相册。使用 StandardMethodCodec,支持基础类型、List、Map。
  • EventChannel:原生侧主动、持续地向 Dart 推送数据流,Dart 侧以 Stream 消费。适合传感器数据、电量变化、网络状态监听等持续事件。
  • BasicMessageChannel:双向的、无方法名概念的裸消息通道,可自定义 codec(StringCodecJSONMessageCodecBinaryCodec)。适合传输大块二进制或自定义协议的场景。
// MethodChannel:调用原生方法
const platform = MethodChannel('com.example/battery');

Future<int> getBatteryLevel() async {
try {
// invokeMethod 是异步的,返回 Future
final int level = await platform.invokeMethod('getBatteryLevel');
return level;
} on PlatformException catch (e) {
// 原生抛出的错误会被包装成 PlatformException
return -1;
}
}

// EventChannel:监听原生持续推送的事件流
const eventChannel = EventChannel('com.example/battery_stream');
final sub = eventChannel.receiveBroadcastStream().listen((event) {
// 每次原生 sink.success(...) 都会走到这里
});

注意事项:

  • 通道名必须全局唯一,建议用反向域名加业务名。
  • 所有调用都是异步的,无法同步拿到返回值;频繁的小数据调用会有明显开销,应尽量批量传输。
  • 消息在平台线程与 UI 线程之间切换,原生侧耗时操作要放到后台线程,否则会阻塞。

FFI(dart:ffi)是另一条路径:直接调用 C/C++ 动态库的符号,同步执行、无序列化开销、无消息往返。适合接入音视频编解码、加解密、图像处理等已有的 C 库,性能远优于平台通道;代价是需要处理内存管理(malloc/free)与类型映射,且不能直接访问 Java/OC 的对象体系。

Dart 的单线程模型、事件循环与 Isolate 并发

Dart 代码运行在一个 Isolate 中,每个 Isolate 拥有独立的内存堆和一个事件循环线程,Isolate 之间不共享内存,只能通过端口(SendPort/ReceivePort)传递消息。因此 Dart 没有多线程共享内存的锁竞争问题,但也意味着单个 Isolate 内的任何耗时同步代码都会卡住整个 UI。

事件循环维护两个队列,执行优先级不同:

  • microtask 队列:由 scheduleMicrotaskFuture.microtask 加入,优先级最高。每次事件循环会先清空整个 microtask 队列,才处理下一个 event。
  • event 队列:由 I/O、定时器、手势、Future 的普通回调等产生,优先级低。

执行顺序:同步代码全部执行完 -> 清空 microtask 队列 -> 取一个 event 执行 -> 再次清空 microtask 队列 -> 循环往复。

void main() {
print('1 同步');
Future(() => print('4 event 队列')); // 进入 event 队列
Future.microtask(() => print('3 microtask')); // 进入 microtask 队列
Future.value(0).then((_) => print('3.5 已完成的 Future 走 microtask'));
print('2 同步');
}
// 输出顺序: 1 -> 2 -> 3 -> 3.5 -> 4

关键点:async/await 只是把后续代码包装成回调放进队列,它并不创建新线程。一个 CPU 密集的 for 循环即使写在 async 函数里,依然会阻塞 UI,掉帧照样发生。

Isolate 才是真正的并发手段:

  • compute(fn, message):官方提供的语法糖,一次性开启 Isolate 执行顶层函数或静态函数,执行完自动销毁并返回结果。适合 JSON 大文本解析、图片压缩、复杂计算、加解密。
  • 手动 Isolate.spawn:适合需要长期存活、多次通信的后台任务,需自行管理端口与销毁。
  • 限制:传递的消息必须可序列化(基础类型、List、Map、TransferableTypedData 等),不能传闭包捕获的外部可变对象或原生句柄;Isolate 创建本身有开销,短小任务反而得不偿失。

判断是否该用 Isolate:任务是否为纯 CPU 计算且预计耗时超过一帧(16ms)。I/O 等待类任务用 async/await 即可,无需 Isolate。

Flutter 常见性能问题与优化手段

Flutter 的一帧包含 build、layout、paint、composite、raster 几个阶段,UI 线程负责前四步,Raster 线程负责最后的光栅化。定位问题第一步是用 DevTools 的 Performance 面板看是 UI 线程超时还是 Raster 线程超时,二者的优化方向完全不同。

build 阶段耗时:

  • 避免在 build 中做同步耗时计算、json.decode 大文本、创建 controller。
  • const 构造 + 组件拆分收窄重建范围,配合 DevTools 的 “Track Widget Rebuilds” 找出高频重建节点。
  • 慎用 OpacityClipRRect 等触发 saveLayer 的组件,改用 Color.withOpacityBorderRadius 装饰属性等等价写法。

列表优化:

  • 必须用 ListView.builder/GridView.builder/CustomScrollView + SliverList,它们按需构建可视区域内的 item;ListView(children: [...]) 会一次性构建全部子项。
  • 为 item 提供稳定的 key,配合 itemExtentprototypeItem 让框架跳过逐项测量,显著提升长列表滚动性能。
  • item 内部避免嵌套过深与重复的图片解码。

图片与内存:

  • cacheWidth/cacheHeightResizeImage 让图片按显示尺寸解码,避免把 4000x3000 的原图整张解码进内存。
  • Image 默认走 ImageCache(有条数与字节上限),列表滚动场景可适当调大 PaintingBinding.instance.imageCache.maximumSizeBytes
  • 网络图用 cached_network_image 做磁盘缓存,配合占位与淡入减少布局跳动。

光栅化与重绘:

  • RepaintBoundary 把频繁重绘的子树(如动画、视频)隔离到独立的 layer,避免拖着整个页面一起重绘;但每个 boundary 都会占用额外显存,不能到处乱加。
  • 用 DevTools 打开 “Highlight Repaints” 观察哪些区域在无谓重绘。
  • 复杂静态图形可用 RepaintBoundary 缓存,或在 CustomPainter 中正确实现 shouldRepaint 返回 false。

渲染引擎层面:Impeller 是 Flutter 用来替代 Skia 的新渲染引擎,核心目标是消除着色器编译导致的首次运行卡顿(shader jank)。Skia 在运行时按需编译 GLSL 着色器,首次出现某种绘制效果时会有明显掉帧,过去需要用 SkSL 预热来缓解;Impeller 改为在构建期离线编译着色器,运行时直接使用,并使用 Metal(iOS)、Vulkan(Android)等现代图形 API。目前 Impeller 在 iOS 上已是默认引擎,Android 上也已成为新版本的默认选项。

React Native

ReactNative原理

React Native是一种用于构建跨平台移动应用的框架,其核心原理包括以下几个方面:

  1. JavaScriptCore:
    • React Native使用JavaScriptCore引擎来运行JavaScript代码。对于iOS,React Native使用系统自带的JavaScriptCore;对于Android,它会嵌入一个独立的JavaScriptCore实例。
  2. Bridge:
    • React Native的核心是一个称为“Bridge”的机制。这个桥接机制在JavaScript和原生代码之间建立了通信渠道。JavaScript线程和原生线程通过JSON消息进行通信。
    • 当JavaScript代码需要调用原生模块时,它会通过Bridge发送消息到原生线程。原生代码执行相应的操作后,可能会将结果返回给JavaScript线程。
  3. Shadow Tree和布局引擎:
    • React Native使用一个虚拟的Shadow Tree来描述UI结构。这个Shadow Tree类似于React中的虚拟DOM,但它并不直接渲染UI,而是用于计算布局。
    • 布局计算完成后,React Native会将布局信息传递给原生线程,由原生视图系统来实际渲染UI。
  4. UI组件:
    • React Native提供了一系列跨平台的UI组件,如View, Text, Image等。这些组件在JavaScript中定义,但会映射到原生的视图组件。
    • 通过这种方式,React Native可以利用原生平台的高性能和丰富功能,同时保留React的声明式编程风格。
  5. 原生模块和第三方库:
    • 开发者可以编写自己的原生模块并通过Bridge与JavaScript代码进行交互。这样可以扩展React Native的功能,使用平台特定的API。
    • 还有很多第三方库提供了常用功能,如相机、地理位置、推送通知等,它们也通过类似的方式与React Native集成。
  6. 性能优化:
    • React Native通过批量更新和异步渲染来提升性能。例如,批量将多次状态更新合并为一次操作,减少与原生线程的通信次数。
    • 使用虚拟DOM和高效的diff算法来最小化UI更新的开销。
      通过这些核心机制,React Native能够实现一次编码,跨平台运行的目标,同时保留接近原生应用的性能和体验。

ReactNative和Flutter的区别

https://blog.csdn.net/BTTBHT/article/details/131046656
Flutter和ReactNative是目前最流行的跨平台移动应用开发框架。它们分别由谷歌和Facebook开发。

  • 性能:Flutter在性能方面具有明显的优势,可以提供更接近原生的用户体验。
    • Flutter使用Dart语言编写,可以直接编译成本地代码,避免性能损耗和内存泄漏的风险,提高应用的稳定性和流畅度。Flutter还使用了自己的渲染引擎Skia,可以直接绘制像素到屏幕上,实现高质量的UI效果。
    • ReactNative使用JavaScript语言编写,需要通过JavaScript桥接层与本地代码进行通信。这样会增加性能开销和延迟,降低应用的响应速度和运行效率。ReactNative使用了本地的渲染引擎,可以利用本地的UI组件,但也会受到本地平台的限制和差异。
  • 代码书写:
    • Flutter使用Dart语言编写,这是一种静态类型、面向对象、支持多范式的语言。Dart语言相对于JavaScript来说更加严格和规范,可以避免一些常见的错误和异常,提高代码的可读性和可维护性。Dart语言还支持热重载和热重启功能,可以实时查看代码修改后的效果,提高开发效率。
    • ReactNative使用JavaScript语言编写,这是一种动态类型、基于原型、支持多范式的语言。JavaScript语言相对于Dart来说更加灵活和简洁,可以实现更多的功能和逻辑,提高代码的表达力和创造力。JavaScript语言也支持热重载和热重启功能,可以实时查看代码修改后的效果,提高开发效率。
  • 学习难度:ReactNative在学习方面具有一定的优势,可以降低开发者的入门难度
    • Flutter使用Dart语言编写,这是一种相对较新的语言,目前还没有太多的使用者和教程。开发者需要花费一定的时间和精力来学习Dart语言的语法和特性,以及Flutter框架的原理和组件。
    • ReactNative使用JavaScript语言编写,这是一种相对较老的语言,目前有着广泛的使用者和教程。开发者如果已经熟悉JavaScript语言和React框架,可以很快地上手ReactNative框架。
  • 运行速度:Flutter在运行速度方面具有明显的优势,可以提供更快速和流畅的用户体验。
    • Flutter使用Dart语言编写,可以直接编译成本地代码,无需通过JavaScript桥接层。这样可以提高应用的启动速度和运行速度
    • ReactNative使用JavaScript语言编写,需要通过JavaScript桥接层与本地代码进行通信。这样会降低应用的启动速度和运行速度,增加卡顿和闪退的可能性。

RN 的三线程模型与卡顿产生的位置

React Native(旧架构)运行时至少有三条关键线程,理解它们的分工是定位卡顿的前提。

  • JS 线程:执行 JavaScript 业务代码、React 的 reconciliation、事件回调、网络请求发起。整个应用的业务逻辑都挤在这一条线程上。
  • Shadow 线程(布局线程):运行 Yoga 布局引擎,把 JS 侧描述的 Flexbox 样式计算成具体的位置与尺寸,再把结果交给主线程。它维护一棵与 JS 侧对应的 Shadow Tree。
  • UI/主线程(原生线程):创建与更新原生视图、处理触摸事件、执行原生动画与绘制。所有真实的 UI 操作最终都在这里发生。

三者通过 Bridge 异步通信,消息经过 JSON 序列化后批量传递,没有同步调用能力。

卡顿产生的位置与表现:

  • JS 线程被阻塞:最常见。大数组遍历、复杂计算、频繁 setState、长列表一次性渲染都会让 JS 线程掉帧。表现是点击无响应、列表滚动时白屏(新 cell 来不及产出),但纯原生驱动的动画依然流畅。
  • UI 线程被阻塞:原生模块在主线程做了耗时操作(图片解码、数据库同步读写),或视图层级过深导致 measure/layout 过重。表现是整个界面冻结,连原生手势都失灵。
  • Bridge 拥塞:单帧内跨线程消息过多,比如 onScroll 每帧回调 JS、动画逐帧从 JS 下发数值。即使两端线程都不忙,消息排队本身也会造成延迟与掉帧。
  • Shadow 线程压力:一次性挂载超大树导致 Yoga 布局计算耗时。

排查思路:先用 Perf Monitor 分别看 JS FPS 与 UI FPS。JS FPS 低而 UI FPS 正常,说明是 JS 线程问题;两者都低通常是渲染或跨线程通信问题;UI FPS 单独低则要查原生侧。

RN 新架构详解与旧架构对比

旧架构的根本瓶颈在 Bridge:JS 与原生之间所有通信都必须序列化成 JSON、异步排队、批量传输,既无法同步调用,也无法共享内存。这导致三个后果:无法在 JS 侧同步读取原生测量结果、启动时必须初始化全部原生模块、高频交互(手势跟随、列表快速滚动)必然掉帧。新架构围绕“去掉 Bridge”重新设计。

  • JSI(JavaScript Interface):一层 C++ 抽象层,让 JS 引擎能够直接持有并调用 C++ 对象(HostObject)。JS 可以同步调用原生方法、传递引用而非拷贝,彻底摆脱 JSON 序列化。JSI 与具体引擎解耦,因此可以自由替换 JSC、Hermes、V8。
  • Fabric:基于 JSI 的新渲染器。它把 Shadow Tree 用 C++ 重写并在多平台共享,采用不可变的 Shadow Node 树 + 双缓冲提交模型,渲染流程分为 render、commit、mount 三个阶段。带来的能力包括:支持 React 18 的并发特性与 Suspense、支持同步布局测量、优先级调度(用户输入优先于低优先级更新)、更少的跨线程往返。
  • TurboModules:原生模块的懒加载方案。旧架构启动时会实例化所有注册的原生模块,模块越多启动越慢;TurboModules 通过 JSI 在 JS 首次访问某模块时才初始化它,并支持同步调用。
  • Codegen:根据 JS/TS 侧的类型声明(NativeXxx.ts 中的 TurboModuleViewConfig spec)在编译期自动生成 C++/Java/ObjC 的胶水代码与类型校验。它保证了两端接口的类型一致性,避免了旧架构中靠约定传参、运行时才报错的问题。
  • Hermes:Meta 为 RN 优化的 JS 引擎。核心特性是预编译字节码(把解析与编译前置到构建期)、更小的内存占用、更快的冷启动,并且体积比 JSC 小。现在是 RN 的默认引擎,配合 JSI 与新架构协同工作。

对比要点:

  • 通信方式:Bridge 异步 + JSON 序列化 -> JSI 直接引用 + 可同步调用。
  • 模块加载:全量初始化 -> 按需懒加载。
  • 类型安全:运行时约定 -> 编译期 Codegen 校验。
  • 渲染:JS 驱动的单向异步提交 -> C++ 共享 Shadow Tree,支持并发与同步测量。
  • 迁移成本:第三方库需要适配新架构;RN 提供了互操作层(interop layer)让旧的 Native Modules/Views 能在新架构下继续运行,但要拿到完整收益仍需库作者改造。

编写原生模块与原生 UI 组件并暴露给 JS

RN 提供两类扩展能力:原生模块(Native Module)暴露方法给 JS 调用,原生 UI 组件(Native Component)把原生 View 包装成可在 JSX 中使用的组件。核心思路是“注册 + 声明导出项 + 类型映射”。

原生模块的通用步骤:编写实现类 -> 用宏或注解标记要导出的方法 -> 注册到模块列表 -> JS 侧通过 NativeModules(旧架构)或生成的 spec(TurboModule)引用。参数与返回值只能是可跨语言映射的类型(数字、字符串、布尔、数组、字典、Promise、Callback)。Android 侧继承 ReactContextBaseJavaModule,用 @ReactMethod 标记导出方法:

public class ToastModule extends ReactContextBaseJavaModule {
ToastModule(ReactApplicationContext ctx) { super(ctx); }

public String getName() { return "ToastModule"; } // JS 侧的模块名

@ReactMethod
public void show(String message, Promise promise) {
Toast.makeText(getReactApplicationContext(), message, Toast.LENGTH_SHORT).show();
promise.resolve(true); // 通过 Promise 把结果异步回传给 JS
}
}

iOS 侧用 RCT_EXPORT_MODULERCT_EXPORT_METHOD 宏:

@implementation ToastModule
RCT_EXPORT_MODULE(); // 不传参时模块名默认取类名

RCT_EXPORT_METHOD(show:(NSString *)message
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_async(dispatch_get_main_queue(), ^{ resolve(@YES); }); // UI 操作切主线程
}
@end

JS 侧通过 NativeModules 引用并像普通异步函数一样调用:

import { NativeModules, NativeEventEmitter } from 'react-native';
const { ToastModule } = NativeModules;

await ToastModule.show('hello');
// 原生主动发事件时用 NativeEventEmitter 订阅,返回的 sub 需在卸载时 remove
const sub = new NativeEventEmitter(ToastModule).addListener('onProgress', console.log);

原生 UI 组件的思路:Android 继承 SimpleViewManager<T>,实现 createViewInstance 返回原生 View,用 @ReactProp 把属性映射到 setter;iOS 继承 RCTViewManager,实现 -view 方法并用 RCT_EXPORT_VIEW_PROPERTY 导出属性。事件回传通过 RCTBubblingEventBlock/RCTDirectEventBlock(iOS)或 RCTEventEmitter(Android)。JS 侧用 requireNativeComponent(旧架构)或 Codegen 生成的组件(新架构)引用。

注意事项:模块方法默认在自己的队列执行,涉及 UI 必须切主线程;事件订阅必须在组件卸载时移除,否则内存泄漏;导出的常量放在 getConstants;新架构下应优先按 TurboModule/Fabric 的 spec 写法,让 Codegen 生成胶水代码。

RN 的热更新原理与方案

RN 的 JS 代码最终被打包成一个 bundle 文件(以及图片等静态资源),App 启动时由原生侧加载这个 bundle 并交给 JS 引擎执行。热更新的本质就是:把远端新的 bundle 下载到沙盒,下次启动(或立即重载)时让原生侧加载沙盒中的 bundle 而不是内置在包里的那份。

实现要点:

  • bundle 加载路径可控:原生侧的 jsBundleURLForBundleRoot(iOS)或 getJSBundleFile(Android)返回沙盒路径,即可切换到更新后的 bundle。
  • bundle 拆分:把不常变的第三方依赖打成 common/base bundle 内置在 App 中,业务代码打成 business bundle 走热更新。这样增量包体积小、下载快,也能让多个业务 bundle 共享一份基础环境。
  • 差量更新:服务端对新旧 bundle 做 diff(如 bsdiff),客户端下载补丁包后本地合成完整 bundle,进一步降低流量。
  • 版本校验:更新包必须携带目标原生版本号与 bundle hash。原生版本不匹配时禁止应用该包,否则会因缺少原生模块直接崩溃。

CodePush(现由 App Center 提供,也有社区维护的替代实现)的流程:CLI 打包并上传到服务端 -> 客户端 SDK 在启动或指定时机 checkForUpdate -> 下载并校验 -> 按策略在下次启动或立即 restartApp 生效。它内置了几个关键机制:

  • 灰度发布:按 rollout 百分比下发,可指定目标原生版本范围(targetBinaryVersion)。
  • 强制更新与静默更新:mandatory 标记决定是否必须立即应用。
  • 自动回滚:更新后若首次启动未调用 notifyAppReady(即 App 在启动阶段崩溃),SDK 会自动回滚到上一个可用版本。这是热更新方案必须具备的兜底能力。
  • 服务端也可主动 rollback 某个 release,让已下发的客户端回退。

合规注意点:

  • Apple 的 App Store 审核指南允许通过内置解释型引擎(如 JS 引擎)执行下载的代码,但前提是不改变 App 的主要功能与用途、不引入新的功能类别、不绕过审核提供违规内容。用热更新做“上架后换成另一个应用”必然违规。
  • Android 各应用商店对热更新的态度不一,部分国内渠道要求备案或限制动态下发;涉及支付、内容合规的改动尤其敏感。
  • 工程上应约束:热更新只用于修复缺陷与小幅迭代,涉及新增权限、新增原生能力、重大功能变更时必须走版本发布。同时要保留强制更新通道,以便在热更新失效时引导用户升级原生包。

RN 性能优化实践

优化目标是让 JS 线程不被长任务占满、减少跨线程通信次数、降低原生视图与内存开销。以下按收益从高到低排列。

长列表调优:FlatList/SectionList 基于 VirtualizedList,只渲染窗口内的 item,参数配置直接决定滚动体验。

<FlatList
data={data}
keyExtractor={(item) => item.id} // 稳定 key,避免重复挂载
renderItem={renderItem} // 用 useCallback 缓存,避免每次重建
getItemLayout={(_, index) => ( // 定高列表必加,跳过动态测量
{ length: ROW_H, offset: ROW_H * index, index }
)}
initialNumToRender={10} // 首屏渲染数量,越小首屏越快
maxToRenderPerBatch={5} // 每批增量渲染数量,过大易掉帧
windowSize={5} // 可视区前后保留的屏数,过大费内存
removeClippedSubviews // 移出可视区的视图从原生层级摘除
/>
  • item 组件用 React.memo 包裹并保证传入的 props 引用稳定(否则 memo 失效),避免在 renderItem 中创建内联函数、内联样式对象与匿名箭头函数。

图片与内存:

  • 指定明确的宽高避免布局抖动与重复解码;远端图请求已裁剪好的尺寸,不要下发原图再由客户端缩放,并注意 Android 上 Bitmap 的内存占用。
  • 长列表中的图片使用带缓存与优先级控制的库(如 FastImage),滚动停止后再加载高清图,及时释放不可见的大图。

减少跨线程通信:

  • 动画一律加 useNativeDriver: true,把动画驱动交给原生线程,JS 线程繁忙时动画依然流畅。注意它只支持非布局属性(transform、opacity)。
  • 滚动跟随类动画用 Animated.event 配合 useNativeDriver,不要在 onScrollsetState
  • 需要频繁交互的手势用 react-native-gesture-handler + react-native-reanimated,逻辑运行在 UI 线程。
  • 减少不必要的 setState 与 re-render,用 useMemo/useCallback 稳定引用。

启动优化:

  • 开启 Hermes,享受字节码预编译带来的更快冷启动与更低内存;新架构下 TurboModules 的懒加载也能显著缩短启动时间。
  • 使用 inline requires 与 RAM bundle / bundle 拆包,让首屏只加载必要模块,其余模块延迟到实际使用时再解析。
  • 精简首屏依赖,把非必要的初始化(埋点上报、SDK 初始化)推迟到首帧之后。

交互流畅性:用 InteractionManager.runAfterInteractions 把非紧急任务(预加载数据、上报、重计算)推迟到动画与手势结束后执行,避免与转场动画抢占 JS 线程。

InteractionManager.runAfterInteractions(() => {
// 页面转场动画结束后再执行,避免转场卡顿
prefetchNextPageData();
});

RN 常见坑与调试手段

调试手段:

  • Perf Monitor:开发菜单中开启,实时观察 JS FPS 与 UI FPS,是判断卡顿归属的第一步。
  • React DevTools:查看组件树、props 与 Profiler 火焰图,定位高频重渲染组件。
  • Flipper:官方长期主推的桌面调试平台,提供网络面板、日志、布局检查、数据库与偏好设置查看等插件。需要注意的是新版本 RN 已把 Flipper 从模板中移除,转向基于 Hermes 调试协议的内置调试器与 React Native DevTools,新项目应优先使用后者。
  • 网络请求排查:Chrome DevTools 的 Network 面板在 RN 中并不完整,更可靠的方式是用抓包工具(Charles/Proxyman/Whistle)或在 Flipper 的网络插件中查看。

Hermes 下的调试差异:

  • Hermes 不支持传统的 “Debug JS Remotely”(把 JS 放到 Chrome 的 V8 中执行)。这种远程调试模式本身也会改变运行环境,导致同步 JSI 调用不可用、时序与真机不一致,因此该模式已被废弃。
  • Hermes 通过 Chrome DevTools Protocol 直连调试,在 Hermes 引擎内部执行并断点,行为与生产环境一致。
  • Hermes 的报错栈需要用 hermes-sourcemap/compose-source-maps 处理后才能还原,直接看字节码位置没有意义。
  • 部分 ES 特性与 Intl 在 Hermes 上的支持与 JSC 存在差异,需要按版本确认并按需引入 polyfill。

常见坑:

  • 样式差异:RN 的样式不是 CSS,不支持继承(除 Text 内部有限继承)、没有百分比以外的相对单位、flexDirection 默认是 column 而非 row
  • 阴影差异:iOS 用 shadowColor/shadowOffset/shadowOpacity/shadowRadius,Android 只能用 elevation,需要分平台写。
  • 层级与溢出:Android 上 overflow: 'visible' 对超出父容器的内容支持有限,绝对定位元素可能被裁剪;zIndex 在 Android 上的行为也与 iOS 不完全一致。
  • 触摸区域:Touchable 类组件在小图标上点击不灵,需要用 hitSlop 扩大响应区域。
  • 键盘遮挡:需要 KeyboardAvoidingView 并针对 iOS 用 padding、Android 用 height 或依赖 windowSoftInputMode 分别处理。
  • 动画忘记加 useNativeDriver: true,或对 width/height/top 这类布局属性使用原生驱动导致报错,应改用 transform 实现等价效果。
  • 列表 key 使用数组下标,数据变更后造成状态错位与不必要的重挂载。
  • 内存泄漏:事件订阅、定时器、NativeEventEmitter 的监听未在 useEffect 的清理函数中移除。

移动端

对移动端开发的认识?

移动端开发是指开发用于移动设备(如智能手机、平板电脑)的应用程序或网站。随着移动设备的普及和使用量的增加,移动端开发已成为软件开发领域中的重要方向之一。以下是关于移动端开发的一些认识:

  1. 平台多样性:
    • 移动设备的操作系统多样性是移动端开发的一个显著特点。主要的移动操作系统包括 Android、iOS 和可能还有 Windows Phone(已停止开发支持)。因此,移动端开发需要考虑多种操作系统和平台之间的兼容性。
  2. 开发工具:
    • 为了开发移动应用程序,开发人员可以使用各种工具和技术栈。比较常见的移动端开发工具包括 Android Studio(用于 Android 开发)、Xcode(用于 iOS 开发)、React Native、Flutter、Ionic 等跨平台框架,以及 Web 技术(如 HTML、CSS、JavaScript)开发的移动网页应用。
  3. 用户体验:
    • 移动端开发需要特别关注用户体验(UX),因为移动设备的屏幕较小,用户交互方式也有所不同。因此,设计和开发移动应用需要考虑到触摸操作、屏幕尺寸适配、响应式设计等方面,以提供良好的用户体验。
  4. 性能优化:
    • 移动端应用的性能优化是非常重要的,因为移动设备的资源相对有限。开发人员需要注意减少应用的内存占用、优化加载速度、减少功耗等方面,以确保应用在移动设备上的流畅运行。
  5. 发布和更新:
    • 发布移动应用需要遵循各个应用商店的规定和流程。对于 Android 应用,通常使用 Google Play Store 进行发布;而 iOS 应用则需要通过苹果的 App Store 发布。此外,定期更新应用以修复 bug、增加新功能也是移动端开发的常规工作之一。
      总的来说,移动端开发涉及多种技术和方面,包括平台选择、开发工具、用户体验、性能优化、发布和更新等。随着移动技术的不断发展和创新,移动端开发也在不断演进和壮大,为用户提供更便捷、高效、丰富的移动应用体验。

移动端适配的整体思路与主流方案对比

移动端适配要解决的核心矛盾是:设计稿只有一个固定宽度(常见 750px 或 375pt),而真实设备的屏幕宽度、像素密度、系统字体缩放各不相同。适配的本质是建立“设计稿像素”到“设备实际渲染尺寸”的换算关系,并决定哪些元素等比缩放、哪些元素固定、哪些元素自适应拉伸。

前置条件是 viewport 元标签,它决定了布局视口(layout viewport)的宽度:

<!-- width=device-width 让布局视口等于理想视口,initial-scale=1 保证不缩放 -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

主流方案对比:

  • rem + flexible:用 JS 读取 screen.width 动态设置根字体大小(如 1rem = 屏宽/10),所有尺寸按设计稿换算成 rem。优点是兼容性好、老项目多;缺点是需要一段阻塞式 JS、根字号被改写会影响以 rem 为单位的第三方组件,且早期 flexible 还会改写 initial-scale 处理高清屏。现在已不推荐新项目使用。
  • vw + postcss:1vw = 视口宽度的 1%,直接把设计稿尺寸按 设计稿px / 设计稿宽度 * 100 转成 vw,用 postcss-px-to-viewport 之类的插件在构建期自动转换。纯 CSS 方案、无 JS 依赖、无闪烁,是当前的主流选择。缺点是大屏(平板、折叠屏展开)上会无限放大,通常配合 max-width 给根容器加上限。
  • 媒体查询:按断点切换不同布局,适合需要在手机与平板/桌面之间切换信息架构的场景。它解决的是“布局形态变化”,而不是“等比缩放”,与前两者是互补关系而非替代。
  • 弹性布局(Flex/Grid)+ 少量固定值:容器自适应、间距用固定 px、字号用固定 px 或 clamp()。适合内容型页面,避免了小屏字太小、大屏字太大的问题。工程上常见的组合是“布局用 Flex,需要等比的视觉元素用 vw,字号用固定档位”。
  • 设计稿换算:与设计约定基准宽度(如 750 稿对应 375pt 的 2 倍图),开发直接按稿件标注数值书写,由构建插件统一换算,避免人工计算出错。

其他要点:物理像素与 CSS 像素的关系由 devicePixelRatio 决定,图片需要提供 2x/3x 多倍图或使用矢量图;移动端 1px 边框问题与各类适配单位(px、rem、em、vw、vh、rpx)的细节详见 CSS 板块。

300ms 点击延迟与点击穿透

300ms 延迟的成因:早期移动浏览器为了支持“双击缩放”(double-tap to zoom),在用户 touchend 之后必须再等待约 300ms,确认没有第二次点击,才能派发 click 事件。这段等待就是所谓的点击延迟,表现为按钮响应迟钝。

解决方式的演进:

  • 声明视口不可缩放:现代浏览器发现页面设置了 width=device-width 的 viewport 后,会认为页面已针对移动端优化、无需双击缩放,从而移除延迟。这是目前最推荐、零成本的做法。
  • touch-action: manipulation:CSS 属性,告诉浏览器该元素只需要处理平移与缩放手势,禁用双击缩放,从而直接消除延迟。适合无法全局改 viewport 的局部场景。
  • FastClick:历史方案。原理是监听 touchend 后立即用 document.createEvent 手动派发一个合成的 click 事件,并阻止随后到来的原生 click。它在当年解决了问题,但引入了点击穿透、与部分输入法/富文本冲突等副作用。现代浏览器已普遍移除延迟,FastClick 作者也已归档该项目,新项目不应再引入。

点击穿透的成因:触摸事件的派发顺序是 touchstart -> touchmove -> touchend -> click,其中 click 会延迟约 300ms 触发。如果在 touchend 时把上层元素(如遮罩、弹层)隐藏了,300ms 后 click 派发时命中的已经是下层元素,于是下层被“意外点击”。

排查与解决:

  • 判断是否穿透:给下层元素的点击处理加日志,若在关闭弹层的瞬间被触发,即为穿透。
  • 统一事件体系:上下层要么都用 click,要么都用 touch 系列,不要混用。这是最根本的解法。
  • touchend 中调用 preventDefault():阻止后续合成的 click 事件生成。注意此时监听器不能是 passive 的。
  • 延迟隐藏:关闭动画结束后(超过 300ms)再移除遮罩,让 click 落在遮罩上被吞掉。
  • pointer-events: none 临时屏蔽下层,或在下层加一个短暂的忽略窗口(时间戳判断)。

移动端触摸事件与手势

触摸事件是一套独立于鼠标事件的体系,一次完整的触摸会依次触发 touchstart、若干次 touchmovetouchend;被系统中断(来电、通知栏下拉、手势返回)时触发 touchcancel,这个事件容易被遗漏,导致状态没有复位而出现“手指抬起了但元素还在跟手”的 bug。

事件对象上有三个触点列表,区别是常见考点:

  • event.touches:当前屏幕上所有的触点。
  • event.targetTouches:当前元素上的触点。
  • event.changedTouches:本次事件中状态发生变化的触点。touchend 时手指已离开屏幕,touches 为空,只能从 changedTouches 中取坐标。

passive 事件监听对滚动性能的影响:浏览器的滚动通常由合成器线程处理,但如果页面注册了 touchstart/touchmove/wheel 监听器,浏览器无法预知监听器里会不会调用 preventDefault() 阻止滚动,只能等主线程执行完监听器才敢滚动,造成滚动延迟。{ passive: true } 是对浏览器的承诺——我不会调用 preventDefault——浏览器就能立即滚动。现代浏览器对文档级别的 touchstart/touchmove 默认按 passive 处理,因此需要阻止默认行为(如自定义拖拽、禁止橡皮筋)时必须显式传 { passive: false }

// 只读监听,声明 passive 让滚动不被阻塞
el.addEventListener('touchmove', onMove, { passive: true });
// 需要 preventDefault 时必须显式关闭 passive
el.addEventListener('touchmove', onDrag, { passive: false });

一个简易的手势识别实现:

function bindSwipe(el, handlers, threshold = 30) {
let sx = 0, sy = 0, st = 0, moved = false;
el.addEventListener('touchstart', (e) => {
const t = e.touches[0];
sx = t.clientX; sy = t.clientY; st = Date.now(); moved = false;
}, { passive: true });
el.addEventListener('touchmove', () => { moved = true; }, { passive: true });
el.addEventListener('touchend', (e) => {
const t = e.changedTouches[0]; // touchend 时 touches 为空,只能取 changedTouches
const dx = t.clientX - sx, dy = t.clientY - sy, dt = Date.now() - st;
if (!moved && Math.abs(dx) < 10 && Math.abs(dy) < 10 && dt < 250) {
return handlers.tap?.();
}
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > threshold) {
(dx > 0 ? handlers.right : handlers.left)?.(); // 横向位移占优,判定左右滑
} else if (Math.abs(dy) > threshold) {
(dy > 0 ? handlers.down : handlers.up)?.();
}
}, { passive: true });
// 被系统打断时复位,避免状态残留
el.addEventListener('touchcancel', () => { moved = false; });
}

多指手势(捏合缩放、旋转)的思路是从 event.touches 中取两个触点,计算两点距离的变化比得到 scale、计算连线角度的变化得到 rotation。生产环境建议直接使用成熟库(Hammer.js、AlloyFinger 或框架自带的手势方案),自行实现难以覆盖各种边界。

Hybrid 与 WebView:JSBridge 的通信原理与简易实现

JSBridge 解决的是 WebView 中的 JS 与宿主原生代码互相调用的问题。它需要打通两个方向:JS 调用原生(下行)、原生调用 JS 或回传结果(上行)。

JS 调用原生的两种主流做法:

  • URL scheme 拦截:JS 侧创建一个隐藏 iframe 或修改 location,发起形如 mybridge://call?method=xxx&params=...&callbackId=1 的请求。原生侧在 WebView 的导航拦截回调(Android 的 shouldOverrideUrlLoading,iOS 的 decidePolicyForNavigationAction)中识别自定义 scheme 并解析参数。优点是兼容性极好、老系统也能用;缺点是 URL 长度有限、连续调用可能丢失、有一定延迟。
  • 注入 JS 对象:Android 用 webView.addJavascriptInterface(obj, "NativeBridge") 把 Java 对象注入到 JS 全局,被导出的方法必须加 @JavascriptInterface 注解才可见;iOS(WKWebView)用 WKUserContentController.add(handler, name: "NativeBridge") 注册一个实现了 WKScriptMessageHandler 协议的对象,消息由其 userContentController(_:didReceive:) 回调接收,JS 侧通过 window.webkit.messageHandlers.NativeBridge.postMessage(data) 发送。这是现在的主流做法,性能好、可传结构化数据。

原生调用 JS 与回调映射:Android 用 evaluateJavascript("window.xxx(...)", cb),iOS 用 evaluateJavaScript(_:completionHandler:),约定一个全局入口函数(如 window.JSBridge._handleMessageFromNative)由原生统一调用。由于通信天然异步,需要 callbackId 映射机制:JS 发起调用时生成唯一 id,把回调函数存入本地 Map 并把 id 一起发给原生;原生处理完毕后带着同一个 id 回调 JS,JS 侧按 id 取出执行,随后删除该条目以免内存泄漏。

const JSBridge = (function () {
let uid = 0;
const callbacks = {}; // callbackId -> JS 回调
const handlers = {}; // 供原生调用的 JS 方法

function call(method, params, cb) {
const callbackId = `cb_${++uid}`;
if (cb) callbacks[callbackId] = cb;
const payload = { method, params, callbackId };
if (window.webkit?.messageHandlers?.NativeBridge) {
window.webkit.messageHandlers.NativeBridge.postMessage(payload); // iOS WKWebView
} else if (window.NativeBridge?.postMessage) {
window.NativeBridge.postMessage(JSON.stringify(payload)); // Android 注入对象
} else {
const ifr = document.createElement('iframe'); // 兜底: URL scheme 拦截
ifr.style.display = 'none';
ifr.src = `mybridge://call?data=${encodeURIComponent(JSON.stringify(payload))}`;
document.documentElement.appendChild(ifr);
setTimeout(() => ifr.remove(), 0); // 用完即销毁
}
}

// 原生统一通过这个入口回调 JS
function _handleMessageFromNative(msg) {
const d = typeof msg === 'string' ? JSON.parse(msg) : msg;
if (d.callbackId && callbacks[d.callbackId]) {
callbacks[d.callbackId](d.error, d.result);
delete callbacks[d.callbackId]; // 一次性回调,用完即删防泄漏
} else if (d.method) {
handlers[d.method]?.(d.params); // 原生主动调用 JS 注册的方法
}
}
return { call, register: (n, f) => (handlers[n] = f), _handleMessageFromNative };
})();

工程注意点:必须做域名白名单鉴权,否则任意页面都能调用原生能力(Android 的 addJavascriptInterface 在低版本存在反射执行任意代码的历史漏洞,需确保 @JavascriptInterface 注解与最低版本约束);要为每次调用加超时兜底,防止原生不回调导致 Promise 永久挂起;需要版本协商机制,H5 调用不存在的原生方法时应能优雅降级。

移动端首屏与白屏优化

白屏的时间构成大致是:WebView 初始化 -> DNS/TCP/TLS -> HTML 下载 -> CSS/JS 下载与解析 -> JS 执行与框架启动 -> 接口请求 -> 数据渲染。优化就是逐段压缩甚至并行化这些环节。

容器层(Hybrid 特有,收益通常最大):

  • WebView 预热与复用:App 启动后就在后台初始化一个 WebView 实例(甚至预加载一个空白页完成内核初始化),用户点击时直接复用,可省去数百毫秒的容器创建时间。使用完毕后清理状态放回池中。
  • 离线包:把 HTML、CSS、JS、字体、图标等静态资源打包内置或后台静默下载到本地,页面加载时由原生拦截请求并从本地读取,等于把网络请求变成磁盘读取。配合版本管理与差量更新,是 Hybrid 首屏优化的核心手段。
  • 接口预请求(数据预取):在用户点击入口的同时,由原生并行发起首屏接口请求,H5 加载完成后直接从桥里取缓存数据,把“页面加载”与“数据请求”从串行变成并行。
  • 预渲染/页面预加载:对高频入口页提前在后台 WebView 中渲染好,点击时直接展示。

资源层:

  • 关键 CSS 内联到 HTML,避免首屏渲染被外链样式阻塞;非关键 CSS 异步加载。
  • JS 使用 defer/async,路由级代码分割,首屏只加载必要 chunk。
  • 资源压缩:开启 Gzip/Brotli、图片使用 WebP/AVIF 并按需裁剪、字体子集化、移除未使用的依赖。
  • 用 CDN 分发,配置合理的强缓存(带 hash 文件名 + 长 max-age)。
  • preconnect/dns-prefetch 提前建连,preload 提前加载关键资源。

渲染层:

  • 骨架屏:在真实内容返回前展示与最终布局一致的灰块占位,把“白屏”变成“有内容在加载”,显著改善主观等待感受,同时能减少内容到达时的布局抖动(CLS)。骨架屏应内联在 HTML 中,避免自身还要等 JS。
  • SSR/预渲染:服务端直出 HTML,用户能立刻看到内容,随后 hydration 接管交互。适合内容型、SEO 敏感的页面;代价是服务端复杂度与成本上升,且 hydration 本身也有耗时,可考虑流式渲染或部分 hydration。
  • 减少首屏 DOM 数量与嵌套层级,非首屏模块懒加载。
  • 图片指定宽高占位,避免加载完成后的跳动。

度量:以 FCP、LCP 作为首屏指标,配合真机与弱网环境验证,不要只看本地开发机的表现。

刘海屏安全区域与软键盘弹起问题

安全区域(safe area)指的是屏幕上不会被刘海、灵动岛、圆角、底部 Home Indicator 遮挡的可用区域。默认情况下 WebView 内容只渲染在安全区域内,四周会留出系统色的空白;要让背景铺满整屏又保证内容不被遮挡,需要两步配合。

第一步,声明 viewport-fit=cover,让页面内容延伸到整个屏幕(包括刘海区域):

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />

第二步,用环境变量把内容推回安全区域内:

.page {
/* env() 在不支持的环境下取第二个参数作为兜底值 */
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
}

/* 常见场景: 固定在底部的操作栏,避免被 Home Indicator 遮挡 */
.tabbar {
position: fixed;
bottom: 0;
/* 用 calc 在原有内边距基础上叠加安全区高度 */
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}

注意 env() 只有在设置了 viewport-fit=cover 后才会返回非零值;横屏时生效的是 safe-area-inset-left/right;不要用固定的 34px 硬编码,不同机型数值不同。

软键盘弹起导致的布局问题,成因在两个平台上不同:

  • Android(adjustResize):键盘弹起会压缩可视视口,window.innerHeight 变小,position: fixed 的底部元素会被顶到键盘上方,页面高度按 100vh 写死时会出现内容被压缩或滚动异常。
  • iOS:键盘不改变视口高度,而是把整个 WebView 内容“顶起来”(页面整体上移)。position: fixed 元素会随之错位,收起键盘后有时不会自动复位,出现页面留白或元素飘移。

常用解决方案:

  • 优先使用 100dvh(dynamic viewport height)替代 100vh,它会随浏览器 UI 与键盘状态动态变化;对兼容性要求高时用 JS 监听并设置 CSS 变量 --vh
  • 使用 visualViewport API 精确感知键盘:监听 window.visualViewportresizescroll 事件,用 heightoffsetTop 计算键盘高度并动态调整底部栏位置。这是目前最可靠的做法。
  • 输入框聚焦时把底部固定栏改为 position: absolute 或临时隐藏,失焦后恢复。
  • 输入框被遮挡时,聚焦后延迟调用 el.scrollIntoView({ block: 'center' }) 主动滚动到可视区。
  • iOS 上失焦后若页面未复位,可在 blur 时执行 window.scrollTo(0, 0)document.body.scrollTop = 0 强制复位。
  • Android 需要与客户端约定 windowSoftInputModeadjustResizeadjustPan 的表现差异很大,H5 侧的兼容代码必须基于实际配置来写。

移动端调试手段

移动端调试的难点是无法直接打开控制台,需要按“页内工具 -> 远程调试 -> 抓包 -> 真机验证”的顺序逐层深入。

页内调试工具:

  • vConsole:腾讯出品,在页面内嵌入一个可展开的迷你控制台,提供 Log、Network、Element、Storage 面板。集成成本低,适合作为测试环境的常驻工具。
  • eruda:能力更全,支持插件扩展(性能、资源、代码执行),界面更接近 DevTools。
  • 生产环境应通过 URL 参数或特定手势按需动态加载,不要默认打包进正式包。

远程调试(能力最完整,首选):

  • iOS Safari:手机上开启“设置 - Safari - 高级 - 网页检查器”,用数据线连接 Mac,在 Safari 的“开发”菜单中选择设备与页面,即可获得完整的 DevTools。WKWebView 需要 App 侧开启 isInspectable(iOS 16.4 及以后新增的开关)。
  • Android Chrome:手机开启 USB 调试,连接电脑后访问 chrome://inspect,选择对应页面点击 inspect。WebView 需要 App 侧调用 WebView.setWebContentsDebuggingEnabled(true)
  • 无线调试与 Chrome DevTools 的远程端口转发可以避免依赖数据线。

抓包工具:

  • Charles / Proxyman / Whistle / Fiddler:手机配置代理指向电脑,安装并信任根证书后可查看与篡改 HTTPS 请求。Whistle 基于 Node、规则配置灵活,适合做本地资源映射与 mock。
  • 典型用途:把线上页面的 JS 映射到本地文件调试线上问题(map local)、修改接口返回验证边界情况、观察请求耗时定位慢接口。
  • 注意 Android 7 以后应用默认不信任用户安装的 CA 证书,需要 App 配置 network_security_config 或使用 debug 包;启用了证书绑定(SSL Pinning)的 App 无法直接抓包。

真机与模拟器的差异:

  • 模拟器使用宿主机的 CPU/GPU 与网络,性能远好于真机,无法反映真实的卡顿与耗电;iOS 模拟器不支持摄像头、部分传感器与推送。
  • Android 模拟器的 WebView 版本、厂商 ROM 定制(输入法、返回手势、字体缩放、深色模式)与真机差异很大,兼容性问题往往只在特定厂商机型上出现。
  • 性能测试、手势体验、键盘行为、权限弹窗、支付与分享等能力必须在真机上验证,低端机型要单独覆盖。

移动端弱网与网络优化

移动网络的特征是高延迟、带宽波动大、连接易中断(电梯、地铁、隧道)。优化的目标不是“更快”,而是“在网络差时仍然可用、在网络恢复后自动补齐”。

请求层面:

  • 超时与重试:为请求设置合理超时(首屏关键接口更短),失败后按指数退避重试(如 1s、2s、4s)并加随机抖动避免重试风暴。只对幂等请求(GET、带幂等 token 的写操作)自动重试,非幂等写请求必须由用户确认。
  • 请求合并:把首屏的多个小接口合并成一个聚合接口(BFF 层聚合),减少 RTT 次数;同一时刻的重复请求做去重,共享同一个 Promise。
  • 减少连接开销:开启 HTTP/2 多路复用避免队头阻塞与连接数限制,使用 preconnect 提前完成 DNS/TLS 握手,长连接场景考虑 QUIC/HTTP3(在弱网与网络切换时的表现明显更好)。
  • 数据瘦身:接口只返回必要字段、开启 Brotli/Gzip 压缩、分页而非全量返回。

缓存策略:

  • 静态资源用带 hash 的文件名 + 长期强缓存;HTML 用协商缓存或短 max-age。
  • 接口数据用内存缓存 + localStorage/IndexedDB 做二级缓存,采用 stale-while-revalidate 策略:先渲染缓存内容让页面可见,再后台请求最新数据并更新。
  • Service Worker 可以在 H5 场景做请求拦截与离线缓存;Hybrid 场景则由离线包与原生缓存承担同样职责。

图片与媒体降级:

  • 通过 navigator.connection(Network Information API)读取 effectiveTypesaveData,在 2G/3G 或用户开启省流量时降级为低清图、关闭自动播放视频、暂停预加载。
  • 图片按容器尺寸请求,采用渐进式 JPEG 或 LQIP(先加载模糊小图再替换),提升感知速度。
  • 非首屏图片一律懒加载。

离线可用与体验兜底:

  • 关键数据本地持久化,无网时展示上次缓存内容并明确标注“当前为缓存数据”。
  • 写操作走本地队列,联网后自动重放(注意幂等)。
  • 监听 online/offline 事件与实际探测结合(navigator.onLine 只能反映是否连上网络,不能反映能否访问服务),网络恢复后自动重试失败请求。
  • 提供明确的失败态与重试入口,避免无限 loading。

H5、小程序、原生、跨端框架的差异与技术选型

四类方案的本质差异在于“UI 由谁渲染”与“运行时离系统有多近”,这直接决定了性能上限、能力边界与发布方式。

  • 原生(iOS/Android):直接调用系统 UI 框架与全部系统能力,性能与体验上限最高,可深度优化启动、内存与动画。代价是双端各写一套、迭代必须走应用商店审核、人力成本最高。适合对性能与体验要求极致的核心场景(相机、音视频、地图、复杂手势、系统级集成)。
  • H5:浏览器/WebView 渲染,一次开发多端运行,发布即生效、无需审核,是获客与营销场景的首选。短板是首屏与滚动性能弱于原生、系统能力受限(需通过 JSBridge 补齐)、离线能力弱、复杂交互难以做到跟手。适合活动页、内容页、低频功能与需要频繁改版的业务。
  • 小程序:宿主 App 提供的受控运行时,采用逻辑层与渲染层双线程模型(逻辑层跑 JS 且无 DOM/BOM,渲染层负责 UI,两层通过 setData 通信)。优点是有平台流量入口、体验优于普通 H5、原生能力由宿主开放、审核比 App 快。缺点是各平台语法与能力不统一、包体积受限、setData 是性能瓶颈、深度依赖平台生态且能力受平台约束。适合依托微信/支付宝等生态的服务型业务。
  • 跨端框架:又分两类,思路不同。
    • JS 驱动原生控件(React Native、Weex):UI 最终是原生控件,体验接近原生,可复用 React 生态与 Web 开发者,支持热更新;但需要处理跨语言通信开销与双端表现差异,复杂场景仍要写原生模块。
    • 自绘引擎(Flutter):自带渲染引擎绘制全部 UI,双端一致性最好、动画性能强、有完整的组件体系;代价是包体积增加、需要学习 Dart、与原生页面混合栈集成较复杂、接入原生 SDK 需要写平台通道。
    • 另有编译型多端框架(Taro、uni-app):一套代码编译到小程序/H5/App,适合多端投放且各端功能一致的业务,代价是受最小公共能力集约束、疑难问题需要下钻到各端原生排查。

选型时应依次回答几个问题:

  • 业务对性能与交互复杂度的要求有多高,是否涉及高帧率动画、复杂手势、长列表、音视频。
  • 迭代频率如何,是否必须绕过应用商店审核周期。
  • 需要覆盖哪些端,各端的用户量级与投入产出比。
  • 团队现有技术栈与人力储备,能否长期维护该方案(框架选型的隐性成本主要体现在踩坑与升级上)。
  • 是否需要平台流量入口(决定要不要做小程序)。

实践中常见的组合是分层选型:核心链路与高频页面用原生或 Flutter/RN,营销活动与低频页面用 H5,平台生态侧用小程序,通过统一的路由与桥接层把它们串起来,而不是全站押注单一技术。

场景题

虚拟列表实现原理

虚拟列表(Virtual List)是一种在数据量较大时,优化显示性能和用户体验的技术。其核心思想是在渲染和显示过程中只加载和显示当前视窗内的部分数据,而不是一次性加载和渲染全部数据。这样可以显著减少内存消耗和提高渲染速度。虚拟列表通常用于长列表或表格组件中,特别是在Web应用和移动应用中。

  • 实现原理
  1. 可视区域(Viewport):虚拟列表只渲染用户当前可见区域内的列表项。用户滚动时,更新可视区域内的列表项。
  2. 缓冲区(Buffer):为了防止滚动时的卡顿,虚拟列表通常会在可视区域上下增加一定数量的缓冲区列表项。这些缓冲区项可以在用户快速滚动时提前加载,保证平滑滚动。
  3. 位置计算(Position Calculation):通过计算每个列表项在整个列表中的位置,只渲染可视区域和缓冲区内的项。使用绝对定位或CSS变换属性将这些项放置在正确的位置。
  4. 事件监听(Event Listener):监听滚动事件,根据滚动位置更新可视区域和缓冲区内的列表项。
  • 具体实现步骤
  1. 初始设置:确定列表项的高度(或宽度),以及可视区域的高度(或宽度)。如果列表项高度不一致,需要预估一个平均值或进行动态调整。
  2. 渲染可视区域内的项:根据当前滚动位置和可视区域大小,计算需要渲染的列表项的起始和结束索引。
  3. 更新DOM:只将计算得到的列表项添加到DOM中,并更新其位置。未在可视区域和缓冲区内的项应从DOM中移除。
  4. 滚动事件处理:监听滚动事件,当滚动位置发生变化时,重新计算需要渲染的列表项,并更新DOM。

有一张1GB的图片需要加载到手机屏幕上,但是手机内存不够怎么办?

  • 懒加载(lazy loading):在内存不足的情况下,可以分步加载图片以避免一次性占用大量内存。懒加载的基本思路是按需加载图片的部分数据,直到整个图片被完全加载。
    • 实现:
    1. 分块加载:将图片分割成多个小块,每次只加载一部分数据,这样可以有效降低每次加载时的内存占用。
    2. 使用缩略图:在用户需要查看图片之前,先加载较小的缩略图,等用户需要查看详细内容时,再加载高清图像。
    3. 图片压缩:在加载之前,将图片压缩成较小的尺寸和质量,然后在需要时加载完整的图片。
    4. 文件流处理:使用文件流处理方式,一边读取一边显示,不需要一次性加载整个图片。
  • 图片压缩:通过压缩图片的分辨率、质量等方式来减小图片的体积,从而减少内存占用。
  • 缓存管理:实现一个智能缓存系统,只缓存当前屏幕上需要显示的图片数据,释放掉那些暂时不需要显示的图片数据。这样可以有效管理内存占用。
  • 调整图片分辨率:加载合适分辨率的图片,既能保证显示效果,又能减少内存使用。
  • 使用外部存储:将大图片存储在外部存储设备(如SD卡)中,而不是手机的内存中。通过读取外部存储中的图片数据来显示。
  • 内存优化:对应用程序进行内存优化,释放不必要的内存占用,以便为大图片的加载腾出空间。这包括清理缓存、减少后台运行的进程等。

大文件上传如何实现

考察点:对文件切分与流式处理的理解、并发控制能力、前后端协作设计、异常恢复与一致性校验。

思路:把一个大文件拆成固定大小的分片(如 5MB)分别上传,服务端收齐后合并。在此基础上,用“已上传分片列表”实现断点续传,用“整文件 hash 查重”实现秒传,用并发池控制同时上传的分片数量。

关键实现:

  • 计算文件唯一标识:用 file.slice 分片后逐片喂给 SparkMD5(或对分片抽样计算)得到 hash 作为 fileId。整文件 hash 计算是 CPU 密集操作,必须放到 Web Worker 中,否则会卡死主线程。超大文件可采用抽样 hash(取首尾各若干字节 + 每片中间若干字节)在速度与碰撞概率之间取舍。
  • 秒传校验:上传前用 fileId 请求服务端 /check,返回 uploaded: true 则直接返回已有文件地址,无需真正上传;返回已上传的分片索引列表则跳过这些分片,实现断点续传。
  • 分片上传:每片带上 fileId、chunkIndex、chunkHash、总片数,服务端按 fileId 建目录存放。
  • 并发控制:用固定大小的请求池(通常 3~6 并发),一片完成再补入下一片,避免一次性发起上千请求打爆浏览器连接数与服务端。
  • 合并:全部分片上传成功后调用 /merge,服务端按索引顺序流式拼接并校验总 hash,成功后清理临时分片。
// 并发池 + 失败重试
async function uploadChunks(chunks, upload, limit = 4, retry = 3) {
let cursor = 0;
const worker = async () => {
while (cursor < chunks.length) {
const i = cursor++;
for (let n = 0; n <= retry; n++) {
try { await upload(chunks[i], i); break; }
// 指数退避,最后一次仍失败则抛出终止
catch (e) { if (n === retry) throw e; await sleep(2 ** n * 500); }
}
}
};
await Promise.all(Array.from({ length: limit }, worker));
}

注意事项:

  • 分片大小需权衡,太小则请求数量爆炸、握手开销占比高,太大则重传成本高,一般 2~10MB。
  • 断点续传的进度要持久化到 localStorage/IndexedDB,刷新页面后仍可恢复。
  • 服务端必须校验分片数量与总 hash,防止拼接出损坏文件;并发合并要加锁避免重复合并。
  • 临时分片需要有过期清理机制,否则磁盘会被未完成的上传占满。
  • 秒传只校验 hash 存在还不够,还要校验当前用户是否有权访问该文件,避免通过碰撞 hash 越权获取他人文件。
  • 上传中断要支持 AbortController 取消,页面卸载时清理未完成的请求。

页面白屏或首屏加载慢,如何定位和优化

考察点:是否具备完整的性能排查链路思维,而不是罗列零散的优化技巧;能否用工具定位瓶颈并量化收益。

思路:先复现并量化(哪个环节耗时最长),再按“网络 -> 资源 -> 渲染 -> 接口”分段定位,最后针对瓶颈段做优化并用同一套指标验证。核心指标是 TTFB、FCP、LCP、TTI 与长任务总时长。

关键实现(排查链路):

  • 网络段:看瀑布图中 DNS、TCP、TLS、TTFB 的耗时。TTFB 高说明服务端处理慢或未用 CDN;握手耗时高考虑 preconnect、HTTP/2、复用长连接。检查是否有 301/302 重定向链。
  • 资源段:看是否存在阻塞渲染的同步 CSS/JS、首屏 chunk 体积是否过大、是否加载了非首屏依赖、图片是否未压缩未适配。用 webpack-bundle-analyzer 找出体积异常的依赖。
  • 渲染段:用 Performance 面板看主线程火焰图,找超过 50ms 的长任务(通常是框架初始化、大量组件挂载、同步计算)。检查是否有强制同步布局、首屏 DOM 数量是否过多。
  • 接口段:首屏数据是否串行请求(需要等 A 返回才发 B)、是否可以并行或合并、慢接口是否可以先渲染骨架再局部填充。
  • 白屏专项:白屏通常意味着 HTML 已到但内容未出。区分几种情况——JS 报错导致渲染中断(看 Console 与错误监控)、路由配置错误、SSR 降级失败、依赖的 CDN 资源加载失败、低端机上 JS 解析执行过慢。

工具:

  • Chrome DevTools Performance:主线程火焰图与长任务,最重要的分析工具。
  • Network 瀑布图:看资源加载顺序、串行依赖与阻塞关系。
  • Lighthouse:给出 FCP/LCP/TBT/CLS 评分与优化建议,适合快速体检与横向对比。
  • Performance API:线上真实用户数据,用 PerformanceObserver 采集 LCP、FID/INP、CLS,配合 performance.getEntriesByType('navigation') 拆解各阶段耗时。
  • Coverage 面板:查看 CSS/JS 的未使用比例,指导代码分割。

优化手段按段落对应:CDN 与服务端缓存、资源压缩与代码分割、关键 CSS 内联、骨架屏与 SSR、接口并行与预请求、图片懒加载与格式优化。

注意事项:先测量再优化,不要凭直觉改;线上真机(尤其低端安卓)与本地开发机差异巨大,必须用线上真实数据(RUM)验证;优化后要有回归监控,防止后续迭代把指标劣化回去。

如何设计一套前端监控体系

考察点:全面性(是否覆盖错误、性能、行为三大类)、工程落地能力(采集、上报、还原、存储)、对性能与隐私影响的权衡。

思路:分为采集层、上报层、服务端处理层、告警与看板四部分。前端只负责“准确采集 + 低损耗上报”,聚合分析交给服务端。

关键实现(采集):

  • JS 运行时错误:window.addEventListener('error') 捕获同步错误,从 event.error.stack 取堆栈。注意 try/catch 内的错误、框架内部错误(Vue 的 errorHandler、React 的 Error Boundary)需要单独接入。
  • 资源加载错误:同样是 error 事件,但资源错误不冒泡,必须在捕获阶段监听并通过 event.target 是否为 DOM 元素来区分。
  • Promise 未捕获异常:window.addEventListener('unhandledrejection')
  • 接口异常:劫持 XMLHttpRequestfetch,记录 url、method、状态码、耗时、业务错误码,对非 2xx 与超时上报。
  • 性能指标:用 PerformanceObserver 采集 LCP、CLS、INP 与 long task,用 Navigation Timing 拆解首屏各阶段耗时,白屏与自定义业务指标(如“列表可交互时间”)用 performance.mark/measure 打点。
  • 行为埋点:三种方式并存——代码埋点(精准但侵入)、可视化埋点(圈选配置)、无埋点/全埋点(全量采集点击与曝光后端筛选)。同时记录用户行为链路(路由跳转、点击、请求)作为错误发生前的 breadcrumbs,是排障的关键上下文。
// 资源错误不冒泡,必须在捕获阶段监听
window.addEventListener('error', (e) => {
if (e.target && (e.target.src || e.target.href)) {
report({ type: 'resource', url: e.target.src || e.target.href });
} else {
report({ type: 'js', msg: e.message, stack: e.error?.stack });
}
}, true);

sourcemap 还原:生产环境代码经过压缩混淆,堆栈里只有 a.js:1:2345。构建时生成 sourcemap 但不部署到 CDN(避免源码泄露),而是上传到监控平台私有存储;服务端拿到错误的行列号后用 source-map 库反查原始文件、行号与函数名。sourcemap 需按版本号归档,与上报数据中的 release 版本对应。

上报策略:

  • 优先用 navigator.sendBeacon,它在页面卸载时仍能可靠发送且不阻塞卸载流程;不支持时降级为 fetch(url, { keepalive: true }) 或 1x1 图片打点(天然跨域、无需 CORS 配置)。
  • 批量与延迟:把多条数据合并,在空闲时(requestIdleCallback)或达到阈值时统一发送,减少请求数。
  • 采样:性能与行为数据按比例采样(如 10%)控制成本,错误数据通常全量上报但需按错误指纹去重限流,避免死循环中的错误把服务打垮。
  • 上报本身失败不能重试到无限循环,监控代码要用 try/catch 包裹,绝不能影响主业务。

注意事项:注意脱敏与合规(不上报手机号、身份证、token 等敏感信息,遵守隐私政策与用户授权);跨域脚本的错误需要 crossorigin 属性配合 CORS 头才能拿到详细堆栈,否则只有 “Script error.”;监控 SDK 自身要控制体积与初始化耗时;建立告警规则(错误率突增、核心指标劣化)并关联发布版本,才能真正闭环。

十万条数据一次性返回,前端如何渲染

考察点:对浏览器渲染管线与主线程阻塞的理解、能否给出多种方案并说明适用边界。

思路:一次性创建十万个 DOM 节点会造成解析、样式计算、布局、绘制的巨量开销,主线程被占满数秒,页面完全卡死。解决方向有三条:不渲染那么多(虚拟化、分页)、分批渲染(时间分片)、换渲染载体(Canvas)。前提是先反问需求——用户真的需要一次看到十万条吗,多数情况下正确的答案是推动接口分页。

关键实现:

  • 时间分片 + requestIdleCallback:把数据分成小批,每次浏览器空闲时渲染一批,保证每帧留出时间给交互与绘制。requestIdleCallbackdeadline.timeRemaining() 能告诉你本帧还剩多少空闲时间。兼容性不足时用 requestAnimationFrame + 时间预算(每帧不超过 8~10ms)模拟。
function renderInChunks(list, container, size = 200) {
let i = 0;
const step = (deadline) => {
// 本帧仍有空闲且未渲染完,就继续渲染下一批
while (i < list.length && deadline.timeRemaining() > 1) {
const frag = document.createDocumentFragment(); // 批量插入,只触发一次回流
for (let n = 0; n < size && i < list.length; n++, i++) {
const li = document.createElement('li');
li.textContent = list[i].name;
frag.appendChild(li);
}
container.appendChild(frag);
}
if (i < list.length) requestIdleCallback(step);
};
requestIdleCallback(step);
}
  • 文档碎片(DocumentFragment):在内存中组装节点,一次性插入 DOM,把 N 次回流降为 1 次。它是所有批量插入场景的基础优化,与时间分片配合使用。
  • 分页与无限滚动:从产品层面切分数据,配合 IntersectionObserver 触底加载下一页。这是最稳妥、体验也最自然的方案,且能同时减轻接口与内存压力。
  • Canvas 渲染:表格/图表类超大数据集用 Canvas 自绘,绕开 DOM 的全部开销,可轻松承载十万级数据的滚动。代价是要自己实现命中检测、文本换行、选中与无障碍支持,开发成本高。大数据表格库(如 Canvas 版表格组件)走的就是这条路。
  • 数据处理本身也别忘了:十万条数据的排序、过滤、格式化同样会阻塞主线程,可放到 Web Worker 中处理。

虚拟列表(只渲染可视区 + 缓冲区,用占位撑起滚动条)是本题的最优解,其原理详见上文虚拟列表相关内容,此处不再展开。

注意事项:

  • 时间分片解决的是“不卡死”,DOM 节点总量仍然是十万,内存与后续操作依然很重,只适合数据量中等或必须全量渲染(如打印、导出预览)的场景。
  • 无论哪种方案,都要避免在滚动回调中做重计算,滚动事件要节流并使用 passive 监听。
  • 首屏优先渲染可视区数据,剩余数据后台处理,让用户尽快看到内容。

页面出现卡顿如何排查

考察点:对“卡顿”成因的分类能力、对浏览器渲染管线的理解、工具使用的熟练度。

思路:卡顿的本质是主线程在一帧的预算(60fps 下约 16.7ms)内没能完成工作。先确定是持续卡顿还是特定操作卡顿,再用 Performance 面板录制复现过程,从火焰图中找出超时的任务并归类。

关键实现(常见成因与定位):

  • 长任务(Long Task):单个任务执行超过 50ms 就会阻塞输入响应。定位方式是 Performance 面板中标红的长任务块,或用 PerformanceObserver 监听 longtask 类型在线上采集。常见来源是大数据量计算、复杂的框架渲染、第三方脚本。解法是时间分片、Web Worker、缓存计算结果、虚拟化渲染。
  • 强制同步布局(layout thrashing):在修改样式后立即读取 offsetWidthclientHeightgetBoundingClientRectscrollTop 等属性,浏览器为返回准确值必须立即执行布局,打断了原本的批处理。在循环中读写交替会造成 N 次布局。Performance 面板会以紫色的 Layout 块并标注 “Forced reflow” 提示。解法是读写分离——先集中读取所有需要的值,再统一写入。
// 反例: 循环中读写交替,每次迭代都强制同步布局
items.forEach((el) => { el.style.width = el.offsetWidth + 10 + 'px'; });

// 正例: 先批量读,再批量写
const widths = items.map((el) => el.offsetWidth);
items.forEach((el, i) => { el.style.width = widths[i] + 10 + 'px'; });
  • 内存泄漏:表现为使用时间越长越卡、最终崩溃。用 Memory 面板做多次堆快照对比(三快照法),观察哪些对象持续增长;Performance 面板的内存曲线若锯齿后基线持续抬高即为泄漏。常见来源是未移除的事件监听与定时器、闭包持有大对象、被全局变量或缓存 Map 持有的已卸载组件、游离 DOM(detached DOM)。
  • 动画掉帧:CSS 动画若作用于 widthtopmargin 会触发 layout 与 paint,应改用 transformopacity,它们可以在合成器线程处理,不占用主线程。用 Rendering 面板的 “Paint flashing” 看重绘区域、”Frame Rendering Stats” 看实时 FPS。will-changetranslateZ(0) 可提升为独立合成层,但滥用会导致图层爆炸、显存暴涨反而更卡。
  • 频繁事件回调:scrollresizemousemoveinput 未做节流防抖,或在回调中操作 DOM。
  • 大量样式重计算:CSS 选择器过于复杂、频繁切换影响范围广的 class 导致全树样式重算。

工具组合:

  • Performance 面板录制:看 Main 轨道的火焰图(找长任务与其调用栈)、Frames 轨道(掉帧位置)、Timings 轨道(关键指标)。
  • Rendering 面板:FPS meter、Paint flashing、Layer borders、Scrolling performance issues。
  • 线上 FPS 监控:用 requestAnimationFrame 统计每秒帧数,低于阈值时上报当时的页面与操作路径。
  • React/Vue DevTools 的 Profiler:定位组件层面的重复渲染。

注意事项:优先在低端真机上复现,高性能设备容易掩盖问题;区分“首屏慢”与“运行时卡顿”,两者的排查路径完全不同;改动后必须用同样的录制方式对比验证,避免主观判断。

登录态与鉴权方案

考察点:对有状态与无状态鉴权的理解、token 生命周期管理、前端存储的安全权衡、跨域与跨系统场景的处理。

思路:先明确两个概念——认证(你是谁)与授权(你能做什么)。前端要解决的是凭证如何获取、如何携带、如何刷新、如何安全存放,以及多系统间如何共享登录态。

Cookie + Session 与 JWT 的对比:

  • Cookie + Session:服务端存储会话状态,客户端只持有 sessionId。优点是可随时在服务端使会话失效(踢下线、改密码即时生效)、凭证本身不含信息不怕解析。缺点是服务端需要维护会话存储(分布式下依赖 Redis 等共享存储)、跨域携带 Cookie 需要处理 SameSite 与 CORS、移动端与多端共用不便。
  • JWT:服务端无状态,token 自带载荷与签名,服务端验签即可。优点是易于水平扩展、天然适合跨域与多端、可携带少量业务信息。缺点是签发后在过期前无法主动失效(需额外的黑名单机制,这又引入了状态)、载荷是 base64 编码而非加密不能放敏感信息、token 体积比 sessionId 大。
  • 实践中常用折中:JWT 做短时效的 access token,配合服务端可控的 refresh token 或版本号机制来实现失效能力。

access token + refresh token 的无感刷新:

  • access token 有效期短(如 15 分钟),随请求携带;refresh token 有效期长(如 7 天),仅在刷新接口使用,不参与日常请求。
  • 拦截器中判断响应为 401 或 token 即将过期时,调用刷新接口换取新 token,然后自动重放原请求,用户无感知。
  • 关键点是并发控制:多个请求同时 401 会触发多次刷新。解决方式是维护一个“正在刷新”的标志与等待队列,只让第一个请求去刷新,其余请求挂起等待刷新完成后统一重放。
let refreshing = null;          // 复用同一个刷新 Promise,避免并发重复刷新
async function ensureToken() {
if (!refreshing) refreshing = doRefresh().finally(() => { refreshing = null; });
return refreshing; // 并发请求共享同一次刷新
}

instance.interceptors.response.use(null, async (err) => {
const cfg = err.config;
if (err.response?.status !== 401 || cfg._retried) throw err;
cfg._retried = true; // 防止刷新后仍 401 导致死循环
await ensureToken();
return instance(cfg); // 刷新完成后重放原请求
});
  • refresh token 应支持轮换(每次刷新返回新的 refresh token 并作废旧的),若检测到旧 token 被重复使用,说明可能泄露,应作废整个会话族。

单点登录(SSO)的实现思路:

  • 同主域下的多个子域:把 Cookie 的 domain 设为父域(如 .example.com)即可天然共享,最简单。
  • 完全不同域:采用中心认证服务(CAS/OAuth2/OIDC)。用户访问业务系统 A 未登录时重定向到认证中心,认证中心在自己域下写入全局会话 Cookie 并带票据(ticket/code)跳回 A,A 用票据换取用户信息与 token 建立本地会话;之后访问系统 B 时认证中心发现全局会话已存在,直接下发票据完成静默登录。全局登出则由认证中心作废全局会话并通知各接入方(后端通道通知或前端逐个跳转登出地址)。

前端存储 token 的安全考量:

  • localStorage:JS 可读,一旦发生 XSS 即被窃取;但不受 CSRF 影响,且跨域接口携带方便。
  • Cookie 配置 HttpOnly + Secure + SameSite=Lax/Strict:JS 无法读取,能有效防 XSS 窃取,但需要防范 CSRF(配合 CSRF token 或 SameSite)。这是存放长效凭证的推荐方式。
  • 推荐组合:refresh token 放 HttpOnly Cookie,access token 只保存在内存中(JS 变量或状态管理),页面刷新时用 refresh 换取新的 access token。内存存储避免了持久化泄露,安全性最好。
  • 无论哪种方案,XSS 都是根本威胁,必须做好输入输出转义与 CSP;全站 HTTPS、token 最小权限与最短有效期同样是基础要求。

前端权限控制怎么做

考察点:权限模型的理解(RBAC)、前后端职责划分、动态路由与细粒度控制的工程实现。

思路:确立一个基本原则——前端权限控制只解决“体验”问题(不该看到的不展示、不该点的不可点),真正的安全边界必须由后端在每个接口上校验。前端做的是 UI 层的过滤与引导,后端做的是不可绕过的拦截。控制粒度自上而下分为页面(路由)、菜单、按钮/字段三层。

关键实现:

  • 数据模型:通常采用 RBAC(用户-角色-权限)。后端返回的最佳形态是扁平的权限码列表(如 ['order:list', 'order:export', 'user:delete']),而不是角色名。前端只判断权限码,角色与权限码的映射关系由后端维护,这样新增角色时前端无需改动。菜单则由后端返回树形结构,包含路径、名称、图标、组件标识与所需权限码。
  • 路由权限:分为两种实现路线。
    • 前端全量注册路由 + 路由守卫过滤:所有路由都写在前端,登录后根据权限码给路由打标记,beforeEach 中判断无权限则跳转 403。实现简单,缺点是路由表暴露在前端代码中。
    • 动态路由注册:前端只注册登录、404 等基础路由,登录后拿到后端返回的菜单/路由数据,用 router.addRoute 动态挂载。安全性与灵活性更好,是中后台系统的主流做法。
  • 动态路由的关键细节:后端返回的是字符串形式的组件路径,前端需要用 import.meta.glob(Vite)或 require.context(Webpack)建立“路径 -> 组件”的映射表来完成动态导入;动态添加路由后必须补一个通配的 404 路由并放在最后,否则会在路由未挂载完成时误跳 404;刷新页面时路由表会丢失,需要在守卫中检测并重新拉取权限后 next({ ...to, replace: true }) 重新进入。
  • 菜单权限:菜单数据与路由数据同源,侧边栏直接由过滤后的菜单树渲染,避免菜单与路由两套配置不一致。
  • 按钮级权限:用自定义指令或组件包装,判断权限码决定是否渲染。
// 自定义指令: v-permission="'order:export'"
app.directive('permission', {
mounted(el, binding) {
const codes = store.state.user.permissions;
const need = binding.value;
const ok = Array.isArray(need) ? need.some((c) => codes.includes(c)) : codes.includes(need);
// 直接移除节点而非隐藏,避免通过样式面板恢复可见
if (!ok) el.parentNode?.removeChild(el);
},
});
  • 字段级权限:表格列、表单项按权限码过滤,敏感字段由后端直接脱敏返回,前端不做解密。

注意事项:

  • 前端隐藏按钮不等于安全,接口层必须独立鉴权,否则用户直接调接口即可越权。
  • 权限变更的实时性:管理员调整权限后,已登录用户的权限缓存需要有失效机制(token 中带权限版本号,后端发现版本不一致返回特定错误码,前端强制重新拉取或重新登录)。
  • 避免把权限判断散落在业务组件里,应收敛到统一的 hasPermission 工具函数与指令中。
  • 前后端要约定统一的权限码命名规范(如 模块:资源:操作),并有一份共同维护的权限清单。

如何防止表单重复提交

考察点:对幂等性的理解、前后端职责划分、边界场景(网络重试、页面刷新、多端并发)的考虑。

思路:重复提交有多个来源——用户连点、网络慢导致用户以为没成功再点、前端自动重试、页面刷新重放。前端能拦住大部分,但网络层与多端并发只能靠服务端幂等兜底。所以正确答案是“前端做体验拦截,后端做最终保证”。

关键实现:

  • 状态锁 + 按钮禁用:提交时立即置 loading = true 并禁用按钮,请求结束(无论成败)在 finally 中复位。这是最基础也最有效的一层。注意要用变量锁而非仅依赖按钮 disabled,因为表单也可能通过回车提交。
  • 节流/防抖:对提交按钮做节流(首次立即执行,冷却期内忽略后续点击)。防抖不适合提交场景,因为它会延迟首次执行。
  • 请求唯一 id / 幂等 token:进入表单页时向服务端申请一个一次性 token,提交时带上。服务端校验该 token 未被使用则处理业务并作废 token,已使用则直接返回上一次的处理结果。这能同时解决页面刷新重放与网络重试导致的重复。
  • 请求层去重:在 axios 拦截器中维护一个 pending 请求 Map,key 由 method + url + 参数序列化组成,相同 key 的请求在前一个未完成时直接拒绝或复用其 Promise。
const pending = new Map();
const keyOf = (c) => [c.method, c.url, JSON.stringify(c.data || {})].join('&');

instance.interceptors.request.use((config) => {
const key = keyOf(config);
if (pending.has(key)) {
// 相同请求正在进行中,直接中断本次,不再发出
return Promise.reject(new Error('duplicated request'));
}
pending.set(key, true);
return config;
});
// 无论成功失败都要清理,否则该请求将永久无法再发
const clean = (cfgOrErr) => { const c = cfgOrErr.config || cfgOrErr; pending.delete(keyOf(c)); };
instance.interceptors.response.use((r) => (clean(r), r), (e) => (clean(e), Promise.reject(e)));
  • 提交成功后的处理:立即跳转、清空表单或把按钮置为不可用状态,避免用户停留在原页面反复操作。
  • 后端幂等:以业务唯一键(订单号、外部流水号、幂等 token)建唯一索引,重复插入直接返回已有结果;或用 Redis 的 SETNX + 过期时间做分布式锁。这是唯一能覆盖所有场景的兜底手段。

注意事项:

  • 只做前端拦截是不够的——用户可以用两个标签页、可以直接调接口、网络层也可能自动重发。
  • 幂等 token 要有过期时间,且必须与用户绑定,避免被他人复用。
  • 自动重试逻辑与幂等设计必须配套,否则重试反而会制造重复数据;非幂等接口不应自动重试。
  • finally 中复位状态时要注意组件是否已卸载,避免对已销毁组件设置状态。
  • 表单校验失败时不应占用锁,否则用户改完还得等冷却。

图片懒加载的多种实现与预加载、渐进式加载

考察点:对可视区判断方式的掌握、性能差异的理解、加载体验的完整设计。

思路:懒加载的核心是“延迟设置真实 src,直到图片进入或接近可视区”。判断可见性有三种手段,性能与兼容性依次不同。同时要配合占位与预加载,避免布局抖动和滚动时的空白。

关键实现:

  • IntersectionObserver(首选):浏览器原生的异步交叉观察器,回调在非主线程调度、不触发布局计算,性能远优于滚动监听。用 rootMargin 提前若干像素开始加载。
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
const img = e.target;
img.src = img.dataset.src; // 真实地址存在 data-src 上
io.unobserve(img); // 加载后取消观察,避免重复触发
});
}, { rootMargin: '200px 0px' }); // 提前 200px 预加载,滚动时不至于空白

document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
  • scroll + getBoundingClientRect(兜底方案):监听滚动,计算 rect.top < window.innerHeight 判断进入可视区。必须节流并使用 { passive: true },否则每次滚动都触发布局计算,是典型的性能杀手。仅在需要兼容老浏览器时使用。
  • 原生 loading="lazy":给 img/iframe 加上属性即可,零 JS 成本,由浏览器决定加载时机。缺点是加载时机不可控(不同浏览器的提前量策略不同)、无法自定义占位与失败重试逻辑。适合内容型页面的简单场景,可作为渐进增强与 IntersectionObserver 配合使用。

预加载与渐进式加载:

  • 预加载:对即将用到的关键图(首屏大图、下一页首图、轮播下一张)用 <link rel="preload" as="image">new Image().src = url 提前下载进缓存,切换时瞬间显示。注意预加载会争抢带宽,只对高确定性的资源使用。
  • 占位与防抖动:必须给图片设置明确的宽高或 aspect-ratio,否则加载完成后内容跳动,CLS 指标劣化。占位可用纯色块、模糊小图(LQIP,几百字节的缩略图放大后作为背景,加载完成淡入替换)或 SVG 骨架。
  • 渐进式加载:使用渐进式 JPEG 或交错式 PNG,让图片由模糊到清晰逐步呈现,弱网下感知速度明显更好;也可以先加载低清图再加载高清图并做淡入过渡。
  • 响应式图片:用 srcset + sizes 让浏览器按 DPR 与容器宽度选择合适尺寸,用 <picture> + type 优先下发 WebP/AVIF 并保留 JPEG 兜底。

注意事项:

  • 首屏图片不要懒加载,尤其是 LCP 元素,懒加载会推迟其加载时机反而拉低指标,应该用 fetchpriority="high" 提升优先级。
  • 观察器要在组件卸载时 disconnect(),动态列表新增节点要及时 observe
  • 处理加载失败:监听 error 事件替换为兜底图,避免出现裂图。
  • 图片本身的体积优化(格式、压缩、按容器尺寸裁剪)比懒加载的收益往往更大,两者应同时做。

如何做并发请求控制与请求去重

考察点:异步流程控制的编码能力(手写并发池)、对浏览器连接数限制的理解、请求生命周期管理与容错设计。

思路:浏览器对同一域名的并发连接数有限制(HTTP/1.1 下通常 6 个),超量请求会排队,且大量并发还会挤占带宽、拖慢关键请求,因此需要主动限流。去重则是避免同一份数据被重复拉取,既省流量也防止状态竞争。

关键实现(并发池):

// 固定并发数的请求池: 完成一个立刻补一个,而非分批等待
function createPool(limit = 5) {
let active = 0;
const queue = [];
const next = () => {
if (active >= limit || !queue.length) return;
active++;
const { task, resolve, reject } = queue.shift();
// 无论成败都要释放名额,否则池子会被占死
task().then(resolve, reject).finally(() => { active--; next(); });
};
return (task) => new Promise((resolve, reject) => (queue.push({ task, resolve, reject }), next()));
}

const run = createPool(5);
// allSettled 避免单个失败导致整体中断
const results = await Promise.allSettled(urls.map((u) => run(() => fetch(u))));

要点是“完成一个立刻补一个”,而不是把任务切成每组 5 个用 Promise.all 分批——后者会被每批中最慢的请求拖住,吞吐明显更低。

请求去重与竞态处理:

  • 相同请求合并(in-flight 去重):以 method + url + 参数为 key 缓存正在进行的 Promise,重复调用直接返回同一个 Promise,请求结束后从缓存移除。适合多个组件同时请求同一份字典数据的场景。
  • 取消过期请求:搜索联想、快速切换 tab 时,旧请求结果已无意义且可能后到覆盖新结果。发起新请求前先对上一次的 AbortController 调用 abort(),再新建一个并把 signal 传给 fetch。被主动取消的请求会抛出 nameAbortError 的异常,捕获时必须与真实错误区分开,不能当作失败上报或提示用户。
  • 竞态兜底:即使不取消,也应记录请求序号或时间戳,只采纳最新一次请求的结果,丢弃后到的旧响应。

失败重试与降级:

  • 重试:仅对幂等请求与网络类错误(超时、5xx、断网)重试,4xx 参数错误重试无意义;采用指数退避加随机抖动并限制最大次数,避免服务端抖动时的重试风暴。
  • 熔断与降级:连续失败达阈值后短时间内快速失败,给后端恢复窗口;核心接口失败时使用本地缓存、默认配置或简化 UI 保证可用,非核心接口失败静默处理。
  • 超时控制:fetch 本身没有超时选项,需用 AbortController + setTimeout 实现,或直接使用 AbortSignal.timeout()

注意事项:

  • 池中任务必须是“函数返回 Promise”而非已创建的 Promise,否则请求在入队时就已发出,限流失效。
  • finally 中释放名额与清理去重缓存不能遗漏,否则会出现池子占满或请求永久无法重发的死锁。
  • 去重的 key 要包含所有影响结果的参数(含请求体),否则会返回错误的缓存结果。
  • HTTP/2 已支持多路复用,连接数不再是瓶颈,但服务端压力与带宽仍需限流;组件卸载时应取消其发起的未完成请求。