# 进阶篇

# 一、JS基础

# 1 类型及检测方式

⚡ 30 秒速记

  • 原始类型 7 个:numberstringbooleannullundefinedsymbolbigint;其余都是引用类型
  • 存储差异:原始类型值存栈上,引用类型值在堆上、栈里只存地址
  • 四种检测手段:typeof(原始够用,null 和引用类型分不清)、instanceof(看原型链,跨 iframe 失效)、constructor(可被改写)、Object.prototype.toString.call()(最准)
  • typeof null === 'object' 是历史遗留 bug,判断数组一律用 Array.isArray
  • 加分点:Object.prototype.toString 之所以准,是因为它读的是内部标签 Symbol.toStringTag

JavaScript 有 7 种基本类型和 Object 引用类型,检测时要根据目标选择 typeofinstanceofObject.prototype.toString.call() typeof 适合基本类型和函数,但 typeof null、数组及普通对象都会得到 objectinstanceof 本质上检查原型链,适合引用类型,却不能正确判断基本类型,而且 constructor 可能被修改。需要精确区分数组、日期、正则等对象时,我一般会用 Object.prototype.toString.call()

1. JS内置类型

JavaScript 的数据类型有下图所示

其中,前 7 种类型为基础类型,最后 1 种(Object)为引用类型,也是你需要重点关注的,因为它在日常工作中是使用得最频繁,也是需要关注最多技术细节的数据类型

  • JavaScript一共有8种数据类型,其中有7种基本数据类型:UndefinedNullBooleanNumberStringSymboles6新增,表示独一无二的值)和BigIntes10新增);
  • 1种引用数据类型——Object(Object本质上是由一组无序的名值对组成的)。里面包含 function、Array、Date等。JavaScript不支持任何创建自定义类型的机制,而所有值最终都将是上述 8 种数据类型之一。
    • 引用数据类型: 对象Object(包含普通对象-Object,数组对象-Array,正则对象-RegExp,日期对象-Date,数学函数-Math,函数对象-Function

在这里,我想先请你重点了解下面两点,因为各种 JavaScript 的数据类型最后都会在初始化之后放在不同的内存中,因此上面的数据类型大致可以分成两类来进行存储:

  • 原始数据类型:基础类型存储在栈内存,被引用或拷贝时,会创建一个完全相等的变量;占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。
  • 引用数据类型:引用类型存储在堆内存,存储的是地址,多个引用指向同一个地址,这里会涉及一个“共享”的概念;占据空间大、大小不固定。引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

JavaScript 中的数据是如何存储在内存中的?

在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间栈空间堆空间。其中的代码空间主要是存储可执行代码的,原始类型(Number、String、Null、Undefined、Boolean、Symbol、BigInt)的数据值都是直接保存在“栈”中的,引用类型(Object)的值是存放在“堆”中的。因此在栈空间中(执行上下文),原始类型存储的是变量的值,而引用类型存储的是其在"堆空间"中的地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。

在编译过程中,如果 JavaScript 引擎判断到一个闭包,也会在堆空间创建换一个“closure(fn)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存闭包中的变量。所以闭包中的变量是存储在“堆空间”中的。

JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。因此需要“栈”和“堆”两种空间。

题目一:初出茅庐

let a = {
  name: 'lee',
  age: 18
}
let b = a;
console.log(a.name);  //第一个console
b.name = 'son';
console.log(a.name);  //第二个console
console.log(b.name);  //第三个console

这道题比较简单,我们可以看到第一个 console 打出来 name 是 'lee',这应该没什么疑问;但是在执行了 b.name='son' 之后,结果你会发现 a 和 b 的属性 name 都是 'son',第二个和第三个打印结果是一样的,这里就体现了引用类型的“共享”的特性,即这两个值都存在同一块内存中共享,一个发生了改变,另外一个也随之跟着变化。

你可以直接在 Chrome 控制台敲一遍,深入理解一下这部分概念。下面我们再看一段代码,它是比题目一稍复杂一些的对象属性变化问题。

题目二:渐入佳境

let a = {
  name: 'Julia',
  age: 20
}
function change(o) {
  o.age = 24;
  o = {
    name: 'Kath',
    age: 30
  }
  return o;
}
let b = change(a);     // 注意这里没有new,后面new相关会有专门文章讲解
console.log(b.age);    // 第一个console
console.log(a.age);    // 第二个console

这道题涉及了 function,你通过上述代码可以看到第一个 console 的结果是 30b 最后打印结果是 {name: "Kath", age: 30};第二个 console 的返回结果是 24,而 a 最后的打印结果是 {name: "Julia", age: 24}

是不是和你预想的有些区别?你要注意的是,这里的 functionreturn 带来了不一样的东西。

原因在于:函数传参进来的 o,传递的是对象在堆中的内存地址值,通过调用 o.age = 24(第 7 行代码)确实改变了 a 对象的 age 属性;但是第 12 行代码的 return 却又把 o 变成了另一个内存地址,将 {name: "Kath", age: 30} 存入其中,最后返回 b 的值就变成了 {name: "Kath", age: 30}。而如果把第 12 行去掉,那么 b 就会返回 undefined

2. 数据类型检测

(1)typeof

typeof 对于原始类型来说,除了 null 都可以显示正确的类型

console.log(typeof 2);               // number
console.log(typeof true);            // boolean
console.log(typeof 'str');           // string
console.log(typeof []);              // object     []数组的数据类型在 typeof 中被解释为 object
console.log(typeof function(){});    // function
console.log(typeof {});              // object
console.log(typeof undefined);       // undefined
console.log(typeof null);            // object     null 的数据类型被 typeof 解释为 object

typeof 对于对象来说,除了函数都会显示 object,所以说 typeof 并不能准确判断变量到底是什么类型,所以想判断一个对象的正确类型,这时候可以考虑使用 instanceof

(2)instanceof

instanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype

console.log(2 instanceof Number);                    // false
console.log(true instanceof Boolean);                // false
console.log('str' instanceof String);                // false
console.log([] instanceof Array);                    // true
console.log(function(){} instanceof Function);       // true
console.log({} instanceof Object);                   // true
// console.log(undefined instanceof Undefined);
// console.log(null instanceof Null);
  • instanceof 可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型;
  • typeof 也存在弊端,它虽然可以判断基础数据类型(null 除外),但是引用数据类型中,除了 function 类型以外,其他的也无法判断
// 我们也可以试着实现一下 instanceof
function _instanceof(left, right) {
    // 由于instance要检测的是某对象,需要有一个前置判断条件
    //基本数据类型直接返回false
    if(typeof left !== 'object' || left === null) return false;

    // 获得类型的原型
    let prototype = right.prototype
    // 获得对象的原型
    left = left.__proto__
    // 判断对象的类型是否等于类型的原型
    while (true) {
    	if (left === null)
    		return false
    	if (prototype === left)
    		return true
    	left = left.__proto__
    }
}

console.log('test', _instanceof(null, Array)) // false
console.log('test', _instanceof([], Array)) // true
console.log('test', _instanceof('', Array)) // false
console.log('test', _instanceof({}, Object)) // true

(3)constructor

console.log((2).constructor === Number); // true
console.log((true).constructor === Boolean); // true
console.log(('str').constructor === String); // true
console.log(([]).constructor === Array); // true
console.log((function() {}).constructor === Function); // true
console.log(({}).constructor === Object); // true

这里有一个坑,如果我创建一个对象,更改它的原型,constructor就会变得不可靠了

function Fn(){};

Fn.prototype=new Array();

var f=new Fn();

console.log(f.constructor===Fn);    // false
console.log(f.constructor===Array); // true

(4)Object.prototype.toString.call()

toString()Object 的原型方法,调用该方法,可以统一返回格式为 “[object Xxx]” 的字符串,其中 Xxx 就是对象的类型。对于 Object 对象,直接调用 toString() 就能返回 [object Object];而对于其他对象,则需要通过 call 来调用,才能返回正确的类型信息。我们来看一下代码。

Object.prototype.toString({})       // "[object Object]"
Object.prototype.toString.call({})  // 同上结果,加上call也ok
Object.prototype.toString.call(1)    // "[object Number]"
Object.prototype.toString.call('1')  // "[object String]"
Object.prototype.toString.call(true)  // "[object Boolean]"
Object.prototype.toString.call(function(){})  // "[object Function]"
Object.prototype.toString.call(null)   //"[object Null]"
Object.prototype.toString.call(undefined) //"[object Undefined]"
Object.prototype.toString.call(/123/g)    //"[object RegExp]"
Object.prototype.toString.call(new Date()) //"[object Date]"
Object.prototype.toString.call([])       //"[object Array]"
Object.prototype.toString.call(document)  //"[object HTMLDocument]"
Object.prototype.toString.call(window)   //"[object Window]"

// 从上面这段代码可以看出,Object.prototype.toString.call() 可以很好地判断引用类型,甚至可以把 document 和 window 都区分开来。

实现一个全局通用的数据类型判断方法,来加深你的理解,代码如下

function getType(obj){
  let type  = typeof obj;
  if (type !== "object") {    // 先进行typeof判断,如果是基础数据类型,直接返回
    return type;
  }
  // 对于typeof返回结果是object的,再进行如下的判断,正则返回结果
  return Object.prototype.toString.call(obj).replace(/^\[object (\S+)\]$/, '$1');  // 注意正则中间有个空格
}
/* 代码验证,需要注意大小写,哪些是typeof判断,哪些是toString判断?思考下 */
getType([])     // "Array" typeof []是object,因此toString返回
getType('123')  // "string" typeof 直接返回
getType(window) // "Window" toString返回
getType(null)   // "Null"首字母大写,typeof null是object,需toString来判断
getType(undefined)   // "undefined" typeof 直接返回
getType()            // "undefined" typeof 直接返回
getType(function(){}) // "function" typeof能判断,因此首字母小写
getType(/123/g)      //"RegExp" toString返回

小结

  • typeof
    • 直接在计算机底层基于数据类型的值(二进制)进行检测
    • typeof nullobject 原因是对象存在在计算机中,都是以000开始的二进制存储,所以检测出来的结果是对象
    • typeof 普通对象/数组对象/正则对象/日期对象 都是object
    • typeof NaN === 'number'
  • instanceof
    • 检测当前实例是否属于这个类的
    • 底层机制:只要当前类出现在实例的原型上,结果都是true
    • 不能检测基本数据类型
  • constructor
    • 支持基本类型
    • constructor可以随便改,也不准
  • Object.prototype.toString.call([val])
    • 返回当前实例所属类信息

判断 Target 的类型,单单用 typeof 并无法完全满足,这其实并不是 bug,本质原因是 JS 的万物皆对象的理论。因此要真正完美判断时,我们需要区分对待:

  • 基本类型(null): 使用 String(null)
  • 基本类型(string / number / boolean / undefined) + function: - 直接使用 typeof即可
  • 其余引用类型(Array / Date / RegExp Error): 调用toString后根据[object XXX]进行判断

3. 数据类型转换

我们先看一段代码,了解下大致的情况。

'123' == 123   // false or true?
'' == null    // false or true?
'' == 0        // false or true?
[] == 0        // false or true?
[] == ''       // false or true?
[] == ![]      // false or true?
null == undefined //  false or true?
Number(null)     // 返回什么?
Number('')      // 返回什么?
parseInt('');    // 返回什么?
{}+10           // 返回什么?
let obj = {
    [Symbol.toPrimitive]() {
        return 200;
    },
    valueOf() {
        return 300;
    },
    toString() {
        return 'Hello';
    }
}
console.log(obj + 200); // 这里打印出来是多少?

首先我们要知道,在 JS 中类型转换只有三种情况,分别是:

  • 转换为布尔值
  • 转换为数字
  • 转换为字符串

转Boolean

在条件判断时,除了 undefinednullfalseNaN''0-0,其他所有值都转为 true,包括所有对象

Boolean(0)          //false
Boolean(null)       //false
Boolean(undefined)  //false
Boolean(NaN)        //false
Boolean(1)          //true
Boolean(13)         //true
Boolean('12')       //true

对象转原始类型

对象在转换类型的时候,会调用内置的 [[ToPrimitive]] 函数,对于该函数来说,算法逻辑一般来说如下

  • 如果已经是原始类型了,那就不需要转换了
  • 调用 x.valueOf(),如果转换为基础类型,就返回转换的值
  • 调用 x.toString(),如果转换为基础类型,就返回转换的值
  • 如果都没有返回原始类型,就会报错

当然你也可以重写 Symbol.toPrimitive,该方法在转原始类型时调用优先级最高。

let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  },
  [Symbol.toPrimitive]() {
    return 2
  }
}
1 + a // => 3

四则运算符

它有以下几个特点:

  • 运算中其中一方为字符串,那么就会把另一方也转换为字符串
  • 如果一方不是字符串或者数字,那么会将它转换为数字或者字符串
1 + '1' // '11'
true + true // 2
4 + [1,2,3] // "41,2,3"
  • 对于第一行代码来说,触发特点一,所以将数字 1 转换为字符串,得到结果 '11'
  • 对于第二行代码来说,触发特点二,所以将 true 转为数字 1
  • 对于第三行代码来说,触发特点二,所以将数组通过 toString转为字符串 1,2,3,得到结果 41,2,3

另外对于加法还需要注意这个表达式 'a' + + 'b'

'a' + + 'b' // -> "aNaN"
  • 因为 + 'b' 等于 NaN,所以结果为 "aNaN",你可能也会在一些代码中看到过 + '1'的形式来快速获取 number 类型。
  • 那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字
4 * '3' // 12
4 * [] // 0
4 * [1, 2] // NaN

比较运算符

  • 如果是对象,就通过 toPrimitive 转换对象
  • 如果是字符串,就通过 unicode 字符索引来比较
let a = {
  valueOf() {
    return 0
  },
  toString() {
    return '1'
  }
}
a > -1 // true

在以上代码中,因为 a 是对象,所以会通过 valueOf 转换为原始类型再比较值。

强制类型转换

强制类型转换方式包括 Number()parseInt()parseFloat()toString()String()Boolean(),这几种方法都比较类似

  • Number() 方法的强制转换规则
  • 如果是布尔值,truefalse 分别被转换为 10
  • 如果是数字,返回自身;
  • 如果是 null,返回 0
  • 如果是 undefined,返回 NaN
  • 如果是字符串,遵循以下规则:如果字符串中只包含数字(或者是 0X / 0x 开头的十六进制数字字符串,允许包含正负号),则将其转换为十进制;如果字符串中包含有效的浮点格式,将其转换为浮点数值;如果是空字符串,将其转换为 0;如果不是以上格式的字符串,均返回 NaN;
  • 如果是 Symbol,抛出错误;
  • 如果是对象,并且部署了 [Symbol.toPrimitive] ,那么调用此方法,否则调用对象的 valueOf() 方法,然后依据前面的规则转换返回的值;如果转换的结果是 NaN ,则调用对象的 toString() 方法,再次依照前面的顺序转换返回对应的值。
Number(true);        // 1
Number(false);       // 0
Number('0111');      //111
Number(null);        //0
Number('');          //0
Number('1a');        //NaN
Number(-0X11);       //-17
Number('0X11')       //17

Object 的转换规则

对象转换的规则,会先调用内置的 [ToPrimitive] 函数,其规则逻辑如下:

  • 如果部署了 Symbol.toPrimitive 方法,优先调用再返回;
  • 调用 valueOf(),如果转换为基础类型,则返回;
  • 调用 toString(),如果转换为基础类型,则返回;
  • 如果都没有返回基础类型,会报错。
var obj = {
  value: 1,
  valueOf() {
    return 2;
  },
  toString() {
    return '3'
  },
  [Symbol.toPrimitive]() {
    return 4
  }
}
console.log(obj + 1); // 输出5
// 因为有Symbol.toPrimitive,就优先执行这个;如果Symbol.toPrimitive这段代码删掉,则执行valueOf打印结果为3;如果valueOf也去掉,则调用toString返回'31'(字符串拼接)
// 再看两个特殊的case:
10 + {}
// "10[object Object]",注意:{}会默认调用valueOf是{},不是基础类型继续转换,调用toString,返回结果"[object Object]",于是和10进行'+'运算,按照字符串拼接规则来,参考'+'的规则C
[1,2,undefined,4,5] + 10
// "1,2,,4,510",注意[1,2,undefined,4,5]会默认先调用valueOf结果还是这个数组,不是基础数据类型继续转换,也还是调用toString,返回"1,2,,4,5",然后再和10进行运算,还是按照字符串拼接规则,参考'+'的第3条规则

'==' 的隐式类型转换规则

  • 如果类型相同,无须进行类型转换;
  • 如果其中一个操作值是 null 或者 undefined,那么另一个操作符必须为 null 或者 undefined,才会返回 true,否则都返回 false
  • 如果其中一个是 Symbol 类型,那么返回 false
  • 两个操作值如果为 string 和 number 类型,那么就会将字符串转换为 number
  • 如果一个操作值是 boolean,那么转换成 number
  • 如果一个操作值为 object 且另一方为 stringnumber 或者 symbol,就会把 object 转为原始类型再进行判断(调用 objectvalueOf/toString 方法进行转换)。
null == undefined       // true  规则2
null == 0               // false 规则2
'' == null              // false 规则2
'' == 0                 // true  规则4 字符串转隐式转换成Number之后再对比
'123' == 123            // true  规则4 字符串转隐式转换成Number之后再对比
0 == false              // true  e规则 布尔型隐式转换成Number之后再对比
1 == true               // true  e规则 布尔型隐式转换成Number之后再对比
var a = {
  value: 0,
  valueOf: function() {
    this.value++;
    return this.value;
  }
};
// 注意这里a又可以等于1、2、3
console.log(a == 1 && a == 2 && a ==3);  //true f规则 Object隐式转换
// 注:但是执行过3遍之后,再重新执行a==3或之前的数字就是false,因为value已经加上去了,这里需要注意一下

'+' 的隐式类型转换规则

'+' 号操作符,不仅可以用作数字相加,还可以用作字符串拼接。仅当 '+' 号两边都是数字时,进行的是加法运算;如果两边都是字符串,则直接拼接,无须进行隐式类型转换。

  • 如果其中有一个是字符串,另外一个是 undefinednull 或布尔型,则调用 toString() 方法进行字符串拼接;如果是纯对象、数组、正则等,则默认调用对象的转换方法会存在优先级,然后再进行拼接。
  • 如果其中有一个是数字,另外一个是 undefinednull、布尔型或数字,则会将其转换成数字进行加法运算,对象的情况还是参考上一条规则。
  • 如果其中一个是字符串、一个是数字,则按照字符串规则进行拼接
1 + 2        // 3  常规情况
'1' + '2'    // '12' 常规情况
// 下面看一下特殊情况
'1' + undefined   // "1undefined" 规则1,undefined转换字符串
'1' + null        // "1null" 规则1,null转换字符串
'1' + true        // "1true" 规则1,true转换字符串
'1' + 1n          // '11' 比较特殊字符串和BigInt相加,BigInt转换为字符串
1 + undefined     // NaN  规则2,undefined转换数字相加NaN
1 + null          // 1    规则2,null转换为0
1 + true          // 2    规则2,true转换为1,二者相加为2
1 + 1n            // 错误  不能把BigInt和Number类型直接混合相加
'1' + 3           // '13' 规则3,字符串拼接

整体来看,如果数据中有字符串,JavaScript 类型转换还是更倾向于转换成字符串,因为第三条规则中可以看到,在字符串和数字相加的过程中最后返回的还是字符串,这里需要关注一下

null 和 undefined 的区别?

  • 首先 UndefinedNull 都是基本数据类型,这两个基本数据类型分别都只有一个值,就是 undefinednull
  • undefined 代表的含义是未定义, null 代表的含义是空对象(其实不是真的对象,请看下面的注意!)。一般变量声明了但还没有定义的时候会返回 undefinednull 主要用于赋值给一些可能会返回对象的变量,作为初始化。

其实 null 不是对象,虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。

  • undefined 在 js 中不是一个保留字,这意味着我们可以使用 undefined 来作为一个变量名,这样的做法是非常危险的,它会影响我们对 undefined 值的判断。但是我们可以通过一些方法获得安全的 undefined 值,比如说 void 0
  • 当我们对两种类型使用 typeof 进行判断的时候,Null 类型化会返回 “object”,这是一个历史遗留的问题。当我们使用双等号对两种类型的值进行比较时会返回 true,使用三个等号时会返回 false。

💬 面试官追问

  • 埋点 SDKtypeof payload === 'object' 判断可展开数据,线上收到 null 后在 Object.keys(payload) 报错,判断条件该怎样收紧?

    应先排除 null,至少写成 payload !== null && typeof payload === 'object',因为 typeof null 的结果也是 object。若只接受普通对象,还要继续排除数组、日期等引用类型;仅靠 typeof 无法完成这种细分。

  • 表单校验要区分数组、日期、正则和普通对象,数据量不大但来源复杂,你会统一使用哪种检测方式,返回值要注意什么?

    可使用 Object.prototype.toString.call(value) 获取形如 [object Array][object Date] 的标签,再统一提取类型名。它比 typeof 更适合区分这些内置引用类型;返回值的大小写和格式必须固定,否则调用方容易把 Arrayarray 混用。

  • 微前端页面从另一个 iframe 传来数组,当前代码用 value instanceof Array 却返回 false,为什么,应该换什么判断?

    instanceof 检查右侧构造函数的 prototype 是否出现在对象原型链上,不同 iframe 拥有不同的内建构造函数和原型。判断数组应使用 Array.isArray(value),或在统一类型工具中使用 Object.prototype.toString.call;跨执行环境时不要依赖当前窗口的 Array.prototype

  • 线上有个对象被库代码替换了原型,随后 value.constructor === Model 判断失效,你会按什么顺序排查?

    先查看对象当前原型链以及 constructor 属性来自哪里,因为替换 prototype 或覆盖 constructor 都会让该判断失真。若要判断原型关系应改用合适的 instanceof,若要识别数据形状则校验字段;两者语义不同,也都不能仅凭构造器名称兜底。

  • 接口字段可能是数字 0、字符串 "0"nullNaN,产品要求只有后两者显示“数据异常”,能不能用 if (!value)

    不能,!value 会同时把数字 0 等假值判为异常,破坏合法数据。应明确写成 value === null || Number.isNaN(value);不要用全局 isNaN 代替,后者会进行类型转换,使非数字字符串也可能被判为 NaN

# 2 This

⚡ 30 秒速记

  • 核心:普通函数的 this调用时确定,看谁调用;箭头函数在定义时确定,看写在哪
  • 优先级从高到低:new > call/apply/bind > 方法调用 > 独立调用
  • 独立调用时非严格模式指向 globalThis,严格模式(含 ES moduleclass)是 undefined
  • 两个高频坑:方法赋值给变量后单独调用会丢 this;方法里嵌套的普通函数也会丢
  • 箭头函数没有自己的 thiscall/bind 都改不动它

普通函数的 this 在调用时确定:直接调用通常走默认绑定,obj.fn() 指向 obj,构造调用则指向 new 创建的对象。 多种规则同时出现时,优先级是 new、显式绑定、隐式绑定、默认绑定。箭头函数没有自己的 this,它会沿用最近外层普通函数的 this,因此用 bind 也改不了。对普通函数多次调用 bind 时,最终绑定对象由第一次 bind 决定。

不同情况的调用,this指向分别如何。顺带可以提一下 es6 中箭头函数没有 this, arguments, super 等,这些只依赖包含箭头函数最接近的函数

我们先来看几个函数调用的场景

function foo() {
  console.log(this.a)
}
var a = 1
foo()

const obj = {
  a: 2,
  foo: foo
}
obj.foo()

const c = new foo()
  • 对于直接调用 foo 来说,不管 foo 函数被放在了什么地方,this 一定是window
  • 对于 obj.foo() 来说,我们只需要记住,谁调用了函数,谁就是 this,所以在这个场景下 foo 函数中的 this 就是 obj 对象
  • 对于 new 的方式来说,this 被永远绑定在了 c 上面,不会被任何方式改变 this

说完了以上几种情况,其实很多代码中的 this 应该就没什么问题了,下面让我们看看箭头函数中的 this

function a() {
  return () => {
    return () => {
      console.log(this)
    }
  }
}
console.log(a()()())
  • 首先箭头函数其实是没有 this 的,箭头函数中的 this 只取决包裹箭头函数的第一个普通函数的 this。在这个例子中,因为包裹箭头函数的第一个普通函数是 a,所以此时的 thiswindow。另外对箭头函数使用 bind这类函数是无效的。
  • 最后种情况也就是 bind 这些改变上下文的 API 了,对于这些函数来说,this 取决于第一个参数,如果第一个参数为空,那么就是 window
  • 那么说到 bind,不知道大家是否考虑过,如果对一个函数进行多次 bind,那么上下文会是什么呢?
let a = {}
let fn = function () { console.log(this) }
fn.bind().bind(a)() // => ?

如果你认为输出结果是 a,那么你就错了,其实我们可以把上述代码转换成另一种形式

// fn.bind().bind(a) 等于
let fn2 = function fn1() {
  return function() {
    return fn.apply()
  }.apply(a)
}
fn2()

可以从上述代码中发现,不管我们给函数 bind 几次,fn 中的 this 永远由第一次 bind 决定,所以结果永远是 window

let a = { name: 'poetries' }
function foo() {
  console.log(this.name)
}
foo.bind(a)() // => 'poetries'

以上就是 this 的规则了,但是可能会发生多个规则同时出现的情况,这时候不同的规则之间会根据优先级最高的来决定 this 最终指向哪里。

首先,new 的方式优先级最高,接下来是 bind 这些函数,然后是 obj.foo() 这种调用方式,最后是 foo 这种调用方式,同时,箭头函数的 this 一旦被绑定,就不会再被任何方式所改变。

image.png

函数执行改变this

  • 由于 JS 的设计原理: 在函数中,可以引用运行环境中的变量。因此就需要一个机制来让我们可以在函数体内部获取当前的运行环境,这便是this

因此要明白 this 指向,其实就是要搞清楚 函数的运行环境,说人话就是,谁调用了函数。例如

  • obj.fn(),便是 obj 调用了函数,既函数中的 this === obj
  • fn(),这里可以看成 window.fn(),因此 this === window

但这种机制并不完全能满足我们的业务需求,因此提供了三种方式可以手动修改 this 的指向:

  • call: fn.call(target, 1, 2)
  • apply: fn.apply(target, [1, 2])
  • bind: fn.bind(target)(1,2)

💬 面试官追问

  • 管理后台启用严格模式后,工具函数 foo() 内访问 this.user 报错,而旧页面里曾经指向 window,你怎么解释这个差异?

    普通函数直接调用时没有接收者,严格模式下 thisundefined,因此读取 this.user 会报错;非严格浏览器脚本中才可能回退到 window。业务代码不应依赖这种全局回退,应显式传入对象或以 obj.foo() 的形式调用。

  • React 类组件把 this.handleSave 直接交给按钮,点击后 this 丢失;团队在构造器 bindclass 字段箭头函数之间怎么选?

    两种方式都能让回调保留组件实例:构造器中执行一次 bind,或把处理器定义为捕获外层 this 的箭头函数。选型应服从项目编译配置和代码规范;不要在每次渲染时临时 bind,否则会持续产生新的函数引用。

  • 事件系统原来用 handler.call(context, event) 注入上下文,现在有人把处理器改成箭头函数,为什么 context 不再生效?

    箭头函数没有自己的 this,它从定义位置捕获外层 this,所以 call 的第一个参数无法改写它。若事件系统契约依赖动态上下文,应保留普通函数;若处理器只依赖词法作用域,箭头函数更明确,但会放弃运行时注入能力。

  • 一段代码执行 fn.bind(user).bind(admin)(),日志仍打印 user.name,排查时你会如何判断是哪次绑定生效?

    第一次 bind(user) 已返回一个绑定函数,后续再对该绑定函数执行 bind(admin) 不会替换原目标函数的绑定上下文。因此应沿函数创建链找到最早的 bind;多次绑定会增加理解成本,若需要动态切换对象,应改用 callapply 或显式参数。

  • 对象方法 obj.run 既先被 bind(config),又被 new 调用,代码评审中有人认为实例的 this 仍是 config,你会怎么判断?

    new 调用绑定函数时,构造调用创建的新实例应成为原函数的 this,绑定对象不会作为实例上下文。实现手写 bind 时必须识别构造调用并转交原型关系;若原函数返回对象,还要遵循构造函数显式返回对象的语义,简单 apply 并不完整。

# 3 apply/call/bind 原理

⚡ 30 秒速记

  • 三者都用来显式指定 thiscall 逐个传参、apply 传数组、bind 返回新函数不立即执行
  • call/apply 的实现思路:把函数临时挂到目标对象上执行再删掉(用 Symbol 当 key 避免覆盖同名属性)
  • bind 的实现要点:闭包保存 this 和预置参数,支持柯里化
  • bind 最容易忽略的一点:返回的函数被 new 调用时,绑定的 this失效,此时应指向新实例
  • 还要处理原型继承:绑定函数的 prototype 要接上原函数的,用空函数中转避免互相污染

callapplybind 都能指定函数执行时的 this,区别在传参形式和是否立即执行。 call 逐个接收参数,apply 接收参数数组,两者会马上调用函数;bind 则返回新函数,还能预先保存部分参数。手写 callapply 时,可以把原函数临时挂到目标对象上执行,再删除该属性,使用 Symbol 能避免属性冲突。实现 bind 还要处理构造调用:通过 new 执行返回函数时,应让新实例成为 this

call、applybind 是挂在 Function 对象上的三个方法,调用这三个方法的必须是一个函数。

func.call(thisArg, param1, param2, ...)
func.apply(thisArg, [param1,param2,...])
func.bind(thisArg, param1, param2, ...)
  • 在浏览器里,在全局范围内this 指向window对象;
  • 在函数中,this永远指向最后调用他的那个对象;
  • 构造函数中,this指向new出来的那个新的对象;
  • call、apply、bind中的this被强绑定在指定的那个对象上;
  • 箭头函数中this比较特殊,箭头函数this为父作用域的this,不是调用时的this.要知道前四种方式,都是调用时确定,也就是动态的,而箭头函数的this指向是静态的,声明的时候就确定了下来;
  • apply、call、bind都是js给函数内置的一些API,调用他们可以为函数指定this的执行,同时也可以传参。

let a = {
    value: 1
}
function getValue(name, age) {
    console.log(name)
    console.log(age)
    console.log(this.value)
}
getValue.call(a, 'poe', '24')
getValue.apply(a, ['poe', '24'])

bind 和其他两个方法作用也是一致的,只是该方法会返回一个函数。并且我们可以通过 bind 实现柯里化

方法的应用场景

下面几种应用场景,你多加体会就可以发现它们的理念都是“借用”方法的思路。我们来看看都有哪些。

  1. 判断数据类型

Object.prototype.toString 来判断类型是最合适的,借用它我们几乎可以判断所有类型的数据

function getType(obj){
  let type  = typeof obj;
  if (type !== "object") {
    return type;
  }
  return Object.prototype.toString.call(obj).replace(/^$/, '$1');
}
  1. 类数组借用方法

类数组因为不是真正的数组,所有没有数组类型上自带的种种方法,所以我们就可以利用一些方法去借用数组的方法,比如借用数组的 push 方法,看下面的一段代码。

var arrayLike = {
  0: 'java',
  1: 'script',
  length: 2
}
Array.prototype.push.call(arrayLike, 'jack', 'lily');
console.log(typeof arrayLike); // 'object'
console.log(arrayLike);
// {0: "java", 1: "script", 2: "jack", 3: "lily", length: 4}

call 的方法来借用 Array 原型链上的 push 方法,可以实现一个类数组的 push 方法,给 arrayLike 添加新的元素

  1. 获取数组的最大 / 最小值

我们可以用 apply 来实现数组中判断最大 / 最小值,apply 直接传递数组作为调用方法的参数,也可以减少一步展开数组,可以直接使用 Math.max、Math.min 来获取数组的最大值 / 最小值,请看下面这段代码。

let arr = [13, 6, 10, 11, 16];
const max = Math.max.apply(Math, arr);
const min = Math.min.apply(Math, arr);

console.log(max);  // 16
console.log(min);  // 6

实现一个 bind 函数

对于实现以下几个函数,可以从几个方面思考

  • 不传入第一个参数,那么默认为 window
  • 改变了 this 指向,让新的对象可以执行该函数。那么思路是否可以变成给新的对象添加一个函数,然后在执行完以后删除?
Function.prototype.myBind = function (context) {
  if (typeof this !== 'function') {
    throw new TypeError('Error')
  }
  var _this = this
  var args = [...arguments].slice(1)
  // 返回一个函数
  return function F() {
    // 因为返回了一个函数,我们可以 new F(),所以需要判断
    if (this instanceof F) {
      return new _this(...args, ...arguments)
    }
    return _this.apply(context, args.concat(...arguments))
  }
}

实现一个 call 函数

Function.prototype.myCall = function (context) {
  var context = context || window
  // 给 context 添加一个属性
  // getValue.call(a, 'pp', '24') => a.fn = getValue
  context.fn = this
  // 将 context 后面的参数取出来
  var args = [...arguments].slice(1)
  // getValue.call(a, 'pp', '24') => a.fn('pp', '24')
  var result = context.fn(...args)
  // 删除 fn
  delete context.fn
  return result
}

实现一个 apply 函数

Function.prototype.myApply = function(context = window, ...args) {
  // this-->func  context--> obj  args--> 传递过来的参数

  // 在context上加一个唯一值不影响context上的属性
  let key = Symbol('key')
  context[key] = this; // context为调用的上下文,this此处为函数,将这个函数作为context的方法
  // let args = [...arguments].slice(1)   //第一个参数为obj所以删除,伪数组转为数组

  let result = context[key](...args);
  delete context[key]; // 不删除会导致context属性越来越多
  return result;
}
// 使用
function f(a,b){
 console.log(a,b)
 console.log(this.name)
}
let obj={
 name:'张三'
}
f.myApply(obj,[1,2])  //arguments[1]

💬 面试官追问

  • 代码评审里有人说 callapplybind 都只是参数写法不同,但保存 const saved = fn.bind(user, 1) 后没有立即执行,你怎么反驳?

    callapply 会立即调用函数,只是前者逐个传参、后者接收参数集合;bind 返回新的绑定函数,调用可以延后并支持预置参数。把三者当成同一种立即调用 API,会导致副作用时机和返回值判断错误。

  • 你手写的 myCall 用固定属性 context.fn = this,线上对象本来就有 fn 字段,调用后字段丢了,怎么修?

    固定属性名会覆盖对象已有字段,随后 delete context.fn 又把原值删除,应改用临时 Symbol 作为键并在调用后清理。清理最好放进 finally,否则目标函数抛错时临时属性会残留;此外还要处理不可扩展对象,这种挂载模拟存在天然边界。

  • 旧工具的 myCall 写成 context = context || window,传入数字 0 或空字符串时却把 this 指向全局对象,问题在哪里?

    || 会把 0、空字符串和 false 都当成缺省值,因而错误替换合法的 thisArg。实现应只按目标语义处理 nullundefined,并考虑原始值装箱及严格模式差异;简单的对象挂载法无法完全复刻原生行为。

  • 批量统计页面对一个超大数组执行 Math.max.apply(null, values) 后抛出参数数量相关异常,你会如何改造并验证?

    apply 会把数组元素作为独立实参传入,数组足够大时可能超过引擎允许的参数数量,不能把具体上限当成稳定契约。应使用 reduce 逐项比较,或分块求局部最大值再合并;同时明确空数组结果,避免默认得到不符合业务预期的值。

  • 手写 bind 只做 return (...args) => fn.apply(context, args),测试普通调用通过,但 new Bound() 的实例关系失败,缺了哪些语义?

    这种箭头包装既不能作为构造函数,也没有处理 new 对绑定上下文的覆盖,因此无法模拟原生 bind 的构造调用。返回值应使用普通函数,构造时让新实例作为 this 并衔接原函数原型;完整复制原生函数的所有内部行为仍不现实,应标明实现范围。

# 4 变量提升

⚡ 30 秒速记

  • 「提升」不是代码被移动,而是执行上下文创建阶段先扫描声明、建立词法环境
  • var:提升并初始化为 undefined,声明前访问得到 undefined
  • let/const:也提升但不初始化,声明前访问抛 ReferenceError,这段区间叫暂时性死区(TDZ
  • 函数声明整体提升(连函数体),且优先级高于同名变量声明
  • 高频细节:函数表达式不提升,提前调用抛的是 TypeError 而不是 ReferenceError

变量提升不是把代码搬到顶部,而是在创建执行环境时提前处理声明。 var 会先被初始化为 undefined,所以声明前读取不会报错,但赋值仍留在原来的位置。letconst 同样会建立绑定,只是初始化前处于 TDZ,提前访问会报错。函数声明会连同函数体一起提升;同名声明出现时,后面的函数会覆盖前面的函数,而且函数声明优先于变量声明。

当执行 JS 代码时,会生成执行环境,只要代码不是写在函数中的,就是在全局执行环境中,函数中的代码会产生函数执行环境,只此两种执行环境。

b() // call b
console.log(a) // undefined

var a = 'Hello world'

function b() {
    console.log('call b')
}

想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行环境时,会有两个阶段。第一个阶段是创建的阶段,JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用

  • 在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
b() // call b second

function b() {
    console.log('call b fist')
}
function b() {
    console.log('call b second')
}
var b = 'Hello world'

var 会产生很多错误,所以在 ES6中引入了 letlet不能在声明前使用,但是这并不是常说的 let 不会提升,let提升了,在第一阶段内存也已经为他开辟好了空间,但是因为这个声明的特性导致了并不能在声明前使用

💬 面试官追问

  • 你接手的活动页里先执行 console.log(price),下一行才写 var price = 99,页面没有报错却打印了 undefined;如果同事坚持说“引擎把代码搬到了顶部”,你怎么纠正?

    结果是 undefined,但引擎并没有真的重排源码。执行环境创建阶段会登记 var price 并初始化为 undefined,执行阶段到赋值语句才写入 99;“搬到顶部”只能作为表象解释,遇到函数声明冲突时容易误判。

  • 一个旧版结算页同时声明了两个 function submit(),后面又写 var submit = 'disabled',但在赋值前调用 submit() 执行的是第二个函数;你如何解释这段初始化过程?

    创建阶段会处理函数声明与变量声明,同名的后一个函数声明覆盖前一个,而同名 var 声明不会把已登记的函数改成 undefined。执行到 var submit = 'disabled' 时才发生赋值,此后 submit() 才会因值不再是函数而失败。

  • 团队把配置读取从 var token 改为 let token,监控从静默拿到 undefined 变成了启动时抛出 ReferenceError;这是回归还是更严格的约束?

    这是声明语义改变带来的主动失败,不代表 let 完全没有提升。绑定在块级作用域创建时已经存在,但声明执行前处于暂时性死区,任何读取都会抛出 ReferenceError;代价是旧代码会暴露初始化顺序问题,却能避免错误值继续传播。

  • 线上首屏偶发 ReferenceError: Cannot access 'config' before initialization,同一代码块里确实写了 let config;你会沿着什么方向定位,而不是把它当成变量未声明?

    先查报错读取点是否位于 let config 的声明执行之前,包括条件分支、初始化表达式和间接函数调用。该名称已绑定到当前块,外层同名变量也会被遮蔽,因此不能继续沿作用域链取外层值;修复应调整初始化顺序或拆分作用域。

  • 报表页的循环用 var i 注册五个延迟回调,评审中有人主张只改 let,另一人要求兼容现有写法;你会怎样选择?

    能使用块级绑定时优先改为 let,因为循环每轮都会形成独立绑定,回调自然取得当轮值。若必须保留 var,可用 IIFEi 作为参数封入独立作用域,或用 setTimeout(fn, 0, i) 透传实参;后两种更啰嗦,也更依赖调用形式。

# 5 执行上下文

⚡ 30 秒速记

  • 三种执行上下文:全局、函数、eval;用执行栈管理,函数调用入栈、返回出栈
  • 每个上下文创建时做三件事:建立词法环境、变量环境、确定 this 指向
  • 词法环境存 let/const,变量环境存 var —— 这就是两者提升表现不同的根因
  • 执行上下文里的外部环境引用串起来就是作用域链,它在函数定义时就确定了
  • 加分点:栈溢出(Maximum call stack size exceeded)就是执行栈被递归压爆了

执行上下文就是 JS 执行一段代码时所需的运行环境,核心包含变量对象、作用域链和 this 创建阶段会登记变量、函数声明和形参,因此会出现变量提升;执行阶段才逐行完成赋值和调用。全局代码、函数和 eval 会形成不同上下文,函数调用时,新上下文会压入执行栈,结束后再弹出。函数内部实际通过活动对象 AO 管理局部变量及 arguments,箭头函数没有自己的 arguments

当执行 JS 代码时,会产生三种执行上下文

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文

每个执行上下文中都有三个重要的属性

  • 变量对象(VO),包含变量、函数声明和函数的形参,该属性只能在全局上下文中访问
  • 作用域链(JS 采用词法作用域,也就是说变量的作用域是在定义时就决定了)
  • this
var a = 10
function foo(i) {
  var b = 20
}
foo()

对于上述代码,执行栈中有两个上下文:全局上下文和函数 foo 上下文。

stack = [
    globalContext,
    fooContext
]

对于全局上下文来说,VO大概是这样的

globalContext.VO === globe
globalContext.VO = {
    a: undefined,
	foo: <Function>,
}

对于函数 foo 来说,VO 不能访问,只能访问到活动对象(AO

fooContext.VO === foo.AO
fooContext.AO {
    i: undefined,
	b: undefined,
    arguments: <>
}
// arguments 是函数独有的对象(箭头函数没有)
// 该对象是一个伪数组,有 `length` 属性且可以通过下标访问元素
// 该对象中的 `callee` 属性代表函数本身
// `caller` 属性代表函数的调用者

对于作用域链,可以把它理解成包含自身变量对象和上级变量对象的列表,通过 [[Scope]]属性查找上级变量

fooContext.[[Scope]] = [
    globalContext.VO
]
fooContext.Scope = fooContext.[[Scope]] + fooContext.VO
fooContext.Scope = [
    fooContext.VO,
    globalContext.VO
]

接下来让我们看一个老生常谈的例子,var

b() // call b
console.log(a) // undefined

var a = 'Hello world'

function b() {
	console.log('call b')
}

想必以上的输出大家肯定都已经明白了,这是因为函数和变量提升的原因。通常提升的解释是说将声明的代码移动到了顶部,这其实没有什么错误,便于大家理解。但是更准确的解释应该是:在生成执行上下文时,会有两个阶段。第一个阶段是创建的阶段(具体步骤是创建 VO),JS 解释器会找出需要提升的变量和函数,并且给他们提前在内存中开辟好空间,函数的话会将整个函数存入内存中,变量只声明并且赋值为 undefined,所以在第二个阶段,也就是代码执行阶段,我们可以直接提前使用。

  • 在提升的过程中,相同的函数会覆盖上一个函数,并且函数优先于变量提升
b() // call b second

function b() {
	console.log('call b fist')
}
function b() {
	console.log('call b second')
}
var b = 'Hello world'

var会产生很多错误,所以在 ES6中引入了 letlet不能在声明前使用,但是这并不是常说的 let 不会提升,let 提升了声明但没有赋值,因为临时死区导致了并不能在声明前使用。

  • 对于非匿名的立即执行函数需要注意以下一点
var foo = 1
(function foo() {
    foo = 10
    console.log(foo)
}()) // -> ƒ foo() { foo = 10 ; console.log(foo) }

因为当 JS 解释器在遇到非匿名的立即执行函数时,会创建一个辅助的特定对象,然后将函数名称作为这个对象的属性,因此函数内部才可以访问到 foo,但是这个值又是只读的,所以对它的赋值并不生效,所以打印的结果还是这个函数,并且外部的值也没有发生更改。

specialObject = {};

Scope = specialObject + Scope;

foo = new FunctionExpression;
foo.[[Scope]] = Scope;
specialObject.foo = foo; // {DontDelete}, {ReadOnly}

delete Scope[0]; // remove specialObject from the front of scope chain

总结

执行上下文可以简单理解为一个对象:

它包含三个部分:

  • 变量对象(VO)
  • 作用域链(词法作用域)
  • this指向

它的类型:

  • 全局执行上下文
  • 函数执行上下文
  • eval执行上下文

代码执行过程:

  • 创建 全局上下文 (global EC)
  • 全局执行上下文 (caller) 逐行 自上而下 执行。遇到函数时,函数执行上下文 (callee) 被push到执行栈顶层
  • 函数执行上下文被激活,成为 active EC, 开始执行函数中的代码,caller 被挂起
  • 函数执行完后,calleepop移除出执行栈,控制权交还全局上下文 (caller),继续执行

💬 面试官追问

  • 订单页运行 foo() 时,调用栈里既有全局代码又有 foo,同事却说整个页面只有一个“作用域对象”;你会拿这次调用怎样反驳?

    页面启动会建立全局执行上下文,调用 foo() 时还会把新的函数执行上下文压入执行栈,两者不是同一个对象。foo 执行期间位于栈顶,全局上下文暂停;函数返回后其上下文出栈,控制权才交回调用方。

  • 你在调试器里检查 function foo(i){ var b = 20 },产品要求解释为什么函数内既能找到 ib,又能读取页面级变量;你会怎么说明查找路径?

    函数上下文的活动对象会包含形参 i、局部变量 b 和函数独有的 arguments,标识符先在这里查找。未命中时再沿词法作用域链访问上级变量对象,直到全局对象;这条链由函数定义位置决定,不会因调用位置改变。

  • 一段遗留脚本把普通函数改成箭头函数后,读取 arguments[0] 直接失败;在不改变调用参数数量的前提下,根因是什么?

    普通函数执行上下文具有自己的 arguments 伪数组,可通过下标和 length 读取实参;箭头函数没有自己的 arguments。若业务仍需收集参数,应显式使用剩余参数,不能假定箭头函数会复制外层普通函数的活动对象。

  • 生产环境出现 Maximum call stack size exceeded,调用栈显示 renderNode 上百层重复,而每层只创建少量局部变量;你如何把故障和执行上下文联系起来排查?

    每次调用 renderNode 都会创建函数执行上下文并压栈,递归没有及时结束就会持续占用调用栈,最终溢出。先核对终止条件和输入树是否存在异常回路,再考虑改成显式栈迭代;仅减少局部变量不能消除调用层数。

  • 插件系统允许传入一段 eval 配置脚本,架构师认为它与普通函数执行完全相同;在执行上下文模型里,你会指出什么差异和代价?

    eval 会产生独立类型的执行上下文,不能简单等同于一次普通函数调用。它还让标识符和作用域关系在运行时才更难确定,增加理解与排查成本;若配置只需要数据,应优先采用结构化输入,避免引入动态代码执行边界。

# 6 作用域

⚡ 30 秒速记

  • 三种作用域:全局、函数、块级(let/const 带来的)
  • JavaScript词法作用域(静态作用域):作用域链在函数定义时就确定,不是调用时 —— 这点和 this 正好相反
  • 变量查找沿作用域链由内向外,找到为止,全找不到抛 ReferenceError
  • 闭包就是函数带着自己的作用域链离开了定义位置
  • 别混:作用域链决定「能不能访问到变量」,原型链决定「能不能访问到属性

作用域决定变量在哪里可访问,而作用域链决定当前找不到变量时去哪里继续找。 JS 使用词法作用域,链路在函数定义时就确定,调用位置不会把它改掉;查找会从当前作用域逐层向外,最后到全局作用域。常见范围可以归为全局、函数和块级作用域,其中 letconst 能形成块级作用域并存在 TDZ。工程中应尽量缩小变量范围,避免全局变量过多造成命名冲突。

  • 作用域: 作用域是定义变量的区域,它有一套访问变量的规则,这套规则来管理浏览器引擎如何在当前作用域以及嵌套的作用域中根据变量(标识符)进行变量查找
  • 作用域链: 作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,我们可以访问到外层环境的变量和 函数。

作用域链的本质上是一个指向变量对象的指针列表。变量对象是一个包含了执行环境中所有变量和函数的对象。作用域链的前 端始终都是当前执行上下文的变量对象。全局执行上下文的变量对象(也就是全局对象)始终是作用域链的最后一个对象。

  • 当我们查找一个变量时,如果当前执行环境中没有找到,我们可以沿着作用域链向后查找
  • 作用域链的创建过程跟执行上下文的建立有关....

作用域可以理解为变量的可访问性,总共分为三种类型,分别为:

  • 全局作用域
  • 函数作用域
  • 块级作用域,ES6 中的 letconst 就可以产生该作用域

其实看完前面的闭包、this 这部分内部的话,应该基本能了解作用域的一些应用。

一旦我们将这些作用域嵌套起来,就变成了另外一个重要的知识点「作用域链」,也就是 JS 到底是如何访问需要的变量或者函数的。

  • 首先作用域链是在定义时就被确定下来的,和箭头函数里的 this 一样,后续不会改变,JS 会一层层往上寻找需要的内容。
  • 其实作用域链这个东西我们在闭包小结中已经看到过它的实体了:[[Scopes]]

图中的 [[Scopes]] 是个数组,作用域的一层层往上寻找就等同于遍历 [[Scopes]]

1. 全局作用域

全局变量是挂载在 window 对象下的变量,所以在网页中的任何位置你都可以使用并且访问到这个全局变量

var globalName = 'global';
function getName() {
  console.log(globalName) // global
  var name = 'inner'
  console.log(name) // inner
}
getName();
console.log(name); //
console.log(globalName); //global
function setName(){
  vName = 'setName';
}
setName();
console.log(vName); // setName
  • 从这段代码中我们可以看到,globalName 这个变量无论在什么地方都是可以被访问到的,所以它就是全局变量。而在 getName 函数中作为局部变量的 name 变量是不具备这种能力的
  • 当然全局作用域有相应的缺点,我们定义很多全局变量的时候,会容易引起变量命名的冲突,所以在定义变量的时候应该注意作用域的问题。

2. 函数作用域

函数中定义的变量叫作函数变量,这个时候只能在函数内部才能访问到它,所以它的作用域也就是函数的内部,称为函数作用域

function getName () {
  var name = 'inner';
  console.log(name); //inner
}
getName();
console.log(name);

除了这个函数内部,其他地方都是不能访问到它的。同时,当这个函数被执行完之后,这个局部变量也相应会被销毁。所以你会看到在 getName 函数外面的 name 是访问不到的

3. 块级作用域

ES6 中新增了块级作用域,最直接的表现就是新增的 let 关键词,使用 let 关键词定义的变量只能在块级作用域中被访问,有“暂时性死区”的特点,也就是说这个变量在定义之前是不能被使用的。

在 JS 编码过程中 if 语句for 语句后面 {...} 这里面所包括的,就是块级作用域

console.log(a) //a is not defined
if(true){
  let a = '123';
  console.log(a)// 123
}
console.log(a) //a is not defined

从这段代码可以看出,变量 a 是在 if 语句{...} 中由 let 关键词进行定义的变量,所以它的作用域是 if 语句括号中的那部分,而在外面进行访问 a 变量是会报错的,因为这里不是它的作用域。所以在 if 代码块的前后输出 a 这个变量的结果,控制台会显示 a 并没有定义

💬 面试官追问

  • 运营页在 if (enabled) { let banner = 'A' } 后执行 console.log(banner),同事认为条件为真就应该能读取;你如何解释这个反例?

    即使条件为真,banner 的可访问范围也只在 if 的花括号内,块执行结束后外部读取仍会报未定义。是否执行过赋值不会扩大词法作用域;若外部确实需要该值,应在外层声明并在块内赋值。

  • 两个通过普通 <script> 接入的营销组件都声明了顶层 var status,后加载的组件覆盖前者;在不引入模块系统的旧页面里你会怎样隔离?

    可把每个组件包进 IIFE,让内部变量落在各自的函数作用域中,只显式暴露必要接口。这样能减少顶层 var 挂到全局对象造成的命名冲突;代价是依赖和导出需要手工组织,不能获得模块系统的静态边界。

  • 代码评审里有人把函数从模块顶部移动到点击处理器内部,理由是“调用位置没变,查到的变量也不会变”;若内外层都有 theme,结果会怎样变化?

    函数的作用域链由定义位置决定,移动定义位置可能改变它捕获的 theme,即使调用语句完全不变。查找会从函数自身环境逐层向外,命中最近的同名绑定即停止;因此这种重构必须核对变量遮蔽,而不能只比较调用点。

  • 线上日志出现 vName is not defined,代码只在某个开关分支里执行 vName = 'setName',另一个分支随后读取它;你会先检查哪些作用域条件?

    先确认赋值分支是否真正执行,以及脚本是否处于允许隐式创建全局变量的运行环境。未声明赋值不能作为可靠的跨分支通信方式,严格约束下还可能直接报错;应在明确的外层作用域声明状态,并保证所有读取路径都有初始化。

  • 一个页面需要让计数器只被组件内部使用,团队在“顶层全局变量”和“函数作用域封装”之间争论;你会如何取舍?

    应选函数作用域封装,因为全局变量位于作用域链末端,页面各处都可能访问或覆盖,命名冲突面更大。封装后只返回必要操作函数,让内部计数保持不可直接访问;代价是外部调试和修改必须经过暴露的接口。

# 7 闭包

⚡ 30 秒速记

  • 定义:函数和它定义时所处词法环境的组合 —— 函数记住了自己出生的地方
  • 经典考法是循环里的 setTimeoutvar 版打印三个 3(共享同一个 i),let 版打印 0/1/2(每次迭代新建绑定)
  • ES5 时代用 IIFE 拷贝当时的值,现在直接用 let
  • 实用价值:私有变量、函数工厂(柯里化)、缓存(记忆化)、防抖节流里的计时器
  • 代价:被引用的变量无法回收,用完要解除引用,否则就是内存泄漏

闭包本质上是函数保留了对其定义时外层作用域的引用,因此外层函数结束后仍能访问相关变量。 它常用于封装私有变量,定时器、事件监听和作为参数传递的回调也会自然形成这种引用关系。比如循环里用 var 配合 setTimeout,回调执行时共享的 i 已变成 6,所以会连续输出 6。改用 letIIFE,或把当前值作为定时器参数传入,都能让每次回调拿到对应的值。

闭包其实就是一个可以访问其他函数内部变量的函数。创建闭包的最常见的方式就是在一个函数内创建另一个函数,创建的函数可以 访问到当前函数的局部变量。

因为通常情况下,函数内部变量是无法在外部访问的(即全局变量和局部变量的区别),因此使用闭包的作用,就具备实现了能在外部访问某个函数内部变量的功能,让这些内部变量的值始终可以保存在内存中。下面我们通过代码先来看一个简单的例子

function fun1() {
	var a = 1;
	return function(){
		console.log(a);
	};
}
fun1();
var result = fun1();
result();  // 1

// 结合闭包的概念,我们把这段代码放到控制台执行一下,就可以发现最后输出的结果是 1(即 a 变量的值)。那么可以很清楚地发现,a 变量作为一个 fun1 函数的内部变量,正常情况下作为函数内的局部变量,是无法被外部访问到的。但是通过闭包,我们最后还是可以拿到 a 变量的值

闭包有两个常用的用途

  • 闭包的第一个用途是使我们在函数外部能够访问到函数内部的变量。通过使用闭包,我们可以通过在外部调用闭包函数,从而在外部访问到函数内部的变量,可以使用这种方法来创建私有变量。
  • 函数的另一个用途是使已经运行结束的函数上下文中的变量对象继续留在内存中,因为闭包函数保留了这个变量对象的引用,所以这个变量对象不会被回收。

其实闭包的本质就是作用域链的一个特殊的应用,只要了解了作用域链的创建过程,就能够理解闭包的实现原理。

let a = 1
// fn 是闭包
function fn() {
  console.log(a);
}

function fn1() {
  let a = 1
  // 这里也是闭包
  return () => {
    console.log(a);
  }
}
const fn2 = fn1()
fn2()
  • 大家都知道闭包其中一个作用是访问私有变量,就比如上述代码中的 fn2 访问到了 fn1 函数中的变量 a。但是此时 fn1 早已销毁,我们是如何访问到变量 a 的呢?不是都说原始类型是存放在栈上的么,为什么此时却没有被销毁掉?
  • 接下来笔者会根据浏览器的表现来重新理解关于原始类型存放位置的说法。
  • 先来说下数据存放的正确规则是:局部、占用空间确定的数据,一般会存放在栈中,否则就在堆中(也有例外)。 那么接下来我们可以通过 Chrome 来帮助我们验证这个说法说法。

上图中画红框的位置我们能看到一个内部的对象 [[Scopes]],其中存放着变量 a,该对象是被存放在堆上的,其中包含了闭包、全局对象等等内容,因此我们能通过闭包访问到本该销毁的变量。

另外最开始我们对于闭包的定位是:假如一个函数能访问外部的变量,那么这个函数它就是一个闭包,因此接下来我们看看在全局下的表现是怎么样的。

let a = 1
var b = 2
// fn 是闭包
function fn() {
  console.log(a, b);
}

从上图我们能发现全局下声明的变量,如果是 var 的话就直接被挂到 globe 上,如果是其他关键字声明的话就被挂到 Script 上。虽然这些内容同样还是存在 [[Scopes]],但是全局变量应该是存放在静态区域的,因为全局变量无需进行垃圾回收,等需要回收的时候整个应用都没了。

只有在下图的场景中,原始类型才可能是被存储在栈上。

这里为什么要说可能,是因为 JS 是门动态类型语言,一个变量声明时可以是原始类型,马上又可以赋值为对象类型,然后又回到原始类型。这样频繁的在堆栈上切换存储位置,内部引擎是不是也会有什么优化手段,或者干脆全部都丢堆上?只有 const 声明的原始类型才一定存在栈上?当然这只是笔者的一个推测,暂时没有深究,读者可以忽略这段瞎想

因此笔者对于原始类型存储位置的理解为:局部变量才是被存储在栈上,全局变量存在静态区域上,其它都存储在堆上。

当然这个理解是建立的 Chrome 的表现之上的,在不同的浏览器上因为引擎的不同,可能存储的方式还是有所变化的。

闭包产生的原因

我们在前面介绍了作用域的概念,那么你还需要明白作用域链的基本概念。其实很简单,当访问一个变量时,代码解释器会首先在当前的作用域查找,如果没找到,就去父级作用域去查找,直到找到该变量或者不存在父级作用域中,这样的链路就是作用域链

需要注意的是,每一个子函数都会拷贝上级的作用域,形成一个作用域的链条。那么我们还是通过下面的代码来详细说明一下作用域链

var a = 1;
function fun1() {
  var a = 2
  function fun2() {
    var a = 3;
    console.log(a);//3
  }
}
  • 从中可以看出,fun1 函数的作用域指向全局作用域(window)和它自己本身;fun2 函数的作用域指向全局作用域 (window)、fun1 和它本身;而作用域是从最底层向上找,直到找到全局作用域 window 为止,如果全局还没有的话就会报错。
  • 那么这就很形象地说明了什么是作用域链,即当前函数一般都会存在上层函数的作用域的引用,那么他们就形成了一条作用域链。
  • 由此可见,闭包产生的本质就是:当前环境中存在指向父级作用域的引用。那么还是拿上的代码举例。
function fun1() {
  var a = 2
  function fun2() {
    console.log(a);  //2
  }
  return fun2;
}
var result = fun1();
result();
  • 从上面这段代码可以看出,这里 result 会拿到父级作用域中的变量,输出 2。因为在当前环境中,含有对 fun2 函数的引用,fun2 函数恰恰引用了 window、fun1 和 fun2 的作用域。因此 fun2 函数是可以访问到 fun1 函数的作用域的变量。
  • 那是不是只有返回函数才算是产生了闭包呢?其实也不是,回到闭包的本质,我们只需要让父级作用域的引用存在即可,因此还可以这么改代码,如下所示
var fun3;
function fun1() {
  var a = 2
  fun3 = function() {
    console.log(a);
  }
}
fun1();
fun3();

可以看出,其中实现的结果和前一段代码的效果其实是一样的,就是在给 fun3 函数赋值后,fun3 函数就拥有了 window、fun1 和 fun3 本身这几个作用域的访问权限;然后还是从下往上查找,直到找到 fun1 的作用域中存在 a 这个变量;因此输出的结果还是 2,最后产生了闭包,形式变了,本质没有改变。

因此最后返回的不管是不是函数,也都不能说明没有产生闭包

闭包的表现形式

  1. 返回一个函数
  2. 在定时器、事件监听、Ajax 请求、Web Workers 或者任何异步中,只要使用了回调函数,实际上就是在使用闭包。请看下面这段代码,这些都是平常开发中用到的形式
// 定时器
setTimeout(function handler(){
  console.log('1');
}1000);
// 事件监听
$('#app').click(function(){
  console.log('Event Listener');
});
  1. 作为函数参数传递的形式,比如下面的例子。
var a = 1;
function foo(){
  var a = 2;
  function baz(){
    console.log(a);
  }
  bar(baz);
}
function bar(fn){
  // 这就是闭包
  fn();
}
foo();  // 输出2,而不是1
  1. IIFE(立即执行函数),创建了闭包,保存了全局作用域(window)和当前函数的作用域,因此可以输出全局的变量,如下所示
var a = 2;
(function IIFE(){
  console.log(a);  // 输出2
})();

IIFE 这个函数会稍微有些特殊,算是一种自执行匿名函数,这个匿名函数拥有独立的作用域。这不仅可以避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域,我们经常能在高级的 JavaScript 编程中看见此类函数。

如何解决循环输出问题?

在互联网大厂的面试中,解决循环输出问题是比较高频的面试题,一般都会给一段这样的代码让你来解释

for(var i = 1; i <= 5; i ++){
  setTimeout(function() {
    console.log(i)
  }, 0)
}

上面这段代码执行之后,从控制台执行的结果可以看出来,结果输出的是 5 个 6,那么一般面试官都会先问为什么都是 6?我想让你实现输出 1、2、3、4、5 的话怎么办呢?

因此结合本讲所学的知识我们来思考一下,应该怎么给面试官一个满意的解释。你可以围绕这两点来回答。

  • setTimeout 为宏任务,由于 JS 中单线程 eventLoop 机制,在主线程同步任务执行完后才去执行宏任务,因此循环结束后 setTimeout 中的回调才依次执行
  • 因为 setTimeout 函数也是一种闭包,往上找它的父级作用域链就是 window变量 i 为 window 上的全局变量,开始执行 setTimeout 之前变量 i 已经就是 6 了,因此最后输出的连续就都是 6。

那么我们再来看看如何按顺序依次输出 1、2、3、4、5 呢?

  1. 利用 IIFE

可以利用 IIFE(立即执行函数),当每次 for 循环时,把此时的变量 i 传递到定时器中,然后执行,改造之后的代码如下。

for(var i = 1;i <= 5;i++){
  (function(j){
    setTimeout(function timer(){
      console.log(j)
    }, 0)
  })(i)
}
  1. 使用 ES6 中的 let

ES6 中新增的 let 定义变量的方式,使得 ES6 之后 JS 发生革命性的变化,让 JS 有了块级作用域,代码的作用域以块级为单位进行执行。通过改造后的代码,可以实现上面想要的结果。

for(let i = 1; i <= 5; i++){
  setTimeout(function() {
    console.log(i);
  },0)
}
  1. 定时器传入第三个参数

setTimeout 作为经常使用的定时器,它是存在第三个参数的,日常工作中我们经常使用的一般是前两个,一个是回调函数,另外一个是时间,而第三个参数用得比较少。那么结合第三个参数,调整完之后的代码如下。

for(var i=1;i<=5;i++){
  setTimeout(function(j) {
    console.log(j)
  }, 0, i)
}

从中可以看到,第三个参数的传递,可以改变 setTimeout 的执行逻辑,从而实现我们想要的结果,这也是一种解决循环输出问题的途径

常见考点

  • 闭包能考的很多,概念和笔试题都会考。
  • 概念题就是考考闭包是什么了。
  • 笔试题的话基本都会结合上异步,比如最常见的:
for (var i = 0; i < 6; i++) {
  setTimeout(() => {
    console.log(i)
  })
}

这道题会问输出什么,有哪几种方式可以得到想要的答案?

💬 面试官追问

  • 搜索页的工厂函数已经返回,但它返回的 getKeyword 仍能打印函数内的 keyword;同事据此断言“父函数根本没有结束”,你怎么纠正?

    父函数的调用已经结束并从执行栈退出,保留下来的是内部函数对其词法环境的引用。只要 getKeyword 仍可达,它需要的 keyword 就不会被回收;这不等于整个调用过程仍在执行,也不意味着所有局部数据都必须保留。

  • 你要为支付组件保存一个不能被外部直接改写的重试次数,团队不允许把它挂到全局对象;闭包方案应怎样设计?

    可在工厂函数内声明计数变量,并只返回读取、递增或重置它的函数,外部通过这些闭包访问状态。变量不暴露到全局,调用结束后仍因闭包引用而保留;接口一旦泄露给长期存活对象,该状态的生命周期也会随之延长。

  • 通知中心用 var i 给五个按钮注册延迟回调,点击后全部显示 6;如果暂时不能改循环声明,怎样修复且保持输出 15

    IIFE 在每轮把当前 i 作为参数传入,使每个回调引用不同的局部绑定;也可通过 setTimeout 的第三个参数把当轮值传给回调。两者都避开共享的全局 i,但前者增加一层函数,后者依赖定时器的传参接口。

  • 单页应用切换路由十次后内存持续增加,旧页面的按钮节点和定时器回调仍能在堆快照中找到;你会怎样验证是否由闭包引用造成?

    检查未解绑的事件监听器、未清理的定时器及其回调,因为这些闭包可能继续引用页面函数作用域中的变量。沿保留引用链确认旧节点或大对象是否被回调的作用域链持有,再在卸载阶段解除监听和清除定时器;闭包本身并不等于泄漏,关键是引用是否仍有业务必要。

  • 数据面板既可用全局对象保存筛选状态,也可返回一组闭包函数维护私有状态;多人协作下你会选哪一种?

    若状态只服务单个面板,闭包更适合限制访问范围,并能让状态在创建函数结束后继续存在。全局对象便于直接观察和跨模块共享,却更容易被任意代码覆盖;闭包的代价是生命周期不直观,需要控制返回函数被谁长期持有。

  • 异步请求回调能读取发起请求时函数里的 requestId,但回调是在函数返回后才执行;这与作用域链有什么关系?

    回调函数在定义时就形成了对外层词法环境的引用,因此稍后执行仍能沿作用域链找到 requestId。异步机制只改变回调执行时机,不会把标识符改为按调用位置查找;若外层绑定被多个回调共享,读取到的也会是该共享绑定当时的值。

# 8 New的原理

⚡ 30 秒速记

  • 四步:创建空对象 → 原型指向 Fn.prototype → 以它为 this 执行构造函数 → 决定返回值
  • 第四步是考点:构造函数显式 return 对象会覆盖默认返回值,return 原始值则被忽略
  • 手写:const o = Object.create(Fn.prototype); const r = Fn.apply(o, args); return (r !== null && typeof r === 'object') ? r : o
  • 箭头函数没有 prototype 也没有自己的 this,不能被 new
  • class 必须用 new 调用,直接调用会抛 TypeError

new 本质上会创建对象、连接构造函数原型、绑定 this 并决定最终返回值。 构造函数执行时,新增的属性会写到这个实例上,使实例既能访问自身属性,也能沿原型链访问共享方法。若构造函数显式返回对象,结果会被该对象替代;返回字符串等非对象值时,仍返回原实例。手写时可用 Object.create() 建立原型关系,再通过 apply() 执行构造函数并处理返回值。

常见考点

  • new 做了那些事?
  • new 返回不同的类型时会有什么表现?
  • 手写 new 的实现过程

new 关键词的主要作用就是执行一个构造函数、返回一个实例对象,在 new 的过程中,根据构造函数的情况,来确定是否可以接受参数的传递。下面我们通过一段代码来看一个简单的 new 的例子

function Person(){
   this.name = 'Jack';
}
var p = new Person();
console.log(p.name)  // Jack

这段代码比较容易理解,从输出结果可以看出,p 是一个通过 person 这个构造函数生成的一个实例对象,这个应该很容易理解。

new 操作符可以帮助我们构建出一个实例,并且绑定上 this,内部执行步骤可大概分为以下几步:

  1. 创建一个新对象
  2. 对象连接到构造函数原型上,并绑定 this(this 指向新对象)
  3. 执行构造函数代码(为这个新对象添加属性)
  4. 返回新对象

在第四步返回新对象这边有一个情况会例外:

那么问题来了,如果不用 new 这个关键词,结合上面的代码改造一下,去掉 new,会发生什么样的变化呢?我们再来看下面这段代码

function Person(){
  this.name = 'Jack';
}
var p = Person();
console.log(p) // undefined
console.log(name) // Jack
console.log(p.name) // 'name' of undefined
  • 从上面的代码中可以看到,我们没有使用 new 这个关键词,返回的结果就是 undefined。其中由于 JavaScript 代码在默认情况下 this 的指向是 window,那么 name 的输出结果就为 Jack,这是一种不存在 new 关键词的情况。
  • 那么当构造函数中有 return 一个对象的操作,结果又会是什么样子呢?我们再来看一段在上面的基础上改造过的代码。
function Person(){
   this.name = 'Jack';
   return {age: 18}
}
var p = new Person();
console.log(p)  // {age: 18}
console.log(p.name) // undefined
console.log(p.age) // 18

通过这段代码又可以看出,当构造函数最后 return 出来的是一个和 this 无关的对象时,new 命令会直接返回这个新对象而不是通过 new 执行步骤生成的 this 对象

但是这里要求构造函数必须是返回一个对象,如果返回的不是对象,那么还是会按照 new 的实现步骤,返回新生成的对象。接下来还是在上面这段代码的基础之上稍微改动一下

function Person(){
   this.name = 'Jack';
   return 'tom';
}
var p = new Person();
console.log(p)  // {name: 'Jack'}
console.log(p.name) // Jack

可以看出,当构造函数中 return 的不是一个对象时,那么它还是会根据 new 关键词的执行逻辑,生成一个新的对象(绑定了最新 this),最后返回出来

因此我们总结一下:new 关键词执行之后总是会返回一个对象,要么是实例对象,要么是 return 语句指定的对象

手工实现New的过程

function create(fn, ...args) {
  if(typeof fn !== 'function') {
    throw 'fn must be a function';
  }
	// 1、用new Object() 的方式新建了一个对象obj
  // var obj = new Object()
	// 2、给该对象的__proto__赋值为fn.prototype,即设置原型链
  // obj.__proto__ = fn.prototype

  // 1、2步骤合并
  // 创建一个空对象,且这个空对象继承构造函数的 prototype 属性
  // 即实现 obj.__proto__ === constructor.prototype
  var obj = Object.create(fn.prototype);

	// 3、执行fn,并将obj作为内部this。使用 apply,改变构造函数 this 的指向到新建的对象,这样 obj 就可以访问到构造函数中的属性
  var res = fn.apply(obj, args);
	// 4、如果fn有返回值,则将其作为new操作返回内容,否则返回obj
	return res instanceof Object ? res : obj;
};
  • 使用 Object.createobj 的proto指向为构造函数的原型
  • 使用 apply 方法,将构造函数内的 this 指向为 obj
  • create 返回时,使用三目运算符决定返回结果。

我们知道,构造函数如果有显式返回值,且返回值为对象类型,那么构造函数返回结果不再是目标实例

如下代码:

function Person(name) {
  this.name = name
  return {1: 1}
}
const person = new Person(Person, 'lucas')
console.log(person)
// {1: 1}

测试

//使用create代替new
function Person() {...}
// 使用内置函数new
var person = new Person(1,2)

// 使用手写的new,即create
var person = create(Person, 1,2)

new 被调用后大致做了哪几件事情

  • 让实例可以访问到私有属性;
  • 让实例可以访问构造函数原型(constructor.prototype)所在原型链上的属性;
  • 构造函数返回的最后结果是引用数据类型。

💬 面试官追问

  • 用户中心执行 new Person() 后能读到实例属性和 Person.prototype 上的方法,同事却说 new 只是调用了一次函数;你会用对象关系怎样反驳?

    new 不只是调用函数,它还创建新对象,并把该对象连接到 Person.prototype,再以新对象作为 this 执行构造函数。构造函数写入的是实例自身属性,原型方法则通过原型链访问;普通调用不会自动完成这些关系。

  • 遗留代码里 Person() 漏写 new,返回值变成 undefined,同时页面出现了全局 name;你会怎样阻止同类污染?

    在该非严格示例中,普通调用没有创建实例,构造函数默认返回 undefined,而 this.name 写到了全局对象。工程上应统一构造入口并检查调用方式,或使用必须通过 new 调用的构造形式;仅依靠调用者记忆仍可能再次遗漏。

  • 缓存模块的构造函数执行了 this.key = 'A',随后 return { key: 'B' };调用方要求实例必须保留原型方法,实际会发生什么?

    显式返回对象会取代原本创建的实例,因此调用方拿到 { key: 'B' },也不会自动保留构造函数原型链上的方法。若必须返回标准实例,就不要返回无关对象,或确保返回对象具有所需原型关系;返回原始值则会被忽略。

  • 团队手写的 create(Constructor, ...args) 生成对象后,实例读不到原型方法;你会按 new 的哪几个环节定位?

    先确认是否用 Object.create(Constructor.prototype) 建立了原型连接,再检查是否以新对象为 this 调用了构造函数并传入参数。最后核对返回逻辑:构造函数返回对象时采用该对象,否则返回创建的实例;缺少任一环节都会偏离 new 的行为。

  • 评审手写 new 时,一版用 obj.__proto__ = fn.prototype,另一版用 Object.create(fn.prototype);你会选择哪版并说明边界?

    应优先使用 Object.create(fn.prototype),它在创建对象时直接建立所需的原型关系,表达也更贴近目标。随后仍需用 apply 绑定 this 并处理构造函数的显式返回值;只完成原型连接并不能等价实现完整的 new

  • 构造函数返回 null、字符串或普通对象时,测试负责人要求列出三种调用结果;你的断言应该是什么?

    返回普通对象时,new 的结果被该对象替代;返回字符串等原始值时,结果仍是新创建的实例。null 虽然历史上 typeof 表现特殊,但不是可替代实例的对象返回值,因此也应保留实例;手写实现不宜只靠含糊的类型判断。

# 9 原型/原型链

⚡ 30 秒速记

  • 三个概念别混:__proto__ 是实例指向原型的指针;prototype 只有函数才有;constructor 是原型上指回构造函数的属性
  • 一句话串起来:实例.__proto__ === 构造函数.prototype
  • 查找属性沿链向上,找到为止,走到 null 返回 undefined —— 这就是继承的实现原理
  • 链的尽头是 Object.prototype,它的 __proto__null
  • 绕人题:Function.__proto__ === Function.prototype(函数也是 Function 的实例);取原型用 Object.getPrototypeOf 而不是 __proto__

原型链就是对象通过原型逐级连接起来的属性查找链,核心关系是 实例.__proto__ === 构造函数.prototype prototype 用来保存实例共享的属性和方法,原型上的 constructor 通常再指回构造函数。访问属性时会先查对象自身,再沿原型向上查找,直到 Object.prototype,仍未找到就得到 undefined。工程中获取原型优先使用 Object.getPrototypeOf(),不建议直接依赖非标准的 __proto__

__proto__和prototype关系__proto__constructor对象独有的。2️⃣prototype属性是函数独有的

在 js 中我们是使用构造函数来新建一个对象的,每一个构造函数的内部都有一个 prototype 属性值,这个属性值是一个对象,这个对象包含了可以由该构造函数的所有实例共享的属性和方法。当我们使用构造函数新建一个对象后,在这个对象的内部将包含一个指针,这个指针指向构造函数的 prototype 属性对应的值,在 ES5 中这个指针被称为对象的原型。一般来说我们是不应该能够获取到这个值的,但是现在浏览器中都实现了 proto 属性来让我们访问这个属性,但是我们最好不要使用这个属性,因为它不是规范中规定的。ES5 中新增了一个 Object.getPrototypeOf() 方法,我们可以通过这个方法来获取对象的原型。

当我们访问一个对象的属性时,如果这个对象内部不存在这个属性,那么它就会去它的原型对象里找这个属性,这个原型对象又会有自己的原型,于是就这样一直找下去,也就是原型链的概念。原型链的尽头一般来说都是 Object.prototype 所以这就是我们新建的对象为什么能够使用 toString() 等方法的原因。

特点:JavaScript 对象是通过引用来传递的,我们创建的每个新对象实体中并没有一份属于自己的原型副本。当我们修改原型时,与 之相关的对象也会继承这一改变

  • 原型(prototype): 一个简单的对象,用于实现对象的 属性继承。可以简单的理解成对象的爹。在 FirefoxChrome 中,每个JavaScript对象中都包含一个__proto__(非标准)的属性指向它爹(该对象的原型),可obj.__proto__进行访问。
  • 构造函数: 可以通过new来 新建一个对象 的函数。
  • 实例: 通过构造函数和new创建出来的对象,便是实例。 实例通过__proto__指向原型,通过constructor指向构造函数。

Object为例,我们常用的Object便是一个构造函数,因此我们可以通过它构建实例。

// 实例
const instance = new Object()

则此时, 实例为instance, 构造函数为Object,我们知道,构造函数拥有一个prototype的属性指向原型,因此原型为:

// 原型
const prototype = Object.prototype

这里我们可以来看出三者的关系:

  • 实例.__proto__ === 原型
  • 原型.constructor === 构造函数
  • 构造函数.prototype === 原型
// 这条线其实是是基于原型进行获取的,可以理解成一条基于原型的映射线
// 例如:
// const o = new Object()
// o.constructor === Object   --> true
// o.__proto__ = null;
// o.constructor === Object   --> false
实例.constructor === 构造函数

原型链

原型链是由原型对象组成,每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型,__proto__ 将对象连接起来组成了原型链。是一个用来实现继承和共享属性的有限的对象链

  • 属性查找机制: 当查找对象的属性时,如果实例对象自身不存在该属性,则沿着原型链往上一级查找,找到时则输出,不存在时,则继续沿着原型链往上一级查找,直至最顶级的原型对象Object.prototype,如还是没找到,则输出undefined
  • 属性修改机制: 只会修改实例对象本身的属性,如果不存在,则进行添加该属性,如果需要修改原型的属性时,则可以用: b.prototype.x = 2;但是这样会造成所有继承于该对象的实例的属性发生改变。

js 获取原型的方法

  • p.proto
  • p.constructor.prototype
  • Object.getPrototypeOf(p)

总结

  • 每个函数都有 prototype 属性,除了 Function.prototype.bind(),该属性指向原型。
  • 每个对象都有 __proto__ 属性,指向了创建该对象的构造函数的原型。其实这个属性指向了 [[prototype]],但是 [[prototype]]是内部属性,我们并不能访问到,所以使用 _proto_来访问。
  • 对象可以通过 __proto__ 来寻找不属于该对象的属性,__proto__ 将对象连接起来组成了原型链。

💬 面试官追问

  • 控制台里 user.name 能读到值,但 Object.keys(user) 和接口序列化都没有 name,你会怎么解释这个反例?

    name 很可能不在 user 自身,而是沿原型链从原型对象上找到的。用 Object.hasOwn(user, 'name') 判断自有属性,再用 Object.getPrototypeOf(user) 逐层定位;依赖继承属性做序列化会产生遗漏。

  • 组件库想让所有实例共享一份格式化方法,你会把方法放在构造函数里,还是放到 prototype 上?

    共享且无实例状态的方法应放到构造函数的 prototype 上,实例通过原型链复用同一个函数。若在构造函数中逐个创建方法,每个实例都会持有独立函数;但会被实例改写的方法不适合直接共享。

  • 线上热修复把 Widget.prototype.render 换成了新函数,已创建的几百个组件会立即使用新版吗?

    只要实例自身没有同名 render,后续查找就会命中新原型方法,因此已有实例也会受到影响。若某个实例已定义自身的 render,它会遮蔽原型实现;修改共享原型属于全局影响操作,需要评估兼容性。

  • 调试时看到 item.constructor !== Item,同事据此认定对象不是 Item 实例,你会怎么排查?

    不能只依赖 constructor,因为它通常也是沿原型链取得的普通属性,可能被覆盖或因重设原型而丢失。应同时检查 Object.getPrototypeOf(item)Item.prototypeitem instanceof Item;原型链被人为改写时,这些结果也会随之变化。

  • 旧代码大量使用 obj.__proto__ 改继承关系,新模块要继续沿用还是改成标准 API

    新模块应使用 Object.getPrototypeOf 读取原型,用 Object.create 建立所需原型关系,避免继续依赖 __proto____proto__ 暴露的是内部 [[Prototype]] 的访问入口,但直接改链会让属性来源和实例判断更难追踪。

# 10 继承

⚡ 30 秒速记

  • 按演进讲最清楚:原型链继承(引用类型共享)→ 借用构造函数(方法无法复用)→ 组合继承(父构造函数调用两次)→ 寄生组合式(ES5 最优解)
  • 寄生组合式核心:Child.prototype = Object.create(Parent.prototype) + 修正 constructor + 构造函数里 Parent.call(this)
  • ES6class extends 就是它的语法糖,额外能继承静态方法(Child.__proto__ === Parent
  • class 的子类没有自己的 this,必须先调 super() 才能用
  • 实践建议:组合优于继承,层级一深就难维护

JavaScript 继承的核心是连接原型并初始化父类属性,常用结果是寄生组合继承或 class extends 单独借用构造函数无法继承原型方法,单独使用原型链又会让多个实例共享引用类型属性。组合继承解决了这两个问题,但父构造函数会执行两次;用 Object.create(Parent.prototype) 可避免这次多余调用,并修正 constructorclass 本质上仍基于原型,子类构造函数需要调用 super(),静态继承还涉及构造函数本身的原型关系。

涉及面试题:原型如何实现继承?Class 如何实现继承?Class 本质是什么?

首先先来讲下 class,其实在 JS中并不存在类,class 只是语法糖,本质还是函数

class Person {}
Person instanceof Function // true

组合继承

组合继承是最常用的继承方式

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}
function Child(value) {
  Parent.call(this, value)
}
Child.prototype = new Parent()

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true
  • 以上继承的方式核心是在子类的构造函数中通过 Parent.call(this) 继承父类的属性,然后改变子类的原型为 new Parent() 来继承父类的函数。
  • 这种继承方式优点在于构造函数可以传参,不会与父类引用属性共享,可以复用父类的函数,但是也存在一个缺点就是在继承父类函数的时候调用了父类构造函数,导致子类的原型上多了不需要的父类属性,存在内存上的浪费

寄生组合继承

这种继承方式对组合继承进行了优化,组合继承缺点在于继承父类函数时调用了构造函数,我们只需要优化掉这点就行了

function Parent(value) {
  this.val = value
}
Parent.prototype.getValue = function() {
  console.log(this.val)
}

function Child(value) {
  Parent.call(this, value)
}
Child.prototype = Object.create(Parent.prototype, {
  constructor: {
    value: Child,
    enumerable: false,
    writable: true,
    configurable: true
  }
})

const child = new Child(1)

child.getValue() // 1
child instanceof Parent // true

以上继承实现的核心就是将父类的原型赋值给了子类,并且将构造函数设置为子类,这样既解决了无用的父类属性问题,还能正确的找到子类的构造函数。

Class 继承

以上两种继承方式都是通过原型去解决的,在 ES6 中,我们可以使用 class 去实现继承,并且实现起来很简单

class Parent {
  constructor(value) {
    this.val = value
  }
  getValue() {
    console.log(this.val)
  }
}
class Child extends Parent {
  constructor(value) {
    super(value)
    this.val = value
  }
}
let child = new Child(1)
child.getValue() // 1
child instanceof Parent // true

class 实现继承的核心在于使用 extends 表明继承自哪个父类,并且在子类构造函数中必须调用 super,因为这段代码可以看成 Parent.call(this, value)

ES5 和 ES6 继承的区别:

  • ES6 继承的子类需要调用 super() 才能拿到子类,ES5 的话是通过 apply 这种绑定的方式
  • 类声明不会提升,和 let 这些一致
function Super() {}
Super.prototype.getNumber = function() {
  return 1
}

function Sub() {}
Sub.prototype = Object.create(Super.prototype, {
  constructor: {
    value: Sub,
    enumerable: false,
    writable: true,
    configurable: true
  }
})
let s = new Sub()
s.getNumber()

以下详细讲解几种常见的继承方式

1. 方式1: 借助call

 function Parent1(){
    this.name = 'parent1';
  }
  function Child1(){
    Parent1.call(this);
    this.type = 'child1'
  }
  console.log(new Child1);

这样写的时候子类虽然能够拿到父类的属性值,但是问题是父类原型对象中一旦存在方法那么子类无法继承。那么引出下面的方法。

2. 方式2: 借助原型链

 function Parent2() {
    this.name = 'parent2';
    this.play = [1, 2, 3]
  }
  function Child2() {
    this.type = 'child2';
  }
  Child2.prototype = new Parent2();

  console.log(new Child2());

看似没有问题,父类的方法和属性都能够访问,但实际上有一个潜在的不足。举个例子:

var s1 = new Child2();
var s2 = new Child2();
s1.play.push(4);
console.log(s1.play, s2.play);

可以看到控制台:

明明我只改变了s1的play属性,为什么s2也跟着变了呢?很简单,因为两个实例使用的是同一个原型对象。

那么还有更好的方式么?

3. 方式3:将前两种组合

  function Parent3 () {
    this.name = 'parent3';
    this.play = [1, 2, 3];
  }
  function Child3() {
    Parent3.call(this);
    this.type = 'child3';
  }
  Child3.prototype = new Parent3();
  var s3 = new Child3();
  var s4 = new Child3();
  s3.play.push(4);
  console.log(s3.play, s4.play);

可以看到控制台:

之前的问题都得以解决。但是这里又徒增了一个新问题,那就是Parent3的构造函数会多执行了一次(Child3.prototype = new Parent3();)。这是我们不愿看到的。那么如何解决这个问题?

4. 方式4: 组合继承的优化1

  function Parent4 () {
    this.name = 'parent4';
    this.play = [1, 2, 3];
  }
  function Child4() {
    Parent4.call(this);
    this.type = 'child4';
  }
  Child4.prototype = Parent4.prototype;

这里让将父类原型对象直接给到子类,父类构造函数只执行一次,而且父类属性和方法均能访问,但是我们来测试一下:

var s3 = new Child4();
var s4 = new Child4();
console.log(s3)

子类实例的构造函数是Parent4,显然这是不对的,应该是Child4。

5. 方式5(最推荐使用): 组合继承的优化2

 function Parent5 () {
    this.name = 'parent5';
    this.play = [1, 2, 3];
  }
  function Child5() {
    Parent5.call(this);
    this.type = 'child5';
  }
  Child5.prototype = Object.create(Parent5.prototype);
  Child5.prototype.constructor = Child5;

这是最推荐的一种方式,接近完美的继承,它的名字也叫做寄生组合继承。

6. ES6的extends被编译后的JavaScript代码

ES6的代码最后都是要在浏览器上能够跑起来的,这中间就利用了babel这个编译工具,将ES6的代码编译成ES5让一些不支持新语法的浏览器也能运行。

那最后编译成了什么样子呢?

function _possibleConstructorReturn(self, call) {
    // ...
    return call && (typeof call === 'object' || typeof call === 'function') ? call : self;
}

function _inherits(subClass, superClass) {
    // ...
    //看到没有
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}


var Parent = function Parent() {
    // 验证是否是 Parent 构造出来的 this
    _classCallCheck(this, Parent);
};

var Child = (function (_Parent) {
    _inherits(Child, _Parent);

    function Child() {
        _classCallCheck(this, Child);

        return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));
    }

    return Child;
}(Parent));

核心是_inherits函数,可以看到它采用的依然也是第五种方式————寄生组合继承方式,同时证明了这种方式的成功。不过这里加了一个Object.setPrototypeOf(subClass, superClass),这是用来干啥的呢?

答案是用来继承父类的静态方法。这也是原来的继承方式疏忽掉的地方。

追问: 面向对象的设计一定是好的设计吗?

不一定。从继承的角度说,这一设计是存在巨大隐患的。

💬 面试官追问

  • 有人只在 Child 里写 Parent.call(this),实例属性都有了,却调用 child.getValue() 报错,缺的是哪条继承关系?

    Parent.call(this) 只把父构造函数中的实例属性初始化到子实例上,不会让 Child.prototype 继承 Parent.prototype。应再用 Object.create(Parent.prototype) 建立原型链并修正 constructor,否则父类原型方法不可见。

  • 表单模型有数组字段,多个 Child 实例修改其中一个的选项后全部联动,原型链代码该从哪里查?

    先检查是否写了 Child.prototype = new Parent(),从而把父构造函数创建的数组留在共享原型上。子构造函数中调用 Parent.call(this) 可让每个实例获得独立引用,再用寄生组合继承复用父类方法。

  • 父构造函数新增了日志注册逻辑后,每创建一个子实例却出现额外初始化记录,组合继承为什么会放大这个副作用?

    传统组合继承在设置 Child.prototype = new Parent() 时执行一次父构造函数,创建子实例时又通过 Parent.call(this) 执行一次。改为 Object.create(Parent.prototype) 可避免为建原型而调用构造函数,也不会在子类原型上残留无用实例属性。

  • Child.prototype 直接赋成 Parent.prototype 后,修复子类 constructor 却连父类实例的 constructor 也变了,原因是什么?

    两者此时引用同一个原型对象,修改 Child.prototype.constructor 等同于修改 Parent.prototype.constructor。应让子类拥有以父原型为原型的新对象,即 Child.prototype = Object.create(Parent.prototype),再单独恢复不可枚举的 constructor

  • ES5 继承迁成 class Child extends Parent 后,父类静态校验方法也能从 Child 调用,原来的寄生组合代码为什么做不到?

    传统寄生组合继承通常只连接 Child.prototypeParent.prototype,因此只覆盖实例侧继承。extends 还会建立构造函数之间的继承关系,使子类可访问父类静态成员;手写方案需要额外设置 Child 的原型,但会增加兼容和维护成本。

# 11 面向对象

⚡ 30 秒速记

  • 三大特性:封装(隐藏实现)、继承(复用)、多态(同一接口不同实现)
  • JavaScript 的特殊之处:它是基于原型而不是基于类的,class 只是原型继承的语法糖
  • 封装在 JS 里的实现:闭包私有变量、Symbol 私有键、class# 私有字段(现代写法)
  • 设计原则记 SOLID:单一职责、开闭、里氏替换、接口隔离、依赖倒置
  • 现代前端是混合范式:React 用函数式,业务建模用 OOP,两者不冲突

面向对象是用对象组织状态和行为,并通过封装、继承、多态与抽象控制代码关系的一种编程思想。 封装可以借助闭包和局部变量隐藏实现,只向外暴露必要方法;继承则可通过原型链与父构造函数调用复用属性和行为。多态强调同一父级下的不同子对象可以呈现不同形态,抽象类还可在构造器或待实现方法中抛错,避免被直接使用。对象拷贝也要注意边界:浅拷贝会共享引用类型,确需隔离时才使用递归深拷贝。

编程思想

  • 基本思想是使用对象,类,继承,封装等基本概念来进行程序设计
  • 优点
    • 易维护
      • 采用面向对象思想设计的结构,可读性高,由于继承的存在,即使改变需求,那么维护也只是在局部模块,所以维护起来是非常方便和较低成本的
    • 易扩展
    • 开发工作的重用性、继承性高,降低重复工作量。
    • 缩短了开发周期

一般面向对象包含:继承,封装,多态,抽象

1. 对象形式的继承

浅拷贝

var Person = {
    name: 'poetry',
    age: 18,
    address: {
        home: 'home',
        office: 'office',
    }
    sclools: ['x','z'],
};

var programer = {
    language: 'js',
};

function extend(p, c){
    var c = c || {};
    for( var prop in p){
        c[prop] = p[prop];
    }
}
extend(Person, programer);
programer.name;  // poetry
programer.address.home;  // home
programer.address.home = 'house';  //house
Person.address.home;  // house

从上面的结果看出,浅拷贝的缺陷在于修改了子对象中引用类型的值,会影响到父对象中的值,因为在浅拷贝中对引用类型的拷贝只是拷贝了地址,指向了内存中同一个副本

深拷贝

function extendDeeply(p, c){
    var c = c || {};
    for (var prop in p){
        if(typeof p[prop] === "object"){
            c[prop] = (p[prop].constructor === Array)?[]:{};
            extendDeeply(p[prop], c[prop]);
        }else{
            c[prop] = p[prop];
        }
    }
}

利用递归进行深拷贝,这样子对象的修改就不会影响到父对象

extendDeeply(Person, programer);
programer.address.home = 'poetry';
Person.address.home; // home

利用call和apply继承

function Parent(){
    this.name = "abc";
    this.address = {home: "home"};
}
function Child(){
    Parent.call(this);
    this.language = "js";
}

ES5中的Object.create()

var p = { name : 'poetry'};
var obj = Object.create(p);
obj.name; // poetry

Object.create()作为new操作符的替代方案是ES5之后才出来的。我们也可以自己模拟该方法:

//模拟Object.create()方法
function myCreate(o){
    function F(){};
    F.prototype = o;
    o = new F();
    return o;
}
var p = { name : 'poetry'};
var obj = myCreate(p);
obj.name; // poetry

目前,各大浏览器的最新版本(包括IE9)都部署了这个方法。如果遇到老式浏览器,可以用下面的代码自行部署

 if (!Object.create) {
    Object.create = function (o) {
       function F() {}
      F.prototype = o;
      return new F();
    };
  }

2. 类的继承

Object.create()

function Person(name, age){}
Person.prototype.headCount = 1;
Person.prototype.eat = function(){
    console.log('eating...');
}
function Programmer(name, age, title){}

Programmer.prototype = Object.create(Person.prototype); //建立继承关系
Programmer.prototype.constructor = Programmer;  // 修改constructor的指向

调用父类方法

function Person(name, age){
    this.name = name;
    this.age = age;
}
Person.prototype.headCount = 1;
Person.prototype.eat = function(){
    console.log('eating...');
}

function Programmer(name, age, title){
    Person.apply(this, arguments); // 调用父类的构造器
}


Programmer.prototype = Object.create(Person.prototype);
Programmer.prototype.constructor = Programmer;

Programmer.prototype.language = "js";
Programmer.prototype.work = function(){
    console.log('i am working code in '+ this.language);
    Person.prototype.eat.apply(this, arguments); // 调用父类上的方法
}

3. 封装

  • 命名空间
    • js是没有命名空间的,因此可以用对象模拟
var app = {};  // 命名空间app
//模块1
app.module1 = {
    name: 'poetry',
    f: function(){
        console.log('hi robot');
    }
};
app.module1.name; // "poetry"
app.module1.f();  // hi robot

对象的属性外界是可读可写 如何来达到封装的额目的?答:可通过闭包+局部变量来完成

  • 在构造函数内部声明局部变量 和普通方法
  • 因为作用域的关系 只有构造函数内的方法
  • 才能访问局部变量 而方法对于外界是开放的
  • 因此可以通过方法来访问 原本外界访问不到的局部变量 达到函数封装的目的
function Girl(name,age){
	var love = '小明';//love 是局部变量 准确说不属于对象 属于这个函数的额激活对象 函数调用时必将产生一个激活对象 love在激活对象身上   激活对象有作用域的关系 有办法访问  加一个函数提供外界访问
	this.name = name;
	this.age = age;
	this.say = function () {
		return love;
	};

	this.movelove = function (){
		love = '小轩'; //35
	}

}

var g = new Girl('yinghong',22);

console.log(g);
console.log(g.say());//小明
console.log(g.movelove());//undefined  因为35行没有返回
console.log(g.say());//小轩


function fn(){
	function t(){
		//var age = 22;//声明age变量 在t的激活对象上
		age = 22;//赋值操作 t的激活对象上找age属性 ,找不到 找fn的激活对象....再找到 最终找到window.age = 22;
				//不加var就是操作window全局属性

	}
	t();
}
console.log(fn());//undefined

4. 静态成员

面向对象中的静态方法-静态属性:没有new对象 也能引用静态方法属性

function Person(name){
    var age = 100;
    this.name = name;
}
//静态成员
Person.walk = function(){
    console.log('static');
};
Person.walk();  // static

5. 私有与公有

function Person(id){
    // 私有属性与方法
    var name = 'poetry';
    var work = function(){
        console.log(this.id);
    };
    //公有属性与方法
    this.id = id;
    this.say = function(){
        console.log('say hello');
        work.call(this);
    };
};
var p1 = new Person(123);
p1.name; // undefined
p1.id;  // 123
p1.say();  // say hello 123

6. 模块化

var moduleA;
moduleA = function() {
    var prop = 1;

    function func() {}

    return {
        func: func,
        prop: prop
    };
}(); // 立即执行匿名函数

7. 多态

多态:同一个父类继承出来的子类各有各的形态

function Cat(){
	this.eat = '肉';
}

function Tiger(){
	this.color = '黑黄相间';
}

function Cheetah(){
	this.color = '报文';
}

function Lion(){
	this.color = '土黄色';
}

Tiger.prototype =  Cheetah.prototype = Lion.prototype = new Cat();//共享一个祖先 Cat

var T = new Tiger();
var C = new Cheetah();
var L = new Lion();

console.log(T.color);
console.log(C.color);
console.log(L.color);


console.log(T.eat);
console.log(C.eat);
console.log(L.eat);

8. 抽象类

在构造器中 throw new Error(''); 抛异常。这样防止这个类被直接调用

function DetectorBase() {
    throw new Error('Abstract class can not be invoked directly!');
}

DetectorBase.prototype.detect = function() {
    console.log('Detection starting...');
};
DetectorBase.prototype.stop = function() {
    console.log('Detection stopped.');
};
DetectorBase.prototype.init = function() {
    throw new Error('Error');
};

// var d = new DetectorBase();
// Uncaught Error: Abstract class can not be invoked directly!

function LinkDetector() {}
LinkDetector.prototype = Object.create(DetectorBase.prototype);
LinkDetector.prototype.constructor = LinkDetector;

var l = new LinkDetector();
console.log(l); //LinkDetector {}__proto__: LinkDetector
l.detect(); //Detection starting...
l.init(); //Uncaught Error: Error

💬 面试官追问

  • 商品模板用浅拷贝生成两个编辑对象,修改其中一个的 address.home 后另一个也变了,为什么顶层字段却正常?

    浅拷贝只复制顶层值,嵌套对象仍是同一引用,所以修改 address.home 会反映到所有副本。需要隔离嵌套状态时应做与数据结构匹配的深拷贝;简单递归还要谨慎处理 null、特殊对象和循环引用。

  • 插件系统要求外部只能调用 start,不能直接改内部令牌,ES5 环境下你会怎样封装?

    可把令牌保存在构造函数或立即执行函数的局部变量中,只通过闭包暴露 start 等受控方法。这样私有数据不挂在实例上,外部无法直接读写;代价是每个实例可能创建自己的闭包方法,也不便于原型复用。

  • 一个页面要创建上万个轻量模型,当前构造函数为每个实例定义 save 方法,你会怎样调整对象设计?

    save 不依赖私有闭包变量,应把它移到构造函数的 prototype 上,让所有实例共享同一个方法。若它必须访问局部私有状态,则不能直接迁移而不改变封装方式,需要在共享方法与私有性之间取舍。

  • 重构后 programmer.work() 能执行,但内部调用父类 eatthis.name 变成了 undefined,你会检查哪一行?

    应检查父类方法是否以 Person.prototype.eat.apply(this, arguments) 或等价方式调用。若把方法取出后直接执行,调用上下文可能丢失,父类方法便读不到当前子实例属性;显式绑定会增加耦合,但能保持预期接收者。

  • 团队为三种检测器设计继承树,但业务只要求它们都实现 detectstop,你会坚持抽象父类吗?

    只有共享实现或需要统一约束时,抽象父类才有明显价值,可在基类构造器或未实现的方法中抛错以阻止误用。若只是能力相同而状态与实现无关,深继承会增加耦合,组合独立对象或约定统一接口通常更易扩展。

# 12 事件机制

⚡ 30 秒速记

  • 一次点击走三个阶段:捕获(window 往下)→ 目标阶段 → 冒泡(往上回到 window
  • addEventListener 第三参数 true 挂捕获,默认 false 挂冒泡
  • stopPropagation() 阻止继续传播,preventDefault() 阻止默认行为,两者互不相干
  • 事件委托靠冒泡实现:监听挂父元素,用 event.target.closest() 判断真正被点的是谁
  • 现代要补第三参数的对象形式 { capture, once, passive, signal } —— passive: true 显著提升滚动性能,signalAbortController 可批量解绑

DOM 事件触发时会依次经历捕获、目标和冒泡三个阶段。 捕获从根节点向目标元素传播,冒泡则从目标元素向外返回,addEventListener 的第三个参数决定监听器注册在哪个阶段。事件委托本质上是利用冒泡,把多个子元素的同类事件交给父元素处理,适合列表这类节点较多或动态增删的场景。需要中断传播时用 stopPropagation(),若还要阻止同一元素上的其他监听器,则用 stopImmediatePropagation();取消默认行为用 preventDefault()

涉及面试题:事件的触发过程是怎么样的?知道什么是事件代理嘛?

1. 简介

事件流是一个事件沿着特定数据结构传播的过程。冒泡和捕获是事件流在DOM中两种不同的传播方法

事件流有三个阶段

  • 事件捕获阶段
  • 处于目标阶段
  • 事件冒泡阶段

事件捕获

事件捕获(event capturing):通俗的理解就是,当鼠标点击或者触发dom事件时,浏览器会从根节点开始由外到内进行事件传播,即点击了子元素,如果父元素通过事件捕获方式注册了对应的事件的话,会先触发父元素绑定的事件

事件冒泡

事件冒泡(dubbed bubbling):与事件捕获恰恰相反,事件冒泡顺序是由内到外进行事件传播,直到根节点

无论是事件捕获还是事件冒泡,它们都有一个共同的行为,就是事件传播

img

2. 捕获和冒泡

<div id="div1">
  <div id="div2"></div>
</div>

<script>
    let div1 = document.getElementById('div1');
    let div2 = document.getElementById('div2');

    div1.onClick = function(){
        alert('1')
    }

    div2.onClick = function(){
        alert('2');
    }

</script>

当点击 div2时,会弹出两个弹出框。在 ie8/9/10chrome浏览器,会先弹出”2”再弹出“1”,这就是事件冒泡:事件从最底层的节点向上冒泡传播。事件捕获则跟事件冒泡相反

W3C的标准是先捕获再冒泡, addEventListener的第三个参数决定把事件注册在捕获(true)还是冒泡(false)

3. 事件对象

img

4. 事件流阻止

在一些情况下需要阻止事件流的传播,阻止默认动作的发生

  • event.preventDefault():取消事件对象的默认动作以及继续传播。
  • event.stopPropagation()/ event.cancelBubble = true:阻止事件冒泡。

事件的阻止在不同浏览器有不同处理

  • IE下使用 event.returnValue= false
  • 在非IE下则使用 event.preventDefault()进行阻止

preventDefault与stopPropagation的区别

  • preventDefault告诉浏览器不用执行与事件相关联的默认动作(如表单提交)
  • stopPropagation是停止事件继续冒泡,但是对IE9以下的浏览器无效

5. 事件注册

  • 通常我们使用 addEventListener 注册事件,该函数的第三个参数可以是布尔值,也可以是对象。对于布尔值 useCapture 参数来说,该参数默认值为 falseuseCapture 决定了注册的事件是捕获事件还是冒泡事件
  • 一般来说,我们只希望事件只触发在目标上,这时候可以使用 stopPropagation 来阻止事件的进一步传播。通常我们认为 stopPropagation 是用来阻止事件冒泡的,其实该函数也可以阻止捕获事件。stopImmediatePropagationDOM3级新增事件) 同样也能实现阻止事件冒泡和捕获,但是还能阻止该事件目标执行别的注册事件

stopImmediatePropagation()stopPropagation()的区别在哪儿呢?后者只会阻止冒泡或者是捕获。 但是前者除此之外还会阻止该元素的其他事件发生,但是后者就不会阻止其他事件的发生。

node.addEventListener('click',(event) =>{
	event.stopImmediatePropagation()
	console.log('冒泡')
},false);
node.addEventListener('click',(event) => {
	console.log('冒泡2 ')
},false)

6. 事件委托

  • js中性能优化的其中一个主要思想是减少dom操作。
  • 节省内存
  • 不需要给子节点注销事件

假设有100li,每个li有相同的点击事件。如果为每个Li都添加事件,则会造成dom访问次数过多,引起浏览器重绘与重排的次数过多,性能则会降低。 使用事件委托则可以解决这样的问题

原理

实现事件委托是利用了事件的冒泡原理实现的。当我们为最外层的节点添加点击事件,那么里面的ullia的点击事件都会冒泡到最外层节点上,委托它代为执行事件

<ul id="ul">
    <li>1</li>
    <li>2</li>
    <li>3</li>
</ul>
<script>
  window.onload = function(){
    var ulEle = document.getElementById('ul');
    ul.onclick = function(ev){
        //兼容IE
        ev = ev || window.event;
        var target = ev.target || ev.srcElement;

        if(target.nodeName.toLowerCase() == 'li'){
            alert( target.innerHTML);
        }

    }
  }
</script>

💬 面试官追问

  • 弹窗里的链接点击后既跳转页面又触发外层关闭逻辑,同事只调用了 preventDefault(),为什么弹窗仍会关闭?

    preventDefault() 取消的是链接跳转等默认动作,不会停止事件沿 DOM 传播,因此外层监听仍会执行。若确实不应触发外层逻辑,需要调整外层判断或调用 stopPropagation();随意停止传播会破坏依赖冒泡的其他功能。

  • 商品列表有数百个动态增删的条目,每项都有同样的收藏按钮,你会把监听器挂在哪里?

    可在稳定的列表容器上注册一个冒泡阶段监听器,根据事件目标定位对应条目并执行收藏逻辑。事件委托减少逐项绑定和注销,也能覆盖后来插入的节点;不冒泡的事件或传播被中途阻止时不能直接套用。

  • 埋点要求在子组件调用 stopPropagation() 后仍尽量观察点击,你会选择捕获还是冒泡阶段注册?

    可在更外层以 addEventListener 的捕获选项注册,因为事件会先从根向目标传播,再进入目标和冒泡阶段。若事件在更早的捕获节点已被停止,监听仍可能收不到;捕获监听也不能替代对业务事件边界的明确设计。

  • 同一个按钮注册了两个 click 监听,第一个执行了 stopPropagation(),第二个仍然打印日志,这是浏览器异常吗?

    不是,stopPropagation() 只阻止事件继续传播到其他节点,不会取消当前节点上其余监听器。若必须阻止同一目标的后续监听,应使用 stopImmediatePropagation();这会让监听器之间产生强顺序依赖,排查成本较高。

  • 线上委托监听偶尔拿到内部图标节点,导致用 nodeName === 'LI' 的分支失效,你会怎样修复并验证传播链?

    点击嵌套元素时,事件目标是实际命中的最深节点,不保证就是承载业务数据的 li。应从目标向上寻找符合条件且仍位于委托容器内的节点,并检查沿途是否调用了传播阻止方法;选择器过宽还可能误匹配容器外结构。

# 13 模块化

⚡ 30 秒速记

  • 演进:全局函数 → 命名空间 → IIFECommonJS/AMD/CMDUMDES Module(语言标准)
  • ESM vs CommonJS 四点差异:编译期静态解析 vs 运行时加载、值的引用 vs 值的拷贝、支持 Tree Shaking vs 不支持、异步 vs 同步
  • ESM 顶层可以 awaitCommonJS 因为同步加载所以不行
  • 循环依赖:ESMTDZ 保护处理得更好,CJS 会拿到未完成的部分导出
  • 工程现状:库同时产出双格式,用 package.jsonexports 字段声明入口

现在谈 JavaScript 模块化,核心就是比较 ESMCommonJSAMDCMD 作为历史背景了解即可。 ESM 的依赖关系可在编译期静态分析,因此构建工具能做 Tree Shaking,导入值也是实时绑定;CommonJS 则通过 require 在运行时加载,拿到的是 module.exports 导出的值。加载方式上,CommonJS 更适合服务端的同步读取,ESM 更适合浏览器和现代构建工具。维护老项目时可能遇到 require.jssea.js,但新项目一般优先使用 importexport

版本说明(面试主线):下面列的四种方案里,现在的主线只有两个——浏览器和构建工具用 ES Module(ESM,import/export),Node 端历史上用 CommonJSrequire/module.exports)、现也全面支持 ESMAMD(require.js)和 CMD(sea.js)是 ES Module 出现之前、为解决浏览器异步加载模块而生的社区方案,如今已基本退出历史舞台,了解它们主要是为理解「模块化的演进」和偶尔维护老项目。面试回答模块化,应以「ESM vs CommonJS 的区别」为主线(ESM 编译期静态分析、可 Tree Shaking、实时绑定、异步;CJS 运行时加载、值拷贝、同步),把 AMD/CMD 作为历史背景一笔带过即可。

js 中现在比较成熟的有四种模块加载方案:

  • 第一种是 CommonJS 方案,它通过 require 来引入模块,通过 module.exports 定义模块的输出接口。这种模块加载方案是服务器端的解决方案,它是以同步的方式来引入模块的,因为在服务端文件都存储在本地磁盘,所以读取非常快,所以以同步的方式加载没有问题。但如果是在浏览器端,由于模块的加载是使用网络请求,因此使用异步加载的方式更加合适。
  • 第二种是 AMD 方案,这种方案采用异步加载的方式来加载模块,模块的加载不影响后面语句的执行,所有依赖这个模块的语句都定义在一个回调函数里,等到加载完成后再执行回调函数。require.js 实现了 AMD 规范
  • 第三种是 CMD 方案,这种方案和 AMD 方案都是为了解决异步模块加载的问题,sea.js 实现了 CMD 规范。它和require.js的区别在于模块定义时对依赖的处理不同和对依赖模块的执行时机的处理不同。
  • 第四种方案是 ES6 提出的方案,使用 import 和 export 的形式来导入导出模块

在有 Babel 的情况下,我们可以直接使用 ES6的模块化

// file a.js
export function a() {}
export function b() {}
// file b.js
export default function() {}

import {a, b} from './a.js'
import XXX from './b.js'

CommonJS

CommonJsNode 独有的规范,浏览器中使用就需要用到 Browserify解析了。

// a.js
module.exports = {
    a: 1
}
// or
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1

在上述代码中,module.exportsexports 很容易混淆,让我们来看看大致内部实现

var module = require('./a.js')
module.a
// 这里其实就是包装了一层立即执行函数,这样就不会污染全局变量了,
// 重要的是 module 这里,module 是 Node 独有的一个变量
module.exports = {
    a: 1
}
// 基本实现
var module = {
  exports: {} // exports 就是个空对象
}
// 这个是为什么 exports 和 module.exports 用法相似的原因
var exports = module.exports
var load = function (module) {
    // 导出的东西
    var a = 1
    module.exports = a
    return module.exports
};

再来说说 module.exportsexports,用法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果。

对于 CommonJSES6 中的模块化的两者区别是:

  • 前者支持动态导入,也就是 require(${path}/xx.js),后者目前不支持,但是已有提案,前者是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。
  • 而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
  • 前者在导出时都是值拷贝,就算导出的值变了,导入的值也不会改变,所以如果想更新值,必须重新导入一次。
  • 但是后者采用实时绑定的方式,导入导出的值都指向同一个内存地址,所以导入值会跟随导出值变化
  • 后者会编译成 require/exports 来执行的

AMD

AMD 是由 RequireJS 提出的

AMD 和 CMD 规范的区别?

  • 第一个方面是在模块定义时对依赖的处理不同。AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块。而 CMD 推崇就近依赖,只有在用到某个模块的时候再去 require。
  • 第二个方面是对依赖模块的执行时机处理不同。首先 AMD 和 CMD 对于模块的加载方式都是异步加载,不过它们的区别在于模块的执行时机,AMD 在依赖模块加载完成后就直接执行依赖模块,依赖模块的执行顺序和我们书写的顺序不一定一致。而 CMD在依赖模块加载完成后并不执行,只是下载而已,等到所有的依赖模块都加载好后,进入回调函数逻辑,遇到 require 语句的时候才执行对应的模块,这样模块的执行顺序就和我们书写的顺序保持一致了。
// CMD
define(function(require, exports, module) {
  var a = require("./a");
  a.doSomething();
  // 此处略去 100 行
  var b = require("./b"); // 依赖可以就近书写
  b.doSomething();
  // ...
});

// AMD 默认推荐
define(["./a", "./b"], function(a, b) {
  // 依赖必须一开始就写好
  a.doSomething();
  // 此处略去 100 行
  b.doSomething();
  // ...
})
  • AMDrequirejs 在推广过程中对模块定义的规范化产出,提前执行,推崇依赖前置
  • CMDseajs 在推广过程中对模块定义的规范化产出,延迟执行,推崇依赖就近
  • CommonJs:模块输出的是一个值的 copy,运行时加载,加载的是一个对象(module.exports 属性),该对象只有在脚本运行完才会生成
  • ES6 Module:模块输出的是一个值的引用,编译时输出接口,ES6模块不是对象,它对外接口只是一种静态定义,在代码静态解析阶段就会生成。

谈谈对模块化开发的理解

  • 我对模块的理解是,一个模块是实现一个特定功能的一组方法。在最开始的时候,js 只实现一些简单的功能,所以并没有模块的概念,但随着程序越来越复杂,代码的模块化开发变得越来越重要。
  • 由于函数具有独立作用域的特点,最原始的写法是使用函数来作为模块,几个函数作为一个模块,但是这种方式容易造成全局变量的污染,并且模块间没有联系。
  • 后面提出了对象写法,通过将函数作为一个对象的方法来实现,这样解决了直接使用函数作为模块的一些缺点,但是这种办法会暴露所有的所有的模块成员,外部代码可以修改内部属性的值。
  • 现在最常用的是立即执行函数的写法,通过利用闭包来实现模块私有作用域的建立,同时不会对全局作用域造成污染。

💬 面试官追问

  • 计数模块导出 count 后持续自增,require 方保存的值不再变化,而 import 方观察到更新,你怎么解释?

    CommonJS 导出通常表现为运行时取得导出值,解构或保存后不会自动形成 ESM 的实时绑定。ES Module 的导入绑定指向导出接口,源模块更新时导入侧可观察到变化;业务代码仍不应尝试给导入绑定重新赋值。

  • 浏览器首屏直接使用同步 require 风格加载几十个远程模块,架构评审时你会否决吗?

    浏览器模块需要经过网络获取,同步加载会阻塞后续执行,不适合作为现代页面的主线方案。应采用原生或构建工具支持的 ES ModuleAMDCMD 主要用于理解旧项目中的异步模块演进。

  • 插件入口路径要由租户配置在运行时决定,同事坚持所有依赖都写成静态 import,冲突点在哪里?

    静态 import 的接口和依赖可在解析阶段确定,利于构建工具分析,但不能直接用任意运行时字符串替代模块说明符。确需按条件加载时应采用环境支持的动态导入机制;路径范围过于开放会削弱构建可分析性并增加加载失败风险。

  • 一个 CommonJS 文件先写 exports.a = 1,随后又写 exports = { b: 2 },调用方为什么只能拿到 a

    初始时 exports 只是 module.exports 的引用,给 exports.a 赋值会修改真正的导出对象。把 exports 重新赋成新对象只改变局部变量指向,不会替换 module.exports;要整体导出必须直接给 module.exports 赋值。

  • 老后台同时存在 require.jssea.js 和新写的 ESM,迁移时要不要把三者当成同等长期方案维护?

    不应同等投入,现代浏览器与构建工具的主线应收敛到 ES ModuleNode 侧则根据现有包体系处理 CommonJSESMAMD 强调依赖前置,CMD 强调就近依赖和不同执行时机,保留它们通常只为兼容尚未迁移的旧模块。

# 14 Iterator迭代器

⚡ 30 秒速记

  • 迭代器协议:对象有 next() 方法,每次返回 { value, done }
  • 可迭代协议:对象有 [Symbol.iterator] 方法,返回一个迭代器
  • 实现了可迭代协议就能用 for...of、展开运算符、解构、Array.from
  • 原生可迭代对象:ArrayStringMapSetargumentsNodeList普通对象不可迭代
  • Generator 函数返回的对象同时满足两个协议,所以是实现自定义迭代最省事的方式

一个对象能否被 for...of 遍历,关键看它是否实现了 [Symbol.iterator] 这个方法需要返回迭代器对象,而迭代器通过 next() 逐项返回 { value, done },两者分别负责提供遍历入口和推进遍历过程。数组、类数组对象、SetMap 已经部署了该接口,普通对象默认没有,所以不能直接使用 for...of。如果确实需要遍历对象,可以手动规定属性顺序并实现接口,也可以用 Generator 简化实现,但顺序规则要由开发者明确。

Iterator(迭代器)是一种接口,也可以说是一种规范。为各种不同的数据结构提供统一的访问机制。任何数据结构只要部署Iterator接口,就可以完成遍历操作(即依次处理该数据结构的所有成员)。

Iterator语法:

const obj = {
    [Symbol.iterator]:function(){}
}

[Symbol.iterator] 属性名是固定的写法,只要拥有了该属性的对象,就能够用迭代器的方式进行遍历。

  • 迭代器的遍历方法是首先获得一个迭代器的指针,初始时该指针指向第一条数据之前,接着通过调用 next 方法,改变指针的指向,让其指向下一条数据
  • 每一次的 next 都会返回一个对象,该对象有两个属性
    • value 代表想要获取的数据
    • done 布尔值,false表示当前指针指向的数据有值,true表示遍历已经结束

Iterator 的作用有三个:

  • 创建一个指针对象,指向当前数据结构的起始位置。也就是说,遍历器对象本质上,就是一个指针对象。
  • 第一次调用指针对象的next方法,可以将指针指向数据结构的第一个成员。
  • 第二次调用指针对象的next方法,指针就指向数据结构的第二个成员。
  • 不断调用指针对象的next方法,直到它指向数据结构的结束位置。

每一次调用next方法,都会返回数据结构的当前成员的信息。具体来说,就是返回一个包含value和done两个属性的对象。其中,value属性是当前成员的值,done属性是一个布尔值,表示遍历是否结束。

let arr = [{num:1},2,3]
let it = arr[Symbol.iterator]() // 获取数组中的迭代器
console.log(it.next())  // { value: Object { num: 1 }, done: false }
console.log(it.next())  // { value: 2, done: false }
console.log(it.next())  // { value: 3, done: false }
console.log(it.next())  // { value: undefined, done: true }

对象没有布局Iterator接口,无法使用for of 遍历。下面使得对象具备Iterator接口

  • 一个数据结构只要有Symbol.iterator属性,就可以认为是“可遍历的”
  • 原型部署了Iterator接口的数据结构有三种,具体包含四种,分别是数组,类似数组的对象,Set和Map结构

为什么对象(Object)没有部署Iterator接口呢?

  • 一是因为对象的哪个属性先遍历,哪个属性后遍历是不确定的,需要开发者手动指定。然而遍历遍历器是一种线性处理,对于非线性的数据结构,部署遍历器接口,就等于要部署一种线性转换
  • 对对象部署Iterator接口并不是很必要,因为Map弥补了它的缺陷,又正好有Iteraotr接口
let obj = {
    id: '123',
    name: '张三',
    age: 18,
    gender: '男',
    hobbie: '睡觉'
}

obj[Symbol.iterator] = function () {
    let keyArr = Object.keys(obj)
    let index = 0
    return {
        next() {
            return index < keyArr.length ? {
                value: {
                    key: keyArr[index],
                    val: obj[keyArr[index++]]
                }
            } : {
                done: true
            }
        }
    }
}

for (let key of obj) {
  console.log(key)
}

💬 面试官追问

  • 订单接口返回 {0:'A',1:'B',length:2},列表页执行 [...data] 却报 is not iterable,明明它能按下标读取,问题出在哪?

    能按下标读取只说明它是类数组,不代表实现了可迭代协议;展开语法会查找 data[Symbol.iterator],缺失时就会抛错。若只需转成数组可用 Array.from(data),若要支持统一遍历,则应显式部署迭代器。

  • 组件库有个树形结果对象,团队希望它既能保留原结构,又能被 for...of 按业务指定顺序遍历,你会怎样设计 [Symbol.iterator]

    应让 [Symbol.iterator]() 返回独立的迭代器对象,并由闭包保存当前位置;每次 next() 返回 {value, done},顺序由业务规则明确决定。不要直接依赖普通对象属性的自然排列,否则遍历语义会隐含在数据结构中,后续调整字段容易改变结果。

  • 同一个分页集合需要同时启动两次 for...of,如果 [Symbol.iterator]() 总是返回缓存的同一个迭代器,会出现什么现象?

    两次遍历会共享指针,一个循环调用 next() 后会推进另一个循环的位置,最终产生跳项、错序或提前结束。每次调用 [Symbol.iterator]() 都应创建新的状态和指针;只有明确设计为一次性消费的数据流,才适合共享迭代状态。

  • 线上导出任务偶尔多出一条 undefined,自定义迭代器结束分支返回了 {value: undefined, done: false},你会怎么定位?

    应直接检查连续调用 next() 的返回序列,因为 done: false 表示当前值仍是合法成员,即使 valueundefined 也会被消费。结束分支必须返回 {done: true},通常同时令 valueundefined;还要核对索引递增是否发生在取值之前。

  • 接口字段对象要依次输出键和值,同事主张给所有普通对象的原型统一加 [Symbol.iterator],你会接受吗?

    不建议修改所有普通对象的原型,因为对象需要先约定按键、按值还是按键值对遍历,统一实现会强加一种线性语义。更稳妥的是在具体数据结构上实现迭代器,或先用 Object.keys 明确顺序;通用键值集合也可考虑使用原生可迭代的 Map

# 15 Promise

⚡ 30 秒速记

  • 三个状态:pendingfulfilled/rejected,只能改变一次且不可逆
  • 解决的是回调地狱和信任问题(回调可能被调多次或不调)
  • 链式:then 返回 Promise;返回普通值会被包成 resolved,返回 Promise 则等它落定
  • 四个静态方法:all(一败俱败)、allSettled(都等完)、race(第一个落定)、any(第一个成功)
  • then 的回调是微任务;手写 Promise 的考点是状态机 + 回调队列 + then 的链式返回

Promise 是保存异步结果的状态容器,状态只能从 pending 变为 fulfilledrejected,一旦改变就不可逆。 每次调用 then() 都会返回新的 Promise,因此可以把异步步骤串起来,避免多层回调嵌套;相关回调会作为微任务参与事件循环。并发任务全部成功才继续时用 Promise.all(),需要保留每项结果时用 Promise.allSettled()。只取首个成功结果可用 Promise.any(),而超时竞争这类场景更适合 Promise.race(),因为它采用最先改变的状态。

这里你谈 promise的时候,除了将他解决的痛点以及常用的 API 之外,最好进行拓展把 eventloop 带进来好好讲一下,microtask(微任务)、macrotask(任务) 的执行顺序,如果看过 promise 源码,最好可以谈一谈 原生 Promise 是如何实现的。Promise 的关键点在于callback 的两个参数,一个是 resovle,一个是 reject。还有就是 Promise 的链式调用(Promise.then(),每一个 then 都是一个责任人)

  • PromiseES6 新增的语法,解决了回调地狱的问题。
  • 可以把 Promise看成一个状态机。初始是 pending 状态,可以通过函数 resolvereject,将状态转变为 resolved 或者 rejected 状态,状态一旦改变就不能再次变化。
  • then 函数会返回一个 Promise 实例,并且该返回值是一个新的实例而不是之前的实例。因为 Promise 规范规定除了 pending 状态,其他状态是不可以改变的,如果返回的是一个相同实例的话,多个 then 调用就失去意义了。 对于 then 来说,本质上可以把它看成是 flatMap

1. Promise 的基本情况

简单来说它就是一个容器,里面保存着某个未来才会结束的事件(通常是异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息

一般 Promise 在执行过程中,必然会处于以下几种状态之一。

  • 待定(pending):初始状态,既没有被完成,也没有被拒绝。
  • 已完成(fulfilled):操作成功完成。
  • 已拒绝(rejected):操作失败。

待定状态的 Promise 对象执行的话,最后要么会通过一个值完成,要么会通过一个原因被拒绝。当其中一种情况发生时,我们用 Promisethen 方法排列起来的相关处理程序就会被调用。因为最后 Promise.prototype.thenPromise.prototype.catch 方法返回的是一个 Promise, 所以它们可以继续被链式调用

关于 Promise 的状态流转情况,有一点值得注意的是,内部状态改变之后不可逆,你需要在编程过程中加以注意。文字描述比较晦涩,我们直接通过一张图就能很清晰地看出 Promise 内部状态流转的情况

从上图可以看出,我们最开始创建一个新的 Promise 返回给 p1 ,然后开始执行,状态是 pending,当执行 resolve之后状态就切换为 fulfilled,执行 reject 之后就变为 rejected 的状态

2. Promise 的静态方法

  • all 方法
    • 语法: Promise.all(iterable)
    • 参数: 一个可迭代对象,如 Array
    • 描述: 此方法对于汇总多个 promise 的结果很有用,在 ES6 中可以将多个 Promise.all 异步请求并行操作,返回结果一般有下面两种情况。
      • 当所有结果成功返回时按照请求顺序返回成功结果。
      • 当其中有一个失败方法时,则进入失败方法
  • 我们来看下业务的场景,对于下面这个业务场景页面的加载,将多个请求合并到一起,用 all 来实现可能效果会更好,请看代码片段
// 在一个页面中需要加载获取轮播列表、获取店铺列表、获取分类列表这三个操作,页面需要同时发出请求进行页面渲染,这样用 `Promise.all` 来实现,看起来更清晰、一目了然。


//1.获取轮播数据列表
function getBannerList(){
  return new Promise((resolve,reject)=>{
      setTimeout(function(){
        resolve('轮播数据')
      },300)
  })
}
//2.获取店铺列表
function getStoreList(){
  return new Promise((resolve,reject)=>{
    setTimeout(function(){
      resolve('店铺数据')
    },500)
  })
}
//3.获取分类列表
function getCategoryList(){
  return new Promise((resolve,reject)=>{
    setTimeout(function(){
      resolve('分类数据')
    },700)
  })
}
function initLoad(){
  Promise.all([getBannerList(),getStoreList(),getCategoryList()])
  .then(res=>{
    console.log(res)
  }).catch(err=>{
    console.log(err)
  })
}
initLoad()
  • allSettled 方法
    • Promise.allSettled 的语法及参数跟 Promise.all 类似,其参数接受一个 Promise 的数组,返回一个新的 Promise唯一的不同在于,执行完之后不会失败,也就是说当 Promise.allSettled 全部处理完成后,我们可以拿到每个 Promise 的状态,而不管其是否处理成功
  • 我们来看一下用 allSettled 实现的一段代码
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const allSettledPromise = Promise.allSettled([resolved, rejected]);
allSettledPromise.then(function (results) {
  console.log(results);
});
// 返回结果:
// [
//    { status: 'fulfilled', value: 2 },
//    { status: 'rejected', reason: -1 }
// ]

从上面代码中可以看到,Promise.allSettled 最后返回的是一个数组,记录传进来的参数中每个 Promise 的返回值,这就是和 all 方法不太一样的地方。

  • any 方法
    • 语法: Promise.any(iterable)
    • 参数: iterable 可迭代的对象,例如 Array
    • 描述: any 方法返回一个 Promise,只要参数 Promise 实例有一个变成 fulfilled状态,最后 any返回的实例就会变成 fulfilled 状态;如果所有参数 Promise 实例都变成 rejected 状态,包装实例就会变成 rejected 状态。
const resolved = Promise.resolve(2);
const rejected = Promise.reject(-1);
const anyPromise = Promise.any([resolved, rejected]);
anyPromise.then(function (results) {
  console.log(results);
});
// 返回结果:
// 2

从改造后的代码中可以看出,只要其中一个 Promise 变成 fulfilled状态,那么 any 最后就返回这个p romise。由于上面 resolved 这个 Promise 已经是 resolve 的了,故最后返回结果为 2

  • race 方法
    • 语法: Promise.race(iterable)
    • 参数: iterable 可迭代的对象,例如 Array
    • 描述: race方法返回一个 Promise,只要参数的 Promise 之中有一个实例率先改变状态,则 race 方法的返回状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给 race 方法的回调函数
  • 我们来看一下这个业务场景,对于图片的加载,特别适合用 race 方法来解决,将图片请求和超时判断放到一起,用 race 来实现图片的超时判断。请看代码片段。
//请求某个图片资源
function requestImg(){
  var p = new Promise(function(resolve, reject){
    var img = new Image();
    img.onload = function(){ resolve(img); }
    img.src = 'http://www.baidu.com/img/flexible/logo/pc/result.png';
  });
  return p;
}
//延时函数,用于给请求计时
function timeout(){
  var p = new Promise(function(resolve, reject){
    setTimeout(function(){ reject('图片请求超时'); }, 5000);
  });
  return p;
}
Promise.race([requestImg(), timeout()])
.then(function(results){
  console.log(results);
})
.catch(function(reason){
  console.log(reason);
});


// 从上面的代码中可以看出,采用 Promise 的方式来判断图片是否加载成功,也是针对 Promise.race 方法的一个比较好的业务场景

promise手写实现,面试够用版:

function myPromise(constructor){
    let self=this;
    self.status="pending" //定义状态改变前的初始状态
    self.value=undefined;//定义状态为resolved的时候的状态
    self.reason=undefined;//定义状态为rejected的时候的状态
    function resolve(value){
        //两个==="pending",保证了状态的改变是不可逆的
       if(self.status==="pending"){
          self.value=value;
          self.status="resolved";
       }
    }
    function reject(reason){
        //两个==="pending",保证了状态的改变是不可逆的
       if(self.status==="pending"){
          self.reason=reason;
          self.status="rejected";
       }
    }
    //捕获构造异常
    try{
       constructor(resolve,reject);
    }catch(e){
       reject(e);
    }
}
// 定义链式调用的then方法
myPromise.prototype.then=function(onFullfilled,onRejected){
   let self=this;
   switch(self.status){
      case "resolved":
        onFullfilled(self.value);
        break;
      case "rejected":
        onRejected(self.reason);
        break;
      default:
   }
}

💬 面试官追问

  • 支付页创建一个 Promise 后先调用 resolve('ok'),异常分支又调用 reject(error),监控却始终看到成功,这是实现缺陷还是预期行为?

    这是预期行为,Promise 只能从 pending 转为 fulfilledrejected,第一次状态变化后便不可逆。排查时应找出哪个异步分支最先落定,并避免多个回调竞争;后续调用不会覆盖既有结果,也不能用来撤销已完成状态。

  • 首页要并行加载轮播、店铺和分类数据,三块全部到齐才允许渲染,团队在串行 awaitPromise.all 之间争论,你会选哪个?

    三个请求彼此无依赖且必须全部成功时,应使用 Promise.all 并行汇总,结果会按传入顺序返回,而不是按完成顺序排列。任意一个请求拒绝都会使汇总结果进入拒绝分支,因此还需在页面层明确整体失败时的降级或重试策略。

  • 批量上传 50 个附件时,产品要求成功项保留、失败项单独展示原因,原来的 Promise.all 一遇失败就进入 catch,该怎么改?

    应改用 Promise.allSettled,等待全部任务落定后读取每项的 status,成功项取 value,失败项取 reason。它解决的是结果汇总不短路,并不负责重试、取消或并发控制;这些策略仍需由上传层单独实现。

  • 图片详情页用 Promise.race([loadImage(), timeout()]) 做五秒超时,界面已经提示失败,稍后图片的 onload 却仍然触发,你如何解释?

    Promise.race 只让最先改变状态的任务决定外层结果,不会停止其余异步操作,因此图片加载仍可能继续并触发原回调。排查时要区分“外层已拒绝”和“底层任务已取消”;若业务要求真正终止,还需要底层操作自身提供取消能力。

  • 同事实现了一个简化版 Promise,只在调用 then 时检查当前状态;异步 resolve 后回调始终不执行,缺了哪部分机制?

    调用 then 时若状态仍为 pending,实现必须保存成功和失败处理函数,待 resolvereject 改变状态后再触发。仅处理已完成状态只能覆盖同步落定场景,也无法支撑可靠链式调用;完整实现还要让每次 then 返回新的 Promise

  • 搜索页同时请求三个镜像源,只需要第一个成功结果;有人选择 race,有人选择 any,哪个更符合需求?

    应选择 Promise.any,因为它忽略先到达的拒绝,直到某个任务成功才完成;只有全部任务都拒绝时,外层才拒绝。Promise.race 采用最先落定的结果,最快返回的若是失败,就会提前结束,不符合“首个成功”的约束。

# 16 Generator

⚡ 30 秒速记

  • function* 声明,yield 暂停、next() 恢复,本质是可暂停恢复的函数
  • 返回的是迭代器对象,同时满足迭代器协议和可迭代协议
  • next(value) 传的值会成为上一个 yield 表达式的返回值 —— 这是双向通信的关键
  • 它是 async/await 的前身:co 库用自动执行器驱动 Generator 实现同步写法
  • 现在直接用 async/awaitGenerator 主要留在 redux-saga 这类需要精细控制流程的场景

Generator 是一种可以暂停并恢复执行的函数,本质上也是 Iterator 接口的一种实现。 调用生成器函数不会立即跑完代码,而是返回迭代器;每次调用 next(),代码会执行到下一个 yield 并交出执行权。再次调用 next(value) 时,传入值会成为上一个 yield 表达式的结果,但第一次传参会被忽略。它适合描述分段执行流程,也是 async/await 演化所依赖的思路之一。

GeneratorES6中新增的语法,和 Promise 一样,都可以用来异步编程。Generator函数可以说是Iterator接口的具体实现方式。Generator 最大的特点就是可以控制函数的执行。

  • function* 用来声明一个函数是生成器函数,它比普通的函数声明多了一个*,*的位置比较随意可以挨着 function 关键字,也可以挨着函数名
  • yield 产出的意思,这个关键字只能出现在生成器函数体内,但是生成器中也可以没有yield 关键字,函数遇到 yield 的时候会暂停,并把 yield 后面的表达式结果抛出去
  • next作用是将代码的控制权交还给生成器函数
function *foo(x) {
  let y = 2 * (yield (x + 1))
  let z = yield (y / 3)
  return (x + y + z)
}
let it = foo(5)
console.log(it.next())   // => {value: 6, done: false}
console.log(it.next(12)) // => {value: 8, done: false}
console.log(it.next(13)) // => {value: 42, done: true}

上面这个示例就是一个Generator函数,我们来分析其执行过程:

  • 首先 Generator 函数调用时它会返回一个迭代器
  • 当执行第一次 next 时,传参会被忽略,并且函数暂停在 yield (x + 1) 处,所以返回 5 + 1 = 6
  • 当执行第二次 next 时,传入的参数等于上一个 yield 的返回值,如果你不传参,yield 永远返回 undefined。此时 let y = 2 * 12,所以第二个 yield 等于 2 * 12 / 3 = 8
  • 当执行第三次 next 时,传入的参数会传递给 z,所以 z = 13, x = 5, y = 24,相加等于 42

yield实际就是暂缓执行的标示,每执行一次next(),相当于指针移动到下一个yield位置

总结一下Generator函数是ES6提供的一种异步编程解决方案。通过yield标识位和next()方法调用,实现函数的分段执行

遍历器对象生成函数,最大的特点是可以交出函数的执行权

  • function 关键字与函数名之间有一个星号;
  • 函数体内部使用 yield表达式,定义不同的内部状态;
  • next指针移向下一个状态

这里你可以说说 Generator的异步编程,以及它的语法糖 asyncawiat,传统的异步编程。ES6 之前,异步编程大致如下

  • 回调函数
  • 事件监听
  • 发布/订阅

传统异步编程方案之一:协程,多个线程互相协作,完成异步任务。

// 使用 * 表示这是一个 Generator 函数
// 内部可以通过 yield 暂停代码
// 通过调用 next 恢复执行
function* test() {
  let a = 1 + 2;
  yield 2;
  yield 3;
}
let b = test();
console.log(b.next()); // >  { value: 2, done: false }
console.log(b.next()); // >  { value: 3, done: false }
console.log(b.next()); // >  { value: undefined, done: true }

从以上代码可以发现,加上 *的函数执行后拥有了 next 函数,也就是说函数执行后返回了一个对象。每次调用 next 函数可以继续执行被暂停的代码。以下是 Generator 函数的简单实现

// cb 也就是编译过的 test 函数
function generator(cb) {
  return (function() {
    var object = {
      next: 0,
      stop: function() {}
    };

    return {
      next: function() {
        var ret = cb(object);
        if (ret === undefined) return { value: undefined, done: true };
        return {
          value: ret,
          done: false
        };
      }
    };
  })();
}
// 如果你使用 babel 编译后可以发现 test 函数变成了这样
function test() {
  var a;
  return generator(function(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        // 可以发现通过 yield 将代码分割成几块
        // 每次执行 next 函数就执行一块代码
        // 并且表明下次需要执行哪块代码
        case 0:
          a = 1 + 2;
          _context.next = 4;
          return 2;
        case 4:
          _context.next = 6;
          return 3;
		// 执行完毕
        case 6:
        case "end":
          return _context.stop();
      }
    }
  });
}

💬 面试官追问

  • 报表代码调用 buildRows() 后没有生成任何数据,直到执行 iterator.next() 才出现第一行;同事认为函数调用已经执行了函数体,错在哪里?

    调用 Generator 函数只会返回迭代器,不会像普通函数那样立即完整执行函数体;第一次 next() 才会运行到首个 yield 并暂停。若调用方忘记驱动迭代器,内部逻辑就不会推进,这也是把控制权交给外部调度者的代价。

  • 面试现场这段代码 const it=foo(5); it.next(); it.next(12); it.next(13) 最终返回 42,第二次传入的 12 到底进入了哪里?

    第二次 next(12) 的参数会成为上一个 yield 表达式的结果,因此 y 被计算为 2 * 12,随后函数在下一个 yield 再次暂停。第一次 next() 的参数会被忽略,因为当时还没有暂停中的 yield 可以接收它。

  • 十万条记录需要分批格式化,主线程不能一次跑完整个循环,你会怎样利用 Generator 让调度器分段推进?

    可以把处理过程写成 Generator,每完成一批记录便 yield 当前进度,外部调度器在合适时机调用 next() 继续。这样执行权会在批次之间交还给调用方,但 Generator 本身不会自动安排时间片,批量大小和再次调度仍需额外设计。

  • 异步流水线第二步依赖第一步的请求结果,但调用方只连续执行 next() 没有传值,后续计算得到 NaN,你会查什么?

    应检查调用方是否把上一步完成值传入下一次 next(result),因为暂停点左侧拿到的是下一次 next 的参数;省略时该 yield 表达式结果就是 undefined。同时确认每次返回的 done,避免在生成器已经结束后仍继续传值。

  • 团队想用 Generator 替代 async/await 编排三个串行请求,理由是它能暂停函数;这两种方案的关键取舍是什么?

    Generator 只提供通过 yield 暂停、由 next() 恢复的控制机制,异步结果何时完成以及如何继续都要由执行器负责。async/await 相当于 Generator 配合自动执行器,语义更直接且返回 Promise;只有需要显式控制推进过程时,手动生成器才更有价值。

# 17 async/await

⚡ 30 秒速记

  • 本质是 Generator + 自动执行器的语法糖,让异步代码写起来像同步
  • async 函数总是返回 Promiseawait 后面接非 Promise 值会被 Promise.resolve 包一层
  • 错误处理用 try/catch,比 then/catch 更直观
  • 高频坑:循环里 await串行的,要并行得先收集 PromisePromise.all
  • await 之后的代码相当于放进了 then 回调,属于微任务 —— 这是事件循环题的常考点

async/awaitGenerator 加自动执行器思路的语法糖,让基于 Promise 的异步流程写起来更像同步代码。 async 函数会返回 Promise,执行到 await 时会暂时交还控制权,等结果完成后再恢复后续逻辑。它比连续的 then 调用更直观,也更容易组织错误处理。多个任务互不依赖时,我一般不会逐个 await,因为这样会失去并发性,更适合用 Promise.all()

Generator 函数的语法糖。有更好的语义、更好的适用性、返回值是 Promise

  • await 和 promise 一样,更多的是考笔试题,当然偶尔也会问到和 promise 的一些区别。
  • await 相比直接使用 Promise 来说,优势在于处理 then 的调用链,能够更清晰准确的写出代码。缺点在于滥用 await 可能会导致性能问题,因为 await 会阻塞代码,也许之后的异步代码并不依赖于前者,但仍然需要等待前者完成,导致代码失去了并发性,此时更应该使用 Promise.all。
  • 一个函数如果加上 async ,那么该函数就会返回一个 Promise
  • async => *
  • await => yield
// 基本用法

async function timeout (ms) {
  await new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}
async function asyncConsole (value, ms) {
  await timeout(ms)
  console.log(value)
}
asyncConsole('hello async and await', 1000)

下面来看一个使用 await 的代码。

var a = 0
var b = async () => {
  a = a + await 10
  console.log('2', a) // -> '2' 10
  a = (await 10) + a
  console.log('3', a) // -> '3' 20
}
b()
a++
console.log('1', a) // -> '1' 1
  • 首先函数b 先执行,在执行到 await 10 之前变量 a 还是 0,因为在 await 内部实现了 generatorsgenerators 会保留堆栈中东西,所以这时候 a = 0 被保存了下来
  • 因为 await 是异步操作,遇到await就会立即返回一个pending状态的Promise对象,暂时返回执行代码的控制权,使得函数外的代码得以继续执行,所以会先执行 console.log('1', a)
  • 这时候同步代码执行完毕,开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 10
  • 然后后面就是常规执行代码了

优缺点:

async/await的优势在于处理 then 的调用链,能够更清晰准确的写出代码,并且也能优雅地解决回调地狱问题。当然也存在一些缺点,因为 await 将异步代码改造成了同步代码,如果多个异步代码没有依赖性却使用了 await 会导致性能上的降低。

async原理

async/await语法糖就是使用Generator函数+自动执行器来运作的

// 定义了一个promise,用来模拟异步请求,作用是传入参数++
function getNum(num){
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(num+1)
        }, 1000)
    })
}

//自动执行器,如果一个Generator函数没有执行完,则递归调用
function asyncFun(func){
  var gen = func();

  function next(data){
    var result = gen.next(data);
    if (result.done) return result.value;
    result.value.then(function(data){
      next(data);
    });
  }

  next();
}

// 所需要执行的Generator函数,内部的数据在执行完成一步的promise之后,再调用下一步
var func = function* (){
  var f1 = yield getNum(1);
  var f2 = yield getNum(f1);
  console.log(f2) ;
};
asyncFun(func);
  • 在执行的过程中,判断一个函数的promise是否完成,如果已经完成,将结果传入下一个函数,继续重复此步骤
  • 每一个 next() 方法返回值的 value 属性为一个 Promise 对象,所以我们为其添加 then 方法, 在 then 方法里面接着运行 next 方法挪移遍历器指针,直到 Generator函数运行完成

💬 面试官追问

  • 用户资料页调用 loadUser() 后直接打印返回值,看到的是 Promise 而不是用户对象;函数里明明写了 return user,为什么?

    只要函数声明为 async,调用结果就会被包装成 Promise,函数体中的 return user 对应其完成值。调用方需要使用 await loadUser() 或注册 then 获取对象;把返回值当普通对象读取,会在异步结果落定前访问错误的数据形态。

  • 首屏循环请求 20 个互不依赖的商品详情,每个都写成 await fetchOne(id),页面等待时间明显累加,你会怎样改?

    这些请求没有前后依赖,应先用 ids.map(fetchOne) 创建任务,再通过 await Promise.all(...) 汇总,使它们保持并发。若任一请求失败,Promise.all 会进入拒绝路径;页面需要部分成功时,应改用能保留每项状态的汇总策略。

  • 订单创建必须先拿用户信息,再用用户编号查询优惠券;评审者仍要求全部放进 Promise.all,你会同意吗?

    不会,因为优惠券请求的参数依赖用户请求结果,两个操作无法在输入尚未产生时真正并行。这里顺序 await 更能表达依赖关系;除非接口或数据结构被重新设计,否则强行使用 Promise.all 只会制造无效请求或复杂的占位逻辑。

  • 线上保存按钮点击后只执行到第一个 await,后续日志和成功提示都没出现,控制台显示拒绝未处理,你会从哪里排查?

    先检查被等待的 Promise 是否进入 rejected,因为拒绝会使当前 async 函数中断,并让它返回的 Promise 同样拒绝。应在能决定补救策略的位置使用 try/catch,或由调用方接住拒绝;只加成功日志无法覆盖异常路径。

  • 同事写了一个基于 GeneratorasyncFun,请求成功时能串行执行,但任一步拒绝后流程悬空;自动执行器还缺什么?

    执行器不能只对 result.value 注册成功回调,还要处理拒绝并把错误交回生成器或让外层 Promise 拒绝。每次推进也应检查 done,完成时解析最终返回值;否则它只是演示版递归驱动器,达不到 async/await 的错误传播语义。

  • 搜索页有两个独立请求,代码为了写起来像同步流程而连续使用两个 await;可读性和并发性冲突时你怎么选?

    若后一个请求不依赖前一个,应先同时启动两个任务,再用一次 Promise.all 等待结果,既保留清晰的赋值结构,也避免无意义串行。await 的价值是表达异步步骤,不意味着每一步都必须顺序启动;错误处理仍要覆盖汇总后的拒绝。

# 18 事件循环

⚡ 30 秒速记

  • 主线:同步代码跑完清空调用栈 → 清空整个微任务队列 → 取一个宏任务执行 → 再清空微任务 → 循环
  • 关键细节一:每执行完一个宏任务要把微任务队列完全清空(不是执行一个),期间新产生的微任务也在本轮处理完
  • 关键细节二:setTimeout(fn, 0) 实际最小延时约 4ms,且只保证「不早于」
  • 宏任务:setTimeout/setInterval/I/O/UI 渲染;微任务:Promise.then/queueMicrotask/MutationObserver
  • Node 的事件循环分六个阶段,还多一个 process.nextTick(优先级高于 Promise

事件循环的核心是执行一个宏任务后清空全部微任务,再进入下一轮宏任务。 同步脚本本身属于宏任务,执行期间遇到 Promise.then 等会登记微任务,遇到 setTimeout、交互或 I/O 回调则进入相应的宏任务队列。当前执行栈清空后,微任务会按登记顺序持续执行,新产生的微任务也要在这一轮清空,之后浏览器才可能渲染并取下一个宏任务。Node.js 还会细分事件循环阶段,并且 process.nextTick 的优先级高于 Promise 微任务。

  • 默认代码从上到下执行,执行环境通过script来执行(宏任务)
  • 在代码执行过程中,调用定时器 promise click事件...不会立即执行,需要等待当前代码全部执行完毕
  • 给异步方法划分队列,分别存放到微任务(立即存放)和宏任务(时间到了或事情发生了才存放)到队列中
  • script执行完毕后,会清空所有的微任务
  • 微任务执行完毕后,会渲染页面(不是每次都调用)
  • 再去宏任务队列中看有没有到达时间的,拿出来其中一个执行
  • 执行完毕后,按照上述步骤不停的循环

例子

自动执行的情况 会输出 listener1 listener2 task1 task2

如果手动点击click 会一个宏任务取出来一个个执行,先执行click的宏任务,取出微任务去执行。会输出 listener1 task1 listener2 task2

console.log(1)

async function asyncFunc(){
  console.log(2)
  // await xx ==> promise.resolve(()=>{console.log(3)}).then()
  // console.log(3) 放到promise.resolve或立即执行
  await console.log(3)
  // 相当于把console.log(4)放到了then promise.resolve(()=>{console.log(3)}).then(()=>{
  //   console.log(4)
  // })
  // 微任务谁先注册谁先执行
  console.log(4)
}

setTimeout(()=>{console.log(5)})

const promise = new Promise((resolve,reject)=>{
  console.log(6)
  resolve(7)
})

promise.then(d=>{console.log(d)})

asyncFunc()

console.log(8)

// 输出 1 6 2 3 8 7 4 5

1. 浏览器事件循环

涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变

js代码执行过程中会有很多任务,这些任务总的分成两类:

  • 同步任务
  • 异步任务

当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。,我们用导图来说明:

我们解释一下这张图:

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
  • 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
  • 主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的Event Loop(事件循环)。

那主线程执行栈何时为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数

以上就是js运行的整体流程

面试中该如何回答呢? 下面是我个人推荐的回答:

  • 首先js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行
  • 在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务
  • 当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行
  • 任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行
  • 当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。
setTimeout(function() {
  console.log(1)
}, 0);
new Promise(function(resolve, reject) {
  console.log(2);
  resolve()
}).then(function() {
  console.log(3)
});
process.nextTick(function () {
  console.log(4)
})
console.log(5)
  • 第一轮:主线程开始执行,遇到setTimeout,将setTimeout的回调函数丢到宏任务队列中,在往下执行new Promise立即执行,输出2,then的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick,同样将回调函数扔到微任务队列,再继续执行,输出5,当所有同步任务执行完成后看有没有可以执行的微任务,发现有then函数和nextTick两个微任务,先执行哪个呢?process.nextTick指定的异步任务总是发生在所有异步任务之前,因此先执行process.nextTick输出4然后执行then函数输出3,第一轮执行结束。
  • 第二轮:从宏任务队列开始,发现setTimeout回调,输出1执行完毕,因此结果是25431

JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

console.log('script end');

不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobsmacrotask 称为 task

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

new Promise((resolve) => {
    console.log('Promise')
    resolve()
}).then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
// script start => Promise => script end => promise1 => promise2 => setTimeout

以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务

微任务

  • process.nextTick
  • promise
  • Object.observe
  • MutationObserver

宏任务

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O 网络请求完成、文件读写完成事件
  • UI rendering
  • 用户交互事件(比如鼠标点击、滚动页面、放大缩小等)

宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务

所以正确的一次 Event loop 顺序是这样的

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中

  • JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务
  • 执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;
  • 然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。

总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务

2. Node 中的 Event loop

当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程

  • Node 中的 Event loop 和浏览器中的不相同。
  • NodeEvent loop 分为6个阶段,它们会按照顺序反复运行

  • 每次执行执行一个宏任务后会清空微任务(执行顺序和浏览器一致,在node11版本以上)
  • process.nextTick node中的微任务,当前执行栈的底部,优先级比promise要高

整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。

  • Timers 阶段:这个阶段执行 setTimeoutsetInterval的回调函数,简单理解就是由这两个函数启动的回调函数。
  • I/O callbacks 阶段:这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。
  • idle,prepare 阶段:仅系统内部使用,你只需要知道有这 2 个阶段就可以。
  • poll 阶段poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeoutsetIntervalsetImmediate 以及一些因为 exception 意外关闭产生的回调)。检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行。这个阶段的主要流程如下图所示。

  • check 阶段setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示。
const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
    console.log('1');
}, 0);
setImmediate( () => {
    console.log('setImmediate 1');
});
/// fs.readFile 将会在 poll 阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
    if (err) throw err;
    console.log('read file success');
});
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
    console.log('poll callback');
});
// 首次事件循环执行
console.log('2');

在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:

  • setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms
  • 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中;
  • 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate

因此这也验证了这句话,先执行回调函数,再执行 setImmediate

  • close callbacks 阶段:执行一些关闭的回调函数,如 socket.on('close', ...)

除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()

可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)

事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢

  • 微任务:在 Node.js 中微任务包含 2 种——process.nextTickPromise微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise也存在优先级,process.nextTick 高于 Promise
  • 宏任务:在 Node.js 中宏任务包含 4 种——setTimeoutsetIntervalsetImmediateI/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列

我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。

  • 同步代码。
  • 将异步任务插入到微任务队列或者宏任务队列中。
  • 执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。
const fs = require('fs');
// 首次事件循环执行
console.log('start');
/// 将会在新的事件循环中的阶段执行
fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => {
    if (err) throw err;
    console.log('read file success');
});
setTimeout(() => { // 新的事件循环的起点
    console.log('setTimeout');
}, 0);
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
    console.log('Promise callback');
});
/// 执行 process.nextTick
process.nextTick(() => {
    console.log('nextTick callback');
});
// 首次事件循环执行
console.log('end');

分析下上面代码的执行过程

  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end
  • 第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;
  • 再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout
  • 先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback
  • 再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success
// 输出结果
start
end
nextTick callback
Promise callback
setTimeout
read file success

当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:

const fs = require('fs');
setTimeout(() => { // 新的事件循环的起点
    console.log('1');
    fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
        if (err) throw err;
        console.log('read file sync success');
    });
}, 0);
/// 回调将会在新的事件循环之前
fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => {
    if (err) throw err;
    console.log('read file success');
});
/// 该部分将会在首次事件循环中执行
Promise.resolve().then(()=>{
    console.log('poll callback');
});
// 首次事件循环执行
console.log('2');

在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve

  • 整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。
  • 接下来执行微任务,输出 poll callback
  • 再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列
  • 最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success
// 结果
2
poll callback
1
read file success
read file sync success

Process.nextick() 和 Vue 的 nextick

Node.js 和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。

setTimeout(() => {
    console.log('setTimeout');
}, 0);
setImmediate(() => {
    console.log('setImmediate');
})
// 这里可能会输出 setTimeout,setImmediate
// 可能也会相反的输出,这取决于性能
// 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate
// 否则会执行 setTimeout

上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

// 以上代码在浏览器和 node 中打印情况是不同的
// 浏览器中一定打印 timer1, promise1, timer2, promise2
// node 中可能打印 timer1, timer2, promise1, promise2
// 也可能打印 timer1, promise1, timer2, promise2

Node 中的 process.nextTick 会先于其他 microtask 执行

setTimeout(() => {
 console.log("timer1");

 Promise.resolve().then(function() {
   console.log("promise1");
 });
}, 0);

// poll阶段执行
fs.readFile('./test',()=>{
  // 在poll阶段里面 如果有setImmediate优先执行,setTimeout处于事件循环顶端 poll下面就是setImmediate
  setTimeout(()=>console.log('setTimeout'),0)
  setImmediate(()=>console.log('setImmediate'),0)
})

process.nextTick(() => {
 console.log("nextTick");
});
// nextTick, timer1, promise1,setImmediate,setTimeout

对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask

谁来启动这个循环过程,循环条件是什么?

当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。

总结来说,Node.js 事件循环的发起点有 4 个:

  • Node.js 启动后;
  • setTimeout 回调函数;
  • setInterval 回调函数;
  • 也可能是一次 I/O 后的回调函数。

无限循环有没有终点

当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行

Node.js 是单线程的还是多线程的?

主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化

EventLoop 对渲染的影响

  • 想必你之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,这两个函数在我们之前的内容中没有讲过,但是当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。
  • 我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行
  • 渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame的出现却把这两件事情给关联起来
  • 通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢?
    • 简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染
  • 有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。
  • 回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果
  • 当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理

但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:

💬 面试官追问

  • 控制台代码先注册 setTimeout(fn,0),后注册 Promise.resolve().then(task),结果 task 先执行;定时器写在前面为什么没有优先?

    当前 script 本身先作为一个宏任务执行,期间定时器回调进入宏任务队列,then 回调进入微任务队列。同步代码结束后会先清空全部微任务,再进入下一轮取宏任务,所以注册先后不能跨越任务类型决定执行次序。

  • 按钮点击后先把状态改成 loading,随后同步计算两秒,页面却直到计算结束才更新,你会怎样解释并调整?

    点击回调作为当前任务占用主线程,同步计算未结束时,浏览器没有机会进入必要的 UI 渲染阶段,因此状态虽已修改却尚未绘制。应拆分长任务并主动让出执行权,或把重计算移出主线程;仅把计算包装进微任务仍可能阻塞渲染。

  • 消息列表一次追加数据后递归创建 Promise.then 微任务,页面长时间不重绘,明明每次回调都很短,问题在哪里?

    一次宏任务结束后,事件循环会持续清空微任务,包括执行过程中新增的微任务;递归产生微任务可能让队列迟迟无法清空。渲染通常位于微任务检查之后,因此应限制递归链,并把后续批次交给新的任务或其他调度机制。

  • 线上日志显示同步代码正常结束,但某个 setTimeout(...,0) 偶尔延迟很久才执行,你会先排查哪些阻塞来源?

    0 只表示满足计时条件后可进入候选队列,不保证立即执行;当前执行栈、此前积压的微任务以及更早待处理的任务都会推迟回调。应检查长同步计算、连续微任务和高频事件处理,而不能仅凭定时参数判断调度异常。

  • Node 服务里同时安排 process.nextTickPromise.thensetTimeoutsetImmediate,同事套用浏览器顺序直接下结论,你会接受吗?

    不能直接套用,因为 Node 的事件循环包含 timerspollcheck 等阶段,setImmediatecheck 阶段执行。process.nextTick 的优先级还高于普通 Promise 微任务;定时器与 setImmediate 的先后需结合注册位置和所处阶段判断。

  • 数据看板想把大量 DOM 更新全部塞进 Promise.then,理由是微任务比定时器更早执行,这个选型有什么风险?

    微任务确实会在当前任务结束后优先清空,但大量 DOM 操作会占满微任务检查阶段,反而推迟浏览器渲染和下一轮事件处理。应按可见更新边界分批执行,并给渲染留下机会;“更早进入执行栈”不等于页面能更早呈现。

# 19 垃圾回收

⚡ 30 秒速记

  • 现代引擎用可达性分析(标记-清除),从 GC Roots 出发遍历不到的就回收 —— 所以循环引用也能被正确回收
  • 引用计数是老方法,致命缺陷就是循环引用永远归不了零,早已弃用
  • V8 分代回收:新生代用 Scavenge(复制算法,适合朝生夕死的对象),老生代用标记-清除 + 标记-整理
  • 增量标记和并发标记:把 GC 拆小步或放后台线程,减少 STW 卡顿
  • 前端实践含义:GC 是自动的,你能做的是别制造不可回收的引用

JavaScript 垃圾回收会识别并释放程序不再使用的对象,V8 则通过分代机制降低回收成本。 新生代对象多数存活时间短,所以使用分成 FromTo 空间的 Scavenge 算法,存活对象会被复制,满足条件时晋升到老生代。老生代对象存活更久,主要采用标记清除,并用标记压缩缓解内存碎片。由于回收会暂停应用逻辑,V8 还通过增量标记把较长的老生代标记过程拆开执行。

  • 对于在JavaScript中的字符串,对象,数组是没有固定大小的,只有当对他们进行动态分配存储时,解释器就会分配内存来存储这些数据,当JavaScript的解释器消耗完系统中所有可用的内存时,就会造成系统崩溃。
  • 内存泄漏,在某些情况下,不再使用到的变量所占用内存没有及时释放,导致程序运行中,内存越占越大,极端情况下可以导致系统崩溃,服务器宕机。
  • JavaScript有自己的一套垃圾回收机制,JavaScript的解释器可以检测到什么时候程序不再使用这个对象了(数据),就会把它所占用的内存释放掉。
  • 针对JavaScript的来及回收机制有以下两种方法(常用):标记清除,引用计数
  • 标记清除

v8 的垃圾回收机制基于分代回收机制,这个机制又基于世代假说,这个假说有两个特点,一是新生的对象容易早死,另一个是不死的对象会活得更久。基于这个假说,v8 引擎将内存分为了新生代和老生代。

  • 新创建的对象或者只经历过一次的垃圾回收的对象被称为新生代。经历过多次垃圾回收的对象被称为老生代。
  • 新生代被分为 From 和 To 两个空间,To 一般是闲置的。当 From 空间满了的时候会执行 Scavenge 算法进行垃圾回收。当我们执行垃圾回收算法的时候应用逻辑将会停止,等垃圾回收结束后再继续执行。

这个算法分为三步:

  • 首先检查 From 空间的存活对象,如果对象存活则判断对象是否满足晋升到老生代的条件,如果满足条件则晋升到老生代。如果不满足条件则移动 To 空间。
  • 如果对象不存活,则释放对象的空间。
  • 最后将 From 空间和 To 空间角色进行交换。

新生代对象晋升到老生代有两个条件:

  • 第一个是判断是对象否已经经过一次 Scavenge 回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。
  • 第二个是 To 空间的内存使用占比是否超过限制。当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置 25% 的原因主要是因为算法结束后,两个空间结束后会交换位置,如果 To 空间的内存太小,会影响后续的内存分配。

老生代采用了标记清除法和标记压缩法。标记清除法首先会对内存中存活的对象进行标记,标记结束后清除掉那些没有标记的对象。由于标记清除后会造成很多的内存碎片,不便于后面的内存分配。所以了解决内存碎片的问题引入了标记压缩法。

由于在进行垃圾回收的时候会暂停应用的逻辑,对于新生代方法由于内存小,每次停顿的时间不会太长,但对于老生代来说每次垃圾回收的时间长,停顿会造成很大的影响。 为了解决这个问题 V8 引入了增量标记的方法,将一次停顿进行的过程分为了多步,每次执行完一小步就让运行逻辑执行一会,就这样交替运行

💬 面试官追问

  • 商品瀑布流每次滚动都创建一批临时对象,内存呈锯齿状上涨后又回落;既然最终都释放了,为什么页面仍可能周期性卡顿?

    内存能回落只说明对象最终可回收,不代表回收没有成本。新生代 From 空间满后会执行 Scavenge,检查存活对象并复制或晋升,期间应用逻辑会暂停;高频分配会增加回收频率,因此还要减少滚动热路径中的临时对象。

  • 编辑器把一个仍被全局缓存引用的文档对象标记成“已关闭”,随后主动调用垃圾回收;引擎会因为业务上不用它而释放内存吗?

    不会,垃圾回收依据对象是否仍可被程序引用,而不是业务状态字段。全局缓存仍保存引用时,该对象会被判断为存活;应删除缓存项或清空引用,单纯改成“已关闭”以及等待回收都不能解决占用。

  • 列表页持续创建短命数据对象,候选人建议把所有对象都长期缓存,避免频繁触发新生代回收;这个方案为什么可能适得其反?

    长期缓存会让原本容易早死的对象继续存活,并可能在经历回收后晋升到老生代。老生代回收需要标记存活对象,还可能通过标记压缩处理碎片,停顿影响通常更明显;缓存只应保留确有复用价值且能及时淘汰的数据。

  • 监控显示一次明显长停顿发生在老生代回收阶段,同事认为标记清除结束后已有连续可用空间;你会怎样纠正并推进排查?

    标记清除会释放未标记对象,但可能留下分散的内存碎片,并不保证形成连续空间。引擎还可能执行标记压缩来整理存活对象,因此应结合对象存活量和晋升情况排查长期引用,而不是只盯着回收后的空闲总量。

  • 动画团队要求彻底消除老生代垃圾回收停顿,并把增量标记当成保证;你会如何评估这个目标?

    增量标记把一次较长的标记过程拆成多步,让应用逻辑在步骤之间获得执行机会,但并不等于垃圾回收零停顿。工程目标应改为控制单次影响和垃圾产生量,同时减少无意义的长期存活对象;不能把增量机制当作实时性承诺。

# 20 内存泄露

⚡ 30 秒速记

  • 六类:意外全局变量、未清理的定时器/监听器、闭包持有大对象、脱离 DOM 的引用、第三方实例未 destroy、缓存无限增长
  • React 里最常见的是 useEffect 没写清理函数
  • WeakMap/WeakSet 存对象关联数据,键可以被 GC 回收
  • 排查:DevToolsMemory 面板拍两次堆快照做 Comparison,重点看 Detached HTMLElement
  • 判断标准:多次操作后内存曲线不回落,就是泄漏而不是正常波动

内存泄露本质上是对象已经不再使用,却仍被某个引用链持有,导致垃圾回收无法释放它。 常见原因可以归为两类:意外全局变量和闭包长期保留引用,以及未关闭的定时器、事件监听或已删除 DOM 的残留引用。排查时我一般用 Chrome Timeline 标记并观察内存变化,定位持续增长的异常点。开发中还要留意 console.log 保留的对象,避免它干扰内存回收和判断。

  • 意外的全局变量: 无法被回收
  • 定时器: 未被正确关闭,导致所引用的外部变量无法被释放
  • 事件监听: 没有正确销毁 (低版本浏览器可能出现)
  • 闭包
    • 第一种情况是我们由于使用未声明的变量,而意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收。
    • 第二种情况是我们设置了setInterval定时器,而忘记取消它,如果循环函数有对外部变量的引用的话,那么这个变量会被一直留在内存中,而无法被回收。
    • 第三种情况是我们获取一个DOM元素的引用,而后面这个元素被删除,由于我们一直保留了对这个元素的引用,所以它也无法被回收。
    • 第四种情况是不合理的使用闭包,从而导致某些变量一直被留在内存当中。
  • dom 引用: dom 元素被删除时,内存中的引用未被正确清空
  • 控制台console.log打印的东西

可用 chrome 中的 timeline 进行内存标记,可视化查看内存的变化情况,找出异常点。

内存泄露排查方法 (opens new window)

💬 面试官追问

  • 单页后台关闭一个弹窗后,节点已经从页面移除,但堆里仍能看到整棵弹窗子树;前端同事说“删了 DOM 就一定会回收”,你怎么判断?

    从文档树移除不等于失去全部引用,代码中的变量、缓存或闭包仍可能持有该节点。应沿引用关系找到保留它的对象并清空对应引用;只执行节点删除无法保证释放,残留引用还可能连带保留子节点和关联数据。

  • 行情组件每次进入页面都会启动一个 setInterval,离开页面只删除界面节点;连续切换后内存上涨且旧回调仍在执行,应该改哪里?

    组件销毁时必须取消对应的 setInterval,并解除回调对外部状态的持续引用。定时器未关闭时,循环函数及其引用的变量会一直存活;只删除界面节点既阻止不了回调,也无法释放被闭包保留的数据。

  • 埋点脚本为了调试,把每次请求的完整响应都赋给未声明变量;数据量持续增加后页面占用不降,开启严格模式或改成局部变量能解决什么?

    未声明赋值可能意外形成全局变量,使响应对象长期处于可达状态;改为受控的局部变量并在生命周期结束后解除引用,才能让数据具备回收条件。严格模式有助于尽早暴露此类赋值,但已有的全局缓存仍需显式清理。

  • 路由页反复进入退出后内存曲线持续抬升,代码里同时存在事件监听、定时器、闭包和已删除节点引用;你会怎样缩小故障范围?

    先用 ChromeTimeline 记录多轮进入、退出过程并标记内存变化,确认异常增长与哪段生命周期重合。随后逐项检查监听是否销毁、定时器是否取消、闭包和变量是否仍持有节点;单看某一次峰值不足以证明泄漏。

  • 团队想把所有闭包都改写成全局函数来治理泄漏,这个取舍合理吗?

    不合理,闭包本身不是必然泄漏,风险来自闭包被长生命周期对象持续引用,同时又捕获了不再需要的数据。应缩小捕获范围并在定时器、监听或缓存结束时断开引用;改成全局函数反而可能引入更长生命周期的全局状态。

# 21 深浅拷贝

⚡ 30 秒速记

  • 浅拷贝只复制第一层,嵌套对象仍共享引用:Object.assign、展开运算符、slice/concat
  • 深拷贝首选 structuredClone():原生、支持循环引用和 Date/RegExp/Map/Set
  • structuredClone 的限制:拷不了函数、SymbolDOM 节点,也不保留原型
  • 别用 JSON.parse(JSON.stringify()):丢 undefined/函数/SymbolDate 变字符串、NaNnull、循环引用直接抛错
  • 手写深拷贝的两个评分点:用 WeakMap 处理循环引用、分别处理各种内置类型

浅拷贝只复制一层,深层对象仍共享引用;深拷贝则要为嵌套数据创建相互独立的副本。 对象可用 Object.assign 或展开运算符浅拷贝,数组也可用 sliceconcat,但修改嵌套属性仍会互相影响。JSON.parse(JSON.stringify(obj)) 虽然简单,却会忽略 undefined、函数和 Symbol,还会改变 DateRegExp 等类型,并且无法处理循环引用。要求完整时可递归结合 Reflect.ownKeys、属性描述符和 WeakMap,兼顾特殊对象、原型及循环引用。

1. 浅拷贝的原理和实现

自己创建一个新的对象,来接受你要重新复制或引用的对象值。如果对象属性是基本的数据类型,复制的就是基本类型的值给新对象;但如果属性是引用数据类型,复制的就是内存中的地址,如果其中一个对象改变了这个内存中的地址,肯定会影响到另一个对象

方法一:object.assign

object.assign是 ES6 中 object 的一个方法,该方法可以用于 JS 对象的合并等多个用途,其中一个用途就是可以进行浅拷贝。该方法的第一个参数是拷贝的目标对象,后面的参数是拷贝的来源对象(也可以是多个来源)。

object.assign 的语法为:Object.assign(target, ...sources)

object.assign 的示例代码如下:

let target = {};
let source = { a: { b: 1 } };
Object.assign(target, source);
console.log(target); // { a: { b: 1 } };

但是使用 object.assign 方法有几点需要注意

  • 它不会拷贝对象的继承属性;
  • 它不会拷贝对象的不可枚举的属性;
  • 可以拷贝 Symbol 类型的属性。
let obj1 = { a:{ b:1 }, sym:Symbol(1)};
Object.defineProperty(obj1, 'innumerable' ,{
    value:'不可枚举属性',
    enumerable:false
});
let obj2 = {};
Object.assign(obj2,obj1)
obj1.a.b = 2;
console.log('obj1',obj1);
console.log('obj2',obj2);

从上面的样例代码中可以看到,利用 object.assign 也可以拷贝 Symbol 类型的对象,但是如果到了对象的第二层属性 obj1.a.b 这里的时候,前者值的改变也会影响后者的第二层属性的值,说明其中依旧存在着访问共同堆内存的问题,也就是说这种方法还不能进一步复制,而只是完成了浅拷贝的功能

方法二:扩展运算符方式

  • 我们也可以利用 JS 的扩展运算符,在构造对象的同时完成浅拷贝的功能。
  • 扩展运算符的语法为:let cloneObj = { ...obj };
/* 对象的拷贝 */
let obj = {a:1,b:{c:1}}
let obj2 = {...obj}
obj.a = 2
console.log(obj)  //{a:2,b:{c:1}} console.log(obj2); //{a:1,b:{c:1}}
obj.b.c = 2
console.log(obj)  //{a:2,b:{c:2}} console.log(obj2); //{a:1,b:{c:2}}
/* 数组的拷贝 */
let arr = [1, 2, 3];
let newArr = [...arr]; //跟arr.slice()是一样的效果

扩展运算符 和 object.assign 有同样的缺陷,也就是实现的浅拷贝的功能差不多,但是如果属性都是基本类型的值,使用扩展运算符进行浅拷贝会更加方便

方法三:concat 拷贝数组

数组的 concat 方法其实也是浅拷贝,所以连接一个含有引用类型的数组时,需要注意修改原数组中的元素的属性,因为它会影响拷贝之后连接的数组。不过 concat 只能用于数组的浅拷贝,使用场景比较局限。代码如下所示。

let arr = [1, 2, 3];
let newArr = arr.concat();
newArr[1] = 100;
console.log(arr);  // [ 1, 2, 3 ]
console.log(newArr); // [ 1, 100, 3 ]

方法四:slice 拷贝数组

slice 方法也比较有局限性,因为它仅仅针对数组类型slice方法会返回一个新的数组对象,这一对象由该方法的前两个参数来决定原数组截取的开始和结束时间,是不会影响和改变原始数组的。

slice 的语法为:arr.slice(begin, end);
let arr = [1, 2, {val: 4}];
let newArr = arr.slice();
newArr[2].val = 1000;
console.log(arr);  //[ 1, 2, { val: 1000 } ]

从上面的代码中可以看出,这就是浅拷贝的限制所在了——它只能拷贝一层对象。如果存在对象的嵌套,那么浅拷贝将无能为力。因此深拷贝就是为了解决这个问题而生的,它能解决多层对象嵌套问题,彻底实现拷贝

手工实现一个浅拷贝

根据以上对浅拷贝的理解,如果让你自己实现一个浅拷贝,大致的思路分为两点:

  • 对基础类型做一个最基本的一个拷贝;
  • 对引用类型开辟一个新的存储,并且拷贝一层对象属性。
const shallowClone = (target) => {
  if (typeof target === 'object' && target !== null) {
    const cloneTarget = Array.isArray(target) ? []: {};
    for (let prop in target) {
      if (target.hasOwnProperty(prop)) {
          cloneTarget[prop] = target[prop];
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

利用类型判断,针对引用类型的对象进行 for 循环遍历对象属性赋值给目标对象的属性,基本就可以手工实现一个浅拷贝的代码了

2. 深拷贝的原理和实现

浅拷贝只是创建了一个新的对象,复制了原有对象的基本类型的值,而引用数据类型只拷贝了一层属性,再深层的还是无法进行拷贝。深拷贝则不同,对于复杂引用数据类型,其在堆内存中完全开辟了一块内存地址,并将原有的对象完全复制过来存放。

这两个对象是相互独立、不受影响的,彻底实现了内存上的分离。总的来说,深拷贝的原理可以总结如下

将一个对象从内存中完整地拷贝出来一份给目标对象,并从堆内存中开辟一个全新的空间存放新对象,且新对象的修改并不会改变原对象,二者实现真正的分离。

方法一:乞丐版(JSON.stringify)

JSON.stringify() 是目前开发过程中最简单的深拷贝方法,其实就是把一个对象序列化成为 JSON 的字符串,并将对象里面的内容转换成字符串,最后再用 JSON.parse() 的方法将 JSON 字符串生成一个新的对象

let a = {
    age: 1,
    jobs: {
        first: 'FE'
    }
}
let b = JSON.parse(JSON.stringify(a))
a.jobs.first = 'native'
console.log(b.jobs.first) // FE

但是该方法也是有局限性的

  • 会忽略 undefined
  • 会忽略 symbol
  • 不能序列化函数
  • 无法拷贝不可枚举的属性
  • 无法拷贝对象的原型链
  • 拷贝 RegExp 引用类型会变成空对象
  • 拷贝 Date 引用类型会变成字符串
  • 对象中含有 NaNInfinity 以及 -InfinityJSON 序列化的结果会变成 null
  • 不能解决循环引用的对象,即对象成环 (obj[key] = obj)。
function Obj() {
  this.func = function () { alert(1) };
  this.obj = {a:1};
  this.arr = [1,2,3];
  this.und = undefined;
  this.reg = /123/;
  this.date = new Date(0);
  this.NaN = NaN;
  this.infinity = Infinity;
  this.sym = Symbol(1);
}
let obj1 = new Obj();
Object.defineProperty(obj1,'innumerable',{
  enumerable:false,
  value:'innumerable'
});
console.log('obj1',obj1);
let str = JSON.stringify(obj1);
let obj2 = JSON.parse(str);
console.log('obj2',obj2);

使用 JSON.stringify 方法实现深拷贝对象,虽然到目前为止还有很多无法实现的功能,但是这种方法足以满足日常的开发需求,并且是最简单和快捷的。而对于其他的也要实现深拷贝的,比较麻烦的属性对应的数据类型,JSON.stringify 暂时还是无法满足的,那么就需要下面的几种方法了

方法二:基础版(手写递归实现)

下面是一个实现 deepClone 函数封装的例子,通过 for in 遍历传入参数的属性值,如果值是引用类型则再次递归调用该函数,如果是基础数据类型就直接复制

let obj1 = {
  a:{
    b:1
  }
}
function deepClone(obj) {
  let cloneObj = {}
  for(let key in obj) {                 //遍历
    if(typeof obj[key] ==='object') {
      cloneObj[key] = deepClone(obj[key])  //是对象就再次调用该函数递归
    } else {
      cloneObj[key] = obj[key]  //基本类型的话直接复制值
    }
  }
  return cloneObj
}
let obj2 = deepClone(obj1);
obj1.a.b = 2;
console.log(obj2);   //  {a:{b:1}}

虽然利用递归能实现一个深拷贝,但是同上面的 JSON.stringify 一样,还是有一些问题没有完全解决,例如:

  • 这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;
  • 这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
  • 对象的属性里面成环,即循环引用没有解决

这种基础版本的写法也比较简单,可以应对大部分的应用情况。但是你在面试的过程中,如果只能写出这样的一个有缺陷的深拷贝方法,有可能不会通过。

所以为了“拯救”这些缺陷,下面我带你一起看看改进的版本,以便于你可以在面试种呈现出更好的深拷贝方法,赢得面试官的青睐。

方法三:改进版(改进后递归实现)

针对上面几个待解决问题,我先通过四点相关的理论告诉你分别应该怎么做。

  • 针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
  • 当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
  • 利用 ObjectgetOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object.create 方法创建一个新对象,并继承传入原对象的原型链;
  • 利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏(你可以关注一下 MapweakMap 的关键区别,这里要用 weakMap),作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值

如果你在考虑到循环引用的问题之后,还能用 WeakMap 来很好地解决,并且向面试官解释这样做的目的,那么你所展示的代码,以及你对问题思考的全面性,在面试官眼中应该算是合格的了

实现深拷贝

const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)

const deepClone = function (obj, hash = new WeakMap()) {
  if (obj.constructor === Date) {
    return new Date(obj)       // 日期对象直接返回一个新的日期对象
  }

  if (obj.constructor === RegExp){
    return new RegExp(obj)     //正则对象直接返回一个新的正则对象
  }

  //如果循环引用了就用 weakMap 来解决
  if (hash.has(obj)) {
    return hash.get(obj)
  }
  let allDesc = Object.getOwnPropertyDescriptors(obj)

  //遍历传入参数所有键的特性
  let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)

  // 把cloneObj原型复制到obj上
  hash.set(obj, cloneObj)

  for (let key of Reflect.ownKeys(obj)) {
    cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ? deepClone(obj[key], hash) : obj[key]
  }
  return cloneObj
}
// 下面是验证代码
let obj = {
  num: 0,
  str: '',
  boolean: true,
  unf: undefined,
  nul: null,
  obj: { name: '我是一个对象', id: 1 },
  arr: [0, 1, 2],
  func: function () { console.log('我是一个函数') },
  date: new Date(0),
  reg: new RegExp('/我是一个正则/ig'),
  [Symbol('1')]: 1,
};
Object.defineProperty(obj, 'innumerable', {
  enumerable: false, value: '不可枚举属性' }
);
obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
obj.loop = obj    // 设置loop成循环引用的属性
let cloneObj = deepClone(obj)
cloneObj.arr.push(4)
console.log('obj', obj)
console.log('cloneObj', cloneObj)

我们看一下结果,cloneObjobj 的基础上进行了一次深拷贝,cloneObj 里的 arr 数组进行了修改,并未影响到 obj.arr 的变化,如下图所示

💬 面试官追问

  • 表单状态执行 {...state} 后,修改副本里的 address.city,原表单也跟着变化;代码评审里有人坚持扩展运算符已经完成复制,你怎么解释?

    扩展运算符只创建新的顶层对象,address 这类嵌套引用仍指向同一块数据,因此修改其内部属性会同时影响两边。若只改顶层基本类型字段,浅拷贝足够;要求嵌套状态完全隔离时才需要继续复制对应层级或执行深拷贝。

  • 数据合并用 Object.assign({}, source),结果继承属性和不可枚举配置没有进入副本,但 Symbol 键被保留;这是实现故障还是方法边界?

    这是 Object.assign 的边界:它不会复制继承属性和不可枚举属性,但能够复制可枚举的 Symbol 属性。若业务要求保留原型和完整属性特性,应结合 Object.getOwnPropertyDescriptorsObject.create;代价是实现复杂度明显增加。

  • 日志快照包含 DateRegExpundefinedNaN 和循环引用,团队仍准备使用 JSON.parse(JSON.stringify(data));上线前你会拦住哪些风险?

    这条路径会让 Date 变成字符串、RegExp 变成空对象,忽略 undefined,并把 NaN 与无穷值变成 null;遇到循环引用还会直接失败。它只适合可安全表示为 JSON 的数据,不能当作通用深拷贝方案。

  • 候选人写的递归深拷贝遇到数组得到普通对象,遇到循环引用则无限递归;你要求在现有思路上修正,会看哪些关键动作?

    应按具体类型创建数组、DateRegExp 等对应实例,并在递归前用 WeakMap 记录“原对象到副本”的映射。再次遇到同一对象时直接返回已有副本即可闭合环;若只用普通对象承接所有引用类型,类型语义仍会丢失。

  • 权限模型依赖不可枚举字段、Symbol 键和自定义原型,业务方又要求副本与原对象完全分离;普通递归赋值为什么不够?

    普通 for...in 递归拿不到完整的自有键和属性描述,也不会自动保留原型链。可用 Reflect.ownKeys 枚举键,以 Object.getOwnPropertyDescriptors 获取特性,再通过 Object.create 保留原型;函数通常仍只能保留引用,不能假定得到独立副本。

  • 评审中一方主张所有接口结果都深拷贝,另一方只愿意用 sliceconcat 或扩展运算符;你会依据什么决定?

    先看调用方是否会修改嵌套引用:只需隔离数组容器或对象顶层时,sliceconcat 和扩展运算符成本更低,但它们都是浅拷贝。只有确实要求多层数据互不影响时才采用深拷贝,并明确特殊类型、属性描述和循环引用的支持范围。

# 22 节流与防抖

⚡ 30 秒速记

  • 防抖是「等你停下来再干」,节流是「隔一段时间干一次」
  • 防抖每次触发都重置计时器,持续操作期间一次都不执行;节流用时间窗口卡住,持续操作期间匀速执行
  • 场景对上就不会记混:搜索联想、表单校验、resize 结束后重算用防抖;滚动加载、拖拽、按钮防重复提交用节流
  • 手写加分点:用 apply 透传 this 和参数、防抖支持 immediate、返回的函数挂 cancel 方法
  • 节流两种实现有区别:时间戳版首次立即末次丢失,定时器版首次延迟末次保留

防抖是在停止触发一段时间后执行,节流是在固定时间窗口内最多执行一次。 防抖每次收到新事件都会清除旧定时器并重新计时,适合避免连续点击产生多次请求。节流则比较当前时间与上次执行时间,常用于降低 scroll 监听的调用频率。手写时返回函数不能用箭头函数替代外层普通函数,还要通过 apply 透传调用时的 this 和参数。

  • 函数防抖 是指在事件被触发 n 秒后再执行回调,如果在这 n 秒内事件又被触发,则重新计时。这可以使用在一些点击请求的事件上,避免因为用户的多次点击向后端发送多次请求。
  • 函数节流 是指规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。节流可以使用在 scroll 函数的事件监听上,通过事件节流来降低事件调用的频率。

// 函数防抖的实现
function debounce(fn, wait) {
  var timer = null;

  return function() {
    var context = this,
      args = arguments;

    // 如果此时存在定时器的话,则取消之前的定时器重新记时
    if (timer) {
      clearTimeout(timer);
      timer = null;
    }

    // 设置定时器,使事件间隔指定事件后执行
    timer = setTimeout(() => {
      fn.apply(context, args);
    }, wait);
  };
}

// 函数节流的实现;
function throttle(fn, delay) {
  var preTime = Date.now();

  return function() {
    var context = this,
      args = arguments,
      nowTime = Date.now();

    // 如果两次时间间隔超过了指定时间,则执行函数。
    if (nowTime - preTime >= delay) {
      preTime = Date.now();
      return fn.apply(context, args);
    }
  };
}

💬 面试官追问

  • 搜索框输入“javascript”时连续触发十次 input,产品要求用户停下后只查询一次;同事却用每 300ms 执行一次的节流,你会怎么改?

    这里应使用防抖:每次输入都取消旧定时器并重新计时,只有连续一段时间没有新输入才执行查询。节流会在持续输入期间按时间窗口放行回调,仍可能产生多次请求;等待时长过大还会增加结果出现的延迟。

  • 长列表监听 scroll 更新吸顶位置,用户持续滚动五秒时页面必须周期性刷新;若改用只在停止滚动后执行的防抖,会出现什么现象?

    防抖会不断重新计时,持续滚动期间回调可能一直不执行,吸顶状态因而滞后到操作结束。此处更适合节流,在规定时间内最多放行一次更新以降低频率;时间窗口过大仍会让视觉反馈不够连续。

  • 支付按钮在一秒内被连点多次,接口不能重复提交;只套用示例里的尾触发防抖,业务上还缺少什么约束?

    尾触发防抖能把密集点击合并为一次延后调用,但它不能证明请求尚未完成时不会再次提交。按钮还应在请求期间进入不可重复触发状态,并由业务层校验提交状态;防抖只是降低调用次数,不能替代接口幂等或状态控制。

  • 组件卸载后,防抖封装里的定时器仍触发旧回调并访问已销毁状态;你会怎样定位和修复这类线上报错?

    先确认卸载前最后一次事件是否留下待执行的 setTimeout,以及回调闭包捕获了哪些组件数据。封装应暴露取消能力,并在组件销毁时清除定时器;仅移除事件监听不会自动取消已经排队的尾触发任务。

  • 同一套拖拽逻辑既要按固定频率更新坐标,又要在停止后保存最终位置,只允许选纯节流或纯防抖吗?

    不必二选一:移动过程中用节流限制坐标更新频率,停止操作后再用防抖触发最终保存,更符合两个阶段的目标。若只用节流,最终一次状态可能未及时持久化;只用防抖则持续拖动时缺少中间反馈。

# 23 Proxy代理

⚡ 30 秒速记

  • Proxy 代理整个对象,用 handler 拦截 13 种操作:get/set/has/deleteProperty/apply/construct
  • 相比 Object.defineProperty 的优势:能拦截属性新增和删除、支持数组索引和 length、可以懒代理(访问到才递归)
  • 必须配 Reflect 使用:Reflect.get(target, key, receiver) 保证 this 指向代理对象而不是原对象
  • 这就是 Vue 3 换掉 Vue 2 响应式方案的原因
  • 局限:无法代理原始值(Vue 3ref 包一层解决)、不能被 polyfill

Proxy 是在目标对象外层建立统一拦截,让读取、赋值、删除等操作先经过自定义的 handler 创建时传入 targethandler,例如访问属性触发 get,修改属性触发 set,因此既能监视外部访问,也能在复杂操作前做校验或资源管理。它还支持原型、属性描述符、函数调用和构造等多类操作,不只是简单的属性读写。由于这些能力无法由 Object.defineProperty 完整模拟,所以现有 polyfill 只能覆盖部分功能。

proxy在目标对象的外层搭建了一层拦截,外界对目标对象的某些操作,必须通过这层拦截

var proxy = new Proxy(target, handler);

new Proxy()表示生成一个Proxy实例,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为

var target = {
   name: 'poetries'
 };
 var logHandler = {
   get: function(target, key) {
     console.log(`${key} 被读取`);
     return target[key];
   },
   set: function(target, key, value) {
     console.log(`${key} 被设置为 ${value}`);
     target[key] = value;
   }
 }
 var targetWithLog = new Proxy(target, logHandler);

 targetWithLog.name; // 控制台输出:name 被读取
 targetWithLog.name = 'others'; // 控制台输出:name 被设置为 others

 console.log(target.name); // 控制台输出: others
  • targetWithLog 读取属性的值时,实际上执行的是 logHandler.get :在控制台输出信息,并且读取被代理对象 target 的属性。
  • targetWithLog 设置属性值时,实际上执行的是 logHandler.set :在控制台输出信息,并且设置被代理对象 target 的属性的值
// 由于拦截函数总是返回35,所以访问任何属性都得到35
var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

proxy.time // 35
proxy.name // 35
proxy.title // 35

Proxy 实例也可以作为其他对象的原型对象

var proxy = new Proxy({}, {
  get: function(target, property) {
    return 35;
  }
});

let obj = Object.create(proxy);
obj.time // 35

proxy对象是obj对象的原型,obj对象本身并没有time属性,所以根据原型链,会在proxy对象上读取该属性,导致被拦截

Proxy的作用

对于代理模式 Proxy 的作用主要体现在三个方面

  • 拦截和监视外部对对象的访问
  • 降低函数或类的复杂度
  • 在复杂操作前对操作进行校验或对所需资源进行管理

Proxy所能代理的范围--handler

实际上 handler 本身就是ES6所新设计的一个对象.它的作用就是用来 自定义代理对象的各种可代理操作 。它本身一共有13中方法,每种方法都可以代理一种操作.其13种方法如下

// 在读取代理对象的原型时触发该操作,比如在执行 Object.getPrototypeOf(proxy) 时。
handler.getPrototypeOf()

// 在设置代理对象的原型时触发该操作,比如在执行 Object.setPrototypeOf(proxy, null) 时。
handler.setPrototypeOf()


// 在判断一个代理对象是否是可扩展时触发该操作,比如在执行 Object.isExtensible(proxy) 时。
handler.isExtensible()


// 在让一个代理对象不可扩展时触发该操作,比如在执行 Object.preventExtensions(proxy) 时。
handler.preventExtensions()

// 在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时。
handler.getOwnPropertyDescriptor()


// 在定义代理对象某个属性时的属性描述时触发该操作,比如在执行 Object.defineProperty(proxy, "foo", {}) 时。
andler.defineProperty()


// 在判断代理对象是否拥有某个属性时触发该操作,比如在执行 "foo" in proxy 时。
handler.has()

// 在读取代理对象的某个属性时触发该操作,比如在执行 proxy.foo 时。
handler.get()


// 在给代理对象的某个属性赋值时触发该操作,比如在执行 proxy.foo = 1 时。
handler.set()

// 在删除代理对象的某个属性时触发该操作,比如在执行 delete proxy.foo 时。
handler.deleteProperty()

// 在获取代理对象的所有属性键时触发该操作,比如在执行 Object.getOwnPropertyNames(proxy) 时。
handler.ownKeys()

// 在调用一个目标对象为函数的代理对象时触发该操作,比如在执行 proxy() 时。
handler.apply()


// 在给一个目标对象为构造函数的代理对象构造实例时触发该操作,比如在执行new proxy() 时。
handler.construct()

为何Proxy不能被Polyfill

  • 如class可以用function模拟;promise可以用callback模拟
  • 但是proxy不能用Object.defineProperty模拟

目前谷歌的polyfill只能实现部分的功能,如get、set https://github.com/GoogleChrome/proxy-polyfill

// commonJS require
const proxyPolyfill = require('proxy-polyfill/src/proxy')();

// Your environment may also support transparent rewriting of commonJS to ES6:
import ProxyPolyfillBuilder from 'proxy-polyfill/src/proxy';
const proxyPolyfill = ProxyPolyfillBuilder();

// Then use...
const myProxy = new proxyPolyfill(...);

💬 面试官追问

  • 权限对象套上 Proxy 后,开发者保留了原始 target,随后直接执行 target.role = 'admin',日志里没有任何拦截记录;代理失效了吗?

    代理没有失效,只有经由代理对象发起的操作才会进入 handler。直接修改原始 target 会绕过外层拦截,但代理读取时仍能看到目标对象的新值;若审计必须完整,就不能继续向业务代码暴露原始对象引用。

  • 配置中心希望读取任意不存在的字段都返回 35,但代理同时被设为另一个对象的原型;访问子对象缺失字段时为什么也会得到 35

    子对象自身找不到属性时会沿原型链读取代理对象,从而触发代理的 get 拦截。由于该拦截无条件返回 35,缺失字段也得到同一结果;这种默认值策略会掩盖拼写错误,生产配置通常需要区分真实属性与兜底值。

  • 一个校验层既要拦截属性赋值和删除,又要监控 in 判断及键枚举;只实现 getset 为什么覆盖不了需求?

    不同对象操作对应不同陷阱:赋值使用 set,删除使用 deletePropertyin 判断使用 has,键枚举则涉及 ownKeys。只实现 getset 只能监控读写属性值;选择 Proxy 前应列出必须覆盖的操作并配置相应 handler

  • 线上发现代理对象的普通读写有日志,但 Object.getPrototypeOf(proxy)Object.defineProperty(proxy, 'x', desc) 完全没进入现有埋点;你会检查哪里?

    应检查 handler 是否实现了对应的 getPrototypeOfdefineProperty 陷阱,而不是继续修改 getsetProxy 会按操作类型分派拦截,普通属性读写正常不能证明其他操作已覆盖;遗漏陷阱时相关行为会直接落到目标对象。

  • 项目必须运行在无法原生支持 Proxy 的旧环境,架构师提出用 Object.defineProperty 做完整垫片;你会如何做选型判断?

    不能把 Object.defineProperty 视为完整的 Proxy 替代,因为它无法模拟全部代理语义,现有垫片也只能覆盖部分能力,例如 getset。若业务依赖删除、枚举、原型或函数调用等拦截,应调整兼容目标或改写方案,而不是承诺透明降级。

  • 安全团队想用 Proxy 同时包装普通对象、函数和构造器,要求分别审计读取、调用与 new;应该如何映射这些行为?

    普通属性读取进入 get,直接调用函数代理进入 apply,通过 new 构造实例则进入 construct。三类行为需要分别实现和校验,单靠属性读取陷阱无法审计调用;代理层还能集中管理资源,但会增加行为边界和调试复杂度。

# 24 Ajax

⚡ 30 秒速记

  • 本质:不刷新整页就与服务器交换数据,再局部更新 DOM
  • XMLHttpRequest 四步:newopen → 监听 onreadystatechangereadyState === 4status2xx)→ send
  • 今天应该主答 fetch:基于 Promise、语法简洁、配 AbortController 可取消
  • fetch 的三个坑:HTTP 错误码(404/500不会 reject、默认不带 cookie、没有原生进度和超时
  • 需要上传进度或超时控制时 XHR 仍不可替代(axios 底层就是它)

Ajax 是浏览器通过 JavaScript 异步发起 HTTP 请求,并局部更新页面而不必整页刷新的通信方式。 原生实现通常用 XMLHttpRequest:创建对象、配置地址、发送请求,再通过 readyStatestatus 判断响应。实际使用时可以封装成 Promise,成功时 resolve 数据,请求失败或网络异常时 reject,让调用链更清晰。

它是一种异步通信的方法,通过直接由 js 脚本向服务器发起 http 通信,然后根据服务器返回的数据,更新网页的相应部分,而不用刷新整个页面的一种方法。

面试手写(原生):

//1:创建Ajax对象
var xhr = window.XMLHttpRequest?new XMLHttpRequest():new ActiveXObject('Microsoft.XMLHTTP');// 兼容IE6及以下版本
//2:配置 Ajax请求地址
xhr.open('get','index.xml',true);
//3:发送请求
xhr.send(null); // 严谨写法
//4:监听请求,接受响应
xhr.onreadysatechange=function(){
     if(xhr.readySate==4&&xhr.status==200 || xhr.status==304 )
          console.log(xhr.responsetXML)
}

jQuery写法

$.ajax({
  type:'post',
  url:'',
  async:ture,//async 异步  sync  同步
  data:data,//针对post请求
  dataType:'jsonp',
  success:function (msg) {

  },
  error:function (error) {

  }
})

promise 封装实现:

// promise 封装实现:

function getJSON(url) {
  // 创建一个 promise 对象
  let promise = new Promise(function(resolve, reject) {
    let xhr = new XMLHttpRequest();

    // 新建一个 http 请求
    xhr.open("GET", url, true);

    // 设置状态的监听函数
    xhr.onreadystatechange = function() {
      if (this.readyState !== 4) return;

      // 当请求成功或失败时,改变 promise 的状态
      if (this.status === 200) {
        resolve(this.response);
      } else {
        reject(new Error(this.statusText));
      }
    };

    // 设置错误监听函数
    xhr.onerror = function() {
      reject(new Error(this.statusText));
    };

    // 设置响应的数据类型
    xhr.responseType = "json";

    // 设置请求头信息
    xhr.setRequestHeader("Accept", "application/json");

    // 发送 http 请求
    xhr.send(null);
  });

  return promise;
}

💬 面试官追问

  • 订单页发起原生 XHR 后,服务端已经返回 200,但页面偶尔在响应尚未完整时就开始渲染,你会检查哪段判断?

    先检查回调是否同时判断了 readyState === 4 和成功状态码,不能只看到 status === 200 就消费响应。readyState === 4 表示请求过程已经完成,再读取 responseresponseXML;条件组合还应加括号,避免 &&|| 优先级造成误判。

  • 老项目要把十几个读取配置的 XHR 调用改成 Promise,调用方希望直接拿到 JSON,你会怎样封装成功、失败和解析过程?

    在封装内创建 XMLHttpRequest,调用 open("GET", url, true),并把 responseType 设为 json。状态完成且为 200 时用 resolve(xhr.response),其他状态和 onerror 都调用 reject;若服务端返回的内容不是合法 JSON,解析行为仍可能失败,需要由调用方处理拒绝。

  • 接口网关把成功响应从 200 调整为 304,原来的请求封装却统一进入失败分支,你会改哪条判定?

    当前封装若只接受 status === 200,自然会把 304 当作失败,需要按接口约定补充允许的状态。修改前应确认 304 对该请求是否确实代表可用结果,并检查客户端是否有可复用内容;机械放宽为所有状态都成功,会掩盖真实的服务端错误。

  • 商品页请求一直没有进入成功回调,控制台也看不到明确异常;代码里写着 xhr.onreadysatechangexhr.readySate,你怎么定位?

    这里首先是属性名拼写错误,正确写法是 onreadystatechangereadyState,错误属性不会建立预期的状态监听。修正后再记录 readyStatestatusstatusText,区分请求未完成、HTTP 状态失败与网络错误;只盯着响应内容无法定位监听器根本没执行的情况。

  • 数据看板每分钟请求一次 JSON,团队争论继续写回调还是统一封成 Promise;在不引入第三方库的前提下你怎么选?

    保留原生 XHR 作为通信能力,但对外返回 Promise,让调用方用统一的成功和失败链路组织刷新逻辑。封装中集中设置 AcceptresponseType、状态判断和 onerror,可减少各页面重复代码;代价是仍要明确哪些 HTTP 状态算成功,不能把协议约定藏进模糊的通用封装。

# 25 深入数组

⚡ 30 秒速记

  • 先分清改原数组push/pop/splice/sort/reverse/fill)和返回新数组map/filter/slice/concat/flat
  • ES2023 新增不可变版本:toSorted/toReversed/toSpliced/with —— 专门解决 sort 改原数组的坑
  • 遍历能不能中断:for/for...of/some/every 可以,forEach/map 不行
  • sort 不传比较函数会按字符串排序,[10, 9] 会排成 [10, 9]V8 用的是稳定的 TimSort
  • 稀疏数组的坑:map/forEach 会跳过空位,flat 会去掉空位

理解数组方法时,我一般先看它是否修改原数组、返回什么,以及能处理哪类数据。 pushsplicesort 等会改变自身,concatslicefilter 等不会;mapreducefind 则分别适合映射、累计和查找。类数组只有索引和 length,像 argumentsHTMLCollectionNodeList,需要时可用 Array.fromslice.call 转成数组。

一、梳理数组 API

1. Array.of

Array.of 用于将参数依次转化为数组中的一项,然后返回这个新数组,而不管这个参数是数字还是其他。它基本上与 Array 构造器功能一致,唯一的区别就在单个数字参数的处理上

Array.of(8.0); // [8]
Array(8.0); // [empty × 8]
Array.of(8.0, 5); // [8, 5]
Array(8.0, 5); // [8, 5]
Array.of('8'); // ["8"]
Array('8'); // ["8"]

2. Array.from

从语法上看,Array.from 拥有 3 个参数:

  • 类似数组的对象,必选;
  • 加工函数,新生成的数组会经过该函数的加工再返回;
  • this 作用域,表示加工函数执行时 this 的值。

这三个参数里面第一个参数是必选的,后两个参数都是可选的。我们通过一段代码来看看它的用法。

var obj = {0: 'a', 1: 'b', 2:'c', length: 3};
Array.from(obj, function(value, index){
  console.log(value, index, this, arguments.length);
  return value.repeat(3);   //必须指定返回值,否则返回 undefined
}, obj);

// return 的 value 重复了三遍,最后返回的数组为 ["aaa","bbb","ccc"]


// 如果这里不指定 this 的话,加工函数完全可以是一个箭头函数。上述代码可以简写为如下形式。
Array.from(obj, (value) => value.repeat(3));
//  控制台返回 (3) ["aaa", "bbb", "ccc"]

除了上述 obj 对象以外,拥有迭代器的对象还包括 String、Set、Map 等,Array.from 统统可以处理,请看下面的代码。

// String
Array.from('abc');         // ["a", "b", "c"]
// Set
Array.from(new Set(['abc', 'def'])); // ["abc", "def"]
// Map
Array.from(new Map([[1, 'ab'], [2, 'de']]));
// [[1, 'ab'], [2, 'de']]

3. Array 的判断

在 ES5 提供该方法之前,我们至少有如下 5 种方式去判断一个变量是否为数组。

var a = [];
// 1.基于instanceof
a instanceof Array;
// 2.基于constructor
a.constructor === Array;
// 3.基于Object.prototype.isPrototypeOf
Array.prototype.isPrototypeOf(a);
// 4.基于getPrototypeOf
Object.getPrototypeOf(a) === Array.prototype;
// 5.基于Object.prototype.toString
Object.prototype.toString.apply(a) === '[object Array]';

ES6 之后新增了一个 Array.isArray 方法,能直接判断数据类型是否为数组,但是如果 isArray 不存在,那么 Array.isArray 的 polyfill 通常可以这样写:

if (!Array.isArray){
  Array.isArray = function(arg){
    return Object.prototype.toString.call(arg) === '[object Array]';
  };
}

4. 改变自身的方法

基于 ES6,会改变自身值的方法一共有 9 个,分别为 pop、push、reverse、shift、sort、splice、unshift,以及两个 ES6 新增的方法 copyWithin 和 fill

// pop方法
var array = ["cat", "dog", "cow", "chicken", "mouse"];
var item = array.pop();
console.log(array); // ["cat", "dog", "cow", "chicken"]
console.log(item); // mouse
// push方法
var array = ["football", "basketball",  "badminton"];
var i = array.push("golfball");
console.log(array);
// ["football", "basketball", "badminton", "golfball"]
console.log(i); // 4
// reverse方法
var array = [1,2,3,4,5];
var array2 = array.reverse();
console.log(array); // [5,4,3,2,1]
console.log(array2===array); // true
// shift方法
var array = [1,2,3,4,5];
var item = array.shift();
console.log(array); // [2,3,4,5]
console.log(item); // 1
// unshift方法
var array = ["red", "green", "blue"];
var length = array.unshift("yellow");
console.log(array); // ["yellow", "red", "green", "blue"]
console.log(length); // 4
// sort方法
var array = ["apple","Boy","Cat","dog"];
var array2 = array.sort();
console.log(array); // ["Boy", "Cat", "apple", "dog"]
console.log(array2 == array); // true
// splice方法
var array = ["apple","boy"];
var splices = array.splice(1,1);
console.log(array); // ["apple"]
console.log(splices); // ["boy"]
// copyWithin方法
var array = [1,2,3,4,5];
var array2 = array.copyWithin(0,3);
console.log(array===array2,array2);  // true [4, 5, 3, 4, 5]
// fill方法
var array = [1,2,3,4,5];
var array2 = array.fill(10,0,3);
console.log(array===array2,array2);
// true [10, 10, 10, 4, 5], 可见数组区间[0,3]的元素全部替换为10

5. 不改变自身的方法

基于 ES7,不会改变自身的方法也有 9 个,分别为 concat、join、slice、toString、toLocaleString、indexOf、lastIndexOf、未形成标准的 toSource,以及 ES7 新增的方法 includes

// concat方法
var array = [1, 2, 3];
var array2 = array.concat(4,[5,6],[7,8,9]);
console.log(array2); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(array); // [1, 2, 3], 可见原数组并未被修改
// join方法
var array = ['We', 'are', 'Chinese'];
console.log(array.join()); // "We,are,Chinese"
console.log(array.join('+')); // "We+are+Chinese"
// slice方法
var array = ["one", "two", "three","four", "five"];
console.log(array.slice()); // ["one", "two", "three","four", "five"]
console.log(array.slice(2,3)); // ["three"]
// toString方法
var array = ['Jan', 'Feb', 'Mar', 'Apr'];
var str = array.toString();
console.log(str); // Jan,Feb,Mar,Apr
// tolocalString方法
var array= [{name:'zz'}, 123, "abc", new Date()];
var str = array.toLocaleString();
console.log(str); // [object Object],123,abc,2016/1/5 下午1:06:23
// indexOf方法
var array = ['abc', 'def', 'ghi','123'];
console.log(array.indexOf('def')); // 1
// includes方法
var array = [-0, 1, 2];
console.log(array.includes(+0)); // true
console.log(array.includes(1)); // true
var array = [NaN];
console.log(array.includes(NaN)); // true

其中 includes 方法需要注意的是,如果元素中有 0,那么在判断过程中不论是 +0 还是 -0 都会判断为 True,这里的 includes 忽略了 +0 和 -0

6. 数组遍历的方法

基于 ES6,不会改变自身的遍历方法一共有 12 个,分别为 forEach、every、some、filter、map、reduce、reduceRight,以及 ES6 新增的方法 entries、find、findIndex、keys、values

// forEach方法
var array = [1, 3, 5];
var obj = {name:'cc'};
var sReturn = array.forEach(function(value, index, array){
  array[index] = value;
  console.log(this.name); // cc被打印了三次, this指向obj
},obj);
console.log(array); // [1, 3, 5]
console.log(sReturn); // undefined, 可见返回值为undefined
// every方法
var o = {0:10, 1:8, 2:25, length:3};
var bool = Array.prototype.every.call(o,function(value, index, obj){
  return value >= 8;
},o);
console.log(bool); // true
// some方法
var array = [18, 9, 10, 35, 80];
var isExist = array.some(function(value, index, array){
  return value > 20;
});
console.log(isExist); // true
// map 方法
var array = [18, 9, 10, 35, 80];
array.map(item => item + 1);
console.log(array);  // [19, 10, 11, 36, 81]
// filter 方法
var array = [18, 9, 10, 35, 80];
var array2 = array.filter(function(value, index, array){
  return value > 20;
});
console.log(array2); // [35, 80]
// reduce方法
var array = [1, 2, 3, 4];
var s = array.reduce(function(previousValue, value, index, array){
  return previousValue * value;
},1);
console.log(s); // 24
// ES6写法更加简洁
array.reduce((p, v) => p * v); // 24
// reduceRight方法 (和reduce的区别就是从后往前累计)
var array = [1, 2, 3, 4];
array.reduceRight((p, v) => p * v); // 24
// entries方法
var array = ["a", "b", "c"];
var iterator = array.entries();
console.log(iterator.next().value); // [0, "a"]
console.log(iterator.next().value); // [1, "b"]
console.log(iterator.next().value); // [2, "c"]
console.log(iterator.next().value); // undefined, 迭代器处于数组末尾时, 再迭代就会返回undefined
// find & findIndex方法
var array = [1, 3, 5, 7, 8, 9, 10];
function f(value, index, array){
  return value%2==0;     // 返回偶数
}
function f2(value, index, array){
  return value > 20;     // 返回大于20的数
}
console.log(array.find(f)); // 8
console.log(array.find(f2)); // undefined
console.log(array.findIndex(f)); // 4
console.log(array.findIndex(f2)); // -1
// keys方法
[...Array(10).keys()];     // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[...new Array(10).keys()]; // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
// values方法
var array = ["abc", "xyz"];
var iterator = array.values();
console.log(iterator.next().value);//abc
console.log(iterator.next().value);//xyz

7. 总结

这些方法之间存在很多共性,如下:

  • 所有插入元素的方法,比如 push、unshift 一律返回数组新的长度;
  • 所有删除元素的方法,比如 pop、shift、splice 一律返回删除的元素,或者返回删除的多个元素组成的数组;
  • 部分遍历方法,比如 forEach、every、some、filter、map、find、findIndex,它们都包含 function(value,index,array){}thisArg 这样两个形参。

数组和字符串方法

二、理解JS的类数组

在 JavaScript 中有哪些情况下的对象是类数组呢?主要有以下几种

  • 函数里面的参数对象 arguments
  • getElementsByTagName/ClassName/Name 获得的 HTMLCollection
  • querySelector 获得的 NodeList

1. arguments对象

arguments对象是函数中传递的参数值的集合。它是一个类似数组的对象,因为它有一个length属性,我们可以使用数组索引表示法arguments[1]来访问单个值,但它没有数组中的内置方法,如:forEach、reduce、filter和map。

function foo(name, age, sex) {
    console.log(arguments);
    console.log(typeof arguments);
    console.log(Object.prototype.toString.call(arguments));
}
foo('jack', '18', 'male');

这段代码比较容易,就是直接将这个函数的 arguments 在函数内部打印出来,那么我们看下这个 arguments 打印出来的结果,请看控制台的这张截图。

从结果中可以看到,typeof 这个 arguments 返回的是 object,通过 Object.prototype.toString.call 返回的结果是 '[object arguments]',可以看出来返回的不是 '[object array]',说明 arguments 和数组还是有区别的。

我们可以使用Array.prototype.slicearguments对象转换成一个数组。

function one() {
  return Array.prototype.slice.call(arguments);
}

注意:箭头函数中没有arguments对象。

function one() {
  return arguments;
}
const two = function () {
  return arguments;
}
const three = function three() {
  return arguments;
}

const four = () => arguments;

four(); // Throws an error  - arguments is not defined

当我们调用函数four时,它会抛出一个ReferenceError: arguments is not defined error。使用rest语法,可以解决这个问题。

const four = (...args) => args;

这会自动将所有参数值放入数组中。

arguments 不仅仅有一个 length 属性,还有一个 callee 属性,我们接下来看看这个 callee 是干什么的,代码如下所示

function foo(name, age, sex) {
    console.log(arguments.callee);
}
foo('jack', '18', 'male');

从控制台可以看到,输出的就是函数自身,如果在函数内部直接执行调用 callee 的话,那它就会不停地执行当前函数,直到执行到内存溢出

2. HTMLCollection

HTMLCollection 简单来说是 HTML DOM 对象的一个接口,这个接口包含了获取到的 DOM 元素集合,返回的类型是类数组对象,如果用 typeof 来判断的话,它返回的是 'object'。它是及时更新的,当文档中的 DOM 变化时,它也会随之变化。

描述起来比较抽象,还是通过一段代码来看下 HTMLCollection 最后返回的是什么,我们先随便找一个页面中有 form 表单的页面,在控制台中执行下述代码

var elem1, elem2;
// document.forms 是一个 HTMLCollection
elem1 = document.forms[0];
elem2 = document.forms.item(0);
console.log(elem1);
console.log(elem2);
console.log(typeof elem1);
console.log(Object.prototype.toString.call(elem1));

在这个有 form 表单的页面执行上面的代码,得到的结果如下。

可以看到,这里打印出来了页面第一个 form 表单元素,同时也打印出来了判断类型的结果,说明打印的判断的类型和 arguments 返回的也比较类似,typeof 返回的都是 'object',和上面的类似。

另外需要注意的一点就是 HTML DOM 中的 HTMLCollection 是即时更新的,当其所包含的文档结构发生改变时,它会自动更新。下面我们再看最后一个 NodeList 类数组。

3. NodeList

NodeList 对象是节点的集合,通常是由 querySlector 返回的。NodeList 不是一个数组,也是一种类数组。虽然 NodeList 不是一个数组,但是可以使用 for...of 来迭代。在一些情况下,NodeList 是一个实时集合,也就是说,如果文档中的节点树发生变化,NodeList 也会随之变化。我们还是利用代码来理解一下 Nodelist 这种类数组。

var list = document.querySelectorAll('input[type=checkbox]');
for (var checkbox of list) {
  checkbox.checked = true;
}
console.log(list);
console.log(typeof list);
console.log(Object.prototype.toString.call(list));

从上面的代码执行的结果中可以发现,我们是通过有 CheckBox 的页面执行的代码,在结果可中输出了一个 NodeList 类数组,里面有一个 CheckBox 元素,并且我们判断了它的类型,和上面的 arguments 与 HTMLCollection 其实是类似的,执行结果如下图所示。

4. 类数组应用场景

  1. 遍历参数操作

我们在函数内部可以直接获取 arguments 这个类数组的值,那么也可以对于参数进行一些操作,比如下面这段代码,我们可以将函数的参数默认进行求和操作。

function add() {
    var sum =0,
        len = arguments.length;
    for(var i = 0; i < len; i++){
        sum += arguments[i];
    }
    return sum;
}
add()                           // 0
add(1)                          // 1
add(12)                       // 3
add(1,2,3,4);                   // 10
  1. 定义链接字符串函数

我们可以通过 arguments 这个例子定义一个函数来连接字符串。这个函数唯一正式声明了的参数是一个字符串,该参数指定一个字符作为衔接点来连接字符串。该函数定义如下。

// 这段代码说明了,你可以传递任意数量的参数到该函数,并使用每个参数作为列表中的项创建列表进行拼接。从这个例子中也可以看出,我们可以在日常编码中采用这样的代码抽象方式,把需要解决的这一类问题,都抽象成通用的方法,来提升代码的可复用性
function myConcat(separa) {
  var args = Array.prototype.slice.call(arguments, 1);
  return args.join(separa);
}
myConcat(", ", "red", "orange", "blue");
// "red, orange, blue"
myConcat("; ", "elephant", "lion", "snake");
// "elephant; lion; snake"
myConcat(". ", "one", "two", "three", "four", "five");
// "one. two. three. four. five"
  1. 传递参数使用
// 使用 apply 将 foo 的参数传递给 bar
function foo() {
    bar.apply(this, arguments);
}
function bar(a, b, c) {
   console.log(a, b, c);
}
foo(1, 2, 3)   //1 2 3

5. 如何将类数组转换成数组

  1. 类数组借用数组方法转数组
function sum(a, b) {
  let args = Array.prototype.slice.call(arguments);
 // let args = [].slice.call(arguments); // 这样写也是一样效果
  console.log(args.reduce((sum, cur) => sum + cur));
}
sum(1, 2);  // 3
function sum(a, b) {
  let args = Array.prototype.concat.apply([], arguments);
  console.log(args.reduce((sum, cur) => sum + cur));
}
sum(1, 2);  // 3
  1. ES6 的方法转数组
function sum(a, b) {
  let args = Array.from(arguments);
  console.log(args.reduce((sum, cur) => sum + cur));
}
sum(1, 2);    // 3
function sum(a, b) {
  let args = [...arguments];
  console.log(args.reduce((sum, cur) => sum + cur));
}
sum(1, 2);    // 3
function sum(...args) {
  console.log(args.reduce((sum, cur) => sum + cur));
}
sum(1, 2);    // 3

Array.fromES6 的展开运算符,都可以把 arguments这个类数组转换成数组 args

类数组和数组的异同点

在前端工作中,开发者往往会忽视对类数组的学习,其实在高级 JavaScript 编程中经常需要将类数组向数组转化,尤其是一些比较复杂的开源项目,经常会看到函数中处理参数的写法,例如:[].slice.call(arguments) 这行代码。

三、实现数组扁平化的 6 种方式

1. 方法一:普通的递归实

普通的递归思路很容易理解,就是通过循环递归的方式,一项一项地去遍历,如果每一项还是一个数组,那么就继续往下遍历,利用递归程序的方法,来实现数组的每一项的连接。我们来看下这个方法是如何实现的,如下所示

// 方法1
var a = [1, [2, [3, 4, 5]]];
function flatten(arr) {
  let result = [];

  for(let i = 0; i < arr.length; i++) {
    if(Array.isArray(arr[i])) {
      result = result.concat(flatten(arr[i]));
    } else {
      result.push(arr[i]);
    }
  }
  return result;
}
flatten(a);  //  [1, 2, 3, 4,5]

从上面这段代码可以看出,最后返回的结果是扁平化的结果,这段代码核心就是循环遍历过程中的递归操作,就是在遍历过程中发现数组元素还是数组的时候进行递归操作,把数组的结果通过数组的 concat 方法拼接到最后要返回的 result 数组上,那么最后输出的结果就是扁平化后的数组

2. 方法二:利用 reduce 函数迭代

从上面普通的递归函数中可以看出,其实就是对数组的每一项进行处理,那么我们其实也可以用 reduce 来实现数组的拼接,从而简化第一种方法的代码,改造后的代码如下所示。

// 方法2
var arr = [1, [2, [3, 4]]];
function flatten(arr) {
    return arr.reduce(function(prev, next){
        return prev.concat(Array.isArray(next) ? flatten(next) : next)
    }, [])
}
console.log(flatten(arr));//  [1, 2, 3, 4,5]

3. 方法三:扩展运算符实现

这个方法的实现,采用了扩展运算符和 some 的方法,两者共同使用,达到数组扁平化的目的,还是来看一下代码

// 方法3
var arr = [1, [2, [3, 4]]];
function flatten(arr) {
    while (arr.some(item => Array.isArray(item))) {
        arr = [].concat(...arr);
    }
    return arr;
}
console.log(flatten(arr)); //  [1, 2, 3, 4,5]

从执行的结果中可以发现,我们先用数组的 some 方法把数组中仍然是组数的项过滤出来,然后执行 concat 操作,利用 ES6 的展开运算符,将其拼接到原数组中,最后返回原数组,达到了预期的效果。

前三种实现数组扁平化的方式其实是最基本的思路,都是通过最普通递归思路衍生的方法,尤其是前两种实现方法比较类似。值得注意的是 reduce 方法,它可以在很多应用场景中实现,由于 reduce 这个方法提供的几个参数比较灵活,能解决很多问题,所以是值得熟练使用并且精通的

4. 方法四:split 和 toString 共同处理

我们也可以通过 split 和 toString 两个方法,来共同实现数组扁平化,由于数组会默认带一个 toString 的方法,所以可以把数组直接转换成逗号分隔的字符串,然后再用 split 方法把字符串重新转换为数组,如下面的代码所示。

// 方法4
var arr = [1, [2, [3, 4]]];
function flatten(arr) {
    return arr.toString().split(',');
}
console.log(flatten(arr)); //  [1, 2, 3, 4]

通过这两个方法可以将多维数组直接转换成逗号连接的字符串,然后再重新分隔成数组,你可以在控制台执行一下查看结果。

5. 方法五:调用 ES6 中的 flat

我们还可以直接调用 ES6 中的 flat 方法,可以直接实现数组扁平化。先来看下 flat 方法的语法:

arr.flat([depth])

其中 depth 是 flat 的参数,depth 是可以传递数组的展开深度(默认不填、数值是 1),即展开一层数组。那么如果多层的该怎么处理呢?参数也可以传进 Infinity,代表不论多少层都要展开。那么我们来看下,用 flat 方法怎么实现,请看下面的代码。

// 方法5
var arr = [1, [2, [3, 4]]];
function flatten(arr) {
  return arr.flat(Infinity);
}
console.log(flatten(arr)); //  [1, 2, 3, 4,5]
  • 可以看出,一个嵌套了两层的数组,通过将 flat 方法的参数设置为 Infinity,达到了我们预期的效果。其实同样也可以设置成 2,也能实现这样的效果。
  • 因此,你在编程过程中,发现对数组的嵌套层数不确定的时候,最好直接使用 Infinity,可以达到扁平化。下面我们再来看最后一种场景

6. 方法六:正则和 JSON 方法共同处理

我们在第四种方法中已经尝试了用 toString 方法,其中仍然采用了将 JSON.stringify 的方法先转换为字符串,然后通过正则表达式过滤掉字符串中的数组的方括号,最后再利用 JSON.parse 把它转换成数组。请看下面的代码

// 方法 6
let arr = [1, [2, [3, [4, 5]]], 6];
function flatten(arr) {
  let str = JSON.stringify(arr);
  str = str.replace(/(\[|\])/g, '');
  str = '[' + str + ']';
  return JSON.parse(str);
}
console.log(flatten(arr)); //  [1, 2, 3, 4,5]

可以看到,其中先把传入的数组转换成字符串,然后通过正则表达式的方式把括号过滤掉,这部分正则的表达式你不太理解的话,可以看看下面的图片

通过这个在线网站 https://regexper.com/ 可以把正则分析成容易理解的可视化的逻辑脑图。其中我们可以看到,匹配规则是:全局匹配(g)左括号或者右括号,将它们替换成空格,最后返回处理后的结果。之后拿着正则处理好的结果重新在外层包裹括号,最后通过 JSON.parse 转换成数组返回。

四、如何用 JS 实现各种数组排序

数据结构算法中排序有很多种,常见的、不常见的,至少包含十种以上。根据它们的特性,可以大致分为两种类型:比较类排序和非比较类排序。

  • 比较类排序:通过比较来决定元素间的相对次序,其时间复杂度不能突破 O(nlogn),因此也称为非线性时间比较类排序。
  • 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。

我们通过一张图片来看看这两种分类方式分别包括哪些排序方法。

非比较类的排序在实际情况中用的比较少

1. 冒泡排序

冒泡排序是最基础的排序,一般在最开始学习数据结构的时候就会接触它。冒泡排序是一次比较两个元素,如果顺序是错误的就把它们交换过来。走访数列的工作会重复地进行,直到不需要再交换,也就是说该数列已经排序完成。请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function bubbleSort(array) {
  const len = array.length
  if (len < 2) return array
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < i; j++) {
      if (array[j] > array[i]) {
        const temp = array[j]
        array[j] = array[i]
        array[i] = temp
      }
    }
  }
  return array
}
bubbleSort(a);  // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

从上面这段代码可以看出,最后返回的是排好序的结果。因为冒泡排序实在太基础和简单,这里就不过多赘述了。下面我们来看看快速排序法

2. 快速排序

快速排序的基本思想是通过一趟排序,将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可以分别对这两部分记录继续进行排序,以达到整个序列有序。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function quickSort(array) {
  var quick = function(arr) {
    if (arr.length <= 1) return arr
    const len = arr.length
    const index = Math.floor(len >> 1)
    const pivot = arr.splice(index, 1)[0]
    const left = []
    const right = []
    for (let i = 0; i < len; i++) {
      if (arr[i] > pivot) {
        right.push(arr[i])
      } else if (arr[i] <= pivot) {
        left.push(arr[i])
      }
    }
    return quick(left).concat([pivot], quick(right))
  }
  const result = quick(array)
  return result
}
quickSort(a);//  [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

上面的代码在控制台执行之后,也可以得到预期的结果。最主要的思路是从数列中挑出一个元素,称为 “基准”(pivot);然后重新排序数列,所有元素比基准值小的摆放在基准前面、比基准值大的摆在基准的后面;在这个区分搞定之后,该基准就处于数列的中间位置;然后把小于基准值元素的子数列(left)和大于基准值元素的子数列(right)递归地调用 quick 方法排序完成,这就是快排的思路。

3. 插入排序

插入排序算法描述的是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入,从而达到排序的效果。来看一下代码

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function insertSort(array) {
  const len = array.length
  let current
  let prev
  for (let i = 1; i < len; i++) {
    current = array[i]
    prev = i - 1
    while (prev >= 0 && array[prev] > current) {
      array[prev + 1] = array[prev]
      prev--
    }
    array[prev + 1] = current
  }
  return array
}
insertSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

从执行的结果中可以发现,通过插入排序这种方式实现了排序效果。插入排序的思路是基于数组本身进行调整的,首先循环遍历从 i 等于 1 开始,拿到当前的 current 的值,去和前面的值比较,如果前面的大于当前的值,就把前面的值和当前的那个值进行交换,通过这样不断循环达到了排序的目的

4. 选择排序

选择排序是一种简单直观的排序算法。它的工作原理是,首先将最小的元素存放在序列的起始位置,再从剩余未排序元素中继续寻找最小元素,然后放到已排序的序列后面……以此类推,直到所有元素均排序完毕。请看下面的代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function selectSort(array) {
  const len = array.length
  let temp
  let minIndex
  for (let i = 0; i < len - 1; i++) {
    minIndex = i
    for (let j = i + 1; j < len; j++) {
      if (array[j] <= array[minIndex]) {
        minIndex = j
      }
    }
    temp = array[i]
    array[i] = array[minIndex]
    array[minIndex] = temp
  }
  return array
}
selectSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

这样,通过选择排序的方法同样也可以实现数组的排序,从上面的代码中可以看出该排序是表现最稳定的排序算法之一,因为无论什么数据进去都是 O(n 平方) 的时间复杂度,所以用到它的时候,数据规模越小越好

5. 堆排序

堆排序是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质,即子结点的键值或索引总是小于(或者大于)它的父节点。堆的底层实际上就是一棵完全二叉树,可以用数组实现。

根节点最大的堆叫作大根堆,根节点最小的堆叫作小根堆,你可以根据从大到小排序或者从小到大来排序,分别建立对应的堆就可以。请看下面的代码

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function heap_sort(arr) {
  var len = arr.length
  var k = 0
  function swap(i, j) {
    var temp = arr[i]
    arr[i] = arr[j]
    arr[j] = temp
  }
  function max_heapify(start, end) {
    var dad = start
    var son = dad * 2 + 1
    if (son >= end) return
    if (son + 1 < end && arr[son] < arr[son + 1]) {
      son++
    }
    if (arr[dad] <= arr[son]) {
      swap(dad, son)
      max_heapify(son, end)
    }
  }
  for (var i = Math.floor(len / 2) - 1; i >= 0; i--) {
    max_heapify(i, len)
  }

  for (var j = len - 1; j > k; j--) {
    swap(0, j)
    max_heapify(0, j)
  }

  return arr
}
heap_sort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

从代码来看,堆排序相比上面几种排序整体上会复杂一些,不太容易理解。不过你应该知道两点:

  • 一是堆排序最核心的点就在于排序前先建堆;
  • 二是由于堆其实就是完全二叉树,如果父节点的序号为 n,那么叶子节点的序号就分别是 2n2n+1

你理解了这两点,再看代码就比较好理解了。堆排序最后有两个循环:第一个是处理父节点的顺序;第二个循环则是根据父节点和叶子节点的大小对比,进行堆的调整。通过这两轮循环的调整,最后堆排序完成。

6. 归并排序

归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。我们先看一下代码。

var a = [1, 3, 6, 3, 23, 76, 1, 34, 222, 6, 456, 221];
function mergeSort(array) {
  const merge = (right, left) => {
    const result = []
    let il = 0
    let ir = 0
    while (il < left.length && ir < right.length) {
      if (left[il] < right[ir]) {
        result.push(left[il++])
      } else {
        result.push(right[ir++])
      }
    }
    while (il < left.length) {
      result.push(left[il++])
    }
    while (ir < right.length) {
      result.push(right[ir++])
    }
    return result
  }
  const mergeSort = array => {
    if (array.length === 1) { return array }
    const mid = Math.floor(array.length / 2)
    const left = array.slice(0, mid)
    const right = array.slice(mid, array.length)
    return merge(mergeSort(left), mergeSort(right))
  }
  return mergeSort(array)
}
mergeSort(a); // [1, 1, 3, 3, 6, 6, 23, 34, 76, 221, 222, 456]

从上面这段代码中可以看到,通过归并排序可以得到想要的结果。上面提到了分治的思路,你可以从 mergeSort 方法中看到,通过 mid 可以把该数组分成左右两个数组,分别对这两个进行递归调用排序方法,最后将两个数组按照顺序归并起来。

归并排序是一种稳定的排序方法,和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好得多,因为始终都是 O(nlogn) 的时间复杂度。而代价是需要额外的内存空间。

其中你可以看到排序相关的时间复杂度和空间复杂度以及稳定性的情况,如果遇到需要自己实现排序的时候,可以根据它们的空间和时间复杂度综合考量,选择最适合的排序方法

💬 面试官追问

  • 报表代码写了 Array(8) 想得到 [8],结果页面生成了 8 个空槽;如果参数来自用户输入,你会怎么修?

    单个数字传给 Array 会被解释为数组长度,因此结果是包含 8 个空槽的数组,而不是 [8]。需要把参数作为一个元素时应使用 Array.of(value);继续依赖构造器会让数字参数与字符串参数产生不同语义,容易形成数据类型相关的隐患。

  • 接口返回 {0:"a",1:"b",2:"c",length:3},列表组件既要转成数组又要把每项重复三次,你会怎样写而不再遍历一轮?

    可以直接使用 Array.from(data, value => value.repeat(3)),转换类数组的同时完成映射,得到 ['aaa','bbb','ccc']。映射函数必须显式返回加工后的值,否则对应位置会变成 undefined;若回调依赖普通函数的 this,还可通过第三个参数传入作用域。

  • 微前端页面从另一个执行环境传来一个数组,旧代码用 value instanceof Array 判断后走错分支,你会替换成什么?

    应优先改用 Array.isArray(value),它直接表达数组判断,也避免把判断绑定到当前环境的 Array 构造器。需要兼容缺少该方法的旧环境时,可回退到 Object.prototype.toString.call(value) === '[object Array]';依赖 constructor 或原型相等会受到对象来源和原型变化影响。

  • 表格排序后发现上游缓存里的原始顺序也被改了,代码只有 const result = rows.sort();为什么两个变量一起变化?

    sort 会直接修改原数组,并返回同一个数组引用,所以 resultrows 指向同一对象。若上游数据必须保持不变,应先用 rows.slice()rows.concat() 复制,再对副本排序;浅复制只能隔离数组结构,元素对象内部的修改仍会共享。

  • 批量删除标签时,开发者以为 splice(1, 2) 返回删除后的原数组,结果后续保存的数据只剩两个标签;你如何解释返回值约定?

    splice 会修改原数组,但返回的是被删除元素组成的新数组,而不是修改后的原数组。保存剩余列表应继续使用原变量,若要记录删除项再接收 splice 的返回值;这类接口同时具有副作用和返回值,混用时尤其容易覆盖业务数据。

  • 页面用 querySelectorAllgetElementsByClassName 各拿到一批节点,DOM 更新后两边数量表现不同,你会怎样处理遍历快照?

    两者得到的都是类数组,但 HTMLCollection 会随文档结构及时更新,而 querySelectorAll 返回的 NodeList 在该场景下适合按查询结果遍历。需要稳定快照时可用 Array.from(collection) 转成真正数组,再使用 mapfilter 等数组方法;转换后的数组不会继续自动反映 DOM 变化。

# 二、HTML

# 1 meta 标签:自动刷新/跳转

⚡ 30 秒速记

  • 写法:<meta http-equiv="refresh" content="5; url=https://xxx">5 秒后跳转
  • content 只写数字则是定时刷新当前页,带 url 才是跳转
  • 严重的可访问性问题:会打断读屏器、用户无法预期,WCAG 明确不建议使用
  • SEO 影响:搜索引擎可能判定为可疑跳转,正规重定向要用 HTTP301/302
  • 现代替代:服务端重定向、或前端 setTimeout + location.replace 并给用户可取消的提示

meta refresh 可以让页面在指定时间后自动刷新,或者跳转到另一个地址。 例如 content="5; URL=page2.html" 表示五秒后跳转,去掉 URL 后,content="60" 就表示每隔六十秒刷新。它适合简单的页面轮播或监控大屏刷新;如果还要根据业务状态决定是否跳转,单靠这个标签就不够灵活。

假设要实现一个类似 PPT 自动播放的效果,你很可能会想到使用 JavaScript 定时器控制页面跳转来实现。但其实有更加简洁的实现方法,比如通过 meta 标签来实现:

<meta http-equiv="Refresh" content="5; URL=page2.html">

上面的代码会在 5s 之后自动跳转到同域下的 page2.html 页面。我们要实现 PPT 自动播放的功能,只需要在每个页面的 meta 标签内设置好下一个页面的地址即可。

另一种场景,比如每隔一分钟就需要刷新页面的大屏幕监控,也可以通过 meta 标签来实现,只需去掉后面的 URL 即可:

<meta http-equiv="Refresh" content="60">

meta viewport相关

<!DOCTYPE html>  <!--H5标准声明,使用 HTML5 doctype,不区分大小写-->
<head lang=”en”> <!--标准的 lang 属性写法-->
<meta charset=’utf-8′>    <!--声明文档使用的字符编码-->
<meta http-equiv=”X-UA-Compatible” content=”IE=edge,chrome=1″/>   <!--优先使用 IE 最新版本和 Chrome-->
<meta name=”description” content=”不超过150个字符”/>       <!--页面描述-->
<meta name=”keywords” content=””/>     <!-- 页面关键词-->
<meta name=”author” content=”name, email@gmail.com”/>    <!--网页作者-->
<meta name=”robots” content=”index,follow”/>      <!--搜索引擎抓取-->
<meta name=”viewport” content=”initial-scale=1, maximum-scale=3, minimum-scale=1, user-scalable=no”> <!--为移动设备添加 viewport-->
<meta name=”apple-mobile-web-app-title” content=”标题”> <!--iOS 设备 begin-->
<meta name=”apple-mobile-web-app-capable” content=”yes”/>  <!--添加到主屏后的标题(iOS 6 新增)
是否启用 WebApp 全屏模式,删除苹果默认的工具栏和菜单栏-->
<meta name=”apple-itunes-app” content=”app-id=myAppStoreID, affiliate-data=myAffiliateData, app-argument=myURL”>
<!--添加智能 App 广告条 Smart App Banner(iOS 6+ Safari)-->
<meta name=”apple-mobile-web-app-status-bar-style” content=”black”/>
<meta name=”format-detection” content=”telphone=no, email=no”/>  <!--设置苹果工具栏颜色-->
<meta name=”renderer” content=”webkit”> <!-- 启用360浏览器的极速模式(webkit)-->
<meta http-equiv=”X-UA-Compatible” content=”IE=edge”>     <!--避免IE使用兼容模式-->
<meta http-equiv=”Cache-Control” content=”no-siteapp” />    <!--不让百度转码-->
<meta name=”HandheldFriendly” content=”true”>     <!--针对手持设备优化,主要是针对一些老的不识别viewport的浏览器,比如黑莓-->
<meta name=”MobileOptimized” content=”320″>   <!--微软的老式浏览器-->
<meta name=”screen-orientation” content=”portrait”>   <!--uc强制竖屏-->
<meta name=”x5-orientation” content=”portrait”>    <!--QQ强制竖屏-->
<meta name=”full-screen” content=”yes”>              <!--UC强制全屏-->
<meta name=”x5-fullscreen” content=”true”>       <!--QQ强制全屏-->
<meta name=”browsermode” content=”application”>   <!--UC应用模式-->
<meta name=”x5-page-mode” content=”app”>   <!-- QQ应用模式-->
<meta name=”msapplication-tap-highlight” content=”no”>    <!--windows phone 点击无高亮
设置页面不缓存-->
<meta http-equiv=”pragma” content=”no-cache”>
<meta http-equiv=”cache-control” content=”no-cache”>
<meta http-equiv=”expires” content=”0″>

💬 面试官追问

  • 产品把展厅页面做成多页 PPT,要求每页停留 5 秒后进入同域的下一页,不想维护定时器代码,你会在页面头部写什么?

    可以在当前页的 <head> 中设置 <meta http-equiv="Refresh" content="5; URL=page2.html">,浏览器会在 5 秒后跳到指定页面。每一页都要配置自己的下一页地址;任何一页地址错误或缺失都会中断播放链路,而且这种方式不负责保存页面运行状态。

  • 监控大屏要求每 60 秒整页刷新一次,值班同事却复制了带 URL 的模板,结果页面不断跳走;正确配置是什么?

    定时刷新当前页面时应去掉目标地址,使用 <meta http-equiv="Refresh" content="60">。带上 URL 表示延时跳转而非单纯刷新,因此排查时先检查 content 中是否包含分号后的地址;整页刷新会重建页面,不适合必须保留前端临时状态的界面。

  • 同一套展示页部署到不同域名,content="5; URL=page2.html" 是否需要为每个环境重写完整域名?

    示例中的 page2.html 是同域相对地址,因此同样的页面结构通常不必写死完整域名。部署时仍要保证目标文件与当前路径关系一致,否则相对地址会解析到错误位置;跨域跳转或目录结构不同的环境不能直接套用这一配置。

  • 下线页设置了 Refresh,测试反馈等待 5 秒后仍停在原页,你会按什么顺序检查?

    先确认标签位于文档 <head> 内,并核对 http-equiv="Refresh"、秒数及 URL 的拼写和引号。随后直接访问目标地址,排除文件不存在或相对路径错误;如果页面还有其他跳转逻辑,也要避免多套机制同时控制导航而让现象难以判断。

  • 搜索落地页既要自动轮播,又希望搜索结果准确展示页面摘要,只有 Refresh 标签够吗?

    不够,Refresh 只负责延时刷新或跳转,不提供页面描述信息。还应设置语义清晰的 <meta name="description" content="...">,供搜索引擎提取和展示摘要;描述应反映当前页面内容,不能把导航控制与搜索信息混成一个标签。

# 2 viewport

⚡ 30 秒速记

  • 移动端必写:<meta name="viewport" content="width=device-width, initial-scale=1">
  • width=device-width 让布局视口等于理想视口,不写的话移动端按 980px 渲染再缩小
  • 三个视口要分清:布局视口(CSS 布局的参照)、视觉视口(当前看到的)、理想视口(设备宽度)
  • 不要写 user-scalable=no:禁止缩放是无障碍红线,iOS 10 之后 Safari 也已忽略它
  • 刘海屏适配:viewport-fit=coverenv(safe-area-inset-*)

viewport 用来控制移动端页面的视口宽度、初始缩放比例,以及用户能否手动缩放。 常见写法是设置 width=device-widthinitial-scale=1.0,让页面按设备宽度布局。minimum-scalemaximum-scaleuser-scalable 可以继续限制缩放范围,但要结合交互需求取舍。处理移动端细线时,也可以配合 remtransform: scale(0.5) 做局部缩放。

 <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no" />
    // width    设置viewport宽度,为一个正整数,或字符串‘device-width’
    // device-width  设备宽度
    // height   设置viewport高度,一般设置了宽度,会自动解析出高度,可以不用设置
    // initial-scale    默认缩放比例(初始缩放比例),为一个数字,可以带小数
    // minimum-scale    允许用户最小缩放比例,为一个数字,可以带小数
    // maximum-scale    允许用户最大缩放比例,为一个数字,可以带小数
    // user-scalable    是否允许手动缩放
  • 延伸提问
    • 怎样处理 移动端 1px 被 渲染成 2px问题

局部处理

  • meta标签中的 viewport属性 ,initial-scale 设置为 1
  • rem按照设计稿标准走,外加利用transformscale(0.5) 缩小一倍即可;

全局处理

  • mate标签中的 viewport属性 ,initial-scale 设置为 0.5
  • rem 按照设计稿标准走即可

💬 面试官追问

  • 设计稿标注页面宽度跟随手机屏幕,开发却把 viewportwidth 写成固定数字,横竖屏下布局都不对;你会怎么改?

    应将 width 设置为 device-width,让布局视口宽度跟随设备宽度,并通常配合 initial-scale=1.0。固定宽度会让浏览器按同一个布局视口缩放不同设备上的页面;改完仍需检查 CSS 是否另有固定宽度,否则仅调整 meta 不能解决全部溢出。

  • 活动页要求初始按原尺寸展示,同时允许用户在 13 倍之间缩放,你会怎样调整现有的 user-scalable=no

    应改为允许手动缩放,并设置 initial-scale=1minimum-scale=1maximum-scale=3。这几个值分别控制初始、最小和最大缩放比例,user-scalable=no 与允许缩放的需求直接冲突;范围仍要结合页面布局验证,避免放大后关键操作区域被遮挡。

  • 移动端边框按设计稿写成 1px,在高像素密度设备上视觉却像 2px;只改这个组件时你会怎么处理?

    局部处理可保持 <meta name="viewport">initial-scale=1,页面的 rem 继续按设计稿标准计算,再对细线元素使用 transform: scale(0.5) 缩小。缩放会同时影响元素视觉尺寸和定位关系,因此应把变换限制在边框或专用伪元素上,避免整个业务组件一起缩小。

  • 团队想用 initial-scale=0.5 一次解决全站的移动端细线问题,但站内已有大量按现行 rem 编写的页面,你会直接合并吗?

    不会直接合并,因为把 initial-scale 调为 0.5 属于全局视口策略,所有页面的初始缩放都会受到影响。只有在整站 rem 规则都按对应设计标准统一时才适合采用,并需要完整回归布局;已有页面单位体系不一致时,局部 transform 的影响范围更可控。

  • 线上页面在部分手机上显得异常放大,代码同时设置了 initial-scale=1minimum-scale=1maximum-scale=1;你会先核对哪些配置含义?

    先确认 width 是否为 device-width,再检查三个缩放参数是否确实为合法数字,以及 user-scalable 是否符合产品要求。initial-scale 决定初始比例,另外两个只限定允许的缩放边界,不能替代正确的视口宽度;若配置无误,还要继续检查页面自身的固定尺寸和 rem 基准。

# 3 性能优化

⚡ 30 秒速记

  • HTML 层面的优化点:结构精简、避免深层嵌套、重要内容靠前、少用 iframe
  • 资源提示:preload(本页关键资源)、prefetch(下页可能用到)、preconnect(提前建连)、dns-prefetch
  • 脚本:defer 保序不阻塞解析(首选),async 用于独立第三方脚本
  • 图片必须写 width/heightaspect-ratio,否则加载完顶动布局,CLS 不及格
  • 语义化标签本身就是优化:帮助浏览器、读屏器和搜索引擎更快理解结构

HTML 层面的性能优化,本质上是减少脚本阻塞,并提前完成关键资源的连接或加载。 普通 script 会暂停解析,async 在下载完成后立即执行,defer 和默认的 type="module" 则等文档解析完成后执行。跨域资源可按需要使用 dns-prefetchpreconnectprefetchpreload,其中 preload 的加载确定性更高。搜索场景还应设置 meta description,方便搜索引擎提取页面描述。

性能优化是前端开发中避不开的问题,性能问题无外乎两方面原因:渲染速度慢、请求时间长。性能优化虽然涉及很多复杂的原因和解决方案,但其实只要通过合理地使用标签,就可以在一定程度上提升渲染速度以及减少请求时间

1. script 标签:调整加载顺序提升渲染速度

  • 由于浏览器的底层运行机制,渲染引擎在解析 HTML 时,若遇到 script 标签引用文件,则会暂停解析过程,同时通知网络线程加载文件,文件加载后会切换至 JavaScript 引擎来执行对应代码代码执行完成之后切换至渲染引擎继续渲染页面
  • 在这一过程中可以看到,页面渲染过程中包含了请求文件以及执行文件的时间,但页面的首次渲染可能并不依赖这些文件,这些请求和执行文件的动作反而延长了用户看到页面的时间,从而降低了用户体验。

为了减少这些时间损耗,可以借助 script 标签的 3 个属性来实现。

  • async 属性。立即请求文件,但不阻塞渲染引擎,而是文件加载完毕后阻塞渲染引擎并立即执行文件内容
  • defer 属性。立即请求文件,但不阻塞渲染引擎,等到解析完 HTML 之后再执行文件内容
  • HTML5 标准 type 属性,对应值为“module”。让浏览器按照 ECMA Script 6 标准将文件当作模块进行解析,默认阻塞效果同 defer,也可以配合 async 在请求完成后立即执行。

绿色的线表示执行解析 HTML ,蓝色的线表示请求文件,红色的线表示执行文件

当渲染引擎解析 HTML 遇到 script 标签引入文件时,会立即进行一次渲染。所以这也就是为什么构建工具会把编译好的引用 JavaScript 代码的 script 标签放入到 body 标签底部,因为当渲染引擎执行到 body 底部时会先将已解析的内容渲染出来,然后再去请求相应的 JavaScript 文件

2. link 标签:通过预处理提升渲染速度

在我们对大型单页应用进行性能优化时,也许会用到按需懒加载的方式,来加载对应的模块,但如果能合理利用 link 标签的 rel 属性值来进行预加载,就能进一步提升渲染速度。

  • dns-prefetch。当 link 标签的 rel 属性值为“dns-prefetch”时,浏览器会对某个域名预先进行 DNS 解析并缓存。这样,当浏览器在请求同域名资源的时候,能省去从域名查询 IP 的过程,从而减少时间损耗。下图是淘宝网设置的 DNS 预解析
  • preconnect。让浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作,这包括DNS 解析、TLS 协商、TCP 握手,通过消除往返延迟来为用户节省时间
  • prefetch/preload。两个值都是让浏览器预先下载并缓存某个资源,但不同的是,prefetch 可能会在浏览器忙时被忽略,而 preload 则是一定会被预先下载
  • prerender。浏览器不仅会加载资源,还会解析执行页面,进行预渲染

这几个属性值恰好反映了浏览器获取资源文件的过程,在这里我绘制了一个流程简图,方便你记忆。

3. 搜索优化

  • meta 标签:提取关键信息
    • 通过 meta 标签可以设置页面的描述信息,从而让搜索引擎更好地展示搜索结果。
    • 示例 <meta name="description" content="全球最大的中文搜索引擎、致力于让网民更便捷地获取信息,找到所求。百度超过千亿的中文网页数据库,可以瞬间找到相关的搜索结果。">

💬 面试官追问

  • 首页把统计脚本直接放在 <head>,网络稍慢时正文长期不出现;浏览器遇到普通外链 script 时发生了什么?

    渲染引擎解析到普通外链 script 时会暂停 HTML 解析,等待文件请求完成并切换到 JavaScript 引擎执行,之后才继续渲染。若首屏不依赖该脚本,这段下载和执行时间就会推迟内容出现;脚本若必须同步生成后续 DOM,则不能只为提速而随意改成异步。

  • 页面有两个存在依赖关系的初始化脚本,要求下载期间不阻塞 HTML,并在文档解析完成后按顺序执行,你选 async 还是 defer

    这里应选 defer,它会立即请求文件但不阻塞 HTML 解析,并在文档解析完成后执行。async 会在文件下载完毕后立即打断解析并执行,多个文件的完成先后也可能不符合依赖顺序;依赖脚本使用 async 容易出现偶发初始化失败。

  • 团队把入口改成 <script type="module"> 后又额外加了 async,结果模块在 HTML 尚未解析完时访问节点失败;差异在哪里?

    模块脚本默认具有类似 defer 的阻塞效果,会在 HTML 解析完成后执行;加入 async 后则会在请求完成时尽快执行。入口代码依赖页面节点时不应贸然添加 async,或必须让代码自行等待 DOM 就绪;模块化并不意味着执行时机可以忽略。

  • 首屏字体来自独立 CDN,性能记录显示请求前耗时主要落在域名解析、连接和 TLS 协商,你会配置 dns-prefetch 还是 preconnect

    这种场景更适合对该 CDN 使用 preconnect,因为它可以提前完成 DNS 解析、TCP 握手和 TLS 协商。dns-prefetch 只预先解析域名,覆盖不了后续连接成本;预连接应留给确定很快会访问的关键域名,过度配置会产生没有收益的连接工作。

  • 路由懒加载的下一页资源有时根本没被提前下载,代码使用的是 prefetch;产品要求进入下一页前必须准备好,你会怎么取舍?

    prefetch 在浏览器繁忙时可能被忽略,因此不能把它当作必须完成的下载保证;确定当前流程必需的资源可考虑改用 preloadpreload 会更积极地预先下载,但也会占用当前页面的网络资源,若下一页并非高概率访问,反而可能挤压首屏关键请求。

  • 搜索结果中的页面摘要混乱,开发准备靠增加预加载标签修复;你会把排查方向转到哪里?

    预加载标签解决的是资源获取和渲染时机,不负责搜索结果摘要,应检查页面是否设置了准确的 <meta name="description">。描述内容需要提炼当前页面的关键信息,帮助搜索引擎展示结果;它与 preloadpreconnect 属于不同优化目标,混用不会修复摘要质量。

# 4 如何高效操作DOM

⚡ 30 秒速记

  • 核心矛盾:DOM 操作本身不慢,慢的是它触发的重排和重绘
  • 批量插入用 DocumentFragment 或先拼字符串,只触发一次重排
  • 读写分离:避免在循环里「写样式再读 offsetHeight」,那会强制同步布局(layout thrashing
  • 改样式用切换 class 而不是逐条改 style;改结构前可以先 display:none 脱离渲染树
  • 长列表用虚拟滚动,只渲染可视区;事件用委托代替逐个绑定

高效操作 DOM 的核心是减少频繁访问和修改,因为它既会带来引擎切换,也可能触发重排与重绘。 循环中反复读取节点时,我一般先把节点引用缓存到循环外,避免重复访问 document.body。批量创建元素时,可以先拼好完整的 HTML,再通过一次 innerHTML 更新,减少多次插入。修改尺寸、边距或增删节点可能引发重排,而颜色、背景等变化通常只需重绘,成本并不完全相同。

1. 为什么说 DOM 操作耗时

1.1 线程切换

  • 浏览器为了避免两个引擎同时修改页面而造成渲染结果不一致的情况,增加了另外一个机制,这两个引擎具有互斥性,也就是说在某个时刻只有一个引擎在运行,另一个引擎会被阻塞。操作系统在进行线程切换的时候需要保存上一个线程执行时的状态信息并读取下一个线程的状态信息,俗称上下文切换。而这个操作相对而言是比较耗时的
  • 每次 DOM 操作就会引发线程的上下文切换——从 JavaScript 引擎切换到渲染引擎执行对应操作,然后再切换回 JavaScript 引擎继续执行,这就带来了性能损耗。单次切换消耗的时间是非常少的,但是如果频繁地大量切换,那么就会产生性能问题

比如下面的测试代码,循环读取一百万次 DOM 中的 body 元素的耗时是读取 JSON 对象耗时的 10 倍。

// 测试次数:一百万次
const times = 1000000
// 缓存body元素
console.time('object')
let body = document.body
// 循环赋值对象作为对照参考
for(let i=0;i<times;i++) {
  let tmp = body
}
console.timeEnd('object')// object: 1.77197265625ms

console.time('dom')
// 循环读取body元素引发线程切换
for(let i=0;i<times;i++) {
  let tmp = document.body
}
console.timeEnd('dom')// dom: 18.302001953125ms

1.2 重新渲染

另一个更加耗时的因素是元素及样式变化引起的再次渲染,在渲染过程中最耗时的两个步骤为重排(Reflow)与重绘(Repaint)

浏览器在渲染页面时会将 HTML 和 CSS 分别解析成 DOM 树和 CSSOM 树,然后合并进行排布,再绘制成我们可见的页面。如果在操作 DOM 时涉及到元素、样式的修改,就会引起渲染引擎重新计算样式生成 CSSOM 树,同时还有可能触发对元素的重新排布和重新绘制

  • 可能会影响到其他元素排布的操作就会引起重排,继而引发重绘
    • 修改元素边距、大小
    • 添加、删除元素
    • 改变窗口大小
  • 引起重绘
    • 设置背景图片
    • 修改字体颜色
    • 改变 visibility属性值

了解更多关于重绘和重排的样式属性,可以参看这个网址:https://csstriggers.com/ (opens new window)

2. 如何高效操作 DOM

明白了 DOM 操作耗时之后,要提升性能就变得很简单了,反其道而行之,减少这些操作即可

2.1 在循环外操作元素

比如下面两段测试代码对比了读取 1000 次 JSON 对象以及访问 1000 次 body 元素的耗时差异,相差一个数量级

const times = 10000;
console.time('switch')
for (let i = 0; i < times; i++) {
  document.body === 1 ? console.log(1) : void 0;
}
console.timeEnd('switch') // 1.873046875ms
var body = JSON.stringify(document.body)
console.time('batch')
for (let i = 0; i < times; i++) {
  body === 1 ? console.log(1) : void 0;
}
console.timeEnd('batch') // 0.846923828125ms

2.2 批量操作元素

比如说要创建 1 万个 div 元素,在循环中直接创建再添加到父元素上耗时会非常多。如果采用字符串拼接的形式,先将 1 万个 div 元素的 html 字符串拼接成一个完整字符串,然后赋值给 body 元素的 innerHTML 属性就可以明显减少耗时

const times = 10000;
console.time('createElement')
for (let i = 0; i < times; i++) {
  const div = document.createElement('div')
  document.body.appendChild(div)
}
console.timeEnd('createElement')// 54.964111328125ms
console.time('innerHTML')
let html=''
for (let i = 0; i < times; i++) {
  html+='<div></div>'
}
document.body.innerHTML += html // 31.919921875ms
console.timeEnd('innerHTML')

💬 面试官追问

  • 商品详情页只循环读取一万次 document.body,既没改样式也没增删节点,为什么仍可能明显慢于读取已缓存的普通对象?

    瓶颈不一定来自重排,频繁访问 DOM 本身也需要在 JavaScript 执行与渲染相关能力之间反复交互。应在循环外缓存 document.body 或目标节点,循环内复用引用;但缓存节点只能减少重复查询,无法消除后续样式修改引起的渲染成本。

  • 数据看板循环处理一千个卡片,每轮先读 offsetHeight,再写 style.height,页面滚动时持续卡顿,你会怎样重构?

    交替读写会让前一次修改影响布局,随后的尺寸读取可能迫使浏览器立即完成样式和布局计算。先批量读取高度并保存,再集中写入样式,必要时把写入安排到 requestAnimationFrame;若每帧仍处理大量节点,还要减少参与渲染的元素数量。

  • 后台表格一次追加一万个 <tr>,开发者在循环里反复 appendChild,另一位坚持拼字符串后一次写入 innerHTML,你如何判断?

    两种写法的关键差异是提交给页面的次数,先在内存中完成构造再一次更新通常更符合批量操作原则。纯静态且内容可信时可一次赋值 innerHTML,需要保留节点引用或逐项绑定行为时更适合离线创建后统一挂载;用户输入未经处理时不能直接拼入 HTML

  • 线上筛选器只改了文字颜色却出现闪烁,团队直接认定发生了重排,你会从哪里纠正并排查?

    文字颜色变化通常只要求重绘,不能仅凭视觉闪烁断定发生重排;尺寸、边距、节点增删等才更可能改变元素排布。应在性能面板查看样式计算、布局与绘制记录,并定位同一交互中是否还读取了布局属性或修改了几何样式。

  • 消息列表从一千条增长到十万条,已经把节点创建改成一次批量插入,首屏仍然很慢,继续优化 innerHTML 还有意义吗?

    批量插入只能减少操作次数,十万个节点仍会扩大样式计算、排布和绘制的工作量,继续微调字符串拼接通常不是核心矛盾。应分页或虚拟化,只保留视口附近节点;代价是滚动定位、动态高度和可访问性处理会更复杂。

# 三、CSS基础

# 1 盒模型

⚡ 30 秒速记

  • 盒子由内到外四层:contentpaddingbordermargin
  • 标准盒模型 content-boxwidth 只算内容区,实际占位 = width + padding×2 + border×2
  • IE 怪异盒模型 border-boxwidth 包含 paddingborder,实际占位就等于 width
  • box-sizing 切换;现代项目基本全局设 border-box
  • 为什么全局设:改 padding 不会撑破布局,百分比宽度 + padding 的多栏布局才不会塌

盒模型从内到外由 contentpaddingbordermargin 组成,元素最终占用多大空间取决于 box-sizing 默认的 content-box 中,声明的 width 只表示内容宽度,总宽度还要加上内边距、边框和外边距。使用 border-box 时,width 已包含 paddingborder,因此总宽度只需再计算 margin。如果设置为 inherit,元素会沿用父元素的盒模型规则。

content(元素内容) + padding(内边距) + border(边框) + margin(外边距)

延伸:box-sizing

  • content-box:默认值,总宽度 = margin + border + padding + width
  • border-box:盒子宽度包含 paddingborder总宽度 = margin + width
  • inherit:从父元素继承 box-sizing 属性

💬 面试官追问

  • 登录页输入框写了 width: 200px; padding: 0 12px; border: 1px solid,验收量到的占用宽度却是 226px,是哪一层计算错了?

    默认 content-box 中的 200px 只表示内容区,左右内边距增加 24px,左右边框再增加 2px,所以边框盒宽度是 226px。改为 box-sizing: border-box 后,内边距和边框被计入声明宽度,但外边距仍在盒子之外。

  • 组件库要求所有表单控件按设计稿总宽度布局,你会怎样设置 box-sizing,又为什么要覆盖伪元素?

    可在根元素声明 box-sizing: border-box,再让普通元素及 ::before::after 通过 inherit 继承,统一宽度口径。伪元素也可能带内边距和边框,遗漏后会出现局部计算差异;接入依赖 content-box 的旧组件时需为其作用域显式恢复。

  • 一个 width: 100% 的侧栏在窄屏仍横向溢出,它还有左右 paddingbordermargin,切成 border-box 就一定能修好吗?

    border-box 只把内边距和边框纳入 100%,左右外边距依然额外占据空间,因此仍可能超过父容器。应同时检查 margin、父级可用宽度和内容最小尺寸;若内容本身不可收缩,单改盒模型也不会消除溢出。

  • 线上卡片声明 width: 320px,开发者工具显示内容区不足 320px,团队怀疑浏览器算错,你会核对哪些属性?

    先看计算后的 box-sizing;若为 border-box,声明宽度代表边框盒,内容区会扣除左右 paddingborder。再检查规则是否由全局继承、组件样式或伪元素覆盖,并分别读取内容盒与边框盒尺寸,避免把不同测量口径混为一谈。

  • 设计系统在 content-boxborder-box 之间选全局默认值,表单、栅格和第三方富文本组件同时存在,你怎么取舍?

    自有组件按设计稿总宽度布局时,border-box 能减少增加内边距或边框后尺寸意外膨胀,通常更易维护。切换全局默认值会改变既有组件的尺寸语义,应先做视觉回归,并给依赖 content-box 的第三方子树设置明确边界;盒模型统一不等于可以忽略外边距。

# 2 BFC

⚡ 30 秒速记

  • 定义:BFC 是一块独立的布局区域,内部布局不影响外部
  • 三个可见后果:不与浮动元素重叠(两栏布局)、计算高度时包含浮动子元素(清浮动)、内部 margin 不与外部合并(防塌陷)
  • 触发方式记四个:overflowvisibledisplay:flow-rootfloatnoneposition:absolute/fixed
  • flex/grid 容器的子项也自带独立格式化上下文
  • 首选 display:flow-root —— 它就是为触发 BFC 而生的,没有 overflow:hidden 的裁剪副作用

BFC 是独立的块级布局区域,内部元素的布局不会直接影响外部元素。 它可由根元素、浮动元素、绝对或固定定位,以及特定的 display、非 visibleoverflow 等方式触发。它不会与外部浮动区域重叠,计算高度时也会包含浮动子元素,所以常用于清除内部浮动或实现自适应两栏布局。需要注意,同一 BFC 内相邻块的垂直 margin 仍会重叠,要隔离它们需建立不同的 BFC

块级格式化上下文,是一个独立的渲染区域,让处于 BFC 内部的元素与外部的元素相互隔离,使内外元素的定位不会相互影响。

IE下为 Layout,可通过 zoom:1 触发

触发条件:

  • 根元素
  • position: absolute/fixed
  • display: inline-block / table
  • float 元素
  • ovevflow !== visible

规则:

  • 属于同一个 BFC 的两个相邻 Box 垂直排列
  • 属于同一个 BFC 的两个相邻 Boxmargin 会发生重叠
  • BFC 中子元素的 margin box 的左边, 与包含块 (BFC) border box的左边相接触 (子元素 absolute 除外)
  • BFC 的区域不会与 float 的元素区域重叠
  • 计算 BFC 的高度时,浮动子元素也参与计算
  • 文字层不会被浮动层覆盖,环绕于周围

应用:

  • 阻止margin重叠
  • 可以包含浮动元素 —— 清除内部浮动(清除浮动的原理是两个div都位于同一个 BFC 区域之中)
  • 自适应两栏布局
  • 可以阻止元素被浮动元素覆盖

💬 面试官追问

  • 订单页两个垂直卡片分别有 margin-bottom: 20pxmargin-top: 30px,实际间距只有 30px,为什么简单相加不成立?

    两个卡片处于同一 BFC 且垂直相邻时,上下外边距会发生重叠,因此不能按 50px 计算。工程上优先让间距只由一侧负责,或用父级布局统一控制;额外创建 BFC 虽能隔离重叠,却会改变布局边界。

  • 资讯流容器内部全是浮动卡片,父容器高度变成零,后面的页脚顶了上来,你会如何让父级重新包住内容?

    让父容器建立 BFC 后,计算其高度时会把浮动子元素纳入,从而恢复对页脚的正常推开。可根据约束选择 display: flow-root,旧代码也常见非 visibleoverflow;后者可能裁剪阴影、菜单或其他溢出内容。

  • 两栏页面左侧头像 float: left,右侧正文背景却伸到头像下面,产品要求正文区域始终不与头像重叠,你会改哪一层?

    可让右侧正文建立新的 BFC,其区域不会与浮动元素的区域重叠,从而形成自适应剩余宽度的两栏效果。触发方式会附带不同布局语义,例如 overflow: hidden 可能裁剪内容,position: absolute 又会脱离常规文档流,不能只为触发而盲选。

  • 线上下拉菜单被截断,排查发现祖先为清除内部浮动设置了 overflow: hidden,怎样确认并修复而不让容器再次塌陷?

    先临时关闭该祖先的 overflow,确认菜单恢复且父容器高度随之丢失,就能验证裁剪与包含浮动来自同一规则。可改用 display: flow-root,或使用清除浮动的伪元素方案;若必须兼容特定旧环境,则需结合兼容范围选择替代实现。

  • 同事提议给所有模块都加 overflow: hidden 来隔离布局,你会接受这种全局策略吗?

    不应把 overflow: hidden 当作无成本的布局重置,它虽然能建立 BFC,也会改变溢出内容的可见性。只在确实需要包含浮动、阻止外边距重叠或避开浮动区域的局部容器使用,并优先选择副作用符合需求的触发方式。

# 3 层叠上下文

⚡ 30 秒速记

  • 层叠上下文决定元素在 z 轴上的堆叠顺序,子元素永远被限制在父层叠上下文内部
  • 创建方式:根元素、positionstaticz-indexautoopacity < 1transformfilterwill-changeflex/grid 子项且 z-indexauto
  • 层叠顺序从下往上:背景边框 → 负 z-indexblockfloatinlinez-index:0 → 正 z-index
  • 高频坑:父元素设了 opacity:0.99transform,子元素的 z-index 再大也压不过父元素的兄弟
  • 同理 position:fixed 遇到祖先有 transform 会退化成相对该祖先定位

z-index 只在同一个层叠上下文中比较,并不是数值越大全局层级就越高。 根元素会创建层叠上下文,定位元素以及带有 transformopacityfilter 等属性的元素也可能形成新的上下文。子元素的 z-index 再大,也无法直接越过父级上下文与外部元素竞争。遇到弹窗被遮挡时,我一般先检查祖先元素是否创建了独立层叠上下文。

元素提升为一个比较特殊的图层,在三维空间中 (z轴) 高出普通元素一等。

触发条件

  • 根层叠上下文(html)
  • position
  • css3属性
    • flex
    • transform
    • opacity
    • filter
    • will-change
    • webkit-overflow-scrolling

层叠等级:层叠上下文在z轴上的排序

  • 在同一层叠上下文中,层叠等级才有意义
  • z-index的优先级最高

💬 面试官追问

  • 支付弹窗设置了 position: fixed; z-index: 9999,仍被导航栏的 z-index: 10 盖住,为什么数值更大却输了?

    z-index 只在所属层叠上下文内比较,弹窗若被包在较低层级的祖先上下文中,9999 无法越过祖先与导航栏竞争。应检查祖先是否由 transformopacityfilterwill-change 等创建上下文,通常把弹窗挂到顶层容器更稳妥。

  • 动画组件为了启用位移给外壳加了 transform: translateZ(0),结果内部提示气泡被相邻卡片遮住,你会怎样落地修复?

    外壳的 transform 会创建新的层叠上下文,气泡的层级被限制在该上下文内,提高气泡自身的 z-index 未必有效。可在动画结束后移除不再需要的 transform,或把气泡通过 Portal 挂到更高层;迁移后还要重新处理定位参照和滚动跟随。

  • 设计师要求半透明容器使用 opacity: .9,同时要求里面的下拉层越过页面顶栏,这两个约束冲突时怎么处理?

    容器的 opacity 会形成层叠上下文,内部下拉层无法仅靠增大 z-index 跨越外部顶栏。可把透明效果放到独立背景层,避免作用于承载下拉层的祖先,或将下拉层移到顶层;后者会增加坐标换算、焦点管理和事件边界成本。

  • 线上只有某个活动页的浮层被遮挡,代码里已经写了 z-index,你会如何快速找到真正限制它的祖先?

    从浮层开始逐级检查祖先的计算样式,重点查看定位与 z-index,以及 transformopacityfilterwill-change 等可能创建层叠上下文的属性。再对照兄弟区域所属的上下文逐层比较,而不是只看两个元素的数值;临时关闭可疑规则可辅助验证。

  • 团队准备建立全站 z-index 数值表,有人主张弹窗统一用 999999,这套方案能彻底避免遮挡吗?

    统一层级令牌只能规范同一层叠上下文内的排序,无法突破祖先上下文的边界,因此极大数值不能根治遮挡。更可靠的方案是规定弹层挂载位置和顶层上下文结构,再为遮罩、弹窗、提示等分配有限层级;集中挂载也会带来定位与可访问性管理责任。

# 4 左右居中方案

⚡ 30 秒速记

  • 行内/行内块:父元素 text-align: center
  • 定宽块级:margin: 0 auto
  • Flex:父元素 display:flex + justify-content:center(首选)
  • Gridplace-items: centerjustify-items: center
  • 绝对定位:left:50% + transform: translateX(-50%)(不需要知道宽度)

左右居中要根据元素类型选择:行内元素用 text-align: center,定宽块用 margin: 0 auto,不定宽元素可用定位配合位移。 text-align 应写在父元素上,因为它控制的是内部行内内容的对齐。块级元素宽度确定后,左右 margin 设为 auto 就能平分剩余空间。如果元素宽度未知,可以用 left: 50%transform: translateX(-50%),避免手算负边距。

  • 行内元素: text-align: center
  • 定宽块状元素: 左右 margin 值为 auto
  • 不定宽块状元素: table布局,position + transform
/* 方案1 */
.wrap {
  text-align: center
}
.center {
  display: inline;
  /* or */
  /* display: inline-block; */
}
/* 方案2 */
.center {
  width: 100px;
  margin: 0 auto;
}
/* 方案2 */
.wrap {
  position: relative;
}
.center {
  position: absulote;
  left: 50%;
  transform: translateX(-50%);
}

💬 面试官追问

  • 工具栏里的提示文字是行内元素,开发者给它写了 margin: 0 auto 却没有居中,页面结构不允许改,你会改哪条规则?

    行内内容应由包含块设置 text-align: centermargin: 0 auto 不是这种场景的居中机制。该规则会同时影响容器内其他行内内容,因此若工具栏还有左对齐按钮,需要增加独立包装层或缩小样式作用范围。

  • 设置页有一个明确为 320px 宽的块级表单,需要在父容器中水平居中,团队在 flex 和自动外边距之间争论,你选哪个?

    仅需居中一个定宽块时,给表单设置 width: 320px; margin: 0 auto 就足够,语义直接且不改变父级布局模式。若父级还要统一排列多个子项,flex 才更有价值;固定宽度在窄屏可能溢出,还需配合响应式宽度约束。

  • 搜索页的按钮组宽度随文案和语言变化,不能预先写死,父容器又不能改成 flex,怎样保证它始终水平居中?

    可让按钮组采用收缩到内容宽度的布局方式,再使用自动外边距;也可在相对定位父级中用 left: 50% 配合 transform: translateX(-50%)。绝对定位方案会脱离常规文档流,父级高度和相邻内容不能再依赖它自然撑开。

  • 角标使用 position: absolute; left: 50% 后总是向右偏半个自身宽度,设计稿宽度还会动态变化,你会怎么修?

    left: 50% 只把角标左边缘放到父容器中线,因此视觉中心必然偏右。补上 transform: translateX(-50%),位移百分比按元素自身宽度计算,可适配动态宽度;同时要确认父级提供了正确的定位包含块。

  • 弹层标题用 position + transform 做水平居中后,内部提示层的 z-index 突然压不过相邻区域,你如何权衡居中方案?

    用于居中的 transform 会创建层叠上下文,内部提示层可能因此无法跨越外部上下文。若标题只是普通流内内容,可改用 text-align、自动外边距或父级 flex 居中;若必须绝对定位,则应调整提示层挂载位置,而不是无限增大其 z-index

# 5 上下垂直居中方案

⚡ 30 秒速记

  • Flexalign-items: center(首选)
  • Gridplace-items: center 一行同时搞定水平垂直
  • 绝对定位:top:50% + transform: translateY(-50%),不需要知道高度
  • 单行文字:line-height 等于容器高度(只对单行有效,多行会溢出)
  • 表格法:父元素 display:table,子元素 display:table-cell + vertical-align:middle

垂直居中主要看元素高度是否已知:定高可用外边距或定位加负边距,不定高更适合 transformflexvertical-align 已知高度时,把元素移动到 top: 50% 后再回退自身高度的一半即可。高度未知时,transform: translateY(-50%) 不依赖具体尺寸,父元素使用 display: flexalign-items: center 也更直接。兼容传统行内布局时,还可以借助 inline-blockvertical-align: middle

  • 定高:marginposition + margin(负值)
  • 不定高:position + transformflexIFC + vertical-align:middle
/* 定高方案1 */
.center {
  height: 100px;
  margin: 50px 0;
}
/* 定高方案2 */
.center {
  height: 100px;
  position: absolute;
  top: 50%;
  margin-top: -25px;
}
/* 不定高方案1 */
.center {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}
/* 不定高方案2 */
.wrap {
  display: flex;
  align-items: center;
}
.center {
  width: 100%;
}
/* 不定高方案3 */
/* 设置 inline-block 则会在外层产生 IFC,高度设为 100% 撑开 wrap 的高度 */
.wrap::before {
  content: '';
  height: 100%;
  display: inline-block;
  vertical-align: middle;
}
.wrap {
  text-align: center;
}
.center {
  display: inline-block;
  vertical-align: middle;
}

💬 面试官追问

  • 活动页把一段可能换成三行的文案写成固定 height,再用上下 margin 居中;运营改字后文案偏到下方,你会保留这套写法吗?

    不会保留依赖定高的 margin 方案,因为内容高度变化后,原先计算的上下间距不再成立。应改用父容器 display: flex; align-items: center,或用绝对定位配合 top: 50%transform: translateY(-50%);前者更适合正常文档流。

  • 弹窗主体高度由接口返回内容决定,现有代码是 position: absolute; top: 50%; margin-top: -150px,短内容正常、长内容上移,你会怎样改?

    把固定负 margin 改成 transform: translateY(-50%),位移比例会依据元素自身实际高度计算,不需要预先知道高度。若弹窗还要水平居中,可同时使用 left: 50%translate(-50%, -50%);内容超过视口时仍需另设最大高度和滚动策略。

  • 一个老专题页不能改浮动和定位结构,父容器高度固定,要求让未知高度的提示块垂直居中,你会怎样用 inline-blockvertical-align 落地?

    可让父容器的 ::before 成为高度 100%inline-block,再让它和提示块都设置 vertical-align: middle。这个伪元素负责撑出可对齐的行内格式化上下文,提示块也必须是 inline-block;空白字符和基线间隙可能影响细节,需要检查模板换行。

  • 列表空状态用了 display: flex; align-items: center,但上线后仍贴着容器顶部;检查发现父节点没有可用高度,你会从哪里排查?

    先确认执行居中的 .wrap 是否真的具有明确高度或被上级布局拉伸,align-items: center 只能在已有的交叉轴剩余空间内分配位置。再检查样式是否被覆盖以及空状态是否为该容器的直接子项;父容器高度随内容收缩时,换任何居中语法都看不出效果。

  • 表单行高度固定为 100px,设计坚持用负 margin 居中,开发希望统一换成 flex;你会如何取舍?

    固定高度且子项高度也稳定时,position 配合负 margin 能成立,但尺寸一改就必须同步更新偏移量。组件会复用或内容可能变化时更适合 flex,只需在父项设置 align-items: center;若绝对定位是为了脱离文档流,则保留定位并改用 transform 更稳妥。

# 6 选择器权重计算方式

⚡ 30 秒速记

  • 权重是 (a, b, c) 三元组a = id 数、b = 类/属性/伪类数、c = 元素/伪元素数
  • 从左往右逐位比较,高位一票否决低位 —— 256 个 class 也赢不了 1 个 id,不存在「进位」
  • 三元组之上还有两层:!important > 内联样式 > 普通规则
  • 完全相等时后写的覆盖先写的;同来源下还要看 @layer 的顺序
  • :not()/:is()/:has() 自身不计权重但参数计;:where() 强制归零,做样式库默认值很好用

选择器权重可以按 ID、类及属性、元素三个层级比较,先比较高层级,完全相同时后写的规则覆盖先写的。 !important 的优先级高于普通声明,元素的内联 style 也高于一般的外部规则。通配符以及后代、兄弟等关系符本身不会带来更高权重,继承样式通常也排在直接指定的规则之后。选择器匹配通常从右向左解析,这样能更早过滤不符合条件的元素。

!important > 内联样式 = 外联样式 > ID选择器 > 类选择器 = 伪类选择器 = 属性选择器 > 元素选择器 = 伪元素选择器 > 通配选择器 = 后代选择器 = 兄弟选择器

  1. 属性后面加!import会覆盖页面内任何位置定义的元素样式
  2. 作为style属性写在元素内的样式
  3. id选择器
  4. 类选择器
  5. 标签选择器
  6. 通配符选择器(*
  7. 浏览器自定义或继承

同一级别:后写的会覆盖先写的

css选择器的解析原则:选择器定位DOM元素是从右往左的方向,这样可以尽早的过滤掉一些不必要的样式规则和元素

💬 面试官追问

  • 组件页中 .dialog .title 写在文件前面,后面的 h2 仍然覆盖不了字号;同事认为“后写一定赢”,你怎么指出判断漏洞?

    后写覆盖先写只发生在同一级权重下,.dialog .title 含两个类选择器,权重高于单个元素选择器 h2。应先比较 !important、内联样式、ID、类或伪类或属性、元素或伪元素等层级,再比较源码顺序;继承值也不能击败直接命中的声明。

  • 线上主题样式盖不住节点上的 style="color:red",维护者准备给主题选择器再加三个类名,你会接受吗?

    继续堆类名通常仍盖不过内联样式,因为内联声明的优先级高于普通的 ID、类和元素选择器。应优先移除或改造内联样式的生成位置;若确实无法控制且业务必须覆盖,只能谨慎使用 !important,代价是后续覆盖会更困难。

  • 表格有一万行,样式规则写成 .page .content table tbody tr td.warning;评审既担心权重失控,也担心匹配路径过长,你会怎样调整?

    把规则收敛为靠近目标节点的稳定类名,例如 .warning-cell,既减少无意义的祖先约束,也避免累积过高的类和元素权重。浏览器定位选择器时按从右向左匹配,右端应尽量有明确目标;是否形成可见性能问题仍应以页面测量为准,不能仅凭行数断言。

  • 灰度发布后只有一部分按钮变色,开发工具里两条规则都是 .panel .button,一条来自主题包、一条来自业务包;你怎么定位是谁赢了?

    两条选择器权重相同,应查看最终加载后的源码顺序,后出现的声明会覆盖先出现的声明。还要核对属性是否带 !important、元素是否存在内联样式,以及选择器是否确实命中同一节点;构建产物的样式注入顺序变化也会改变结果。

  • 设计系统想让业务方容易覆盖默认样式,但业务团队又要求禁止 !important;你会怎样制定选择器约束?

    默认规则应使用短而稳定的类选择器,避免叠加 ID、多层祖先和标签来制造不必要的权重。业务覆盖时保持同级或略高权重,并依靠明确的加载顺序;这样符合层叠规则,也降低双方不断加选择器的对抗成本,但仍需统一样式入口顺序。

# 7 清除浮动

⚡ 30 秒速记

  • 伪元素法(推荐):.clearfix::after { content:''; display:block; clear:both }
  • 触发 BFC:父元素加 overflow:hidden(会裁剪内容)或 display:flow-root(最干净)
  • divclear:both:能用但污染结构,不要写
  • 父元素定高:只适用于高度已知的场景
  • 今天的真实答案:布局用 FlexGrid,压根不会产生浮动塌陷

清除浮动的目的,是让父元素能够正常包住浮动的子元素。 可以在浮动元素后添加带有 clear: both 的空 div,但这会引入没有实际语义的标签。也可以给父元素设置 overflow: hiddenauto 来触发 BFC。我一般更倾向于使用 clearfix 伪元素,因为它同样通过 clear: both 生效,又能保持文档结构清晰;兼容 IE6 时再补 zoom: 1

  1. 在浮动元素后面添加 clear:both的空 div 元素
<div class="container">
    <div class="left"></div>
    <div class="right"></div>
    <div style="clear:both"></div>
</div>
  1. 给父元素添加 overflow:hidden 或者 auto 样式,触发BFC
<div class="container">
    <div class="left"></div>
    <div class="right"></div>
</div>
.container{
    width: 300px;
    background-color: #aaa;
    overflow:hidden;
    zoom:1;   /*IE6*/
}
  1. 使用伪元素,也是在元素末尾添加一个点并带有 clear: both 属性的元素实现的。
<div class="container clearfix">
    <div class="left"></div>
    <div class="right"></div>
</div>
.clearfix{
    zoom: 1; /*IE6*/
}
.clearfix:after{
    content: ".";
    height: 0;
    clear: both;
    display: block;
    visibility: hidden;
}

推荐使用第三种方法,不会在页面新增div,文档结构更加清晰

💬 面试官追问

  • 资讯页左右两栏都设了 float,父容器背景高度却变成 0,有人只给父容器补一个固定高度;你会同意吗?

    不同意用固定高度掩盖塌陷,因为浮动子项脱离普通文档流,内容增加后仍会溢出。应在末尾清除浮动,或让父元素通过 overflow: hiddenauto 形成 BFC;固定高度只适合内容尺寸被严格约束的少数场景。

  • 老后台几十个列表都存在父级塌陷,模板团队不允许在每个列表末尾插入空 div,你会怎样统一修复?

    给这些父容器统一挂 .clearfix,使用 ::after 生成块级伪元素并设置 clear: both,即可在末尾清除浮动。它不新增无语义的真实节点,文档结构更清晰;若仍需兼容非常旧的环境,源码中的 zoom: 1 属于针对旧版 IE 的兼容处理。

  • 卡片父级用 overflow: hidden 清浮动后,子元素阴影被截断;结构不能加真实节点时,你会怎么改?

    应撤掉用于清浮动的 overflow: hidden,改用父元素的 ::after 配合 contentdisplay: blockclear: bothoverflow 方案通过形成 BFC 包含浮动,但同时会裁剪超出边界的视觉内容;伪元素法更适合需要保留阴影或外溢效果的容器。

  • 线上页脚压到浮动商品列表上方,开发工具显示 .clearfix:after 存在却没有撑开父级;你会逐项检查哪些声明?

    先检查伪元素是否实际生成,content 缺失时 ::after 不会产生可用于清除的盒子。再确认它具有 display: blockclear: both,并排除规则被覆盖;若清除节点不在浮动元素所属的同一父容器末尾,也无法修复目标父级的高度。

  • 维护者在“空 div 清除”“父级 overflow: auto”“伪元素清除”之间争论,页面还要求结构简洁且不能出现额外滚动条,你会选哪一个?

    优先选伪元素清除,因为它不增加真实 div,也不会像 overflow: auto 那样可能引入滚动或改变溢出表现。空节点法直观但污染结构,overflow 法代码短却附带裁剪或滚动语义;只有这些副作用符合容器需求时才应采用后两者。

# 8 左边定宽,右边自适应方案

⚡ 30 秒速记

  • Flex(首选):父 display:flex,左 flex: 0 0 200px,右 flex: 1
  • Gridgrid-template-columns: 200px 1fr,一行搞定
  • 老方法:左 float:left + 定宽,右 margin-left: 200px
  • 更老的:左 absolute,右 padding-left
  • 高频坑:flex:1 的子项要加 min-width: 0,否则内容过长时不会收缩(flex 子项默认 min-width:auto

左边定宽、右边自适应,可以让左侧设置固定宽度并左浮动,再由右侧占据剩余空间。 常见写法是左侧 width: 120pxfloat: left,右侧通过 margin-left: 120px 留出位置。另一种方式是让两边都浮动,并把右侧宽度写成 calc(100% - 120px)。前一种写法更直接,后一种适合确实需要明确计算右侧宽度的场景。

float + margin,float + calc

/* 方案1 */
.left {
  width: 120px;
  float: left;
}
.right {
  margin-left: 120px;
}
/* 方案2 */
.left {
  width: 120px;
  float: left;
}
.right {
  width: calc(100% - 120px);
  float: left;
}

💬 面试官追问

  • 管理台左栏固定 120px 并浮动,右栏也写了 width: 100%,结果被挤到下一行;同事说右栏本来就是“占满剩余空间”,错在哪里?

    width: 100% 按包含块宽度计算,并不会自动扣除左侧浮动的 120px,两栏总宽度因此可能超过容器。可让右栏不设宽度而使用 margin-left: 120px,或把右栏宽度明确写成 calc(100% - 120px) 并配合浮动。

  • 老门户页必须保留 float,侧栏固定 120px,右侧文章要随窗口伸缩;你会怎样写最少且稳定的布局规则?

    让左栏设置 width: 120px; float: left,右栏设置 margin-left: 120px,即可把右侧可用区域留出来。右栏宽度由普通块级布局自动填充,通常不必再计算;若左右间还要留缝,应把间距一并计入 margin-left

  • 产品把侧栏从 120px 改成可配置值,但现有 float + calc 在多处各写了一遍宽度;你会怎样避免两边配置失配?

    左栏宽度和右栏扣减值必须来自同一个尺寸约束,否则任一处漏改都会产生空隙或换行。可统一维护该值,再用于左栏 width 与右栏的 calc(100% - 固定值);如果无需显式控制右栏宽度,改用等值 margin-left 会减少一处计算。

  • 发布后右栏偶尔掉到左栏下方,只有容器加了边框和内边距的页面复现;你会怎样排查 calc(100% - 120px)

    先检查两栏最终占用宽度是否还包含边框、内边距或额外外边距,因为这些尺寸可能让总和超过父容器。再核对两栏是否都成功浮动、calc 是否被覆盖,以及父级可用宽度;必要时调整盒模型或把额外间距纳入计算,避免临界宽度换行。

  • 代码评审中,一方主张右栏用 margin-left: 120px,另一方坚持 float + calc;右栏只是普通文章流,你会选哪套?

    普通文章流优先用左栏浮动、右栏等值 margin-left,它不要求右栏也浮动,规则更少且能自然占据剩余宽度。float + calc 适合确实需要明确右栏宽度或两栏都参与浮动的结构,但尺寸、间距和盒模型必须同步计算,维护成本更高。

# 9 左右两边定宽,中间自适应

⚡ 30 秒速记

  • Flex:两侧 flex: 0 0 200px,中间 flex: 1
  • Gridgrid-template-columns: 200px 1fr 200px
  • 经典老方案:圣杯布局(padding + 负 margin + relative)、双飞翼布局(中间套一层 divmargin
  • 两者的区别:圣杯用父容器 padding 留位,双飞翼用内层 divmargin 留位
  • 这两个布局今天只当历史知识,面试提一句「Grid 一行代码就够」更有说服力

左右两边定宽、中间自适应,可以用浮动配合外边距,也可以直接用 flex 浮动方案让左右分别靠两侧排列,中间通过 margin: 0 120px 留出空间;需要明确宽度时,也能用 calc(100% - 240px) 计算。更简洁的写法是父容器设置 display: flex,左右固定为 120px,中间设置 flex: 1。具体选择取决于现有布局方式,维护旧浮动页面时不必强行改写。

float,float + calc, 圣杯布局(设置BFC,margin负值法),flex

.wrap {
  width: 100%;
  height: 200px;
}
.wrap > div {
  height: 100%;
}
/* 方案1 */
.left {
  width: 120px;
  float: left;
}
.right {
  float: right;
  width: 120px;
}
.center {
  margin: 0 120px;
}
/* 方案2 */
.left {
  width: 120px;
  float: left;
}
.right {
  float: right;
  width: 120px;
}
.center {
  width: calc(100% - 240px);
  margin-left: 120px;
}
/* 方案3 */
.wrap {
  display: flex;
}
.left {
  width: 120px;
}
.right {
  width: 120px;
}
.center {
  flex: 1;
}

💬 面试官追问

  • 直播页左右栏各 120px,中间栏写了 width: 100%,上线后被挤到下一行;负责人认为左右浮动不会占普通流宽度,你怎么解释并修正?

    浮动虽脱离普通文档流,但中间栏的 100% 仍按父容器完整宽度计算,无法与两侧共同容纳。可让左右分别 float: leftfloat: right,中间使用 margin: 0 120px;也可显式计算中间宽度,但必须把两侧总宽扣除。

  • 新闻详情页要求左右广告栏固定 120px,中间正文自适应,现有 float 结构不能重写;你会给出什么落地代码?

    左栏设 width: 120px; float: left,右栏设 width: 120px; float: right,中间正文设 margin: 0 120px。中间不必固定宽度,会由块级布局占据扣除外边距后的空间;还要保证页面中的排列和清浮动规则与现有结构相容。

  • 设计把右栏改为 160px、左栏仍为 120px,原来的 .center { margin: 0 120px } 出现遮挡,你会怎样调整而不是继续套对称值?

    中间栏应改为 margin-left: 120px; margin-right: 160px,分别为两侧保留实际宽度。若采用 calc,中间宽度也要扣除总计 280px,不能沿用 100% - 240px;侧栏还有额外间距时同样必须纳入约束。

  • 一个三栏页面在窄窗口下中间栏消失,开发工具显示 .center { width: calc(100% - 240px); margin-left: 120px },你会先查哪些尺寸关系?

    先确认父容器实际可用宽度是否小于两侧合计 240px,以及边框、内边距、外边距是否使三栏总占用继续增加。再检查左右浮动方向和中间宽度是否被其他规则覆盖;该方案没有为极窄容器定义降级行为,必要时必须另设最小宽度或重排布局。

  • 新建后台的三栏框架不受旧浏览器约束,团队在 float + marginfloat + calcflex 之间选型,你会推荐哪一种?

    推荐父容器使用 display: flex,左右栏固定 width: 120px,中间栏设 flex: 1,约束关系直接且无需负担浮动清除。旧结构难改时,float + margin 比显式 calc 少一层宽度计算;若中间内容存在不可收缩项,仍需另外处理溢出风险。

  • 三栏容器改成 flex 后,中间长表格把右侧固定栏顶出屏幕;这和“中间 flex: 1 自适应”是否矛盾,你怎么排查?

    不矛盾,flex: 1 只声明中间项分配剩余空间,内部内容仍可能提出更大的最小尺寸需求。应检查长表格或不换行内容的溢出约束,并确保左右固定栏没有被允许意外收缩;浮动方案同样需要面对超长内容,只是故障表现可能不同。

# 10 CSS动画和过渡

⚡ 30 秒速记

  • transition:状态 AB 的补间,需要触发条件,只能跑一次
  • animation + @keyframes:多关键帧、能循环、能自动播放,不需要触发条件
  • 关键属性:durationtiming-functiondelayiteration-countdirectionfill-modeforwards 保持终态,最容易忘)
  • 性能:只对 transformopacity 做动画能走合成层GPU;改 width/left 会逐帧重排
  • 必须配 @media (prefers-reduced-motion: reduce) 关掉动画,这是无障碍要求

transition 适合属性状态变化时的平滑过渡,animation 配合 @keyframes 则能描述完整的多阶段动画。 transition 主要控制目标属性、持续时间、速度曲线和延迟;animation 还可以设置播放次数、方向及结束后的状态,并通过 animationend 监听完成。关键帧既能写成 fromto,也能用百分比安排多个阶段。实现位移、旋转或缩放时,我会优先使用 transform,简单显隐可用 opacity,通常更适合连续动画。

animation / keyframes

  • animation-name: 动画名称,对应@keyframes
  • animation-duration: 间隔
  • animation-timing-function: 曲线
  • animation-delay: 延迟
  • animation-iteration-count: 次数
    • infinite: 循环动画
  • animation-direction: 方向
    • alternate: 反向播放
  • animation-fill-mode: 静止模式
    • forwards: 停止时,保留最后一帧
    • backwards: 停止时,回到第一帧
    • both: 同时运用 forwards / backwards
  • 常用钩子: animationend

动画属性: 尽量使用动画属性进行动画,能拥有较好的性能表现

  • translate
  • scale
  • rotate
  • skew
  • opacity
  • color

transform

  • 位移属性 translate( x , y )
  • 旋转属性 rotate()
  • 缩放属性 scale()
  • 倾斜属性 skew()

transition

  • transition-property(过渡的属性的名称)。
  • transition-duration(定义过渡效果花费的时间,默认是 0)。
  • transition-timing-function:linear(匀速) ease(慢速开始,然后变快,然后慢速结束)(规定过渡效果的时间曲线,最常用的是这两个)。
  • transition-delay(规定过渡效果何时开始。默认是 0)

般情况下,我们都是写一起的,比如:transition: width 2s ease 1s

关键帧动画animation

一个关键帧动画,最少包含两部分,animation 属性及属性值(动画的名称和运行方式运行时间等)。@keyframes(规定动画的具体实现过程)

animation 属性可以拆分为

  • animation-name 规定@keyframes 动画的名称。
  • animation-duration 规定动画完成一个周期所花费的秒或毫秒。默认是 0
  • animation-timing-function 规定动画的速度曲线。默认是 “ease”,常用的还有linear,同transtion
  • animation-delay 规定动画何时开始。默认是 0。
  • animation-iteration-count 规定动画被播放的次数。默认是 1,但我们一般用infinite,一直播放

@keyframes的使用方法,可以是from->to(等同于0%和100%),也可以是从0%->100%之间任意个的分层设置。我们通过下面一个稍微复杂点的demo来看一下,基本上用到了上面说到的大部分知识

eg:
   @keyframes mymove
  {
      from {top:0px;}
      to {top:200px;}
  }

/* 等同于: */

@keyframes mymove
{
 0%   {top:0px;}
 25%  {top:200px;}
 50%  {top:100px;}
 75%  {top:200px;}
 100% {top:0px;}
}

用css3动画使一个图片旋转

#loader {

    display: block;

    position: relative;

    -webkit-animation: spin 2s linear infinite;

    animation: spin 2s linear infinite;

}

@-webkit-keyframes spin {

    0%   {

        -webkit-transform: rotate(0deg);

        -ms-transform: rotate(0deg);

        transform: rotate(0deg);

    }

    100% {

        -webkit-transform: rotate(360deg);

        -ms-transform: rotate(360deg);

        transform: rotate(360deg);

    }

}

@keyframes spin {

    0%   {

        -webkit-transform: rotate(0deg);

        -ms-transform: rotate(0deg);

        transform: rotate(0deg);

    }

    100% {

        -webkit-transform: rotate(360deg);

        -ms-transform: rotate(360deg);

        transform: rotate(360deg);

    }

}

💬 面试官追问

  • 商品卡片从列表左侧滑入时,开发写了 transition: left 300ms ease,滚动过程中位移不连贯;只依据这些样式,你会先改哪一处?

    先把位移从 left 改为 transform: translateX(),并让 transition-property 指向 transform。资料明确把 translatescalerotateskewopacity 列为更适合动画的属性,但仅凭样式不能断言卡顿一定来自布局计算,仍需结合页面负载排查。

  • 设计稿要求加载图标持续匀速旋转,另一位开发却用悬停触发的 transition 实现;这套方案为什么无法直接满足要求,你会怎么写?

    持续旋转应使用 animation 配合 @keyframes,例如从 transform: rotate(0deg) 变化到 rotate(360deg),并设置 linear infinitetransition 依赖属性状态发生变化,本身不负责循环播放;代价是无限动画会持续运行,离开可见区域后是否暂停需要另行设计。

  • 弹窗打开后要分三段完成上移、轻微回落和停稳,产品又要求关闭时反向播放;你会怎样组织关键帧和播放方向?

    用百分比关键帧描述三个阶段,并通过 animation-durationanimation-timing-function 控制整体节奏;需要往返播放时可设置 animation-direction: alternatealternate 会按迭代轮次反向执行,不等同于任意时刻点击关闭就从当前进度倒放,交互中断仍需额外状态控制。

  • 线上反馈横幅动画结束后突然跳回初始位置,代码里 @keyframes100% 已经是目标位置;你会优先检查哪个属性?

    优先检查 animation-fill-mode,若要结束后保留最后一帧,应使用 forwards,需要同时处理开始前与结束后的样式可用 both。它只影响动画执行区间外采用哪一帧,不会把关键帧结果永久写回元素原始样式,后续状态切换仍可能覆盖视觉结果。

  • 表单校验提示只需在错误出现时淡入,开发在 transitionanimation 之间争执;你会依据什么选型?

    若提示只是从既有状态平滑过渡到错误状态,使用 transition 配置属性、时长、曲线和延迟更直接。若要安排多个阶段或独立循环,则 animation@keyframes 更合适;两者都应优先选择资料列出的动画属性,避免为了统一写法引入不必要的关键帧。

# 11 CSS3的新特性

⚡ 30 秒速记

  • 选择器:属性选择器、结构伪类 nth-child:not()、伪元素双冒号
  • 布局:box-sizingFlexboxGrid、多列
  • 视觉:圆角、阴影、渐变、多背景、rgba
  • 变换动画:transformtransitionanimation
  • 加分点:CSS3 这个说法已经过时了 —— CSS 现在按模块独立演进没有版本号;更该提 CSS 变量、:is()/:where()/:has()、容器查询 @containeraspect-ratioclamp()

CSS3 常见的新特性可以归为视觉效果、盒模型与响应式能力、变换动画三类。 视觉上有圆角、阴影和渐变,布局相关可用 box-sizing,响应式页面则依赖 @media。交互效果通常由 transformtransitionanimation 完成,其中 transition 需要状态变化触发,animation 可以按关键帧自行运行。文本场景还可以用 word-breaktext-overflow 处理换行与溢出。

  • transition:过渡
  • transform: 旋转、缩放、移动或倾斜
  • animation: 动画
  • gradient: 渐变
  • box-shadow: 阴影
  • border-radius: 圆角
  • word-break: normal|break-all|keep-all; 文字换行(默认规则|单词也可以换行|只在半角空格或连字符换行)
  • text-overflow: 文字超出部分处理
  • text-shadow: 水平阴影,垂直阴影,模糊的距离,以及阴影的颜色。
  • box-sizing: content-box|border-box 盒模型
  • 媒体查询 @media screen and (max-width: 960px) {}还有打印print

💬 面试官追问

  • 资讯卡片标题限定一行,内容超出后产品要求显示省略号;开发只加了 text-overflow: ellipsis,页面仍把整段文字画出来,你会怎么判断?

    text-overflow 只规定溢出内容的呈现方式,单独设置通常不足以形成可见截断,还要确保元素确实产生受限宽度和溢出状态。资料只列出了该特性的用途,未给出完整组合,因此不能把失败归因于浏览器版本,应继续核对换行与溢出相关样式。

  • 英文订单号把移动端表格撑出屏幕,运营又要求普通英文句子尽量按单词换行;word-break 应怎样选,风险是什么?

    普通文本优先保留 normal 的默认换行规则;若订单号这类长串必须在任意字符处断开,可针对该字段使用 break-allkeep-all 只允许在半角空格或连字符等位置换行,不适合无分隔符长串;全局使用 break-all 则可能破坏普通单词的可读性。

  • 设计系统规定按钮视觉宽度为 120px,组件同时有边框和内边距,接入方发现最终占位超过 120px;你会改哪个盒模型配置?

    可将组件设为 box-sizing: border-box,让声明宽度包含内容区、内边距和边框,更容易维持设计稿给出的外部尺寸。content-box 下声明宽度只约束内容区,额外边框和内边距会扩大盒子;切换盒模型后,依赖原内容区宽度的子元素可能需要重新校验。

  • 打印订单页时,网页导航和操作按钮也出现在纸面上;团队想再复制一套打印页面,你会先提出什么方案?

    先使用 @media print 为打印介质覆盖样式,隐藏导航与操作区,并调整正文布局,而不是立即维护第二套页面。媒体查询也能按屏幕条件应用规则,例如 max-width;但资料没有覆盖所有打印兼容细节,分页、背景和纸张尺寸仍应在目标浏览器实际验证。

  • 营销页需要圆角卡片、阴影、渐变背景和悬停放大,开发准备全部导出成图片;你会保留哪些为 CSS,实现时注意什么?

    圆角、盒阴影、渐变以及悬停缩放都可分别用 border-radiusbox-shadowgradienttransform: scale() 表达,通常无需把整块视觉固化成图片。这样便于尺寸和状态变化,但复杂视觉是否能完全还原仍取决于设计要求,不能仅凭特性清单承诺一致效果。

# 12 列举几个css中可继承和不可继承的元素

⚡ 30 秒速记

  • 会继承的基本都跟文字排版有关:font 系列、colorline-heighttext-alignletter-spacingvisibilitycursorlist-style
  • 不继承的:盒模型(width/height/margin/padding/border)、背景、定位、displayfloatoverflowz-index
  • 记忆法:影响「文字长什么样」的会继承,影响「盒子在哪、多大」的不继承
  • 三个通用关键字:inherit 强制继承、initial 恢复初始值、unset(继承属性等于 inherit,非继承属性等于 initial
  • all: revert 可以一次性重置元素所有属性,写组件重置时很好用

通常,控制文字表现的属性更容易继承,而控制盒子尺寸、位置和布局的属性通常不会继承。 例如 colorfont-familyline-heighttext-align 可以从父元素传给后代,visibilitycursor 也可继承。相对地,displaymarginpaddingwidthposition 等不会自动继承,否则父级布局很容易意外影响子元素。遇到边界情况我会查看属性定义,而不是只靠这条经验判断。

  • 不可继承的:display、margin、border、padding、background、height、min-height、max-height、width、min-width、max-width、overflow、position、left、right、top、bottom、z-index、float、clear、table-layout、vertical-align
  • 所有元素可继承:visibilitycursor
  • 内联元素可继承:letter-spacing、word-spacing、white-space、line-height、color、font、font-family、font-size、font-style、font-variant、font-weight、text-decoration、text-transform、direction
  • 终端块状元素可继承:text-indent和text-align
  • 列表元素可继承:list-style、list-style-type、list-style-position、list-style-image`。

transition和animation的区别

Animationtransition大部分属性是相同的,他们都是随时间改变元素的属性值,他们的主要区别是transition需要触发一个事件才能改变属性,而animation不需要触发任何事件的情况下才会随时间改变属性值,并且transition为2帧,从from .... to,而animation可以一帧一帧的

# 四、浏览器

💬 面试官追问

  • 评审浏览器章节时,正文只有一张外链图片,候选人却据此断言浏览器一定采用某种进程模型;作为面试官你会接受吗?

    不会接受,因为当前资料只提供图片引用,没有可核验的文字说明,无法支持具体架构结论。候选人应明确区分图中可直接读出的信息与自己的推断;若图片不可访问,还应主动说明证据缺失,而不是补写未经来源支持的事实。

  • 公司文档站迁移后,这一节的外链图片加载失败,读者看到的只剩标题“浏览器”;你会怎样处理内容可用性?

    应先恢复或替换图片资源,并为图示补充能够独立表达核心信息的文字说明和替代文本。当前章节把全部信息放在单一外链图片中,一旦资源失效就没有可读内容;仅更换链接仍保留外部依赖和后续失效风险。

  • 无障碍审查发现浏览器架构图使用空的替代文本 ![](...),但图片承载了本节全部知识;装饰图和信息图该怎么取舍?

    这张图不是装饰内容,因为移除后章节只剩标题,因此不应继续使用空替代文本。应提供概括图中关键信息的替代描述,必要时在正文补充结构化说明;替代文本若塞入全部细节也会难以阅读,复杂内容更适合正文承载。

  • 面试题生成器只抓取 Markdown 文本,不解析图片,却要从这一节自动生成四道浏览器题;你会让它直接生成还是阻断流程?

    应阻断基于事实的自动出题,并把该章节标记为内容不足,因为解析结果没有任何浏览器技术细节。可以生成“资源缺失”类质量检查项,但不能冒充原文知识题;继续生成会把模型常识混入来源,失去以资料为准的约束。

# 1 浏览器架构

⚡ 30 秒速记

  • 现代浏览器是多进程架构:浏览器主进程、渲染进程(每个站点一个)、GPU 进程、网络进程、插件进程
  • 渲染进程内部又是多线程:GUI 渲染线程、JS 引擎线程、事件触发线程、定时器线程、异步 HTTP 请求线程
  • 关键约束:GUI 渲染线程和 JS 引擎线程互斥 —— 所以 JS 执行时页面卡住
  • 多进程的收益:一个页面崩溃不影响其它标签页、安全沙箱隔离、可以充分利用多核
  • Chrome 的站点隔离(Site Isolation):同一标签内的跨站 iframe 也会拆到独立进程,防 Spectre 类攻击

现代浏览器采用多进程架构,把浏览器界面、页面渲染、网络、GPU 和插件等职责隔离开。 浏览器主进程负责界面、交互和子进程管理,渲染进程运行 BlinkV8,把 HTMLCSSJavaScript 转成可交互页面。进程隔离后,某个页面或插件崩溃通常不会拖垮整个浏览器,沙箱也能限制恶意程序获取系统权限。代价是进程会占用独立资源,但能换来更好的稳定性、安全性和多核利用。

单进程浏览器时代

单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript运行环境、渲染引擎和页面等。其实早在2007年之前,市面上浏览器都是单进程的

  • 缺点
    • 不稳定:一个插件的意外崩溃会引起整个浏览器的崩溃
    • 不流畅:所有页面的渲染模块、JavaScript执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行
    • 不安全:可以通过浏览器的漏洞来获取系统权限,这些脚本获取系统权限之后也可以对你的电脑做一些恶意的事情,同样也会引发安全问题
  • 以上这些就是当时浏览器的特点,不稳定,不流畅,而且不安全

多进程浏览器时代

  • 由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面,这就完美地解决了页面或者插件的崩溃会导致整个浏览器崩溃,也就是不稳定的问题
  • JavaScript也是运行在渲染进程中的,所以即使JavaScript阻塞了渲染进程,影响到的也只是当前的渲染页面,而并不会影响浏览器和其他页面,因为其他页面的脚本是运行在它们自己的渲染进程中的
  • Chrome把插件进程和渲染进程锁在沙箱里面,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。

最新的Chrome浏览器包括:1个浏览器(Browser)主进程1个 GPU 进程1个网络(NetWork)进程多个渲染进程多个插件进程

  • 浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU进程。其实,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程。
  • 网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响

💬 面试官追问

  • 客服说一个页面里的插件崩溃后整个浏览器都应该退出,因为“插件属于浏览器”;结合现代多进程架构,你会怎样反驳?

    现代浏览器会把插件和页面等职责放入相互隔离的进程,插件崩溃通常只影响对应插件进程,不应必然带走浏览器和其他页面。单进程时代才容易因一个模块异常导致整体崩溃;进程隔离降低故障范围,但不能据此保证所有异常都绝不扩散。

  • 后台报表执行一段很长的 JavaScript 后当前标签页失去响应,但地址栏和其他页面仍可操作;这与哪些进程职责吻合?

    JavaScript 引擎 V8 与排版引擎 Blink 都运行在渲染进程中,长脚本阻塞该进程时,主要影响当前页面的执行和渲染。浏览器主进程负责界面、交互和子进程管理,其他页面通常还有各自的渲染进程;具体进程分配策略不宜由现象单独推断。

  • 安全评审认为把渲染进程拆开只是为了防崩溃,与恶意网页获取系统权限无关;你会指出哪处判断有问题?

    多进程设计还配合沙箱限制渲染进程和插件进程访问系统资源,即使其中执行恶意程序,也更难直接突破边界取得系统权限。进程隔离解决的是影响范围,沙箱解决的是权限约束,两者目标相关但并不等价;沙箱也不是可以忽略浏览器漏洞的绝对安全保证。

  • 页面能显示但图片和脚本长期加载失败,另一个标签页的界面交互仍正常;排查时你会优先区分哪些进程?

    应优先区分负责资源加载的网络进程、负责页面转换与交互的渲染进程,以及管理界面和子进程的浏览器主进程。资源普遍加载异常更应关注网络链路或网络进程,单页解析执行异常则偏向渲染进程;仅凭一个标签页现象仍不足以锁定故障点。

  • 团队讨论是否把所有浏览器能力塞回单进程以节省开销,稳定性、安全性和流畅性之间该怎么取舍?

    单进程减少了进程边界,却会让插件、页面、网络和脚本故障更容易牵连整个浏览器,也难以借助隔离与沙箱控制影响范围。多进程的价值是稳定性、安全性及利用多核能力,代价是架构和资源管理更复杂;资料没有给出内存数据,不能虚构量化收益。

# 2 JavaScript单线程模型

⚡ 30 秒速记

  • JS 单线程指的是只有一个调用栈,同一时刻只跑一段代码
  • 为什么单线程:最初为操作 DOM 设计,多线程同时改 DOM 会有竞态和锁的问题
  • 单线程 ≠ 阻塞:耗时 I/O 交给浏览器其它线程,完成后把回调塞进任务队列 —— 这就是事件循环
  • 所以「异步」是宿主环境提供的能力,不是 JS 语言本身多线程
  • 真正的多线程方案:Web Worker(独立线程、不能碰 DOM、靠 postMessage 通信)、SharedArrayBuffer 共享内存

JavaScript 单线程指同一时刻只有一个调用链在执行,并不代表浏览器只有一个线程。 这样设计能避免多个线程同时修改 DOM 时产生冲突,但长时间运行的脚本也会阻塞页面渲染。异步任务会进入任务队列,等当前任务完成、执行线程空闲后再按事件循环取出执行。计算密集型工作可以交给 Web Worker,不过它不能直接操作 DOM,而且通信成本高于计算收益时并不划算。

JavaScript语言的一大特点就是单线程,也就是说,同一时间只能做一件事,前面的任务没做完,后面的任务只能等着。

1. 为什么JavaScript是单线程的呢?

  • 这主要与JavaScript用途有关。它的主要用途是与用户互动,以及操作DOM。如果JavaScript是多线程的,会带来很多复杂的问题,假如 JavaScript有A和B两个线程,A线程在DOM节点上添加了内容,B线程删除了这个节点,应该是哪个为准呢? 所以,为了避免复杂性,所以设计成了单线程。
  • 虽然 HTML5 提出了Web Worker标准。Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。但是子线程完全受主线程控制,且不得操作DOM。所以这个并没有改变JavaScript单线程的本质。一般使用 Web Worker 的场景是代码中有很多计算密集型或高延迟的任务,可以考虑分配给 Worker 线程。
  • 但是使用的时候一定要注意,worker 线程是为了让你的程序跑的更快,但是如果 worker 线程和主线程之间通信的时间大于了你不使用worker线程的时间,结果就得不偿失了。

2. 浏览器内核中线程之间的关系

  • GUI渲染线程和JS引擎线程互斥
    • js是可以操作DOM的,如果在修改这些元素的同时渲染页面(js线程和ui线程同时运行),那么渲染线程前后获得的元素数据可能就不一致了。
  • JS阻塞页面加载
    • js如果执行时间过长就会阻塞页面

3. 浏览器是多进程的优点

  • 默认新开 一个 tab 页面 新建 一个进程,所以单个 tab 页面崩溃不会影响到整个浏览器。
  • 第三方插件崩溃也不会影响到整个浏览器。
  • 多进程可以充分利用现代 CPU 多核的优势。
  • 方便使用沙盒模型隔离插件等进程,提高浏览器的稳定性。

4. 进程和线程又是什么呢

进程(process)和线程(thread)是操作系统的基本概念。

  • 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
  • 线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。

由于每个进程至少要做一件事,所以一个进程至少有一个线程。系统会给每个进程分配独立的内存,因此进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。

进程可以理解为一个工厂不不同车间,相互独立。线程是车间里的工人,可以自己做自己的事情,也可以相互配合做同一件事情。

5. 任务队列

  • 单线程就意味着,所有任务都要排队执行,前一个任务结束,才会执行后一个任务。
  • 如果一个任务需要执行,但此时JavaScript引擎正在执行其他任务,那么这个任务就需要放到一个队列中进行等待。等到线程空闲时,就可以从这个队列中取出最早加入的任务进行执行(类似于我们去银行排队办理业务,单线程相当于说这家银行只有一个服务窗口,一次只能为一个人服务,后面到的就需要排队,而任务队列就是排队区,先到的就优先服务)

注意: 如果当前线程空闲,并且队列为空,那每次加入队列的函数将立即执行。

为什么会有任务队列? 由于 JS 是单线程的,同步执行任务会造成浏览器的阻塞,所以我们将 JS 分成一个又一个的任务,通过不停的循环来执行事件队列中的任务。

💬 面试官追问

  • 同事说浏览器能同时下载图片,所以页面里的 JavaScript 也会自动并行修改同一个 DOM;你会怎样指出概念混淆?

    浏览器内部可以有多个进程和线程,但页面主线程上的 JavaScript 仍按单线程模型执行,不会自动由两个线程同时修改同一 DOM。这种设计避免添加节点与删除节点并发发生时的结果冲突;网络并行能力不能直接推出脚本执行模型也相同。

  • 数据看板需要在前端执行大量计算,输入期间明显卡顿;开发打算把计算搬到 Web Worker,你会先确认哪些边界?

    计算密集型或高延迟任务适合交给 Web Worker,从而避免长时间占用页面主线程。Worker 受主线程控制且不能操作 DOM,结果必须回传后再由主线程更新界面;若任务很小或通信成本超过计算收益,迁移反而可能得不偿失。

  • 动画播放时,一段同步脚本持续处理大批数据,结果点击和页面绘制一起停住;为什么渲染线程没有绕过脚本继续工作?

    资料指出 GUI 渲染线程与 JS 引擎线程互斥,脚本执行时间过长会阻塞页面加载和渲染。这样可避免脚本修改 DOM 的同时渲染线程读取到前后不一致的数据;拆分任务或移出重计算能缓解阻塞,但不能让主线程上的 DOM 操作并行执行。

  • 线上按钮点击后偶尔很晚才响应,监控显示事件已经进入任务队列;你会先排查事件是否丢失,还是主线程是否被占用?

    应先检查主线程是否正在执行耗时任务,因为队列中的任务只有在线程空闲后才能按顺序取出执行。事件进入队列说明它不一定丢失,更可能是在等待前面的同步工作完成;若线程空闲且队列原本为空,新增任务才可能立即得到执行。

  • 两个标签页中,一个页面脚本死循环,另一个页面仍正常;有人据此断言 JavaScript 已经变成多线程,你会如何解释?

    这反映的是浏览器多进程隔离,而不是单个页面里的 JavaScript 改成了多线程。不同标签页默认可由不同渲染进程承载,各自运行脚本,因此一个页面阻塞通常不影响其他页面;具体标签与进程映射可能受实现策略影响,不应把“默认”理解为绝对一一对应。

  • 架构师要在主线程分片执行和 Web Worker 之间选方案,任务还需要频繁更新 DOM;你会偏向哪边?

    若核心工作必须频繁读写 DOMWorker 不能直接完成这些操作,只能承担可独立计算的部分,再把结果交回主线程。计算量大且通信较少时 Worker 更有价值,交互步骤多且每次计算很轻时,主线程分任务可能更简单;最终取舍应比较阻塞时间与通信成本。

# 3 Chrome 打开一个页面需要启动多少进程?分别有哪些进程?

⚡ 30 秒速记

  • 最少 4 个:浏览器主进程、网络进程、GPU 进程、渲染进程(各 1 个)
  • 有插件时再加插件进程;开了站点隔离后跨站 iframe 还会各占一个渲染进程
  • 各自职责:主进程管界面和下载,网络进程管请求,GPU 进程管绘制合成,渲染进程管排版和 JS
  • 渲染进程是每个站点一个(同源标签页可能共用),这是崩溃隔离和安全沙箱的基础
  • 代价是内存占用高,所以 Chrome 一直在做进程数上限和进程复用的优化

Chrome 打开一个页面至少需要四个进程:浏览器主进程、网络进程、GPU 进程和渲染进程。 主进程负责界面、交互和子进程管理,网络进程加载资源,GPU 进程负责绘制与合成,渲染进程则运行 BlinkV8,把页面变成可交互内容。实际数量通常不止四个,因为还可能存在多个渲染进程和插件进程。渲染与插件独立运行,本质上是为了通过沙箱和进程隔离控制安全风险及崩溃影响。

打开 1 个页面至少需要 1 个网络进程、1 个浏览器进程、1 个 GPU 进程以及 1 个渲染进程,共 4 个;最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1 个 GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。
  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU 进程:其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

💬 面试官追问

  • 测试同学说打开一个空白标签页只需要一个进程,因为页面里没有业务代码;你会怎么纠正这个判断?

    即使是空白页,也不能把整个 Chrome 页面理解成单进程运行;按基础架构至少涉及浏览器、网络、GPU 和渲染进程。它们分别承担界面与子进程管理、资源加载、绘制合成和页面解析执行,是否空白只影响工作量,不改变职责隔离。

  • 一个后台管理页同时加载 HTML、样式、脚本和接口数据,负责人要求你说明这些任务分别落在哪些进程,你会怎么划分?

    网络资源加载主要由网络进程负责,HTMLCSSJavaScript 转换成交互页面的核心工作由渲染进程完成,其中包含 BlinkV8。浏览器进程负责界面、用户交互和子进程管理,最终绘制与图层合成还会使用 GPU 进程。

  • 产品从单标签页改成 20 个标签页,并安装了多个插件,却要求进程数仍固定为 4 个,这个约束为什么不成立?

    4 个只是打开一个页面时所涉及的基础进程类别和最低数量描述,并不是整个浏览器会长期固定为 4 个进程。Chrome 默认可能为各个 Tab 创建渲染进程,插件也可能运行在独立插件进程中,因此标签页和插件增加后,实际进程数通常会随之变化。

  • 线上页面突然白屏,但浏览器地址栏、菜单和其他标签页仍能操作,你会优先怀疑哪个进程,并怎样缩小范围?

    应优先检查该页面对应的渲染进程,因为 BlinkV8 以及页面内容生成都运行在那里,而浏览器界面仍正常说明主进程未必失效。可用 Chrome 任务管理器观察对应任务是否崩溃或资源异常,再区分是脚本执行、页面渲染还是插件影响。

  • 架构评审时有人建议把插件直接放进浏览器主进程,以减少进程数量和通信成本,你会接受吗?

    通常不应接受,因为插件更容易发生崩溃,独立插件进程的价值正是隔离故障,避免连带破坏浏览器和页面。合并可能减少部分进程与通信开销,却会扩大故障影响面;除非运行环境和插件风险受到严格控制,否则不值得牺牲隔离性。

  • 页面请求已经返回,但内容迟迟没有显示;后端认定网络进程有问题,你会怎样串联网络进程与渲染进程判断责任边界?

    请求返回只能说明网络进程完成了资源加载,不代表页面已经完成解析、布局、绘制和合成。应继续检查渲染进程中的 BlinkV8 是否被脚本或渲染工作阻塞,并观察 GPU 绘制合成环节;仅凭接口成功或失败不能覆盖整条显示链路。

# 4 渲染机制

⚡ 30 秒速记

  • 流水线:HTMLDOM 树、CSSCSSOM 树 → 合并成渲染树 → 布局 Layout → 分层 → 绘制 Paint → 栅格化 → 合成 Composite
  • 渲染树只含可见节点:display:none 不在树里,visibility:hidden 在(因为占位)
  • 阻塞关系:CSS 阻塞渲染(要等 CSSOM),同步 JS 阻塞 DOM 解析(因为可能改 DOM
  • 这就是「CSSheadJSdefer」的根本原因
  • 重排 > 重绘 > 合成,动画只改 transform/opacity 就能跳过前两步

浏览器渲染是一条从 DOMCSSOM 到渲染树,再经过布局、绘制和图层合成的流水线。 修改元素尺寸或位置会触发布局,也就是回流,后续通常还要重绘;只改颜色、背景等外观,一般只需重绘,所以回流成本更高。像 transformopacity 这类动画可以利用独立图层,尽量减少对布局的影响,但图层也不能创建过多。我一般还会把频繁更新放进 requestAnimationFrame,并避免循环读取 offsetWidthoffsetHeight 后又立即改样式。

1. 浏览器如何渲染网页

概述:浏览器渲染一共有五步

  1. 处理 HTML 并构建 DOM 树。
  2. 处理 CSS构建 CSSOM 树。
  3. DOMCSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,计算每个节点的位置。
  5. 调用 GPU 绘制,合成图层,显示在屏幕上

第四步和第五步是最耗时的部分,这两步合起来,就是我们通常所说的渲染

具体如下图过程如下图所示

img

img

渲染

  • 网页生成的时候,至少会渲染一次
  • 在用户访问的过程中,还会不断重新渲染

重新渲染需要重复之前的第四步(重新生成布局)+第五步(重新绘制)或者只有第五个步(重新绘制)

  • 在构建 CSSOM 树时,会阻塞渲染,直至 CSSOM树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢
  • HTML 解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件。并且CSS也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建 DOM

2. 浏览器渲染五个阶段

2.1 第一步:解析HTML标签,构建DOM树

在这个阶段,引擎开始解析html,解析出来的结果会成为一棵domdom的目的至少有2

  • 作为下个阶段渲染树状图的输入
  • 成为网页和脚本的交互界面。(最常用的就是getElementById等等)

当解析器到达script标签的时候,发生下面四件事情

  1. html解析器停止解析,
  2. 如果是外部脚本,就从外部网络获取脚本代码
  3. 将控制权交给js引擎,执行js代码
  4. 恢复html解析器的控制权

由此可以得到第一个结论1

  • 由于<script>标签是阻塞解析的,将脚本放在网页尾部会加速代码渲染。
  • deferasync属性也能有助于加载外部脚本。
  • defer使得脚本会在dom完整构建之后执行;
  • async标签使得脚本只有在完全available才执行,并且是以非阻塞的方式进行的

2.2 第二步:解析CSS标签,构建CSSOM树

  • 我们已经看到html解析器碰到脚本后会做的事情,接下来我们看下html解析器碰到样式表会发生的情况
  • js会阻塞解析,因为它会修改文档(document)。css不会修改文档的结构,如果这样的话,似乎看起来css样式不会阻塞浏览器html解析。但是事实上 css样式表是阻塞的。阻塞是指当cssom树建立好之后才会进行下一步的解析渲染

通过以下手段可以减轻cssom带来的影响

  • script脚本放在页面底部
  • 尽可能快的加载css样式表
  • 将样式表按照media typemedia query区分,这样有助于我们将css资源标记成非阻塞渲染的资源。
  • 非阻塞的资源还是会被浏览器下载,只是优先级较低

2.3 第三步:把DOM和CSSOM组合成渲染树(render tree)

img

2.4 第四步:在渲染树的基础上进行布局,计算每个节点的几何结构

布局(layout):定位坐标和大小,是否换行,各种position, overflow, z-index属性

2.5 调用 GPU 绘制,合成图层,显示在屏幕上

将渲染树的各个节点绘制到屏幕上,这一步被称为绘制painting

3. 渲染优化相关

3.1 Load 和 DOMContentLoaded 区别

  • Load 事件触发代表页面中的 DOMCSSJS,图片已经全部加载完毕。
  • DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSSJS,图片加载

3.2 图层

一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。

通过以下几个常用属性可以生成新图层

  • 3D 变换:translate3dtranslateZ
  • will-change
  • videoiframe 标签
  • 通过动画实现的 opacity 动画转换
  • position: fixed

3.3 重绘(Repaint)和回流(Reflow)

重绘和回流是渲染步骤中的一小节,但是这两个步骤对于性能影响很大

  • 重绘是当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘
  • 回流是布局或者几何属性需要改变就称为回流。

回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流

以下几个动作可能会导致性能问题

  • 改变 window 大小
  • 改变字体
  • 添加或删除样式
  • 文字改变
  • 定位或者浮动
  • 盒模型

很多人不知道的是,重绘和回流其实和 Event loop 有关

  • Event loop 执行完Microtasks 后,会判断 document 是否需要更新。因为浏览器是 60Hz 的刷新率,每 16ms 才会更新一次。
  • 然后判断是否有 resize 或者 scroll ,有的话会去触发事件,所以 resizescroll 事件也是至少 16ms才会触发一次,并且自带节流功能。
  • 判断是否触发了 media query
  • 更新动画并且发送事件
  • 判断是否有全屏操作事件
  • 执行 requestAnimationFrame 回调
  • 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好
  • 更新界面
  • 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 requestIdleCallback 回调

常见的引起重绘的属性

  • color
  • border-style
  • visibility
  • background
  • text-decoration
  • background-image
  • background-position
  • background-repeat
  • outline-color
  • outline
  • outline-style
  • border-radius
  • outline-width
  • box-shadow
  • background-size

3.4 常见引起回流属性和方法

任何会改变元素几何信息(元素的位置和尺寸大小)的操作,都会触发重排,下面列一些栗子

  • 添加或者删除可见的DOM元素;
  • 元素尺寸改变——边距、填充、边框、宽度和高度
  • 内容变化,比如用户在input框中输入文字
  • 浏览器窗口尺寸改变——resize事件发生时
  • 计算 offsetWidthoffsetHeight 属性
  • 设置 style 属性的值

回流影响的范围

由于浏览器渲染界面是基于流失布局模型的,所以触发重排时会对周围DOM重新排列,影响的范围有两种

  • 全局范围:从根节点html开始对整个渲染树进行重新布局。
  • 局部范围:对渲染树的某部分或某一个渲染对象进行重新布局

全局范围回流

<body>
  <div class="hello">
    <h4>hello</h4>
    <p><strong>Name:</strong>BDing</p>
    <h5>male</h5>
    <ol>
      <li>coding</li>
      <li>loving</li>
    </ol>
  </div>
</body>

p节点上发生reflow时,hellobody也会重新渲染,甚至h5ol都会收到影响

局部范围回流

用局部布局来解释这种现象:把一个dom的宽高之类的几何信息定死,然后在dom内部触发重排,就只会重新渲染该dom内部的元素,而不会影响到外界

3.5 减少重绘和回流

使用 translate 替代 top

<div class="test"></div>
<style>
    .test {
        position: absolute;
        top: 10px;
        width: 100px;
        height: 100px;
        background: red;
    }
</style>
<script>
    setTimeout(() => {
        // 引起回流
        document.querySelector('.test').style.top = '100px'
    }, 1000)
</script>
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)
  • DOM 离线后修改,比如:先把 DOMdisplay:none (有一次 Reflow),然后你修改100次,然后再把它显示出来
  • 不要把 DOM 结点的属性值放在一个循环里当成循环里的变量
for(let i = 0; i < 1000; i++) {
    // 获取 offsetTop 会导致回流,因为需要去获取正确的值
    console.log(document.querySelector('.test').style.offsetTop)
}
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS选择符从右往左匹配查找,避免 DOM深度过深
  • 将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于 video标签,浏览器会自动将该节点变为图层。

img

img

💬 面试官追问

  • 一个卡片组件只把文字颜色从黑色改成红色,开发却说一定会重新计算整页布局;你会怎样反驳?

    单纯修改 color 通常只改变外观而不改变元素几何结构,因此会触发重绘,不必然触发布局回流。只有宽高、边距、字体或内容等影响位置和尺寸的变化才需要重新布局;不过重绘仍有成本,影响区域过大时也可能造成卡顿。

  • 一个 1000 行列表在循环中反复读取 offsetHeight,同时逐项修改 style,滚动时明显卡顿,你会怎样改造?

    这类读写交替可能迫使浏览器反复获得最新布局结果,应先批量读取所需尺寸并缓存,再集中修改样式,避免在循环中持续查询几何属性。大量节点还可先离线或隐藏后统一修改,但 display: none 的切换本身会产生回流,需要权衡修改次数。

  • 动效设计要求悬浮按钮每帧修改 top,视觉同学又不接受改变运动轨迹;你会选择什么实现,并说明限制?

    应保持相同轨迹但改用 transform: translate(...) 表达位移,避免持续修改 top 引发几何布局和后续重绘。频繁动画还可使用 requestAnimationFrame 对齐浏览器更新节奏;若动画必须真实改变文档流位置,就无法完全绕开回流。

  • 首屏的 HTML 很小,但头部同步加载大型脚本和多份样式表后长时间空白,你会沿渲染链路排查什么?

    先检查同步 script 是否暂停了 HTML 解析,以及样式表是否迟迟未完成 CSSOM 构建并影响后续渲染和脚本执行。可将非关键脚本后移或使用 deferasync,并尽快加载必要样式、按 media 条件拆分;依赖执行顺序的脚本不能盲目改为 async

  • 一个长列表滚动时大量区域持续重绘,团队准备给每个列表项都加 will-change,你会批准这个方案吗?

    不应直接给所有列表项提层;独立图层可隔离频繁渲染区域,但图层过多也会产生反作用。应先确认真正高频变化的节点,只对动画或重绘热点使用 will-changetranslate3d 等手段,并在性能面板中验证布局与绘制是否确实减少。

  • 监控显示 DOMContentLoaded 很早触发,但首屏图片仍未出现,产品却认为页面已经完全加载;你会如何解释并设置判断口径?

    DOMContentLoaded 只表示初始 HTML 已完成加载和解析,不要求图片等资源全部完成,因此不能作为完整页面就绪的唯一口径。若关注全部资源完成应观察 load,若关注可交互首屏则还要结合脚本执行和实际渲染状态;两者代表的业务体验并不相同。

# 5 缓存机制

⚡ 30 秒速记

  • 分两层:强缓存(不发请求)和协商缓存(发请求但可能拿 304
  • 强缓存:Cache-Controlmax-age/no-cache/no-store/immutable)优先级高于 Expires
  • 协商缓存:ETag/If-None-Match(内容指纹,精确)优先级高于 Last-Modified/If-Modified-Since(秒级精度)
  • 易错点:no-cache 是「可以存但每次都要验证」,no-store 才是真不缓存
  • 查找顺序:Memory CacheService WorkerDisk Cache → 网络;工程实践是 HTMLno-cache、带 hash 的静态资源用 max-age=31536000, immutable

浏览器缓存的关键区别是是否发送请求:强缓存命中时不请求服务器,协商缓存则要向服务器确认资源有没有变化。 首次响应会连同缓存头一起保存,后续浏览器根据 Cache-ControlExpires 判断缓存是否仍然有效;失效后再进入协商阶段。no-cache 并不是完全不缓存,而是使用前必须向服务器校验,资源未变化时可以避免重新下载。真正禁止保存的是 no-store,而 publicprivate 则用于控制代理缓存能否保存响应。

1. 首先得明确 http 缓存的好处

  • 减少了冗余的数据传输,减少网费
  • 减少服务器端的压力
  • Web 缓存能够减少延迟与网络阻塞,进而减少显示某个资源所用的时间
  • 加快客户端加载网页的速度

2. 常见 http 缓存的类型

  • 私有缓存(一般为本地浏览器缓存)
  • 代理缓存

3. 然后谈谈本地缓存

本地缓存是指浏览器请求资源时命中了浏览器本地的缓存资源,浏览器并不会发送真正的请求给服务器了。它的执行过程是

  • 第一次浏览器发送请求给服务器时,此时浏览器还没有本地缓存副本,服务器返回资源给浏览器,响应码是200 OK,浏览器收到资源后,把资源和对应的响应头一起缓存下来
  • 第二次浏览器准备发送请求给服务器时候,浏览器会先检查上一次服务端返回的响应头信息中的Cache-Control,它的值是一个相对值,单位为秒,表示资源在客户端缓存的最大有效期,过期时间为第一次请求的时间减去Cache-Control的值,过期时间跟当前的请求时间比较,如果本地缓存资源没过期,那么命中缓存,不再请求服务器
  • 如果没有命中,浏览器就会把请求发送给服务器,进入缓存协商阶段。

与本地缓存相关的头有:Cache-ControlExpiresCache-Control有多个可选值代表不同的意义,而Expires就是一个日期格式的绝对值。

3.1 Cache-Control

Cache-ControlHTPP缓存策略中最重要的头,它是HTTP/1.1中出现的,它由如下几个值

  • no-cache:不使用本地缓存。需要使用缓存协商,先与服务器确认返回的响应是否被更改,如果之前的响应中存在ETag,那么请求的时候会与服务端验证,如果资源未被更改,则可以避免重新下载
  • no-store:直接禁止游览器缓存数据,每次用户请求该资源,都会向服务器发送一个请求,每次都会下载完整的资源
  • public:可以被所有的用户缓存,包括终端用户和CDN等中间代理服务器。
  • private:只能被终端用户的浏览器缓存,不允许CDN等中继缓存服务器对其缓存。
  • max-age:从当前请求开始,允许获取的响应被重用的最长时间(秒)。
  • must-revalidate,当缓存过期时,需要去服务端校验缓存的有效性。
# 例如:

Cache-Control: public, max-age=1000
# 表示资源可以被所有用户以及代理服务器缓存,最长时间为1000秒。

注意,虽然你可能在其他资料中看到可以使用 meta 标签来设置缓存,比如像下面的形式:

<meta http-equiv="expires" content="Wed, 20 Jun 2021 22:33:00 GMT"

但在 HTML5 规范中,并不支持这种方式,所以尽量不要使用 meta 标签来设置缓存

3.2 Expires

ExpiresHTTP/1.0出现的头信息,同样是用于决定本地缓存策略的头,它是一个绝对时间,时间格式是如Mon, 10 Jun 2015 21:31:12 GMT,只要发送请求时间是在Expires之前,那么本地缓存始终有效,否则就会去服务器发送请求获取新的资源。如果同时出现Cache-Control:max-ageExpires,那么max-age优先级更高。他们可以这样组合使用

Cache-Control: public
Expires: Wed, Jan 10 2018 00:27:04 GMT

3.3 所谓的缓存协商

当第一次请求时服务器返回的响应头中存在以下情况时

  • 没有 Cache-ControlExpires
  • Cache-ControlExpires 过期了
  • Cache-Control 的属性设置为 no-cache

那么浏览器第二次请求时就会与服务器进行协商,询问浏览器中的缓存资源是不是旧版本,需不需要更新,此时,服务器就会做出判断,如果缓存和服务端资源的最新版本是一致的,那么就无需再次下载该资源,服务端直接返回304 Not Modified 状态码,如果服务器发现浏览器中的缓存已经是旧版本了,那么服务器就会把最新资源的完整内容返回给浏览器,状态码就是200 Ok,那么服务端是根据什么来判断浏览器的缓存是不是最新的呢?其实是根据HTTP的另外两组头信息,分别是:Last-Modified/If-Modified-SinceETag/If-None-Match

Last-Modified 与 If-Modified-Since

具体工作流程如下:

  • 浏览器第一次请求资源时,服务器会把资源的最新修改时间Last-Modified:Thu, 29 Dec 2011 18:23:55 GMT放在响应头中返回给浏览器
  • 第二次请求时,浏览器就会把上一次服务器返回的修改时间放在请求头If-Modified-Since:Thu, 29 Dec 2011 18:23:55发送给服务器,服务器就会拿这个时间跟服务器上的资源的最新修改时间进行对比
  • 服务端再次收到请求,根据请求头 If-Modified-Since 的值,判断相关资源是否有变化,如果没有,则返回 304 Not Modified,并且不返回资源内容,浏览器使用资源缓存值;否则正常返回资源内容,且更新Last-Modified 响应头内容。

如果两者相等或者大于服务器上的最新修改时间,那么表示浏览器的缓存是有效的,此时缓存会命中,服务器就不再返回内容给浏览器了,同时Last-Modified头也不会返回,因为资源没被修改,返回了也没什么意义。如果没命中缓存则最新修改的资源连同Last-Modified头一起返回

这种方式虽然能判断缓存是否失效,但也存在两个问题:

  • 精度问题Last-Modified 的时间精度为秒,如果在 1 秒内发生修改,那么缓存判断可能会失效;
  • 准度问题,考虑这样一种情况,如果一个文件被修改,然后又被还原,内容并没有发生变化,在这种情况下,浏览器的缓存还可以继续使用,但因为修改时间发生变化,也会重新返回重复的内容。
# 第一次请求返回的响应头
Cache-Control:max-age=3600
Expires: Fri, Jan 12 2018 00:27:04 GMT
Last-Modified: Wed, Jan 10 2018 00:27:04 GMT
# 第二次请求的请求头信息
If-Modified-Since: Wed, Jan 10 2018 00:27:04 GMT

这组头信息是基于资源的修改时间来判断资源有没有更新,另一种方式就是根据资源的内容来判断,就是接下来要讨论的 ETagIf-None-Match

ETag与If-None-Match

为了解决精度问题和准度问题,HTTP 提供了另一种不依赖于修改时间,而依赖于文件哈希值的精确判断缓存的方式,那就是响应头部字段 ETag 和请求头部字段 If-None-Match。

ETag/If-None-MatchLast-Modified/If-Modified-Since的流程其实是类似的,唯一的区别是它基于资源的内容的摘要信息(比如MD5 hash)来判断

浏览器发送第二次请求时,会把第一次的响应头信息ETag的值放在If-None-Match的请求头中发送到服务器,与最新的资源的摘要信息对比,如果相等,取浏览器缓存,否则内容有更新,最新的资源连同最新的摘要信息返回。用ETag的好处是如果因为某种原因到时资源的修改时间没改变,那么用ETag就能区分资源是不是有被更新。

具体工作流程如下:

  • 浏览器第一次请求资源,服务端在返响应头中加入 Etag 字段,Etag 字段值为该资源的哈希值
  • 当浏览器再次跟服务端请求这个资源时,在请求头上加上 If-None-Match,值为之前响应头部字段 ETag 的值;
  • 服务端再次收到请求,将请求头 If-None-Match 字段的值和响应资源的哈希值进行比对,如果两个值相同,则说明资源没有变化,返回 304 Not Modified;否则就正常返回资源内容,无论是否发生变化,都会将计算出的哈希值放入响应头部的 ETag 字段中

这种缓存比较的方式也会存在一些问题,具体表现在以下两个方面。

  • 计算成本。生成哈希值相对于读取文件修改时间而言是一个开销比较大的操作,尤其是对于大文件而言。如果要精确计算则需读取完整的文件内容,如果从性能方面考虑,只读取文件部分内容,又容易判断出错。
  • 计算误差。HTTP 并没有规定哈希值的计算方法,所以不同服务端可能会采用不同的哈希值计算方式。这样带来的问题是,同一个资源,在两台服务端产生的 Etag 可能是不相同的,所以对于使用服务器集群来处理请求的网站来说,使用 Etag 的缓存命中率会有所降低。

需要注意的是,强制缓存的优先级高于协商缓存,在协商缓存中,Etag 优先级比 Last-Modified

# 第一次请求返回的响应头:

Cache-Control: public, max-age=31536000
ETag: "15f0fff99ed5aae4edffdd6496d7131f"
# 第二次请求的请求头信息:

If-None-Match: "15f0fff99ed5aae4edffdd6496d7131f"

缓存位置

浏览器缓存的位置的话,可以分为四种,优先级从高到低排列分别👇

  • Service Worker
  • Memory Cache
  • Disk Cache
  • Push Cache

Service Worker

这个应用场景比如PWA,它借鉴了Web Worker思路,由于它脱离了浏览器的窗体,因此无法直接访问DOM。它能完成的功能比如:离线缓存消息推送网络代理,其中离线缓存就是Service Worker Cache

Memory Cache

指的是内存缓存,从效率上讲它是最快的,从存活时间来讲又是最短的,当渲染进程结束后,内存缓存也就不存在了。

Disk Cache

存储在磁盘中的缓存,从存取效率上讲是比内存缓存慢的,优势在于存储容量和存储时长。

Disk Cache VS Memory Cache

两者对比,主要的策略👇

  • 内容使用率高的话,文件优先进入磁盘
  • 比较大的JS,CSS文件会直接放入磁盘,反之放入内存。

Push Cache

推送缓存,这算是浏览器中最后一道防线吧,它是HTTP/2的内容

浏览器缓存总结

浏览器缓存分为强缓存和协商缓存。当客户端请求某个资源时,获取缓存的流程如下

  • 先根据这个资源的一些 http header 判断它是否命中强缓存,先检查Cache-Control,如果命中,则直接从本地获取缓存资源,不会发请求到服务器;
  • 当强缓存没有命中时,客户端会发送请求到服务器,服务器通过另一些request header验证这个资源是否命中协商缓存,称为http再验证,如果命中,服务器将请求返回,但不返回资源,而是返回304告诉客户端直接从缓存中获取,客户端收到返回后就会从缓存中获取资源;(服务器通过请求头中的If-Modified-Since或者If-None-Match字段检查资源是否更新)
  • 强缓存和协商缓存共同之处在于,如果命中缓存,服务器都不会返回资源; 区别是,强缓存不对发送请求到服务器,但协商缓存会。
  • 当协商缓存也没命中时,服务器就会将资源发送回客户端。
  • 当 ctrl+f5 强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存;
  • 当 f5刷新网页时,跳过强缓存,但是会检查协商缓存;

强缓存

  • Expires(该字段是 http1.0 时的规范,值为一个绝对时间的 GMT 格式的时间字符串,代表缓存资源的过期时间)
  • Cache-Control:max-age(该字段是 http1.1的规范,强缓存利用其 max-age 值来判断缓存资源的最大生命周期,它的值单位为秒)

协商缓

  • Last-Modified(值为资源最后更新时间,随服务器response返回,即使文件改回去,日期也会变化)
  • If-Modified-Since(通过比较两个时间来判断资源在两次请求期间是否有过修改,如果没有修改,则命中协商缓存)
  • ETag(表示资源内容的唯一标识,随服务器response返回,仅根据文件内容是否变化判断)
  • If-None-Match(服务器通过比较请求头部的If-None-Match与当前资源的ETag是否一致来判断资源是否在两次请求之间有过修改,如果没有修改,则命中协商缓存)

💬 面试官追问

  • 商品详情页第二次打开时,Network 显示请求携带 If-None-Match,但响应是 200 而不是 304;有人据此断言浏览器没有使用缓存,你怎么判断?

    不能仅凭 200 判断浏览器未使用缓存,因为携带 If-None-Match 已说明客户端正在用上次保存的 ETag 发起再验证。若服务端当前 ETag 不一致,就应返回新资源和 200;只有标识一致时才返回不带资源实体的 304

  • 发版后的首页 HTML 已配置协商缓存,第二次请求带着 If-None-Match;服务端需要怎样处理,浏览器收到响应后又怎样取资源?

    服务端应比较请求中的 If-None-Match 与当前资源的 ETag,一致时返回 304,不再传输资源内容。浏览器随后从本地缓存读取实体;若两者不一致,则服务端返回更新后的资源,并随响应提供新的校验标识。

  • 一个高频访问的小图标和一个体积较大的 JS 文件都命中了本地缓存,前端同事认为它们必然进入同一种缓存位置,你会怎样纠正?

    两者不一定进入相同位置,浏览器还会在 Memory CacheDisk Cache 之间进行选择。内存缓存读取更快但随渲染进程结束而消失;较大的 JSCSS 通常更倾向磁盘,而具体归属仍受浏览器策略影响。

  • 用户按普通 F5 后接口返回 304,按 Ctrl+F5 却下载了完整资源;值班同学怀疑服务端随机失效缓存,你怎么解释并排查?

    这种差异符合两种刷新方式的缓存流程:普通 F5 会跳过强缓存,但仍进行协商校验,所以可能收到 304Ctrl+F5 会跳过强缓存和协商缓存,直接从服务端加载;排查时应对比请求头、状态码及实际传输内容。

  • 运营页更新频繁,架构师主张所有资源都只用 Cache-Control: max-age,后端则主张全部依赖 ETag;你如何说明两种方案的请求成本?

    max-age 命中强缓存时不会向服务器发请求,适合生命周期内无需重新确认的资源;ETag 属于协商缓存,每次再验证仍会产生请求,但未变化时只返回 304。选择取决于更新及时性要求,不能把免请求与免传资源视为同一件事。

  • 一个 PWA 页面断网后仍展示旧数据,清理普通磁盘缓存也没有恢复;同时 DevTools 标记资源来自 Service Worker,下一步查哪里?

    应优先检查 Service Worker 的离线缓存和网络代理逻辑,因为它的缓存位置优先于 Memory CacheDisk CachePush Cache。仅清理磁盘缓存可能绕不开其响应;还要核对注册状态与缓存更新策略,但不能直接归因于 HTTP 强缓存。

# 6 浏览器存储

⚡ 30 秒速记

  • cookie 是给服务端用的(每次请求自动携带,所以必须小,约 4KB),Web Storage 是给客户端用的(不上传,约 5MB
  • 生命周期:cookieExpires/Max-AgesessionStorage 跟标签页走,localStorage 不主动删就一直在
  • 作用域:cookieDomain/Path 约束可跨子域;localStorage 同源共享;sessionStorage 连同源的另一个标签页都读不到
  • 安全:cookie 可设 HttpOnlyJS 读不到,防 XSS 窃取)、SecureSameSiteWeb Storage 全是明文且 JS 可读
  • 大数据用 IndexedDB:结构化、异步、容量上百 MB

浏览器端常用的持久化方案有 cookielocalStoragesessionStorageIndexedDB,选择时主要看数据用途、生命周期与容量。 cookie 通常保存身份或登录状态,约 4K,请求时会自动携带;另外三者不随请求发送,更适合客户端数据。localStorage 会一直保留到被清理,sessionStorage 通常随页面会话结束而清理,大量结构化数据可以考虑 IndexedDB。涉及登录态时还要关注 HttpOnlySecureSameSite,分别用于限制脚本访问、安全传输以及降低跨站请求风险。

我们经常需要对业务中的一些数据进行存储,通常可以分为 短暂性存储 和 持久性储存。

  • 短暂性的时候,我们只需要将数据存在内存中,只在运行时可用
  • 持久性存储,可以分为 浏览器端 与 服务器端
    • 浏览器:
      • cookie: 通常用于存储用户身份,登录状态等
        • http 中自动携带, 体积上限为 4K, 可自行设置过期时间
      • localStorage / sessionStorage: 长久储存/窗口关闭删除, 体积限制为 4~5M
      • indexDB
    • 服务器:
      • 分布式缓存 redis
      • 数据库

cookie和localSrorage、session、indexDB 的区别

特性 cookie localStorage sessionStorage indexDB
数据生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面关闭就清理 除非被清理,否则一直存在
数据存储大小 4K 5M 5M 无限
与服务端通信 每次都会携带在 header 中,对于请求性能影响 不参与 不参与 不参与

从上表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStoragesessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

对于 cookie,我们还需要注意安全性

属性 作用
value 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only 不能通过 JS访问 Cookie,减少 XSS攻击
secure 只能在协议为 HTTPS 的请求中携带
same-site 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

  • Name,即该 Cookie 的名称。Cookie 一旦创建,名称便不可更改。
  • Value,即该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码。
  • Max Age,即该 Cookie 失效的时间,单位秒,也常和 Expires 一起使用,通过它可以计算出其有效时间。Max Age如果为正数,则该 CookieMax Age 秒之后失效。如果为负数,则关闭浏览器时 Cookie 即失效,浏览器也不会以任何形式保存该 Cookie
  • Path,即该 Cookie 的使用路径。如果设置为 /path/,则只有路径为 /path/ 的页面可以访问该 Cookie。如果设置为 /,则本域名下的所有页面都可以访问该 Cookie
  • Domain,即可以访问该 Cookie 的域名。例如如果设置为 .zhihu.com,则所有以 zhihu.com,结尾的域名都可以访问该 CookieSize 字段,即此 Cookie 的大小。
  • Http 字段,即 Cookiehttponly 属性。若此属性为 true,则只有在 HTTP Headers 中会带有此 Cookie 的信息,而不能通过 document.cookie 来访问此 Cookie。
  • Secure,即该 Cookie 是否仅被使用安全协议传输。安全协议。安全协议有 HTTPS、SSL 等,在网络上传输数据之前先将数据加密。默认为 false

💬 面试官追问

  • 登录页把明文用户编号写进 Cookie,开发认为加上 HttpOnly 后就绝对安全;作为安全评审你会指出什么?

    HttpOnly 只限制脚本通过 document.cookie 读取,并不等于明文值本身安全。登录态相关的 value 不应直接使用明文用户标识,还应结合 Secure 限制安全协议传输,并用 SameSite 降低跨站请求携带带来的风险。

  • 一个后台系统要保存登录态、当前窗口的筛选条件和较大规模的结构化离线数据,你会分别落到哪类存储?

    登录态通常由 Cookie 承载,筛选条件若只需随当前窗口存在可放 sessionStorage,较大且需持久化的数据更适合 IndexedDBCookie 约受 4K 限制且随请求进入请求头,localStoragesessionStorage 通常只有数兆容量。

  • 客服要求关闭页面后筛选条件消失,但产品又要求重新打开浏览器仍保留用户偏好;只允许在 localStoragesessionStorage 间选,你怎么拆分?

    筛选条件应放入 sessionStorage,因为其生命周期与页面窗口相关,关闭后会被清理;用户偏好应放入 localStorage,除非主动清理否则会持续存在。两者都不会自动参与服务端通信,但都不适合超出其容量边界的数据。

  • 线上接口请求头突然膨胀,排查发现同一域名下写入了大量业务 Cookie;业务方却说这些数据只在个人中心页面使用,你怎么处理?

    应把不需要服务端识别的业务数据移出 Cookie,按生命周期改放 localStoragesessionStorage 或更大容量的 IndexedDBCookie 会在匹配其作用范围的请求中自动进入请求头,即使接口本身不用这些值,也会持续产生传输负担。

  • 多子域系统把登录 CookieDomain 设为顶级业务域、Path 设为 /,便利性与隔离性发生冲突,你会怎么评审?

    该配置会扩大 Cookie 的可用范围,使匹配该域及路径的更多页面和请求都可能携带它。若登录态只服务某个子域或路径,应尽量收窄 DomainPath;范围缩小会增加多系统共享登录态的设计成本。

  • 安全团队要求登录 Cookie 只能走 HTTPS、脚本不可读取,并尽量减少跨站请求携带;你会配置哪些属性,还要提醒什么边界?

    应配置 SecureHttpOnly 与合适的 SameSite:分别限制安全协议传输、阻止脚本读取并约束跨站携带。它们针对的是不同风险面,不能互相替代;同时仍需妥善处理登录态 value,不能把敏感身份直接作为明文保存。

# 7 跨域方案

⚡ 30 秒速记

  • 先说成因:同源策略限制的是浏览器读取跨源响应,请求其实已经发出去了
  • CORS(首选):服务端设 Access-Control-Allow-Origin;复杂请求先发 OPTIONS 预检;带 cookieOrigin 不能是 * 且要配 credentials
  • 开发环境代理:Webpack/VitedevServer.proxyNginx 反代,让请求变成同源
  • JSONP:只支持 GET,靠 script 标签不受同源限制,已过时且有安全风险
  • 页面间通信用 postMessageWebSocket 本身不受同源策略约束

跨域的核心是绕开或满足浏览器同源策略,常见方案可分为服务端放行、代理转发和标签请求。 接口能改服务端时优先配置 CORS;开发环境可用 webpack devServer.proxyhttp-proxy-middleware 做反向代理。只需发起 GET 请求时可以用 JSONP,图片上报则适合 img ping,但它拿不到响应正文。页面间通信可考虑 iframepostMessage,同时要留意交互安全和数据传递限制。

很多种方法,但万变不离其宗,都是为了搞定同源策略。重用的有 jsonpiframecorsimgHTML5 postMessage等等。其中用到 html 标签进行跨域的原理就是 html 不受同源策略影响。但只是接受 Get 的请求方式,这个得清楚。

延伸1:img iframe script 来发送跨域请求有什么优缺点?

1. iframe

  • 优点:跨域完毕之后DOM操作和互相之间的JavaScript调用都是没有问题的
  • 缺点:1.若结果要以URL参数传递,这就意味着在结果数据量很大的时候需要分割传递,巨烦。2.还有一个是iframe本身带来的,母页面和iframe本身的交互本身就有安全性限制。

2. script

  • 优点:可以直接返回json格式的数据,方便处理
  • 缺点:只接受GET请求方式

3. 图片ping

  • 优点:可以访问任何url,一般用来进行点击追踪,做页面分析常用的方法
  • 缺点:不能访问响应文本,只能监听是否响应

延伸2:配合 webpack 进行反向代理?

webpackdevServer 选项里面提供了一个 proxy 的参数供开发人员进行反向代理

'/api': {
  target: 'http://www.example.com', // your target host
  changeOrigin: true, // needed for virtual hosted sites
  pathRewrite: {
    '^/api': ''  // rewrite path
  }
},

然后再配合 http-proxy-middleware 插件对 api 请求地址进行代理

const express = require('express');
const proxy = require('http-proxy-middleware');
// proxy api requests
const exampleProxy = proxy(options); // 这里的 options 就是 webpack 里面的 proxy 选项对应的每个选项

// mount `exampleProxy` in web server
const app = express();
app.use('/api', exampleProxy);
app.listen(3000);

然后再用 nginx 把允许跨域的源地址添加到报头里面即可

说到 nginx ,可以再谈谈 CORS 配置,大致如下

location / {
  if ($request_method = 'OPTIONS') {
    add_header 'Access-Control-Allow-Origin' '*';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
    add_header 'Access-Control-Allow-Credentials' 'true';
    add_header 'Access-Control-Allow-Headers' 'DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type';
    add_header 'Access-Control-Max-Age' 86400;
    add_header 'Content-Type' 'text/plain charset=UTF-8';
    add_header 'Content-Length' 0;
    return 200;
  }
}

💬 面试官追问

  • 埋点页面用 new Image().src 上报成功,产品却要求读取服务端返回的用户标签;沿用图片 ping 为什么行不通?

    图片 ping 可以向跨域 URL 发起请求,但脚本不能读取响应正文,因此无法取得用户标签。它更适合点击追踪和页面分析,只能借助加载或失败事件判断是否响应;若业务依赖响应数据,就必须改用允许读取结果的受控方案。

  • 开发环境把 /api 配到 devServer.proxy 后请求正常,静态文件部署到测试环境却开始跨域;你会把代理落在哪一层?

    devServer.proxy 只属于本地开发服务器,打包后的静态资源不会携带这段转发逻辑。测试环境应由实际承载流量的服务处理,例如让 Nginx/api 反向代理,或由接口端配置 CORS;两边规则不一致仍会造成环境差异。

  • 旧页面用 script 加载跨域数据,接口负责人现在要求改成 POST 并提交较大的筛选条件;原方案还能继续吗?

    依赖 script 的跨域方案只能发起 GET,无法直接满足新的 POST 约束。把大量数据塞进查询参数还会受到 URL 长度、缓存和日志暴露等边界影响,应改由代理或 CORS 承载请求;迁移成本是服务端需要同步调整接口策略。

  • 线上跨域接口连真实业务日志都没有,只看到浏览器先发出 OPTIONS;你会先核对 Nginx 的哪些响应内容?

    真实请求未到业务服务时,应先检查预检响应是否允许当前源、方法和请求头,并确认 OPTIONS 能直接返回成功状态。还要核对 Access-Control-Allow-Credentials 与来源策略是否匹配,以及配置是否覆盖目标路径;任一项不匹配,浏览器都可能拦住后续请求。

  • 安全负责人反对放开整站 CORS,前端又希望所有环境都使用统一的 /api 地址;你会如何取舍?

    更稳妥的选择是让页面始终请求同域 /api,再由各环境的 Nginx 转发到真实接口,从浏览器视角规避跨域。若必须让第三方源直接访问,再按明确来源、方法和请求头配置 CORS;代理增加运维配置,开放 CORS 则扩大暴露边界。

  • 微前端容器需要和跨域 iframe 交换筛选状态,数据量持续增长;你会继续拼接 URL 参数还是改用什么机制?

    大量状态通过 URL 参数传递会面临长度限制、拆分复杂和信息暴露,不适合作为持续通信通道。应使用 postMessage 传递结构化消息,并在接收端校验 origin、消息类型和数据格式;它解决通信问题,但不会自动解除跨域 DOM 的安全限制。

# 8 XSS 和 CSRF

⚡ 30 秒速记

  • XSS:注入并执行恶意脚本,分存储型、反射型、DOM 型。防护是输出转义、用 textContent 而非 innerHTML、开 CSPcookieHttpOnly
  • CSRF:借用户已登录的身份发请求,攻击者拿不到响应但能触发操作。防护是 CSRF TokenSameSite=Lax/Strict、校验 Origin/Referer
  • 一句话区分:XSS盗用你的浏览器执行脚本CSRF盗用你的身份发请求
  • XSS 可以直接绕过 CSRF 防护(能执行脚本就能读 Token),所以 XSS 危害更大
  • 加分点:CSPscript-src 配合 nonce 是防 XSS 最有效的纵深手段

XSS 是把恶意代码注入页面并执行,CSRF 是借用用户已有登录态发送非本人意愿的请求。 XSS 分持久型和非持久型,防护重点是不信任用户输入:普通内容做转义,富文本采用白名单过滤,并用 CSP 限制资源加载和执行。CSRF 利用了浏览器自动携带目标域名 Cookie 的机制,但攻击站点通常拿不到该 Cookie。工程上应避免用 GET 修改数据,并结合验证 token、验证码及 SameSite 等措施。

1. XSS

涉及面试题:什么是 XSS 攻击?如何防范 XSS 攻击?什么是 CSP

  • XSS 简单点来说,就是攻击者想尽一切办法将可以执行的代码注入到网页中。
  • XSS 可以分为多种类型,但是总体上我认为分为两类:持久型和非持久型。
  • 持久型也就是攻击的代码被服务端写入进数据库中,这种攻击危害性很大,因为如果网站访问量很大的话,就会导致大量正常访问页面的用户都受到攻击。

举个例子,对于评论功能来说,就得防范持久型 XSS 攻击,因为我可以在评论中输入以下内容

image.png

  • 这种情况如果前后端没有做好防御的话,这段评论就会被存储到数据库中,这样每个打开该页面的用户都会被攻击到。
  • 非持久型相比于前者危害就小的多了,一般通过修改 URL 参数的方式加入攻击代码,诱导用户访问链接从而进行攻击。

举个例子,如果页面需要从 URL 中获取某些参数作为内容的话,不经过过滤就会导致攻击代码被执行

<!-- http://www.domain.com?name=<script>alert(1)</script> -->
<div>{{name}}</div>

但是对于这种攻击方式来说,如果用户使用 Chrome 这类浏览器的话,浏览器就能自动帮助用户防御攻击。但是我们不能因此就不防御此类攻击了,因为我不能确保用户都使用了该类浏览器。

对于 XSS 攻击来说,通常有两种方式可以用来防御。

  1. 转义字符

首先,对于用户的输入应该是永远不信任的。最普遍的做法就是转义输入输出的内容,对于引号、尖括号、斜杠进行转义

function escape(str) {
  str = str.replace(/&/g, '&amp;')
  str = str.replace(/</g, '&lt;')
  str = str.replace(/>/g, '&gt;')
  str = str.replace(/"/g, '&quto;')
  str = str.replace(/'/g, '&#39;')
  str = str.replace(/`/g, '&#96;')
  str = str.replace(/\//g, '&#x2F;')
  return str
}

通过转义可以将攻击代码 <script>alert(1)</script> 变成

// -> &lt;script&gt;alert(1)&lt;&#x2F;script&gt;
escape('<script>alert(1)</script>')

但是对于显示富文本来说,显然不能通过上面的办法来转义所有字符,因为这样会把需要的格式也过滤掉。对于这种情况,通常采用白名单过滤的办法,当然也可以通过黑名单过滤,但是考虑到需要过滤的标签和标签属性实在太多,更加推荐使用白名单的方式

const xss = require('xss')
let html = xss('<h1 id="title">XSS Demo</h1><script>alert("xss");</script>')
// -> <h1>XSS Demo</h1>&lt;script&gt;alert("xss");&lt;/script&gt;
console.log(html)

以上示例使用了 js-xss 来实现,可以看到在输出中保留了 h1 标签且过滤了 script标签

  1. CSP

CSP 本质上就是建立白名单,开发者明确告诉浏览器哪些外部资源可以加载和执行。我们只需要配置规则,如何拦截是由浏览器自己实现的。我们可以通过这种方式来尽量减少 XSS 攻击。

通常可以通过两种方式来开启 CSP

  • 设置 HTTP Header 中的 Content-Security-Policy
  • 设置 meta 标签的方式 <meta http-equiv="Content-Security-Policy">

这里以设置 HTTP Header 来举例

只允许加载本站资源

Content-Security-Policy: default-src ‘self’

只允许加载 HTTPS 协议图片

Content-Security-Policy: img-src https://*

允许加载任何来源框架

Content-Security-Policy: child-src 'none'

当然可以设置的属性远不止这些,你可以通过查阅 文档 (opens new window) 的方式来学习,这里就不过多赘述其他的属性了。

对于这种方式来说,只要开发者配置了正确的规则,那么即使网站存在漏洞,攻击者也不能执行它的攻击代码,并且 CSP 的兼容性也不错。

2 CSRF

跨站请求伪造(英语:Cross-site request forgery),也被称为 one-click attack或者 session riding,通常缩写为 CSRF 或者 XSRF, 是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法

CSRF 就是利用用户的登录态发起恶意请求

如何攻击

假设网站中有一个通过 Get 请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口

<img src="http://www.domain.com/xxx?comment='attack'"/>

res.setHeader('Set-Cookie', `username=poetry2;sameSite = strict;path=/;httpOnly;expires=${getCookirExpires()}`)

在B网站,危险网站向A网站发起请求

<!DOCTYPE html>
<html>
  <body>
  <!-- 利用img自动发送请求 -->
    <img src="http://localhost:8000/api/user/login" />
  </body>
</html>

会带上A网站的cookie

// 在A网站下发cookie的时候,加上sameSite=strict,这样B网站在发送A网站请求,不会自动带上A网站的cookie,保证了安全


// NAME=VALUE    赋予Cookie的名称及对应值
// expires=DATE  Cookie 的有效期
// path=PATH     赋予Cookie的名称及对应值
// domain=域名   作为 Cookie 适用对象的域名 (若不指定则默认为创建 Cookie 的服务器的域名) (一般不指定)
// Secure        仅在 HTTPS 安全通信时才会发送 Cookie
// HttpOnly      加以限制,使 Cookie 不能被 JavaScript 脚本访问
// SameSite      Lax|Strict|None  它允许您声明该Cookie是否仅限于第一方或者同一站点上下文

res.setHeader('Set-Cookie', `username=poetry;sameSite=strict;path=/;httpOnly;expires=${getCookirExpires()}`)

如何防御

  • Get 请求不对数据进行修改
  • 不让第三方网站访问到用户 Cookie
  • 阻止第三方网站请求接口
  • 请求时附带验证信息,比如验证码或者 token
  • SameSite Cookies: 只能当前域名的网站发出的http请求,携带这个Cookie。当然,由于这是新的cookie属性,在兼容性上肯定会有问题

CSRF攻击,仅仅是利用了http携带cookie的特性进行攻击的,但是攻击站点还是无法得到被攻击站点的cookie。这个和XSS不同,XSS是直接通过拿到Cookie等信息进行攻击的

在CSRF攻击中,就Cookie相关的特性:

  • http请求,会自动携带Cookie。
  • 携带的cookie,还是http请求所在域名的cookie。

3 密码安全

加盐

对于密码存储来说,必然是不能明文存储在数据库中的,否则一旦数据库泄露,会对用户造成很大的损失。并且不建议只对密码单纯通过加密算法加密,因为存在彩虹表的关系

  • 通常需要对密码加盐,然后进行几次不同加密算法的加密
// 加盐也就是给原密码添加字符串,增加原密码长度
sha256(sha1(md5(salt + password + salt)))

但是加盐并不能阻止别人盗取账号,只能确保即使数据库泄露,也不会暴露用户的真实密码。一旦攻击者得到了用户的账号,可以通过暴力破解的方式破解密码。对于这种情况,通常使用验证码增加延时或者限制尝试次数的方式。并且一旦用户输入了错误的密码,也不能直接提示用户输错密码,而应该提示账号或密码错误

前端加密

虽然前端加密对于安全防护来说意义不大,但是在遇到中间人攻击的情况下,可以避免明文密码被第三方获取

4. 总结

  • XSS:跨站脚本攻击,是一种网站应用程序的安全漏洞攻击,是代码注入的一种。常见方式是将恶意代码注入合法代码里隐藏起来,再诱发恶意代码,从而进行各种各样的非法活动

防范:记住一点 “所有用户输入都是不可信的”,所以得做输入过滤和转义

  • CSRF:跨站请求伪造,也称 XSRF,是一种挟制用户在当前已登录的Web应用程序上执行非本意的操作的攻击方法。与 XSS 相比,XSS利用的是用户对指定网站的信任,CSRF利用的是网站对用户网页浏览器的信任。

防范:用户操作验证(验证码),额外验证机制(token使用)等

💬 面试官追问

  • 评论页已经把 <> 全部转义了,安全同学却用一个带恶意参数的分享链接触发了脚本;团队因此认定“转义防不了 XSS”,你怎么判断?

    不能据此否定转义,先确认参数最终进入的是文本、属性、脚本还是其他上下文,以及是否存在绕过转义的直接插入。普通文本应按输出上下文转义;富文本则需白名单净化,单靠一套字符替换无法覆盖所有注入位置。

  • 内容平台允许作者提交含标题、链接和图片的 HTML,前端负责人主张提交前用正则删掉 <script>,后端认为原样入库即可;你会怎么落地?

    不能把前端正则作为安全边界,调用接口即可绕过,而且危险标签和属性很多,黑名单容易遗漏。服务端应使用成熟净化方案按允许的标签、属性和资源规则处理,前端可同步净化改善预览,并用严格 CSP 限制可加载、可执行的资源。

  • 业务为了接入外部图片和统计脚本,要求把 CSP 放宽为允许任意来源;富文本白名单仍然保留,这时你接受吗?

    不应直接允许任意来源,否则 CSP 对遗漏注入点的兜底能力会明显下降。应按资源类型分别配置可信来源,例如限制脚本、图片和子页面,并优先通过响应头下发;若业务必须放宽某类资源,要明确它不会连带放宽脚本执行。

  • 用户反馈打开一条历史评论后账号出现异常操作,数据库里发现了可执行片段,但安全人员抓不到攻击者读取 Cookie 的请求;你如何区分是持久型 `XSS还是CSRF``?

    评论中的代码被入库并在访问者页面执行,首先按持久型 XSS 排查,不以是否成功读取 Cookie 作为必要条件。检查渲染入口、净化结果和 CSP 报告;若只是外站借浏览器自动携带目标域 Cookie 发请求、外站拿不到其内容,才更符合 CSRF

  • 支付团队已给会话 Cookie 设置 SameSite=Strict,因此想删除所有 CSRF token;接口里还保留一个通过 GET 修改订阅状态的入口,你同意吗?

    不同意把 SameSite 当成唯一措施,更不能保留有副作用的 GET,因为图片等元素就能自动发起这类请求。应先把修改操作改为合适的方法,再附带服务端可验证的 token 或必要的用户验证;SameSite、接口访问限制和验证信息属于纵深防御。

# 9 Service Worker

⚡ 30 秒速记

  • 本质是跑在独立线程的代理,能拦截页面的所有网络请求,是 PWA 的基础
  • 三个生命周期:install(预缓存)→ activate(清理旧缓存)→ fetch(拦截请求决定走缓存还是网络)
  • 硬性限制:只能在 HTTPS(或 localhost)下使用、不能访问 DOM、靠 postMessage 与页面通信
  • 常用缓存策略:cache-first(静态资源)、network-first(接口)、stale-while-revalidate(先给旧的再后台更新)
  • 它是 AppCache 的替代品:更新逻辑完全由你的 JS 控制,不像 AppCache 那样死板

Service Worker 本质上是运行在网页与浏览器、网络之间的代理,可拦截请求并提供离线缓存能力。 它通常在 install 事件中预先缓存文件,再通过 fetch 事件决定返回缓存还是访问网络,因此常用于缓存静态文件和改善首屏加载。它不能直接操作 DOM,需要通过 postMessage 与受控页面通信。使用时还要注意只能运行在本地环境或 HTTPS 下,并且作用范围受脚本所在路径限制。

Service workers 本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API

浏览器对 ServiceWorker 做了很多限制

  • ServiceWorker 中无法直接访问 DOM,但可以通过 postMessage 接口发送的消息来与其控制的页面进行通信
  • ServiceWorker 只能在本地环境下或 HTTPS 网站中使用
  • ServiceWorker 有作用域的限制,一个 ServiceWorker 脚本只能作用于当前路径及其子路径;

目前该技术通常用来做缓存文件,提高首屏速度

// index.js
if (navigator.serviceWorker) {
  navigator.serviceWorker
    .register("sw.js")
    .then(function(registration) {
      console.log("service worker 注册成功");
    })
    .catch(function(err) {
      console.log("servcie worker 注册失败");
    });
}
// sw.js
// 监听 `install` 事件,回调中缓存所需文件
self.addEventListener("install", e => {
  e.waitUntil(
    caches.open("my-cache").then(function(cache) {
      return cache.addAll(["./index.html", "./index.js"]);
    })
  );
});

// 拦截所有请求事件
// 如果缓存中已经有请求的数据就直接用缓存,否则去请求数据
self.addEventListener("fetch", e => {
  e.respondWith(
    caches.match(e.request).then(function(response) {
      if (response) {
        return response;
      }
      console.log("fetch source");
    })
  );
});

打开页面,可以在开发者工具中的 Application 看到 Service Worker 已经启动了

在 Cache 中也可以发现我们所需的文件已被缓存

当我们重新刷新页面可以发现我们缓存的数据是从 Service Worker 中读取的

💬 面试官追问

  • 新闻站把资源写入 Cache API 后就宣称“已经支持离线”,但断网刷新时 HTML 仍然白屏;这说明团队混淆了什么?

    写入缓存不等于请求会自动命中缓存,必须由已激活且处于作用域内的 Service Workerfetch 事件中返回匹配响应。还要确认入口文档确实被缓存并有离线回退;首次访问、注册失败或尚未被控制的页面仍可能无法离线。

  • 商城准备缓存 index.htmlindex.js 来提升再次访问速度,代码只在 install 中执行 cache.addAll();你会怎样补全最小可用链路?

    注册脚本后,在 installevent.waitUntil() 中等待预缓存完成,并在 fetch 中用 event.respondWith() 返回 caches.match(request) 的结果。缓存未命中时必须继续请求网络或返回明确回退,否则处理器可能得不到有效响应;同时要保证站点使用 HTTPS 或本地环境。

  • 主站从根路径注册 sw.js 后缓存正常,迁到 /shop/sw.js 后首页请求不再被拦截,但 /shop/cart 正常;运维认为是浏览器缓存损坏,你怎么看?

    更可能是作用域变化,而不是缓存损坏,因为 Service Worker 默认只能控制脚本所在路径及其子路径。应检查注册记录、当前页面的控制器与注册作用域;若确需覆盖首页,应调整脚本位置或在受控配置下声明合适作用域。

  • 线上显示 Service Worker 注册成功,Application 面板也能看到它,但刷新后静态文件仍全部走网络;你按什么顺序排查?

    先确认当前页面已被该注册实例控制,并核对页面路径是否落在其作用域内,再检查 fetch 事件是否触发。随后验证缓存名称、请求键和 caches.match() 是否一致,以及 respondWith() 是否真正返回了响应;只完成注册或安装并不代表页面已经从缓存读取。

  • 运营后台要求直接在 Service Worker 中读取表单 DOM,以便断网时保存编辑内容;架构师则建议页面把数据发给它,你支持哪一方?

    应由页面读取表单,再通过 postMessageService Worker 通信,因为后者不能直接访问 DOM。离线缓存、请求拦截、推送和后台同步适合放在该代理层;页面状态采集仍属于受控页面职责,消息协议也要处理版本与校验。

# 10 DOM 节点操作

⚡ 30 秒速记

  • 创建:createElement/createTextNode/createDocumentFragment/cloneNode(deep)
  • 查找:querySelector(All) 最通用、getElementById 最快、closest 向上找祖先
  • 增删改:现代 API 更好用 —— append/prepend(可传多个节点和字符串)、before/afterremove()insertAdjacentHTML
  • 移动节点直接 append 已存在的节点即可,不需要先删
  • 两个坑:querySelectorAll 返回静态 NodeListgetElementsByTagName 返回动态 HTMLCollectioncloneNode 不会复制事件监听器

DOM 节点操作主要包括创建、增删替换、查找和属性修改,关键是按场景选对接口。 创建元素或文本可用 createElementcreateTextNode,批量组织节点时可先放进 createDocumentFragment。插入、删除和替换分别可以用 appendChildremoveChildreplaceChildinsertBefore。查找时精确 id 可用 getElementById,复杂选择条件更适合 querySelectorquerySelectorAll,属性则通过 getAttributesetAttribute 等接口处理。

(1)创建新节点

createDocumentFragment()    //创建一个DOM片段
createElement()   //创建一个具体的元素
createTextNode()   //创建一个文本节点

(2)添加、移除、替换、插入

appendChild(node)
removeChild(node)
replaceChild(new,old)
insertBefore(new,old)

(3)查找

getElementById();
getElementsByName();
getElementsByTagName();
getElementsByClassName();
querySelector();
querySelectorAll();

(4)属性操作

getAttribute(key);
setAttribute(key, value);
hasAttribute(key);
removeAttribute(key);

💬 面试官追问

  • 表格里有一个 <tr id="row-7">,同事说 querySelector('#row-7') 返回的是“动态集合”,删掉该行后变量会自动变空;你怎么纠正?

    querySelector() 返回首个匹配元素或空值,不是会随文档变化的集合,已保存的变量也不会因节点移除而自动清空。若要获取多个匹配项应使用 querySelectorAll();代码还持有节点引用时,该引用依然存在,不能把查询结果与文档结构变化混为一谈。

  • 评论列表一次插入 300 个新条目,代码在循环里逐个 appendChild() 到可见容器;只允许使用题目列出的原生节点能力,你会怎么改?

    先用 createDocumentFragment() 作为临时容器,把 createElement()createTextNode() 创建的条目依次追加进去,最后一次性挂到列表。这样组织也便于在入树前完成内容和属性设置;片段只减少零散插入,仍需控制总节点规模。

  • 设计稿要求把现有占位节点换成真实卡片,并保证它仍位于原来的兄弟节点之间;有人提议先 removeChild()appendChild(),你选什么?

    应使用父节点的 replaceChild(newNode, oldNode),它直接在原位置完成替换,不会把新卡片移动到容器末尾。若只是把节点插到某个兄弟节点前,则用 insertBefore(newNode, referenceNode);操作前仍要确保旧节点确实属于该父节点。

  • 线上筛选器偶发报错,日志显示 removeChild 的目标节点并不在当前列表中;页面同时会重新查询并替换整块列表,你怎么定位?

    先核对删除时使用的父节点和目标节点是否来自同一轮查询,整块替换后旧引用可能已不属于新容器。删除前检查父子关系或重新按稳定标识查找,再调用 removeChild();若目标可能已被其他流程删除,应把并发更新设计成幂等。

  • 组件要判断按钮是否配置 disabled,评审中一方读取 getAttribute('disabled'),另一方用 hasAttribute('disabled');这里只关心属性是否存在,该怎么选?

    应使用 hasAttribute('disabled'),因为需求判断的是属性存在性,而不是取得其字符串值。需要读取具体属性内容时才用 getAttribute(),写入和清理分别用 setAttribute()removeAttribute();不要把缺失属性和值为空混作同一业务状态。

# 11 掌握页面的加载过程

⚡ 30 秒速记

  • 关键节点顺序:domLoadingdomInteractiveDOM 解析完)→ DOMContentLoadeddefer 脚本执行完)→ load(图片等资源全部加载完)
  • DOMContentLoaded 不等图片,load 要等 —— 这是两者最核心的区别
  • 阻塞点:同步 script 阻塞 DOM 解析;CSS 阻塞渲染,且会阻塞它后面的 JS 执行(因为 JS 可能读样式)
  • 采集手段:PerformanceObserverNavigation Timing APITTFB = responseStart - requestStart
  • 优化落点:关键 CSS 内联、JSdefer、图片懒加载、字体 font-display:swap

页面加载就是浏览器获取并从上到下解析 HTML,遇到外部资源按需下载,解析到 <body> 后逐步渲染页面。 普通 <script> 会暂停文档解析,等脚本下载、解析和执行完再继续,所以放在 <head> 里可能拖慢内容展示。工程中可以把脚本放到文档底部,或用 deferasync 控制加载和执行时机;前者通常等解析完成后执行,后者下载完成就执行且顺序不可预测。判断完成状态时,DOMContentLoaded 表示初始 HTML 已解析,load 则还要等待 DOMCSSJS 和图片全部加载。

网页加载流程

  • 当我们打开网址的时候,浏览器会从服务器中获取到 HTML 内容
  • 浏览器获取到 HTML 内容后,就开始从上到下解析 HTML 的元素
  • <head>元素内容会先被解析,此时浏览器还没开始渲染页面
    • 我们看到<head>元素里有用于描述页面元数据的<meta>元素,还有一些<link>元素涉及外部资源(如图片、CSS 样式等),此时浏览器会去获取这些外部资源。除此之外,我们还能看到<head>元素中还包含着不少的<script>元素,这些<script>元素通过src属性指向外部资源
  • 当浏览器解析到这里时(步骤 3),会暂停解析并下载 JavaScript 脚本
  • 当 JavaScript 脚本下载完成后,浏览器的控制权转交给 JavaScript 引擎。当脚本执行完成后,控制权会交回给渲染引擎,渲染引擎继续往下解析 HTML 页面
  • 此时<body>元素内容开始被解析,浏览器开始渲染页面
  • 在这个过程中,我们看到<head>中放置的<script>元素会阻塞页面的渲染过程:把 JavaScript 放在<head>里,意味着必须把所有 JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面
  • 如果外部脚本加载时间很长(比如一直无法完成下载),就会造成网页长时间失去响应,浏览器就会呈现“假死”状态,用户体验会变得很糟糕
  • 因此,对于对性能要求较高、需要快速将内容呈现给用户的网页,常常会将 JavaScript 脚本放在<body>的最后面。这样可以避免资源阻塞,页面得以迅速展示。我们还可以使用defer/async/preload等属性来标记<script>标签,来控制 JavaScript 的加载顺序

延迟加载的方式有哪些

js 的加载、解析和执行会阻塞页面的渲染过程,因此我们希望 js 脚本能够尽可能的延迟加载,提高页面的渲染速度。

几种方式是:

  • 将 js 脚本放在文档的底部,来使 js 脚本尽可能的在最后来加载执行
  • 给 js 脚本添加 defer 属性,这个属性会让脚本的加载与文档的解析同步解析,然后在文档解析完成后再执行这个脚本文件,这样的话就能使页面的渲染不被阻塞。多个设置了 defer 属性的脚本按规范来说最后是顺序执行的,但是在一些浏览器中可能不是这样
  • 给 js 脚本添加 async属性,这个属性会使脚本异步加载,不会阻塞页面的解析过程,但是当脚本加载完成后立即执行 js脚本,这个时候如果文档没有解析完成的话同样会阻塞。多个 async 属性的脚本的执行顺序是不可预测的,一般不会按照代码的顺序依次执行
  • 动态创建 DOM 标签的方式,我们可以对文档的加载事件进行监听,当文档加载完成后再动态的创建 script 标签来引入 js 脚本

怎么判断页面是否加载完成

  • Load 事件触发代表页面中的 DOMCSSJS,图片已经全部加载完毕。
  • DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSSJS,图片加载

💬 面试官追问

  • 活动页把同步 <script src="vendor.js"> 放在 <head>,网络慢时 <body> 长时间不出现;后端说 HTML 已经返回,所以不该白屏,你怎么解释?

    浏览器自上而下解析到该脚本时会暂停 HTML 解析,等待脚本下载并交给 JavaScript 引擎执行,之后才继续处理 <body>。因此响应到达不等于页面可渲染;可把非关键脚本移到文档末尾,或按依赖关系选择 deferasync

  • 页面有三个存在依赖顺序的脚本,又不希望它们阻塞文档解析;一位开发选 async,另一位选 defer,你支持谁?

    支持 defer,它让脚本下载与文档解析并行,并在文档解析完成后执行,多个脚本按规范保持顺序。async 下载完成就立即执行,执行时仍可能打断尚未完成的解析,而且多个脚本的先后不可预测,不适合存在依赖的链路。

  • 第三方统计脚本完全独立,但产品要求数据尽早上报,前端又不能让它持续阻塞首屏;你会在 asyncdefer 和动态插入之间怎么选?

    独立且希望尽早执行时可选 async,它不会等待文档解析完成,但下载完成后的执行仍可能短暂阻塞解析。若必须等文档完成再加载,可监听加载事件后动态创建 script;选择取决于上报时效与渲染影响,不能假定 async 保证执行顺序。

  • 监控显示 DOMContentLoaded 已触发,但 load 又过了很久才发生,页面包含大图、样式和多个外部脚本;你如何判断卡在哪类资源?

    这表示初始 HTML 已完成加载和解析,但页面依赖的其他资源尚未全部结束。结合网络请求检查仍在下载或失败重试的 CSSJS、图片等资源;load 要等页面资源完成,不能只凭两事件间隔断定某一种资源。

  • 旧项目把所有脚本都移到 <body> 末尾,新负责人要求统一改成 async,理由是“两者都不会阻塞首屏”;你会接受统一替换吗?

    不接受统一替换,末尾脚本按文档顺序遇到并执行,而 async 的执行顺序不可预测,下载完成时还可能打断解析。独立脚本适合 async,有依赖顺序且需延后执行的脚本更适合 defer;动态插入则适用于明确等待某个加载事件的场景。

# 12 从输入URL到页面展示过程

⚡ 30 秒速记

  • 主干七步:URL 解析 → DNSIPTCP 三次握手 → (HTTPS 再加 TLS 握手)→ 发 HTTP 请求 → 服务端响应 → 浏览器解析渲染
  • 渲染子流程:HTMLDOMCSSCSSOM,合并成渲染树 → 布局 → 绘制 → 合成
  • 缓存贯穿全程:DNS 缓存、强缓存(不发请求)、协商缓存(发请求拿 304
  • JS 阻塞 DOM 解析,CSS 阻塞渲染 —— 这是「样式放头部、脚本加 defer」的真正原因
  • 答题技巧:说完主干后主动挑一环深入(DNS 递归查询、握手为什么是三次、重排重绘),比平铺直叙加分

输入 URL 后,浏览器会完成域名解析、建立连接、收发 HTTP 响应,再把资源渲染成可见页面。 域名先经过多级缓存或 DNS 查询得到 IP,随后通过三次握手建立 TCP 连接;如果是 HTTPS,还要进行 TLS 握手来加密传输。服务器处理请求时会校验 if-none-matchif-modified-since 等缓存信息,缓存有效返回 304,否则以 200 返回资源。浏览器拿到内容后构建 DOM、计算样式和布局,再分层、栅格化并合成显示;大页面通常优先处理视口附近的图块,避免无意义的绘制开销。

1. DNS域名解析

  • 根 DNS 服务器 :返回顶级域 DNS 服务器的 IP 地址
  • 顶级域 DNS 服务器:返回权威 DNS 服务器的 IP 地址
  • 权威 DNS 服务器 :返回相应主机的 IP 地址

DNS的域名查找,在客户端和浏览器,本地DNS之间的查询方式是递归查询;在本地DNS服务器与根域及其子域之间的查询方式是迭代查询;

在客户端输入 URL 后,会有一个递归查找的过程,从浏览器缓存中查找->本地的hosts文件查找->找本地DNS解析器缓存查找->本地DNS服务器查找,这个过程中任何一步找到了都会结束查找流程。

如果本地DNS服务器无法查询到,则根据本地DNS服务器设置的转发器进行查询。若未用转发模式,则迭代查找过程如下图:

结合起来的过程,可以用一个图表示:

在查找过程中,有以下优化点:

  • DNS存在着多级缓存,从离浏览器的距离排序的话,有以下几种: 浏览器缓存,系统缓存,路由器缓存,IPS服务器缓存,根域名服务器缓存,顶级域名服务器缓存,主域名服务器缓存
  • 在域名和 IP 的映射过程中,给了应用基于域名做负载均衡的机会,可以是简单的负载均衡,也可以根据地址和运营商做全局的负载均衡。

2. 建立TCP连接

首先,判断是不是https的,如果是,则HTTPS其实是HTTP + SSL / TLS 两部分组成,也就是在HTTP上又加了一层处理加密信息的模块。服务端和客户端的信息传输都会通过TLS进行加密,所以传输的数据都是加密后的数据

进行三次握手,建立TCP连接。

  • 第一次握手:建立连接。客户端发送连接请求报文段
  • 第二次握手:服务器收到SYN报文段。服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认
  • 第三次握手:客户端收到服务器的SYN+ACK报文段,向服务器发送ACK报文段

SSL握手过程

  • 第一阶段 建立安全能力 包括协议版本 会话Id 密码构件 压缩方法和初始随机数
  • 第二阶段 服务器发送证书 密钥交换数据和证书请求,最后发送请求-相应阶段的结束信号
  • 第三阶段 如果有证书请求客户端发送此证书 之后客户端发送密钥交换数据 也可以发送证书验证消息
  • 第四阶段 变更密码构件和结束握手协议

完成了之后,客户端和服务器端就可以开始传送数据

发送HTTP请求,服务器处理请求,返回响应结果

TCP连接建立后,浏览器就可以利用 HTTP/HTTPS 协议向服务器发送请求了。服务器接受到请求,就解析请求头,如果头部有缓存相关信息如if-none-match与if-modified-since,则验证缓存是否有效,若有效则返回状态码为304,若无效则重新返回资源,状态码为200

这里有发生的一个过程是HTTP缓存,是一个常考的考点,大致过程如图:

3. 关闭TCP连接

4. 浏览器渲染

按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、栅格化和显示。如图:

  • 渲染进程将 HTML 内容转换为能够读懂DOM 树结构。
  • 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
  • 创建布局树,并计算元素的布局信息。
  • 对布局树进行分层,并生成分层树。
  • 为每个图层生成绘制列表,并将其提交到合成线程。合成线程将图层分图块,并栅格化将图块转换成位图。
  • 合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上。

构建 DOM 树

  • 转码(Bytes -> Characters)—— 读取接收到的 HTML 二进制数据,按指定编码格式将字节转换为 HTML 字符串
  • Tokens 化(Characters -> Tokens)—— 解析 HTML,将 HTML 字符串转换为结构清晰的 Tokens,每个 Token 都有特殊的含义同时有自己的一套规则
  • 构建 Nodes(Tokens -> Nodes)—— 每个 Node 都添加特定的属性(或属性访问器),通过指针能够确定 Node 的父、子、兄弟关系和所属 treeScope(例如:iframe 的 treeScope 与外层页面的 treeScope 不同)
  • 构建 DOM 树(Nodes -> DOM Tree)—— 最重要的工作是建立起每个结点的父子兄弟关系

样式计算

渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。

CSS 样式来源主要有 3 种,分别是通过 link 引用的外部 CSS 文件、style标签内的 CSS、元素的 style 属性内嵌的 CSS。

页面布局

布局过程,即排除 script、meta 等功能化、非视觉节点,排除 display: none 的节点,计算元素的位置信息,确定元素的位置,构建一棵只包含可见元素布局树。如图:

其中,这个过程需要注意的是回流和重绘

生成分层树

页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)

栅格化

合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图

通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

显示

最后,合成线程发送绘制图块命令给浏览器进程。浏览器进程根据指令生成页面,并显示到显示器上,渲染过程完成。

💬 面试官追问

  • 用户输入域名后很快拿到 IP,同事据此说“接下来浏览器只要发 HTTP 请求,不需要再建立连接”;如果地址是 HTTPS,你怎么纠正?

    拿到 IP 只完成了域名解析,通常还需通过三次握手建立 TCP 连接,并在其上完成 TLS 安全协商,之后才能传输加密的 HTTP 数据。缓存或连接复用可能缩短部分阶段,但不能把 DNS 命中等同于请求已经可发送。

  • 跨地区用户首次打开页面时域名解析明显偏慢,架构组准备调整权威解析和负载均衡;前端排查报告应把完整查询链路写到什么程度?

    应先记录浏览器缓存、hosts、本地解析器缓存到本地 DNS 的递归查找,再说明本地 DNS 未命中时会向根域、顶级域和权威 DNS 迭代查询。域名到 IP 的映射还能承载按地区或运营商的负载均衡,但实际慢点需用观测数据确认。

  • 接口请求带上 If-None-MatchIf-Modified-Since 后返回 304,开发却仍等待一个完整响应体再渲染;这里的缓存流程应怎样理解?

    304 表示服务器验证缓存仍有效,浏览器应复用本地资源,而不是等待服务器重新发送完整实体。缓存无效或资源已变化时才重新返回资源并通常使用 200;条件请求仍发生了网络往返,因此它与完全不发请求的本地命中不同。

  • 线上偶发“接口响应很快但页面仍白屏”,性能记录显示时间集中在样式计算和布局之后;你会沿渲染流水线怎么查?

    响应完成后仍要经历构建 DOM、样式计算、生成布局树、分层、生成绘制列表、栅格化和显示。应定位是复杂样式与布局计算、过大的图层,还是视口附近图块迟迟未完成栅格化;只看接口耗时无法解释渲染进程和合成线程的开销。

  • 长列表页面高达数十屏,产品要求首次打开就栅格化全部内容以保证滚动绝对流畅,浏览器工程师反对;你站哪边?

    支持按视口附近图块优先栅格化,因为用户首次只能看到视口内的一小部分,提前处理全部大图层会产生不必要开销。合成线程负责分块并安排栅格化,再把绘制命令交给浏览器进程显示;过度预处理还可能拖慢首屏。

  • 面试中候选人把“构建 DOM”直接说成“把字符串变成节点数组”,但线上乱码只发生在某种响应编码下;你会怎样继续追问并判断?

    构建 DOM 前先要按指定编码把接收的字节转成字符,编码判断错误就可能在分词前产生乱码。随后才是字符转 TokenToken 转带属性和关系的节点,最终建立父子兄弟关系形成 DOM 树;节点数组的说法遗漏了转码和树结构。

# 13 渲染引擎什么情况下才会为特定的节点创建新的图层

⚡ 30 秒速记

  • 常见提升条件:3D transformwill-change: transform/opacityvideo/canvas/iframeposition:fixedopacity 动画、有合成层后代且自身有层叠上下文
  • 隐式合成是层爆炸的主因:一个元素层级在某个合成层之上,会被迫也提升为合成层
  • 合成层的收益:位移/缩放/透明度交给 GPU,跳过布局和绘制
  • 代价:每层都占显存,层太多反而更卡
  • 排查工具:DevToolsLayers 面板看层数,Rendering 面板勾 Layer borders

当节点形成独立层叠上下文,或者内容需要被裁剪时,渲染引擎会为它创建新的图层。 常见触发条件可以归为定位与层级、视觉效果和渲染隔离,例如非 autoz-index、非 nonetransform、小于 1opacity,以及 will-change。本质上,这些属性要求节点独立参与合成或控制绘制范围。比如固定尺寸容器中的内容发生溢出时,文字裁剪区域会单独成层;如果还有滚动条,滚动条也可能成为独立图层。

层叠上下文是HTML元素的三维概念,这些HTML元素在一条假想的相对于面向(电脑屏幕的)视窗或者网页的用户的z轴上延伸,HTML元素依据其自身属性按照优先级顺序占用层叠上下文的空间。

  1. 拥有层叠上下文属性的元素会被提升为单独的一层。

拥有层叠上下文属性:

  • 根元素 (HTML),
  • z-index 值不为 "auto"的 绝对/相对定位元素,
  • position,固定(fixed) / 沾滞(sticky)定位(沾滞定位适配所有移动设备上的浏览器,但老的桌面浏览器不支持)
  • z-index值不为 "auto"的 flex 子项 (flex item),即:父元素 display: flex|inline-flex,
  • z-index值不为"auto"的grid子项,即:父元素display:grid
  • opacity 属性值小于 1 的元素(参考 the specification for opacity),
  • transform 属性值不为 "none"的元素,
  • mix-blend-mode 属性值不为 "normal"的元素,
  • filter值不为"none"的元素,
  • perspective值不为"none"的元素,
  • clip-path值不为"none"的元素
  • mask / mask-image / mask-border不为"none"的元素
  • isolation 属性被设置为 "isolate"的元素
  • 在 will-change 中指定了任意CSS属性(参考 这篇文章)
  • -webkit-overflow-scrolling 属性被设置 "touch"的元素
  • contain属性值为"layout","paint",或者综合值比如"strict","content"
  1. 需要剪裁(clip)的地方也会被创建为图层。

这里的剪裁指的是,假如我们把 div 的大小限定为 200 * 200 像素,而 div 里面的文字内容比较多,文字所显示的区域肯定会超出 200 * 200 的面积,这时候就产生了剪裁,渲染引擎会把裁剪文字内容的一部分用于显示在 div 区域。出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。

💬 面试官追问

  • 商品卡片只设置了 position: relative,没有 z-index,同事却断言它一定会生成独立图层;你会怎么纠正这个判断?

    不能仅凭 position: relative 得出结论,源码列出的条件是绝对或相对定位元素同时满足 z-index 不为 auto。还要区分层叠上下文与渲染引擎实际创建的图层,不能把所有定位元素一概视为已经提层。

  • 长列表里有 500 个卡片,为了准备悬浮动画统一加了 will-change: transform,上线后资源占用反而增加,你会怎么改?

    不应给全部卡片长期声明 will-change,它会向渲染引擎提示相关属性即将变化,可能促使元素提前拥有独立层。只在动画临近开始时作用于实际运动的卡片,并在结束后移除;大量常驻图层会增加资源和合成管理成本。

  • 设计师把半透明浮层的 opacity0.99 改成 1,同时去掉了 transform,你会预期它的层级行为发生什么变化?

    opacity 小于 1transform 不为 none 都属于源码列出的层叠上下文条件,两者移除后,这个浮层不再因它们获得独立层。它仍可能被 filterisolation、定位与 z-index 等其他属性影响,因此必须结合完整样式判断。

  • 一个 200px × 200px 的滚动容器出现文字残影,现场只检查了 z-index;你会继续查哪些与分层有关的线索?

    应继续检查容器的裁剪范围、溢出内容和滚动条,因为源码指出需要剪裁时,文字内容与滚动条可能被分别创建为图层。再核对 transformfilterclip-pathmaskcontain 等属性,避免把裁剪问题误判成单纯的层叠顺序错误。

  • 弹窗既能用定位配合非 autoz-index,也能在父容器上设置 isolation: isolate;评审时你会如何取舍?

    两者都会建立新的层叠上下文,但表达的约束不同:前者直接参与定位元素的层叠排序,后者用于隔离内部元素与外部混合。应按弹窗的层级关系选择,不能只为“提层”叠加属性,否则会制造新的层叠边界并增加排查难度。

# 14 定时器与requestAnimationFrame、requestIdleCallback

⚡ 30 秒速记

  • setTimeout/setInterval:时间不精确、会累积漂移、页面切后台仍在跑,不适合做动画
  • requestAnimationFrame:在下次重绘之前执行,与屏幕刷新率同步,页面不可见时自动暂停 —— 动画首选
  • requestIdleCallback:在浏览器空闲时段执行,可传 timeout 兜底,用来做埋点上报、预加载这类低优先级任务
  • RIC 回调能拿到 IdleDeadline,用 timeRemaining() 判断还剩多少时间,主动让出主线程
  • 加分点:React 早期用 RIC 做时间切片,后来因触发不稳定改成了基于 MessageChannel 的自研调度器

定时任务用 setTimeout,动画更新用 requestAnimationFrame,低优先级工作用 requestIdleCallback setTimeout 到点只是把回调加入任务队列,主线程忙时仍要等待,并不保证准时执行。requestAnimationFrame 会跟随屏幕刷新,在重绘前回调,后台标签页还会暂停,但主线程阻塞时同样可能掉帧。requestIdleCallback 适合切割长任务或发送数据,不过只有当前帧有空闲时间才会执行,必要时可设置 timeout

1. setTimeout

setTimeout的运行机制:执行该语句时,是立即把当前定时器代码推入事件队列,当定时器在事件列表中满足设置的时间值时将传入的函数加入任务队列,之后的执行就交给任务队列负责。但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间

setTimeout(() => {
    console.log(1);
}, 0)
console.log(2);

输出 2, 1;

setTimeout的第二个参数表示在执行代码前等待的毫秒数。上面代码中,设置为0,表面意思为 执行代码前等待的毫秒数为0,即立即执行。但实际上的运行结果我们也看到了,并不是表面上看起来的样子,千万不要被欺骗了。

实际上,上面的代码并不是立即执行的,这是因为setTimeout有一个最小执行时间,HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔)不得低于4毫秒。 当指定的时间低于该时间时,浏览器会用最小允许的时间作为setTimeout的时间间隔,也就是说即使我们把setTimeout的延迟时间设置为0,实际上可能为 4毫秒后才事件推入任务队列

定时器代码在被推送到任务队列前,会先被推入到事件列表中,当定时器在事件列表中满足设置的时间值时会被推到任务队列,但是如果此时任务队列不为空,则需等待,所以执行定时器内代码的时间可能会大于设置的时间

setTimeout(() => {
    console.log(111);
}, 100);

上面代码表示100ms后执行console.log(111),但实际上实行的时间肯定是大于100ms后的, 100ms 只是表示 100ms 后将任务加入到"任务队列"中,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。

2. setTimeout 和 setInterval区别

  • setTimeout: 指定延期后调用函数,每次setTimeout计时到后就会去执行,然后执行一段时间后才继续setTimeout,中间就多了误差,(误差多少与代码的执行时间有关)。
  • setInterval:以指定周期调用函数,而setInterval则是每次都精确的隔一段时间推入一个事件(但是,事件的执行时间不一定就不准确,还有可能是这个事件还没执行完毕,下一个事件就来了).
btn.onclick = function(){
    setTimeout(function(){
        console.log(1);
    },250);
}

击该按钮后,首先将onclick事件处理程序加入队列。该程序执行后才设置定时器,再有250ms后,指定的代码才被添加到队列中等待执行。 如果上面代码中的onclick事件处理程序执行了300ms,那么定时器的代码至少要在定时器设置之后的300ms后才会被执行。队列中所有的代码都要等到javascript进程空闲之后才能执行,而不管它们是如何添加到队列中的。

如图所示,尽管在255ms处添加了定时器代码,但这时候还不能执行,因为onclick事件处理程序仍在运行。定时器代码最早能执行的时机是在300ms处,即onclick事件处理程序结束之后。

3. setInterval存在的一些问题:

JavaScript中使用 setInterval 开启轮询。定时器代码可能在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次,而之间没有任何停顿。而javascript引擎对这个问题的解决是:当使用setInterval()时,仅当没有该定时器的任何其他代码实例时,才将定时器代码添加到队列中。这确保了定时器代码加入到队列中的最小时间间隔为指定间隔。

但是,这样会导致两个问题:

  • 某些间隔被跳过;
  • 多个定时器的代码执行之间的间隔可能比预期的小

假设,某个onclick事件处理程序使用setInterval()设置了200ms间隔的定时器。如果事件处理程序花了300ms多一点时间完成,同时定时器代码也花了差不多的时间,就会同时出现跳过某间隔的情况

例子中的第一个定时器是在205ms处添加到队列中的,但是直到过了300ms处才能执行。当执行这个定时器代码时,在405ms处又给队列添加了另一个副本。在下一个间隔,即605ms处,第一个定时器代码仍在运行,同时在队列中已经有了一个定时器代码的实例。结果是,在这个时间点上的定时器代码不会被添加到队列中

使用setTimeout构造轮询能保证每次轮询的间隔。

setTimeout(function () {
 console.log('我被调用了');
 setTimeout(arguments.callee, 100);
}, 100);

calleearguments 对象的一个属性。它可以用于引用该函数的函数体内当前正在执行的函数。在严格模式下,第5版 ECMAScript (ES5) 禁止使用arguments.callee()。当一个函数必须调用自身的时候, 避免使用 arguments.callee(), 通过要么给函数表达式一个名字,要么使用一个函数声明.

setTimeout(function fn(){
    console.log('我被调用了');
    setTimeout(fn, 100);
},100);

这个模式链式调用了setTimeout(),每次函数执行的时候都会创建一个新的定时器。第二个setTimeout()调用当前执行的函数,并为其设置另外一个定时器。这样做的好处是,在前一个定时器代码执行完之前,不会向队列插入新的定时器代码,确保不会有任何缺失的间隔。而且,它可以保证在下一次定时器代码执行之前,至少要等待指定的间隔,避免了连续的运行。

4. requestAnimationFrame

4.1 60fps与设备刷新率

目前大多数设备的屏幕刷新率为60次/秒,如果在页面中有一个动画或者渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。

卡顿:其中每个帧的预算时间仅比16毫秒多一点(1秒/ 60 = 16.6毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作是需要在10毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。此现象通常称为卡顿,会对用户体验产生负面影响。

跳帧: 假如动画切换在 16ms, 32ms, 48ms时分别切换,跳帧就是假如到了32ms,其他任务还未执行完成,没有去执行动画切帧,等到开始进行动画的切帧,已经到了该执行48ms的切帧。就好比你玩游戏的时候卡了,过了一会,你再看画面,它不会停留你卡的地方,或者这时你的角色已经挂掉了。必须在下一帧开始之前就已经绘制完毕;

Chrome devtool 查看实时 FPS, 打开 More tools => Rendering, 勾选 FPS meter

4.2 requestAnimationFrame实现动画

requestAnimationFrame是浏览器用于定时循环操作的一个接口,类似于setTimeout,主要用途是按帧对网页进行重绘。

requestAnimationFrame 之前,主要借助 setTimeout/ setInterval 来编写 JS 动画,而动画的关键在于动画帧之间的时间间隔设置,这个时间间隔的设置有讲究,一方面要足够小,这样动画帧之间才有连贯性,动画效果才显得平滑流畅;另一方面要足够大,确保浏览器有足够的时间及时完成渲染。

显示器有固定的刷新频率(60Hz或75Hz),也就是说,每秒最多只能重绘60次或75次,requestAnimationFrame的基本思想就是与这个刷新频率保持同步,利用这个刷新频率进行页面重绘。此外,使用这个API,一旦页面不处于浏览器的当前标签,就会自动停止刷新。这就节省了CPU、GPU和电力。

requestAnimationFrame 是在主线程上完成。这意味着,如果主线程非常繁忙,requestAnimationFrame的动画效果会大打折扣。

requestAnimationFrame 使用一个回调函数作为参数。这个回调函数会在浏览器重绘之前调用。

requestID = window.requestAnimationFrame(callback);

window.requestAnimFrame = (function(){
    return  window.requestAnimationFrame       ||
            window.webkitRequestAnimationFrame ||
            window.mozRequestAnimationFrame    ||
            window.oRequestAnimationFrame      ||
            window.msRequestAnimationFrame     ||
            function( callback ){
            window.setTimeout(callback, 1000 / 60);
        };
})();

上面的代码按照1秒钟60次(大约每16.7毫秒一次),来模拟requestAnimationFrame

5. requestIdleCallback()

MDN上的解释:requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

requestAnimationFrame会在每次屏幕刷新的时候被调用,而requestIdleCallback则会在每次屏幕刷新时,判断当前帧是否还有多余的时间,如果有,则会调用requestIdleCallback的回调函数,

图片中是两个连续的执行帧,大致可以理解为两个帧的持续时间大概为16.67,图中黄色部分就是空闲时间。所以,requestIdleCallback 中的回调函数仅会在每次屏幕刷新并且有空闲时间时才会被调用.

利用这个特性,我们可以在动画执行的期间,利用每帧的空闲时间来进行数据发送的操作,或者一些优先级比较低的操作,此时不会使影响到动画的性能,或者和requestAnimationFrame搭配,可以实现一些页面性能方面的的优化,

react 的 fiber 架构也是基于 requestIdleCallback 实现的, 并且在不支持的浏览器中提供了 polyfill

总结

  • 单线程模型和任务队列出发理解 setTimeout(fn, 0),并不是立即执行。
  • JS 动画, 用requestAnimationFrame 会比 setInterval 效果更好
  • requestIdleCallback()常用来切割长任务,利用空闲时间执行,避免主线程长时间阻塞

# 五、框架通识

框架通识 (opens new window)

💬 面试官追问

  • 后台管理页只是简单表格,候选人却用“框架一定更快”作为引入重型方案的唯一理由;作为负责人你会接受吗?

    不会仅凭这一表述通过选型,因为给定材料只提供了“框架通识”的索引,没有给出任何性能结论。需要补充页面交互复杂度、状态同步需求、团队维护成本与实测依据;缺少这些条件时,不能把框架使用与性能收益直接画等号。

  • 旧项目准备迁移框架,业务方要求两周内完成,研发只比较了语法是否顺手;你会要求评审补齐什么?

    应补齐现有组件边界、状态流转、路由和构建链路的迁移范围,并明确兼容、测试及回滚方案。材料没有提供具体框架能力或版本边界,因此不能替团队指定技术栈;时间受限时,迁移成本与故障恢复能力应先于语法偏好。

  • 微前端页面要同时接入两个框架,评审会上有人认为“框架通识相同,所以可以直接共享组件实例”;你怎么判断?

    不能由“框架通识”推导出不同运行时能够直接共享组件实例,材料也没有支持这种兼容性结论。应先验证组件生命周期、状态所有权、事件边界和渲染容器是否可隔离;无法证明兼容时,优先通过明确的数据与接口边界集成。

  • 线上出现页面更新错乱,值班同学先把故障归因于框架本身,却拿不出最小复现;你会怎么推进排查?

    先固定输入数据、用户操作和视图结果,缩小到能够稳定复现的组件边界,再区分业务状态、框架更新和直接 DOM 操作。给定材料没有具体机制可供直接归因,因此应依赖日志、复现与逐层隔离;无证据地更换框架只会扩大故障面。

  • 团队争论下一套中后台应该选框架还是原生方案,但需求文档只有十个静态页面;你会如何作出阶段性决策?

    当前信息只支持先按静态页面的真实复杂度控制方案规模,不能预设未来一定需要完整框架。若后续出现持续状态同步、组件复用和复杂交互,再基于新增约束复审;过早引入会带来依赖与维护成本,过晚迁移则可能产生重构成本。

# 六、Vue

# 1 Vue 响应式原理

⚡ 30 秒速记

  • Vue 2Object.defineProperty 劫持每个属性的 getter/settergetter 里用 Dep 收集 Watchersetter 里通知更新
  • Vue 2 的三个硬伤:检测不到属性新增和删除(要 $set/$delete)、数组要重写七个变异方法、初始化递归遍历全部属性开销大
  • Vue 3:改用 Proxy 代理整个对象 + Reflect 保证 this 正确
  • Proxy 的优势:能拦截新增删除、支持数组索引和 length懒代理(访问到才递归,初始化更快)
  • Vue 3 依赖收集是 WeakMap<target, Map<key, Set<effect>>> 三层结构,track 收集、trigger 派发;ref 用于原始值,reactive 用于对象

Vue 响应式本质上是拦截数据读写:读取时收集依赖,修改时通知 Watcher 更新视图。 Vue 2Object.defineProperty() 递归劫持属性,数组则重写 pushsplice 等变异方法,再通过虚拟 DOM 的对比局部更新真实 DOM。它无法直接感知对象属性的新增和删除,需要使用 Vue.set()Vue.delete()Vue 3 改用 Proxy 代理对象和数组,嵌套对象可在读取时继续交给 reactive 处理。

Vue 的响应式原理是核心是通过 ES5 的保护对象的 Object.defindeProperty 中的访问器属性中的 get 和 set 方法,data 中声明的属性都被添加了访问器属性,当读取 data 中的数据时自动调用 get 方法,当修改 data 中的数据时,自动调用 set 方法,检测到数据的变化,会通知观察者 Wacher,观察者 Wacher自动触发重新render 当前组件(子组件不会重新渲染),生成新的虚拟 DOM 树,Vue 框架会遍历并对比新虚拟 DOM 树和旧虚拟 DOM 树中每个节点的差别,并记录下来,最后,加载操作,将所有记录的不同点,局部修改到真实 DOM树上。

  • 虚拟DOM (Virtaul DOM): 用 js 对象模拟的,保存当前视图内所有 DOM 节点对象基本描述属性和节点间关系的树结构。用 js 对象,描述每个节点,及其父子关系,形成虚拟 DOM 对象树结构。
  • 因为只要在 data 中声明的基本数据类型的数据,基本不存在数据不响应问题,所以重点介绍数组和对象在vue中的数据响应问题,vue可以检测对象属性的修改,但无法监听数组的所有变动及对象的新增和删除,只能使用数组变异方法及$set方法。

可以看到,arrayMethods 首先继承了 Array,然后对数组中所有能改变数组自身的方法,如 pushpop 等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的 3 个方法 pushunshiftsplice 方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用 ob.dep.notify() 手动触发依赖通知,这就很好地解释了用 vm.items.splice(newLength) 方法可以检测到变化

总结:Vue 采用数据劫持结合发布—订阅模式的方法,通过 Object.defineProperty() 来劫持各个属性的 setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。

  • Observer 遍历数据对象,给所有属性加上 settergetter,监听数据的变化
  • compile 解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图

Watcher 订阅者是 ObserverCompile 之间通信的桥梁,主要做的事情

  • 在自身实例化时往属性订阅器 (dep) 里面添加自己
  • 待属性变动 dep.notice() 通知时,调用自身的 update() 方法,并触发 Compile 中绑定的回调

Object.defineProperty(),那么它的用法是什么,以及优缺点是什么呢?

  • 可以检测对象中数据发生的修改
  • 对于复杂的对象,层级很深的话,是不友好的,需要经行深度监听,这样子就需要递归到底,这也是它的缺点。
  • 对于一个对象中,如果你新增加属性,删除属性,**Object.defineProperty()**是不能观测到的,那么应该如何解决呢?可以通过Vue.set()Vue.delete()来实现。
// 模拟 Vue 中的 data 选项
let data = {
    msg: 'hello'
}
// 模拟 Vue 的实例
let vm = {}
// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
  // 可枚举(可遍历)
  enumerable: true,
  // 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义)
  configurable: true,
  // 当获取值的时候执行
  get () {
    console.log('get: ', data.msg)
    return data.msg
  },
  // 当设置值的时候执行
  set (newValue) {
    console.log('set: ', newValue)
    if (newValue === data.msg) {
      return
    }
    data.msg = newValue
    // 数据更改,更新 DOM 的值
    document.querySelector('#app').textContent = data.msg
  }
})

// 测试
vm.msg = 'Hello World'
console.log(vm.msg)

Vue3.x响应式数据原理

Vue3.x改用Proxy替代Object.defineProperty。因为Proxy可以直接监听对象和数组的变化,并且有多达13种拦截方法。并且作为新标准将受到浏览器厂商重点持续的性能优化。

Proxy只会代理对象的第一层,那么Vue3又是怎样处理这个问题的呢?

判断当前Reflect.get的返回值是否为Object,如果是则再通过reactive方法做代理, 这样就实现了深度观测。

监测数组的时候可能触发多次get/set,那么如何防止触发多次呢?

我们可以判断key是否为当前被代理对象target自身属性,也可以判断旧值与新值是否相等,只有满足以上两个条件之一时,才有可能执行trigger

// 模拟 Vue 中的 data 选项
let data = {
  msg: 'hello',
  count: 0
}
// 模拟 Vue 实例
let vm = new Proxy(data, {
  // 当访问 vm 的成员会执行
  get (target, key) {
    console.log('get, key: ', key, target[key])
    return target[key]
  },
  // 当设置 vm 的成员会执行
  set (target, key, newValue) {
    console.log('set, key: ', key, newValue)
    if (target[key] === newValue) {
      return
    }
    target[key] = newValue
    document.querySelector('#app').textContent = target[key]
  }
})

// 测试
vm.msg = 'Hello World'
console.log(vm.msg)

Proxy 相比于 defineProperty 的优势

  • 数组变化也能监听到
  • 不需要深度遍历监听

ProxyES6 中新增的功能,可以用来自定义对象中的操作

let p = new Proxy(target, handler);
// `target` 代表需要添加代理的对象
// `handler` 用来自定义对象中的操作
// 可以很方便的使用 Proxy 来实现一个数据绑定和监听

let onWatch = (obj, setBind, getLogger) => {
  let handler = {
    get(target, property, receiver) {
      getLogger(target, property)
      return Reflect.get(target, property, receiver);
    },
    set(target, property, value, receiver) {
      setBind(value);
      return Reflect.set(target, property, value);
    }
  };
  return new Proxy(obj, handler);
};

let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
  value = v
}, (target, property) => {
  console.log(`Get '${property}' = ${target[property]}`);
})
p.a = 2 // bind `value` to `2`
p.a // -> Get 'a' = 2

总结

  • Vue
    • 记录传入的选项,设置 $data/$el
    • data 的成员注入到 Vue 实例
    • 负责调用 Observer 实现数据响应式处理(数据劫持)
    • 负责调用 Compiler 编译指令/插值表达式等
  • Observer
    • 数据劫持
      • 负责把 data 中的成员转换成 getter/setter
      • 负责把多层属性转换成 getter/setter
      • 如果给属性赋值为新对象,把新对象的成员设置为 getter/setter
    • 添加 DepWatcher 的依赖关系
    • 数据变化发送通知
  • Compiler
    • 负责编译模板,解析指令/插值表达式
    • 负责页面的首次渲染过程
    • 当数据变化后重新渲染
  • Dep
    • 收集依赖,添加订阅者(watcher)
    • 通知所有订阅者
  • Watcher
    • 自身实例化的时候往dep对象中添加自己
    • 当数据变化dep通知所有的 Watcher 实例更新视图

💬 面试官追问

  • Vue 2 表单里执行 form.newKey = 1 后控制台数据已变化,但输入区没有更新;有人说是虚拟 DOMdiff 漏了,你同意吗?

    不同意,根因更可能是新增属性没有被 Object.defineProperty() 转换成 gettersetter,变化因而没有进入依赖通知链路。应使用 Vue.set()$set 添加属性;若是删除则使用 Vue.delete(),而不是先怀疑 diff

  • 一个多层配置对象初始化后会频繁替换子对象,你要用 ObserverDepWatcher 解释一次更新链路,会怎么说?

    Observer 会把已声明的多层属性转换为访问器,并在新对象被赋值时继续处理其成员;读取阶段由 Dep 收集对应的 Watcher。写入触发通知后,Watcher 更新视图并生成新的虚拟 DOM,再比较差异并局部修改真实 DOM

  • 列表页直接执行 items[3] = rowitems.length = 2 后视图不同步,但改成 splice 就恢复了;你如何解释并修复?

    Vue 2 中,并非所有数组变动都能被访问器机制观察,而 splice 等变异方法经过重写,会处理新增值并调用依赖通知。应使用框架支持的数组变异方法或 $set;直接索引赋值和改长度不能假定会触发视图更新。

  • 把同一份深层对象从 Vue 2 迁到 Vue 3 后,评审者认为 Proxy 只代理第一层,所以嵌套字段不会响应;你怎么回应?

    外层 Proxy 的确先拦截第一层访问,但读取结果若仍是对象,可以在 Reflect.get 后继续通过 reactive 建立代理,从而实现深度观测。它通常是按访问过程继续代理,并不等于所有嵌套对象失去响应性;具体行为仍要检查实际实现。

  • Vue 3 数组一次写入触发了多次 getset 日志,页面也重复更新;你会在哪些条件上收紧触发逻辑?

    应检查写入的 key 是否是目标对象自身属性,并比较旧值与新值,避免没有实际变化时仍调用 trigger。数组操作可能经过多个代理陷阱,不能把每次拦截都等同于有效变更;条件过宽会造成多余通知,过窄则可能漏更新。

  • 有人主张在大型对象初始化时递归给所有层级做 definePropertyProxy 更合适,因为两者都能深度监听;你如何权衡?

    defineProperty 需要遍历并改造已有属性,新增和删除还要借助 Vue.set()Vue.delete();深层对象会放大递归处理成本。Proxy 能直接监听对象和数组,并可在读取到嵌套对象时继续代理,但它属于不同的运行机制,迁移不能只替换一个 API

# 2 发布订阅模式和观察者模式

⚡ 30 秒速记

  • 观察者模式:Subject 直接持有 Observer 列表,状态变化时挨个通知 —— 两者互相知道
  • 发布订阅模式:多了一个中间的事件调度中心,发布者和订阅者互不知道对方存在 —— 完全解耦
  • 一句话区分:观察者是「我认识你,直接叫你」,发布订阅是「我们都认识中介,通过中介传话」
  • Vue 的响应式是观察者模式(Dep 直接持有 Watcher),EventBus/NodeEventEmitter 是发布订阅
  • 代价:发布订阅解耦更彻底,但调用链被中介隔断,调试更难追踪

两种模式的核心区别是有没有事件中心:观察者模式直接关联双方,发布订阅模式通过中间层转发。 在观察者模式中,目标对象保存观察者,状态变化后直接调用其 update(),所以双方存在依赖。发布订阅中,发布者只负责 $emit,订阅者只负责 $on,彼此不需要知道对方。像兄弟组件通过 eventBus 通信,就是典型的发布订阅场景。

1. 发布/订阅模式

  • 发布/订阅模式
    • 订阅者
    • 发布者
    • 信号中心

我们假定,存在一个"信号中心",某个任务执行完成,就向信号中心"发布"(publish)一个信 号,其他任务可以向信号中心"订阅"(subscribe)这个信号,从而知道什么时候自己可以开始执 行。这就叫做"发布/订阅模式"(publish-subscribe pattern)

Vue 的自定义事件

let vm = new Vue()
vm.$on('dataChange', () => { console.log('dataChange')})
vm.$on('dataChange', () => {
  console.log('dataChange1')
})
vm.$emit('dataChange')

兄弟组件通信过程

// eventBus.js
// 事件中心
let eventHub = new Vue()

// ComponentA.vue
// 发布者
addTodo: function () {
  // 发布消息(事件)
  eventHub.$emit('add-todo', { text: this.newTodoText })
  this.newTodoText = ''
}
// ComponentB.vue
// 订阅者
created: function () {
  // 订阅消息(事件)
  eventHub.$on('add-todo', this.addTodo)
}

模拟 Vue 自定义事件的实现

class EventEmitter {
  constructor(){
    // { eventType: [ handler1, handler2 ] }
    this.subs = {}
  }
  // 订阅通知
  $on(eventType, fn) {
    this.subs[eventType] = this.subs[eventType] || []
    this.subs[eventType].push(fn)
  }
  // 发布通知
  $emit(eventType) {
    if(this.subs[eventType]) {
      this.subs[eventType].forEach(v=>v())
    }
  }
}

// 测试
var bus = new EventEmitter()

// 注册事件
bus.$on('click', function () {
  console.log('click')
})

bus.$on('click', function () {
  console.log('click1')
})

// 触发事件
bus.$emit('click')

2. 观察者模式

  • 观察者(订阅者) -- Watcher
    • update():当事件发生时,具体要做的事情
  • 目标(发布者) -- Dep
    • subs 数组:存储所有的观察者
    • addSub():添加观察者
    • notify():当事件发生,调用所有观察者的 update() 方法
  • 没有事件中心
// 目标(发布者)
// Dependency
class Dep {
  constructor () {
    // 存储所有的观察者
    this.subs = []
  }
  // 添加观察者
  addSub (sub) {
    if (sub && sub.update) {
      this.subs.push(sub)
    }
  }
  // 通知所有观察者
  notify () {
    this.subs.forEach(sub => sub.update())
  }
}

// 观察者(订阅者)
class Watcher {
  update () {
    console.log('update')
  }
}

// 测试
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()

3. 总结

  • 观察者模式是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模 式的订阅者与发布者之间是存在依赖的
  • 发布/订阅模式由统一调度中心调用,因此发布者和订阅者不需要知道对方的存在

💬 面试官追问

  • 订单页里 OrderStore 直接保存三个视图观察者并逐个调用 update(),同事却把它叫作发布订阅;你会怎么追问他?

    这更符合观察者模式,因为具体目标直接持有观察者,并在状态变化时调用它们的 update(),中间没有独立信号中心。应让他指出发布者与订阅者之间的调度媒介;若只能找到目标内部的订阅列表,就不能按发布订阅描述。

  • 兄弟组件通过全局 eventHub.$emit('add-todo') 通信,新增页面也要监听同名事件;你会要求怎样组织注册与清理?

    应把事件名称、载荷约定和订阅位置集中管理,并在组件不再需要监听时解除对应处理函数。发布订阅让双方通过事件中心解耦,但也隐藏了调用关系;监听增长后若缺少生命周期管理,会留下重复执行和难以追踪的订阅。

  • 需求改成一个发布者要通知多个完全不认识的业务模块,而且模块可以独立上下线;你会选直接观察还是信号中心?

    更适合使用带信号中心的发布订阅,让发布者只发布事件,各模块自行订阅,不需要互相持有引用。代价是执行链路不再直观,必须约束事件命名、载荷和取消订阅;若参与者固定且关系紧密,直接观察反而更简单。

  • 线上一次点击打印了三遍 click,代码里只有一次 $emit;你会怎样定位这个 EventEmitter 故障?

    先检查同一处理函数是否被多次 $on,以及组件重复创建时旧订阅是否仍保留,因为 $emit 会遍历事件类型对应的全部处理函数。再记录注册与销毁路径确认订阅数量;只在触发处防抖会掩盖重复注册,不能消除泄漏。

  • 状态库里的 Dep 已经能通知 Watcher,架构师仍要求再套一层全局事件中心;你认为值得吗?

    Dep 已直接维护并通知明确的 Watcher,再加信号中心会把观察者链路改成更间接的调度,未必带来收益。只有确实需要让互不认识的发布者和订阅者解耦时才值得引入;否则会增加事件契约和故障定位成本。

  • 评审中有人说 Watcher 既然叫订阅者,Vue 响应式就必然是发布订阅模式;你会如何串联两个概念?

    “订阅者”这个角色名不足以决定模式,关键是通知由谁调度。Dep 保存 Watcher 并直接调用其 update() 时属于观察者结构;只有发布者和订阅者通过独立信号中心交互,才符合这里的发布订阅模式。

# 3 为什么使用 Virtual DOM

⚡ 30 秒速记

  • 首要动机不是「快」,而是跨平台声明式编程 —— 虚拟 DOM 是一层抽象,可以渲染到浏览器、原生、CanvasSSR
  • 性能上的真相:直接精准操作 DOM 永远比虚拟 DOM 快,虚拟 DOM 保证的是下限不是上限
  • 它避免的是「频繁且分散的 DOM 操作」:把多次修改合并成一次批量 patch
  • 代价:多了内存开销和 diff 计算时间
  • 加分点:Svelte/Solid 走编译期路线不用虚拟 DOMVue 3.4+Vapor Mode 也在探索这条路

使用虚拟 DOM,是为了用普通对象描述视图,并在状态变化后统一计算真实 DOM 应该怎样更新。 它会保留上一次视图状态,通过 diff 找出前后差异,只把必要变化同步到页面,避免开发者手动维护复杂的 DOM 操作。它在复杂视图下有助于控制渲染成本,但不能简单理解为任何场景都更快。更重要的是它解耦了具体渲染平台,同一套视图描述还能用于 SSR、原生应用或小程序。

  • 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
  • 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM 出现了
  • Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述DOMVirtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
  • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
  • 通过比较前后两次状态的差异更新真实 DOM

虚拟 DOM 的作用

  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

img

💬 面试官追问

  • 活动页只有一个计数器,候选人说引入 Virtual DOM 后每次更新一定比精准设置 textContent 更快;你认同吗?

    不能作出“一定更快”的结论,因为 Virtual DOM 还要创建虚拟树并比较前后状态,精准的直接更新可能路径更短。它的核心价值是维护视图与状态关系,并由框架确定差异更新;简单节点不应仅凭性能口号选型。

  • 复杂表单有几十处状态联动,团队现在到处手写 DOM 查询和赋值;引入 Virtual DOM 能解决哪部分工程问题?

    它能用虚拟树描述当前视图,在状态变化后比较新旧状态,只把记录到的差异更新到真实 DOM。这样状态与视图的同步由统一机制维护,减少散落的手动操作;但业务状态设计不合理时,虚拟 DOM 本身不会消除逻辑复杂度。

  • 同一套组件既要在浏览器渲染,又计划支持 SSR 和原生容器;你会怎样评价虚拟树这层抽象?

    虚拟树不只用于浏览器 DOM,还可作为视图描述支持 SSR、原生应用或小程序等不同渲染目标。它让状态与具体宿主操作之间多一层抽象,但各目标仍需要对应渲染实现;不能把“能描述”误解为无需适配即可运行。

  • 线上列表更新后出现旧行残留,研发只盯着真实 DOM 截图;按虚拟 DOM 链路你会依次核对什么?

    先确认状态是否真的变化,再检查新虚拟树是否正确描述目标视图,以及新旧树比较是否记录了应有差异,最后核对差异是否应用到真实 DOM。若状态源本身错误,继续调 DOM 更新没有意义;若有人绕过框架直接改 DOM,也会破坏前后状态的一致性。

  • 可视化大屏每帧都要更新大量节点,一方坚持全部交给虚拟 DOM,另一方要求全部手写;你会如何决策?

    不能只凭技术立场决定,应先区分高频热点区域与普通状态视图,并验证比较和真实更新分别占据多少成本。复杂视图可借虚拟 DOM 维护状态关系,高频且更新路径固定的局部则可能需要更直接的策略;混用时必须划清所有权,避免双方同时修改同一节点。

  • 模板引擎已经能根据数据生成 HTML,产品经理质疑为何还要虚拟 DOM;你会怎么解释两者边界?

    模板能简化视图生成,但仅生成内容并不等于持续跟踪状态变化。虚拟 DOM 会保留上一次视图状态,在新状态到来时比较两棵虚拟树并更新差异;代价是增加运行时表示与比较过程,因此静态页面未必需要这层机制。

# 4 VDOM:三个 part

⚡ 30 秒速记

  • 三部分:VNode创建h 函数 / 模板编译产物)、diff 对比新旧树、patch 打补丁到真实 DOM
  • VNode 本质就是一个描述 DOM 的普通对象:{ tag, props, children, key }
  • diff 的三个前提假设把复杂度从 O(n³) 降到 O(n):只做同层比较、类型不同直接替换、用 key 标识身份
  • patch 只做最小必要的真实 DOM 操作
  • Vue 3 在编译期额外做了静态提升、Patch FlagBlock Tree,让 diff 只看动态节点

VDOM 可以拆成三个部分:用 VNode 描述节点、通过 diff 记录差异、再根据 patch 更新真实 DOM VNode 只是保留必要信息的普通 JS 对象,创建成本通常低于直接创建属性繁多的真实节点。数据变化先集中反映到虚拟树,再统一修改 DOM tree,可以减少零散操作引发的重绘和回流。它的价值也不只在性能,还解耦了对 HTML 的依赖,方便做 AOTSSR 和其他平台渲染。

  • 虚拟节点类,将真实 DOM节点用 js 对象的形式进行展示,并提供 render 方法,将虚拟节点渲染成真实 DOM
  • 节点 diff 比较:对虚拟节点进行 js 层面的计算,并将不同的操作都记录到 patch 对象
  • re-render:解析 patch 对象,进行 re-render

补充1��VDOM 的必要性?

  • 创建真实DOM的代价高:真实的 DOM 节点 node 实现的属性很多,而 vnode 仅仅实现一些必要的属性,相比起来,创建一个 vnode 的成本比较低。
  • 触发多次浏览器重绘及回流:使用 vnode ,相当于加了一个缓冲,让一次数据变动所带来的所有 node 变化,先在 vnode 中进行修改,然后 diff 之后对所有产生差异的节点集中一次对 DOM tree 进行修改,以减少浏览器的重绘及回流。

补充2:vue 为什么采用 vdom?

引入 Virtual DOM 在性能方面的考量仅仅是一方面。

  • 性能受场景的影响是非常大的,不同的场景可能造成不同实现方案之间成倍的性能差距,所以依赖细粒度绑定及 Virtual DOM 哪个的性能更好还真不是一个容易下定论的问题。
  • Vue 之所以引入了 Virtual DOM,更重要的原因是为了解耦 HTML依赖,这带来两个非常重要的好处是:
  • 不再依赖 HTML 解析器进行模版解析,可以进行更多的 AOT 工作提高运行时效率:通过模版 AOT 编译,Vue 的运行时体积可以进一步压缩,运行时效率可以进一步提升;
  • 可以渲染到 DOM 以外的平台,实现 SSR、同构渲染这些高级特性,Weex等框架应用的就是这一特性。

综上,Virtual DOM 在性能上的收益并不是最主要的,更重要的是它使得 Vue 具备了现代框架应有的高级特性。

💬 面试官追问

  • 商品列表更新后页面已经变了,同事却说 VDOM 就是缓存的 DOM 字符串;结合一次更新链路,你会怎么纠正他?

    VDOM 不是 HTML 字符串,而是用精简的 JavaScript 对象描述节点。数据变化后先生成并比较虚拟节点,把差异记录为 patch,再解析 patch 更新真实 DOM;如果直接跳过比较操作真实节点,就不属于这条更新链路。

  • 一个表格组件每次筛选都直接遍历并修改多处真实 DOM,评审时你为什么可能要求改成先计算 vnode 差异再统一更新?

    多处真实 DOM 修改可能连续触发重绘和回流,而虚拟节点可充当更新缓冲。应先在 JavaScript 层完成 diff、汇总为 patch,再集中修改 DOM tree;但更新范围很小且路径固定时,不能仅凭使用 VDOM 就断言一定更快。

  • 一个画布编辑器每帧只移动单个元素,负责人仍坚持“用了 VDOM 性能必然最好”,你会怎样判断?

    不能把 VDOM 视为所有场景下的性能保证,它还要承担创建虚拟节点和执行 diff 的成本。应根据更新频率、节点规模和真实 DOM 操作范围验证收益;对高度可预测的细粒度更新,直接绑定或定向修改可能更合适。

  • 线上出现“状态已变化但局部视图没更新”,你沿着 vnode → diff → patch → re-render 会怎么定位?

    先确认新状态是否生成了预期 vnode,再检查 diff 是否识别出对应差异并写入 patch。若 patch 正确,则继续查看解析和真实 DOM 更新阶段;逐段核对能区分响应数据、差异计算与提交渲染三类故障。

  • 团队要让同一套组件既支持浏览器页面又支持 SSR 和非 DOM 平台,为什么 VDOM 的价值不只在减少回流?

    虚拟节点把组件描述与浏览器 HTML、真实 DOM 解耦,使渲染结果能交给不同平台的渲染器处理。模板还可提前编译为 render function,承载更多 AOT 优化;代价是增加虚拟节点、diff 和平台适配层的复杂度。

# 5 vue 和 react技术选型

⚡ 30 秒速记

  • Vue:模板语法上手快、编译期优化多(静态提升、Patch Flag)、官方全家桶统一、中文生态好
  • ReactJSXJS 心智模型统一、类型友好、生态最大、大型应用和跨端(RN)更成熟
  • 心智模型差异:Vue响应式追踪(改数据自动更新),React不可变 + 重新渲染UI = f(state)
  • 团队角度:团队熟悉度和招聘难度往往比技术优劣更重要
  • 别答成「哪个更好」,要答「什么场景选什么」:中小项目和快速迭代偏 Vue,大型复杂应用和跨端需求偏 React

VueReact 都能做数据驱动、组件化和服务端渲染,选型关键是项目复杂度与团队匹配度。 Vue 提供 v-model,写法直观,但底层仍是单向数据流;React 更强调不可变数据和单向更新。大规模数据渲染及多人协作的复杂项目,可以考虑 React 配合 Redux;追求快速开发的小型项目,Vue 通常更顺手。开发风格上,React 常用 JSX,而 Vue 多用单文件组件组织模板、逻辑和样式。

相同点:

  1. 数据驱动页面,提供响应式的视图组件
  2. 都有virtual DOM,组件化的开发,通过props参数进行父子之间组件传递数据,都实现了webComponents规范
  3. 数据流动单向,都支持服务器的渲染SSR
  4. 都有支持native的方法,react有React native, vue有wexx

不同点:

  1. 数据绑定:Vue实现了双向的数据绑定v-model,底层本质上还是单向数据流(v-model 隐藏了背后 :value 和 @input 的细节)
  2. 数据渲染:大规模的数据渲染,react更快
  3. 使用场景:React配合Redux架构适合大规模多人协作复杂项目,Vue适合小快的项目
  4. 开发风格:react推荐做法jsx + inline style把html和css都写在js了

vue是采用webpack +vue-loader单文件组件格式,html, js, css同一个文件

💬 面试官追问

  • 表单页用了 v-model,评审同事因此认定 Vue 是双向数据流、React 才是单向数据流,你会怎么解释?

    v-model 提供双向绑定的使用体验,但底层仍可理解为值向下传递、事件向上通知。它隐藏了类似 :value@input 的配对细节,并没有推翻单向数据流;复杂表单仍要明确数据所有者,避免状态来源不清。

  • 五十人协作的大型业务平台要在 VueReact 间选型,架构负责人只拿模板语法简洁度做决定,你会补充哪些判断?

    大型多人协作项目应重点评估状态管理、代码约束和团队协作方式,来源材料更倾向 React 配合 ReduxVue 的单文件组件适合集中组织模板、逻辑和样式,但规模结论不是绝对规则,最终仍要结合团队能力与既有生态。

  • 数据看板要渲染大规模数据,团队因为材料写着“React 更快”就准备整体迁移,你会批准吗?

    不会仅凭框架级结论批准迁移,材料只给出了大规模数据渲染中 React 更快的概括,未限定组件结构和更新模式。应先定位瓶颈并用当前页面验证;迁移会引入重写与协作成本,而且无法替代列表裁剪等针对性优化。

  • 新项目既要做浏览器端 SSR,半年后又可能扩展原生端,产品要求现在就锁定技术栈,你怎样比较两者?

    两者都支持组件化、单向数据流和服务器端渲染,因此 SSR 本身不足以拉开差距。原生方向可比较 React NativeWeex 相关方案及团队能力;跨端只是选型约束之一,不能忽略生态成熟度和维护成本。

  • 线上页面出现父组件数据已变、子组件显示仍旧值,开发者把原因归结为“VueReact 数据流不同”,你会先查什么?

    两者都通过 props 进行父子数据传递,并以单向数据流组织更新,不能先用框架差异解释故障。应检查父组件传值、子组件是否复制并滞留旧状态,以及更新是否实际触发;若使用 v-model,还要核对值与事件链路。

# 6 nextTick

⚡ 30 秒速记

  • 解决的问题:VueDOM 更新是异步批量的,改完数据立刻读 DOM 拿到的还是旧值
  • 机制:数据变化时把 watcher 推进队列去重,然后把「刷新队列」作为微任务调度;同一 tick 内多次修改只触发一次更新
  • nextTick(cb) 就是把 cb 也放进同一批微任务,排在 DOM 更新之后
  • 降级策略:Promise.thenMutationObserversetImmediatesetTimeoutVue 3 简化为只用 Promise
  • 用法:this.$nextTick(() => {})await nextTick()

nextTick 用来把回调推迟到下一轮 DOM 更新结束后执行,从而拿到更新后的页面节点。 因为 Vue 不会在每次数据变化时立刻更新 DOM,而是把任务放进队列并统一清空,多次调用也会先收集起来。实现时会根据环境依次尝试 PromiseMutationObserversetImmediate,最后才使用 setTimeout。所以修改数据后马上读取尺寸或内容时,我一般会放到 nextTick 回调里。

nextTick 可以让我们在下次 DOM 更新循环结束之后执行延迟回调,用于获得更新后的 DOM

nextTick主要使用了宏任务和微任务。根据执行环境分别尝试采用

  • Promise
  • MutationObserver
  • setImmediate
  • 如果以上都不行则采用setTimeout

定义了一个异步方法,多次调用nextTick会将方法存入队列中,通过这个异步方法清空当前队列

💬 面试官追问

  • 详情页执行 this.count++ 后立刻读取节点文本仍是旧值,同事认为响应式失效了;你会怎样解释并修正?

    数据变化不代表真实 DOM 已在当前同步代码中完成更新,因此立即读取可能仍是旧内容。把读取逻辑放进 nextTick,让它在下一次 DOM 更新循环结束后执行;它只保证等待本轮更新,不负责修复错误的数据依赖。

  • 批量导入页在一个同步循环里调用了二十次 nextTick,这些回调会各自启动一次定时任务吗?

    多次调用会先把回调保存到同一队列,再由选定的异步方法清空当前队列,而不是简单理解为每次都独立刷新。回调仍应保持轻量;如果其中执行大量计算,即使合并调度也会阻塞后续渲染。

  • 老旧运行环境没有原生 Promise,业务依赖 nextTick 更新后聚焦输入框,它是否必然失效?

    不必然失效,nextTick 会按环境尝试 PromiseMutationObserversetImmediate,最后可退回 setTimeout。不同回退方式属于微任务或宏任务,执行时机可能有差异;业务不应依赖比“更新循环结束后”更细的顺序假设。

  • 弹窗打开后偶发拿不到更新后的输入框,日志显示数据已改且 nextTick 已调用,你会怎样排查?

    先确认修改数据和注册 nextTick 的先后关系,并检查输入框是否受条件渲染控制。随后在回调中验证节点是否已生成,排除组件尚未进入对应更新循环;若节点由后续异步数据决定,一次 nextTick 并不能等待那次未来更新。

  • 开发者想把所有 nextTick 都替换成 setTimeout(fn, 0),理由是最终都能异步执行,你会接受吗?

    不应直接等价替换,nextTick 面向的是本轮 DOM 更新结束后的回调队列,并会选择当前环境可用的异步机制。setTimeout 只是最终回退方案,通常属于更晚的宏任务;替换会削弱与框架更新周期之间的语义关联。

# 7 生命周期

⚡ 30 秒速记

  • Vue 2 八个:beforeCreate/created/beforeMount/mounted/beforeUpdate/updated/beforeDestroy/destroyed
  • created 拿得到数据但拿不到 DOMmounted 才有 DOM —— 请求放 createdDOM 操作放 mounted
  • Vue 3 改名:beforeDestroyonBeforeUnmountdestroyedonUnmountedbeforeCreate/createdsetup 取代
  • 父子挂载顺序:父 beforeMount → 子 beforeMount → 子 mounted → 父 mounted子先于父完成
  • keep-alive 组件额外有 activated/deactivated,切换时不会走销毁流程

Vue 生命周期描述了组件从初始化、挂载、更新到销毁的完整过程,不同阶段适合处理不同事情。 beforeCreate 时状态还没初始化,到了 created 已完成数据响应性,但真实 DOM 尚未生成;执行渲染和挂载后才进入 mounted。数据变化时会经过依赖通知、差异比较,并依次触发 beforeUpdateupdated;销毁时则在对应钩子间移除节点、依赖和监听。keep-alive 组件不会直接销毁,而是通过激活与失活钩子切换状态。

init

  • initLifecycle/Event,往vm上挂载各种属性
  • callHook: beforeCreated: 实例刚创建
  • initInjection/initState: 初始化注入和 data 响应性
  • created: 创建完成,属性已经绑定, 但还未生成真实dom`
  • 进行元素的挂载: $el / vm.$mount()
  • 是否有template: 解析成 render function
    • *.vue文件: vue-loader会将<template>编译成render function
  • beforeMount: 模板编译/挂载之前
  • 执行render function,生成真实的dom,并替换到dom tree
  • mounted: 组件已挂载

update

  • 执行diff算法,比对改变是否需要触发UI更新
  • flushScheduleQueue
  • watcher.before: 触发beforeUpdate钩子 - watcher.run(): 执行watcher中的 notify,通知所有依赖项更新UI
  • 触发updated钩子: 组件已更新
  • actived / deactivated(keep-alive): 不销毁,缓存,组件激活与失活
  • destroy
    • beforeDestroy: 销毁开始
    • 销毁自身且递归销毁子组件以及事件监听
      • remove(): 删除节点
      • watcher.teardown(): 清空依赖
      • vm.$off(): 解绑监听
    • destroyed: 完成后触发钩子
Vue2 Vue3
beforeCreate setup(替代)
created setup(替代)
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated nUpdated
beforeDestroy onBeforeUnmount
destroyed onUnmounted
errorCaptured onErrorCaptured
- 🎉onRenderTracked
- 🎉onRenderTriggered

上面是vue的声明周期的简单梳理,接下来我们直接以代码的形式来完成vue的初始化


new Vue({})

// 初始化Vue实例
function _init() {
	 // 挂载属性
    initLifeCycle(vm)
    // 初始化事件系统,钩子函数等
    initEvent(vm)
    // 编译slot、vnode
    initRender(vm)
    // 触发钩子
    callHook(vm, 'beforeCreate')
    // 添加inject功能
    initInjection(vm)
    // 完成数据响应性 props/data/watch/computed/methods
    initState(vm)
    // 添加 provide 功能
    initProvide(vm)
    // 触发钩子
    callHook(vm, 'created')

	 // 挂载节点
    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
}

// 挂载节点实现
function mountComponent(vm) {
	 // 获取 render function
    if (!this.options.render) {
        // template to render
        // Vue.compile = compileToFunctions
        let { render } = compileToFunctions()
        this.options.render = render
    }
    // 触发钩子
    callHook('beforeMounte')
    // 初始化观察者
    // render 渲染 vdom,
    vdom = vm.render()
    // update: 根据 diff 出的 patchs 挂载成真实的 dom
    vm._update(vdom)
    // 触发钩子
    callHook(vm, 'mounted')
}

// 更新节点实现
funtion queueWatcher(watcher) {
	nextTick(flushScheduleQueue)
}

// 清空队列
function flushScheduleQueue() {
	 // 遍历队列中所有修改
    for(){
	    // beforeUpdate
        watcher.before()

        // 依赖局部更新节点
        watcher.update()
        callHook('updated')
    }
}

// 销毁实例实现
Vue.prototype.$destory = function() {
	 // 触发钩子
    callHook(vm, 'beforeDestory')
    // 自身及子节点
    remove()
    // 删除依赖
    watcher.teardown()
    // 删除监听
    vm.$off()
    // 触发钩子
    callHook(vm, 'destoryed')
}

💬 面试官追问

  • 图表页在 created 中读取 this.$refs.chart 得到 undefined,但接口请求能正常发出;这两个现象为什么不矛盾?

    created 时数据、方法和注入等状态已经初始化,因此可以启动不依赖真实节点的逻辑。此时组件尚未挂载并生成真实 DOM,所以 $refs 不可用;图表初始化应放到 mounted,同时考虑卸载时释放实例。

  • 后台页一次修改多项响应式数据,开发者期望每次赋值都立即触发一轮 beforeUpdateupdated,你会如何纠正?

    更新会进入调度队列,由 flushScheduleQueue 统一处理相关 watcher。更新前触发 beforeUpdate,依赖完成更新后再触发 updated;不要把钩子次数与赋值次数机械对应,也不应在 updated 中无条件继续改状态。

  • keep-alive 的搜索页切走后定时器仍在运行,但组件没有触发销毁钩子,你会把清理放在哪里?

    keep-alive 切换时组件只是失活并被缓存,不会走完整销毁流程,因此应在 deactivated 暂停定时器或其他活跃任务。再次进入时在 activated 恢复;真正销毁仍需在 beforeDestroyonBeforeUnmount 等阶段完成最终清理。

  • 单页应用运行数小时后内存持续上涨,路由离开组件时节点消失了,你会沿销毁阶段检查哪些链路?

    先确认是否进入 beforeDestroy、执行自身及子组件移除,再核对 watcher.teardown()vm.$off() 是否完成。还要检查业务自行注册的全局监听、定时器或第三方实例;框架解绑自身事件,并不必然覆盖所有外部资源。

  • 团队把 Vue 2 组件迁到 Vue 3,准备逐个把 created 改成 onCreated,你会怎么调整方案?

    Vue 3 没有对应的 onCreatedbeforeCreatecreated 的初始化职责通常由 setup 承接。其余阶段使用 onBeforeMountonMountedonBeforeUpdateonUpdatedonBeforeUnmountonUnmounted;迁移时还要重新确认逻辑对真实 DOM 的依赖。

# 8 vue-router

⚡ 30 秒速记

  • 两种模式:hash(靠 hashchange# 后内容不发给服务器,刷新不会 404)、history(靠 pushStateURL 干净但刷新会真请求该路径,服务端要配回退)
  • 核心实现:监听 URL 变化 → 匹配路由表 → 渲染对应组件到 <router-view>
  • 导航守卫三级:全局(beforeEach/afterEach)、路由独享(beforeEnter)、组件内(beforeRouteEnter/Update/Leave
  • 完整解析流程要背:失活组件的 beforeRouteLeave → 全局 beforeEach → 路由 beforeEnter → 组件 beforeRouteEnter → 全局 beforeResolveafterEach
  • 路由懒加载用动态 import(),配打包工具自动分包

vue-router 的本质是监听 URL 变化,匹配路由配置,再通过 router-view 渲染对应组件。 hash 模式监听 hashchangehistory 模式监听 popstate,区别主要在于感知地址变化的方式。代码跳转可以用 this.$router.push(),模板里则常用 router-link,最终都驱动当前路由状态变化。它作为插件通过 install 接入 Vue,注册全局组件,并用响应式的 current 让匹配组件自动重新渲染。

mode

  • hash
  • history

跳转

  • this.$router.push()
  • <router-link to=""></router-link>

占位

<router-view></router-view>

vue-router源码实现

  • 作为一个插件存在:实现VueRouter类和install方法
  • 实现两个全局组件:router-view用于显示匹配组件内容,router-link用于跳转
  • 监控url变化:监听hashchangepopstate事件
  • 响应最新url:创建一个响应式的属性current,当它改变时获取对应组件并显示
// 我们的插件:
// 1.实现一个Router类并挂载期实例
// 2.实现两个全局组件router-link和router-view
let Vue;

class VueRouter {
  // 核心任务:
  // 1.监听url变化
  constructor(options) {
    this.$options = options;

    // 缓存path和route映射关系
    // 这样找组件更快
    this.routeMap = {}
    this.$options.routes.forEach(route => {
      this.routeMap[route.path] = route
    })

    // 数据响应式
    // 定义一个响应式的current,则如果他变了,那么使用它的组件会rerender
    Vue.util.defineReactive(this, 'current', '')

    // 请确保onHashChange中this指向当前实例
    window.addEventListener('hashchange', this.onHashChange.bind(this))
    window.addEventListener('load', this.onHashChange.bind(this))
  }

  onHashChange() {
    // console.log(window.location.hash);
    this.current = window.location.hash.slice(1) || '/'
  }
}

// 插件需要实现install方法
// 接收一个参数,Vue构造函数,主要用于数据响应式
VueRouter.install = function (_Vue) {
  // 保存Vue构造函数在VueRouter中使用
  Vue = _Vue

  // 任务1:使用混入来做router挂载这件事情
  Vue.mixin({
    beforeCreate() {
      // 只有根实例才有router选项
      if (this.$options.router) {
        Vue.prototype.$router = this.$options.router
      }

    }
  })

  // 任务2:实现两个全局组件
  // router-link: 生成一个a标签,在url后面添加#
  // <a href="#/about">aaaa</a>
  // <router-link to="/about">aaa</router-link>
  Vue.component('router-link', {
    props: {
      to: {
        type: String,
        required: true
      },
    },
    render(h) {
      // h(tag, props, children)
      return h('a',
        { attrs: { href: '#' + this.to } },
        this.$slots.default
      )
      // 使用jsx
      // return <a href={'#'+this.to}>{this.$slots.default}</a>
    }
  })
  Vue.component('router-view', {
    render(h) {
      // 根据current获取组件并render
      // current怎么获取?
      // console.log('render',this.$router.current);
      // 获取要渲染的组件
      let component = null
      const { routeMap, current } = this.$router
      if (routeMap[current]) {
        component = routeMap[current].component
      }
      return h(component)
    }
  })
}

export default VueRouter

💬 面试官追问

  • 订单页点击 <router-link to="/orders"> 后没有整页刷新,同事却说它只是普通链接;路由插件实际还做了什么?

    router-link 负责生成可跳转的链接,router-view 根据当前地址渲染匹配组件。插件还要监听 hashchangepopstate,并把最新地址写入响应式的 current;仅生成 <a> 标签不足以完成视图联动。

  • 你要实现一个最小版 hash 路由,配置里有二十条静态路径,为什么先构建 routeMap,地址变化后又怎样更新页面?

    初始化时把 path 与路由配置缓存到 routeMap,可在地址变化时直接查找对应组件。监听 hashchange 和首次 load,更新响应式 current,依赖它的 router-view 就会重新渲染;该简化实现未覆盖动态参数等复杂匹配。

  • 产品要求把地址从 #/detail 改成 /detail,开发只删除了 #,结果浏览器前进后退不再更新视图,你会检查哪里?

    # 的历史模式应监听 popstate,不能继续只依赖 hashchange。还要确认跳转逻辑确实更新浏览器历史,并同步响应式当前地址;仅改变链接外观不会自动补齐历史模式的监听和状态维护。

  • 页面地址已经变成 #/about,但 <router-view> 仍显示首页,你按源码链路会怎么定位?

    先检查 hashchange 是否触发,以及处理函数中的 this 是否仍指向路由实例。再核对 current 是否截取为 /aboutrouteMap 是否存在该路径,最后检查 router-view 是否读取同一个路由实例;任一环节断开都会导致视图不更新。

  • 微前端团队争论用 <router-link> 还是业务代码统一调用 this.$router.push(),你会如何划分?

    声明式导航适合模板中明确可点击的页面入口,能让跳转意图直接体现在视图结构里。命令式 this.$router.push() 更适合校验、保存或权限判断后的跳转;两者最终都要驱动地址变化,选择代价主要在控制流程和可读性。

# 9 vuex

⚡ 30 秒速记

  • 五个核心:state(数据源)、getters(派生数据)、mutations同步改状态,唯一途径)、actions异步,提交 mutation)、modules(模块拆分)
  • 为什么 mutation 必须同步:异步会让 devtools 无法追踪状态变化的因果,时间旅行调试失效
  • 数据流是单向的:viewdispatch actioncommit mutation → 改 state → 驱动 view
  • Vue 3 推荐 Pinia:去掉了 mutation、天然支持 TypeScript、支持组合式写法、体积更小
  • 别滥用全局状态:只有跨组件共享生命周期长的状态才值得放进 store

Vuex 是集中管理组件共享状态的方案,核心是用固定的数据流让状态变化可预测。 state 保存状态,getters 负责派生数据,mutations 同步修改状态,actions 承载异步或业务逻辑并提交 mutation,复杂应用再用 modules 拆分。其内部会让 state 响应式,并通过 commitdispatch 找到对应处理函数。实际使用时,我只会把跨组件共享、需要统一管理的状态放进去,局部状态仍留在组件内。

Vuex 集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以可预测的方式发生变化

核心概念

  • state: 状态中心
  • mutations: 更改状态
  • actions: 异步更改状态
  • getters: 获取状态
  • modules: 将state分成多个modules,便于管理
  1. 状态 - state

state保存应用状态

export default new Vuex.Store({ state: { counter:0 },})
  1. 状态变更 - mutations

mutations用于修改状态,store.js

export default new Vuex.Store({
    mutations:
    {
      add(state) {
        state.counter++
      }
    }
  })
  1. 派生状态 - getters

从state派生出新状态,类似计算属性

export default new Vuex.Store({
    getters:
    {
      doubleCounter(state) { // 计算剩余数量 return state.counter * 2;
      }
    }
  })
  1. 动作 - actions

加业务逻辑,类似于controller

export default new Vuex.Store({
    actions:
    {
      add({
        commit
      }) {
        setTimeout(() = >{}
      }
    })

测试代码:

<p @click="$store.commit('add')">counter: {{$store.state.counter}}</p>
<p @click="$store.dispatch('add')">async counter: {{$store.state.counter}}</p>
<p>double:{{$store.getters.doubleCounter}}</p>

vuex原理解析

  • 实现一个插件:声明Store类,挂载$store
  • Store具体实现:
    • 创建响应式的state,保存mutationsactionsgetters
    • 实现commit根据用户传入type执行对应mutation
    • 实现dispatch根据用户传入type执行对应action,同时传递上下文
    • 实现getters,按照getters定义对state做派生
// 目标1:实现Store类,管理state(响应式的),commit方法和dispatch方法
// 目标2:封装一个插件,使用更容易使用
let Vue;

class Store {
  constructor(options) {
    // 定义响应式的state
    // this.$store.state.xx
    // 借鸡生蛋
    this._vm = new Vue({
      data: {
        $$state: options.state
      }
    })

    this._mutations = options.mutations
    this._actions = options.actions

    // 绑定this指向
    this.commit = this.commit.bind(this)
    this.dispatch = this.dispatch.bind(this)
  }

  // 只读
  get state() {
    return this._vm._data.$$state
  }

  set state(val) {
    console.error('不能直接赋值呀,请换别的方式!!天王盖地虎!!');

  }

  // 实现commit方法,可以修改state
  commit(type, payload) {
    // 拿出mutations中的处理函数执行它
    const entry = this._mutations[type]
    if (!entry) {
      console.error('未知mutaion类型');
      return
    }

    entry(this.state, payload)
  }

  dispatch(type, payload) {
    const entry = this._actions[type]

    if (!entry) {
      console.error('未知action类型');
      return
    }

    // 上下文可以传递当前store实例进去即可
    entry(this, payload)
  }
}

function install(_Vue){
  Vue = _Vue

  // 混入store实例
  Vue.mixin({
    beforeCreate() {
      if (this.$options.store) {
        Vue.prototype.$store = this.$options.store
      }
    }
  })
}

// { Store, install }相当于Vuex
// 它必须实现install方法
export default { Store, install }

💬 面试官追问

  • 商品详情页把接口请求直接写进 mutation,点击购买时组件用 commit 触发;评审同事认为“反正最后都要改 state”,你会接受吗?

    不会,mutation 应承担同步、可预测的状态变更,异步流程和业务编排应放进 action,完成后再 commit。把请求塞进 mutation 会让变更发生时间难以判断,也破坏按规则追踪状态的链路;纯同步修改则没必要额外套一层 action

  • 后台有用户、权限、订单三个域,所有 statemutationaction 都堆在一个 store.js,新增成员经常提交错同名操作,你会怎样拆?

    我会按业务域拆成 modules,让每个模块管理自己的 statemutationsactionsgetters,组件只依赖对应域的公开能力。拆分边界应跟业务职责一致,而不是按文件数量平均切割;模块过细会增加跳转和调用成本。

  • 购物车页要求“一键清空”立即完成,而结算页要求先调用接口、成功后再清空;两处都想复用名为 clearCart 的逻辑,你会怎样安排 commitdispatch

    本地立即清空应由 mutation 完成,结算流程则由 action 负责异步调用,成功后再 commit 同一个清空操作。这样状态写入入口保持明确,业务流程也能复用;若接口失败仍提前提交,就需要额外回滚并可能造成界面与服务端不一致。

  • 线上点击“异步加一”后计数始终不变,控制台提示未知 action 类型,但同步按钮正常;你会沿着 Vuex 的哪条执行链排查?

    先核对 dispatchtype 是否存在于已注册的 actions,再确认该 action 是否通过上下文调用了正确的 commitVuexdispatch 只负责找到并执行动作,真正修改 state 仍要落到 mutation;模块注册或命名不一致也会在入口处中断。

  • 团队准备写一个轻量状态库,要求支持响应式 state、同步提交和异步动作,但暂时不要模块系统;最小实现需要保留哪些机制?

    至少要用 Vue 的响应式能力承载 state,保存操作表,并实现按 type 查找的 commitdispatch,同时把实例注入组件成为 $store。还要绑定方法的 this,并拒绝整体替换只读 state;省略 getters 后就不具备统一派生状态能力。

# 10 vue3带来的新特性/亮点

⚡ 30 秒速记

  • 响应式换 Proxy:能检测属性新增删除、支持数组索引、懒代理更快
  • Composition API:按逻辑关注点组织代码,替代 mixin 解决命名冲突和来源不清
  • 编译期优化:静态提升、Patch Flag(只 diff 动态部分)、Block Tree(拉平动态节点)
  • 更好的 TypeScript 支持(源码用 TS 重写)、Tree Shaking 友好(按需引入,包体更小)
  • 新组件/特性:Fragment(多根节点)、TeleportSuspense<script setup>

Vue 3 的亮点可以归为响应式升级、代码组织改善和编译运行性能优化。 响应式从 Object.defineProperty 换成 Proxy,代理对象本身;Composition API 让同一业务逻辑聚合在一起,也更利于复用和类型推导。编译阶段会标记动态节点,使虚拟 DOM 的更新成本更多取决于动态内容,并配合 Tree-shaking 减少未使用代码。组件层面还加入了 FragmentTeleportSuspense,分别覆盖多根节点、跨容器渲染和异步等待场景。

1. 压缩包体积更小

当前最小化并被压缩的 Vue 运行时大小约为 20kB(2.6.10 版为 22.8kB)。Vue 3.0捆绑包的大小大约会减少一半,即只有10kB!

2. Object.defineProperty -> Proxy

  • Object.defineProperty是一个相对比较昂贵的操作,因为它直接操作对象的属性,颗粒度比较小。将它替换为es6的Proxy,在目标对象之上架了一层拦截,代理的是对象而不是对象的属性。这样可以将原本对对象属性的操作变为对整个对象的操作,颗粒度变大。
  • javascript引擎在解析的时候希望对象的结构越稳定越好,如果对象一直在变,可优化性降低,proxy不需要对原始对象做太多操作。

3. Virtual DOM 重构

vdom的本质是一个抽象层,用javascript描述界面渲染成什么样子。react用jsx,没办法检测出可以优化的动态代码,所以做时间分片,vue中足够快的话可以不用时间分片

  • 传统vdom的性能瓶颈:

    • 虽然 Vue 能够保证触发更新的组件最小化,但在单个组件内部依然需要遍历该组件的整个 vdom 树。
    • 传统 vdom 的性能跟模版大小正相关,跟动态节点的数量无关。在一些组件整个模版内只有少量动态节点的情况下,这些遍历都是性能的浪费。
    • JSX 和手写的 render function 是完全动态的,过度的灵活性导致运行时可以用于优化的信息不足
  • 那为什么不直接抛弃vdom呢?

    • 高级场景下手写 render function 获得更强的表达力
    • 生成的代码更简洁
    • 兼容2.x

vue的特点是底层为Virtual DOM,上层包含有大量静态信息的模版。为了兼容手写 render function,最大化利用模版静态信息,vue3.0采用了动静结合的解决方案,将vdom的操作颗粒度变小,每次触发更新不再以组件为单位进行遍历,主要更改如下

  • 将模版基于动态节点指令切割为嵌套的区块
  • 每个区块内部的节点结构是固定的
  • 每个区块只需要以一个 Array 追踪自身包含的动态节点

vue3.0将 vdom 更新性能由与模版整体大小相关提升为与动态内容的数量相关

Vue 3.0 动静结合的 Dom diff

  • Vue3.0 提出动静结合的 DOM diff 思想,动静结合的 DOM diff其实是在预编译阶段进行了优化。之所以能够做到预编译优化,是因为 Vue core 可以静态分析 template,在解析模版时,整个 parse 的过程是利用正则表达式顺序解析模板,当解析到开始标签、闭合标签和文本的时候都会分别执行对应的回调函数,来达到构造 AST 树的目的。
  • 借助预编译过程,Vue 可以做到的预编译优化就很强大了。比如在预编译时标记出模版中可能变化的组件节点,再次进行渲染前 diff 时就可以跳过“永远不会变化的节点”,而只需要对比“可能会变化的动态节点”。这也就是动静结合的 DOM diff 将 diff 成本与模版大小正相关优化到与动态节点正相关的理论依据。

4. Performance

vue3在性能方面比vue2快了2倍。

  • 重写了虚拟DOM的实现
  • 运行时编译
  • update性能提高
  • SSR速度提高

5. Tree-shaking support

vue3中的核心api都支持了tree-shaking,这些api都是通过包引入的方式而不是直接在实例化时就注入,只会对使用到的功能或特性进行打包(按需打包),这意味着更多的功能和更小的体积。

6. Composition API

vue2中,我们一般会采用mixin来复用逻辑代码,用倒是挺好用的,不过也存在一些问题:例如代码来源不清晰、方法属性等冲突。基于此在vue3中引入了Composition API(组合API),使用纯函数分隔复用代码。和React中的hooks的概念很相似

  • 更好的逻辑复用和代码组织
  • 更好的类型推导
<template>
    <div>X: {{ x }}</div>
    <div>Y: {{ y }}</div>
</template>

<script>
import { defineComponent, onMounted, onUnmounted, ref } from "vue";

const useMouseMove = () => {
    const x = ref(0);
    const y = ref(0);

    function move(e) {
        x.value = e.clientX;
        y.value = e.clientY;
    }

    onMounted(() => {
        window.addEventListener("mousemove", move);
    });

    onUnmounted(() => {
        window.removeEventListener("mousemove", move);
    });

    return { x, y };
};

export default defineComponent({
    setup() {
        const { x, y } = useMouseMove();

        return { x, y };
    }
});
</script>

7. 新增的三个组件Fragment、Teleport、Suspense

Fragment

在书写vue2时,由于组件必须只有一个根节点,很多时候会添加一些没有意义的节点用于包裹。Fragment组件就是用于解决这个问题的(这和React中的Fragment组件是一样的)。

这意味着现在可以这样写组件了。

/* App.vue */
<template>
  <header>...</header>
  <main v-bind="$attrs">...</main>
  <footer>...</footer>
</template>

<script>
export default {};
</script>

或者这样

// app.js
import { defineComponent, h, Fragment } from 'vue';

export default defineComponent({
    render() {
        return h(Fragment, {}, [
            h('header', {}, ['...']),
            h('main', {}, ['...']),
            h('footer', {}, ['...']),
        ]);
    }
});

Teleport

Teleport其实就是React中的Portal。Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。

一个 portal 的典型用例是当父组件有 overflow: hidden 或 z-index 样式时,但你需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。

/* App.vue */
<template>
    <div>123</div>
    <Teleport to="#container">
        Teleport
    </Teleport>
</template>

<script>
import { defineComponent } from "vue";

export default defineComponent({
    setup() {}
});
</script>

/* index.html */
<div id="app"></div>
<div id="container"></div>

Suspense

同样的,这和React中的Supense是一样的。

Suspense 让你的组件在渲染之前进行“等待”,并在等待时显示 fallback 的内容

// App.vue
<template>
    <Suspense>
        <template #default>
            <AsyncComponent />
        </template>
        <template #fallback>
            Loading...
        </template>
    </Suspense>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import AsyncComponent from './AsyncComponent.vue';

export default defineComponent({
    name: "App",

    components: {
        AsyncComponent
    }
});
</script>

// AsyncComponent.vue
<template>
    <div>Async Component</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";

const sleep = () => {
    return new Promise(resolve => setTimeout(resolve, 1000));
};

export default defineComponent({
    async setup() {
        await sleep();
    }
});
</script>

8. Better TypeScript support

在vue2中使用过TypesScript的童鞋应该有过体会,写起来实在是有点难受。vue3则是使用ts进行了重写,开发者使用vue3时拥有更好的类型支持和更好的编写体验。

💬 面试官追问

  • 一个展示页只有标题会变化,模板里还有上百个静态节点;同事说 Vue 3 更新时仍必须完整比较整棵 Virtual DOM,你怎么判断?

    这个判断忽略了 Vue 3 的编译期优化:模板会被分析为结构稳定的区块,并追踪其中可能变化的动态节点。更新成本因此更接近动态内容数量,而非模板总体大小;手写 render function 或高度动态的结构无法同等利用静态信息。

  • 组件库要同时提供表单、动画和状态能力,但业务项目只用了其中一部分,构建负责人担心 Vue 3 功能越多包越大;你会关注什么?

    应关注这些核心 API 是否以可导入、可摇树的形式使用,让构建工具只保留实际引用的功能。Vue 3 的 tree-shaking 支持能减少未使用能力进入产物,但最终体积仍受业务代码、第三方依赖和构建配置影响,不能只凭框架特性下结论。

  • 管理后台频繁给响应式对象新增字段,技术负责人认为 Vue 3 仍要像基于 Object.defineProperty 的方案那样逐属性处理,你会如何解释选型变化?

    Vue 3 以 Proxy 在对象外层建立拦截,代理对象操作,而不是预先逐个改造已有属性。它减少了对原对象属性结构的直接操作,也更适合对象结构变化的场景;代价是运行环境必须具备相应的 Proxy 能力,不能把它当作旧机制的无条件替换。

  • 弹窗放在有 overflow: hidden 和复杂 z-index 的卡片组件里,线上始终被裁切;产品又要求弹窗逻辑仍归卡片维护,你会用 Vue 3 的什么能力?

    可用 Teleport 把弹窗实际渲染到卡片外的目标 DOM 容器,同时保留它在原组件中的声明和逻辑归属。这样能绕开祖先容器的裁切或层叠限制;必须确保目标节点存在,并继续处理焦点、遮罩和关闭等交互职责。

  • 订单页的异步子组件加载前会短暂空白,团队在“父组件手写加载状态”和 Vue 3 内建组件之间争论,你会怎么取舍?

    若等待发生在异步组件渲染阶段,可用 Suspense 提供 defaultfallback 内容,把加载占位集中在边界上。简单请求或需要精细控制错误、重试与局部状态时,显式状态可能更清楚;Suspense 解决等待展示,不会自动替代完整的异常处理。

  • 一个大型组件把搜索、分页和鼠标坐标逻辑分散在 datacomputedmethods 与生命周期中,维护者找不到同一业务链;Vue 3 哪项变化最直接?

    Composition API 允许按业务逻辑聚合相关状态、计算、监听和生命周期,并把鼠标等逻辑提取为可复用组合函数。它还能改善来源识别与类型推导;简单组件继续使用 Options API 也成立,强行组合反而可能增加抽象层次。

# 11 Compositon api

⚡ 30 秒速记

  • 解决的问题:Options API 里一个功能的代码被拆散在 data/methods/computed 各处,组件一大就难维护
  • 核心是按逻辑关注点聚合代码,并能抽成可复用的组合函数(useXxx
  • 相比 mixin 的优势:来源清晰(能看出变量从哪来)、没有命名冲突、类型推导友好
  • 常用 APIref(原始值)、reactive(对象)、computedwatch/watchEffect、生命周期 onXxx
  • ref 在模板里自动解包,在 JS 里必须写 .value —— 这是最常踩的坑

Composition API 是按业务功能组织和复用组件逻辑的一套组合式接口。 Options API 会把同一功能拆到 datacomputedmethods 等区域,组件变大后阅读和维护都更费劲;组合式写法则能把相关状态与操作放在一起。相比 mixin,它通过显式调用组合函数返回数据,来源更清楚,也降低了属性命名冲突和类型推断困难。它基于 Vue 响应式系统自动收集依赖,调用也不依赖固定顺序,但中小型、逻辑简单的组件并不一定必须改写。

Composition API也叫组合式API,是Vue3.x的新特性。

通过创建 Vue 组件,我们可以将接口的可重复部分及其功能提取到可重用的代码段中。仅此一项就可以使我们的应用程序在可维护性和灵活性方面走得更远。然而,我们的经验已经证明,光靠这一点可能是不够的,尤其是当你的应用程序变得非常大的时候——想想几百个组件。在处理如此大的应用程序时,共享和重用代码变得尤为重要

  • Vue2.0中,随着功能的增加,组件变得越来越复杂,越来越难维护,而难以维护的根本原因是Vue的API设计迫使开发者使用watch,computed,methods选项组织代码,而不是实际的业务逻辑。
  • 另外Vue2.0缺少一种较为简洁的低成本的机制来完成逻辑复用,虽然可以minxis完成逻辑复用,但是当mixin变多的时候,会使得难以找到对应的data、computed或者method来源于哪个mixin,使得类型推断难以进行。
  • 所以Composition API的出现,主要是也是为了解决Option API带来的问题,第一个是代码组织问题,Compostion API可以让开发者根据业务逻辑组织自己的代码,让代码具备更好的可读性和可扩展性,也就是说当下一个开发者接触这一段不是他自己写的代码时,他可以更好的利用代码的组织反推出实际的业务逻辑,或者根据业务逻辑更好的理解代码。
  • 第二个是实现代码的逻辑提取与复用,当然mixin也可以实现逻辑提取与复用,但是像前面所说的,多个mixin作用在同一个组件时,很难看出property是来源于哪个mixin,来源不清楚,另外,多个mixinproperty存在变量命名冲突的风险。而Composition API刚好解决了这两个问题。

通俗的讲:

没有Composition API之前vue相关业务的代码需要配置到option的特定的区域,中小型项目是没有问题的,但是在大型项目中会导致后期的维护性比较复杂,同时代码可复用性不高。Vue3.x中的composition-api就是为了解决这个问题而生的

compositon api提供了以下几个函数:

  • setup
  • ref
  • reactive
  • watchEffect
  • watch
  • computed
  • toRefs
  • 生命周期的hooks

都说Composition API与React Hook很像,说说区别

从React Hook的实现角度看,React Hook是根据useState调用的顺序来确定下一次重渲染时的state是来源于哪个useState,所以出现了以下限制

  • 不能在循环、条件、嵌套函数中调用Hook
  • 必须确保总是在你的React函数的顶层调用Hook
  • useEffect、useMemo等函数必须手动确定依赖关系

而Composition API是基于Vue的响应式系统实现的,与React Hook的相比

  • 声明在setup函数内,一次组件实例化只调用一次setup,而React Hook每次重渲染都需要调用Hook,使得React的GC比Vue更有压力,性能也相对于Vue来说也较慢
  • Compositon API的调用不需要顾虑调用顺序,也可以在循环、条件、嵌套函数中使用
  • 响应式系统自动实现了依赖收集,进而组件的部分的性能优化由Vue内部自己完成,而React Hook需要手动传入依赖,而且必须必须保证依赖的顺序,让useEffectuseMemo等函数正确的捕获依赖变量,否则会由于依赖不正确使得组件性能下降。

虽然Compositon API看起来比React Hook好用,但是其设计思想也是借鉴React Hook的。

💬 面试官追问

  • 一个表格组件用了五个 mixin,模板里的 loadrows 找不到来源,另一个 mixin 又声明了同名属性;你会怎样重构?

    应把各业务能力提取为显式调用的组合函数,例如由 useTable 返回 rowsload,组件能直接看出来源。需要重名时可在解构或返回结构上主动处理;组合函数过度拆碎也会让调用关系分散,因此边界应围绕完整业务逻辑。

  • 搜索页的筛选、分页、请求取消逻辑分别散在 watchcomputedmethods 和卸载钩子里,新成员改一次要跳四处;setup 中你会怎么组织?

    我会让同一搜索流程的 refcomputedwatch 和生命周期钩子相邻放置,再把稳定可复用的部分提取成 useSearch。这种组织方式按业务关系而非选项类型聚合代码;若组合函数同时承担多个领域,仍会退化成难维护的大函数。

  • 权限组合函数只在管理员分支中需要,评审者套用 React 规则,禁止在条件分支调用它;在 VueComposition API 下这个限制成立吗?

    不应机械套用 React Hook 的调用顺序规则,Vuesetup 对每个组件实例执行一次,组合式调用不靠固定序号匹配状态。它可以出现在条件、循环或嵌套函数中;但分支调用产生的生命周期与资源清理仍要清晰,否则工程上依然容易遗漏。

  • 线上筛选条件变化后副作用没有执行,同事刚把相关读取移出 watchEffect,却认为响应式系统会自动追踪文件里所有变量;你会怎样排查?

    先确认响应式值是否在该副作用执行期间被实际读取,因为依赖收集来自运行中的访问关系,而不是静态扫描整个文件。再检查值是否仍由 refreactive 持有;脱离响应式容器或不在执行链中读取,都不会建立预期依赖。

  • 团队要在 watchwatchEffect 间选型:风控页必须明确监听账户和金额,并记录变更前后值;哪一个更合适?

    这里更适合 watch,因为依赖目标需要显式表达,并且业务要使用新旧值进行审计或判断。watchEffect 更适合让系统自动收集副作用中读取的依赖;自动收集虽简洁,但依赖边界不够显眼,复杂风控逻辑会更难审查。

  • React 开发者说组合式函数就是换名后的 Hook,并准备给每个副作用手写依赖数组;你会指出哪些实现差异?

    Vue 的组合式能力建立在响应式依赖收集上,watchEffect 等机制可根据实际读取自动建立依赖,并不依靠 Hook 调用顺序。setup 通常按组件实例执行一次,而 React Hook 随重渲染再次调用;两者思想相近,但不能照搬依赖数组和顶层调用约束。

# 12 computed 的实现原理

⚡ 30 秒速记

  • 本质是一个带缓存的 Watcher:依赖不变就直接返回缓存值,不重新计算
  • 实现靠 dirty 标志位:依赖变化时把 dirtytrue,下次访问才重新求值
  • methods 的区别:methods 每次渲染都执行,computed 只在依赖变化后才重算
  • watch 的区别:computed 用来派生新值(有返回值),watch 用来响应变化做副作用
  • computed 支持 getter/setter 两种写法,可写的计算属性适合做双向绑定的中间层

computed 本质上是带缓存的惰性观察者,通过 dirty 标记决定是否重新求值。 首次读取时它会执行计算并缓存结果;依赖变化后,若当前没有订阅者,通常只把 dirty 设为 true,等下次读取再计算。存在订阅者时,它会重新计算并比较新旧结果,最终值确实变化才通知渲染观察者,从而减少无效更新。因此,同一派生结果会被多次读取时适合用 computed,单纯执行动作则不必依赖这层缓存。

computed 本质是一个惰性求值的观察者computed watcher。其内部通过 this.dirty 属性标记计算属性是否需要重新求值。

  • 当 computed 的依赖状态发生改变时,就会通知这个惰性的 watcher,computed watcher 通过 this.dep.subs.length 判断有没有订阅者,
  • 有的话,会重新计算,然后对比新旧值,如果变化了,会重新渲染。 (Vue 想确保不仅仅是计算属性依赖的值发生变化,而是当计算属性最终计算的值发生变化时才会触发渲染 watcher 重新渲染,本质上是一种优化。)
  • 没有的话,仅仅把 this.dirty = true (当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备 lazy(懒计算)特性。)

💬 面试官追问

  • 报表页把 totalPrice 写成普通方法,模板在十几个位置调用,任意按钮触发重渲染都会重新遍历订单;改成 computed 为什么有意义?

    computed 由惰性的 computed watcher 管理,依赖未变化时可复用已有结果,不必在每次渲染中重复执行遍历。依赖变化后它才进入需要重新求值的状态;若计算本身极轻且只调用一次,缓存带来的收益可能并不明显。

  • 商品列表有一个昂贵的派生筛选值,但折叠面板关闭时没有任何模板或其他逻辑读取它;源数据更新后会马上重新计算吗?

    在没有订阅者时,依赖变化通常只会把计算观察者的 dirty 标记设为 true,不会立刻执行筛选。等面板展开并再次读取该值时才真正求值,这就是惰性计算;若其他观察者仍订阅它,就不能假设始终无人触发。

  • 一个 computed 同时依赖价格和折扣,价格变化后被连续读取三次;面试现场让你描述 dirty 从变化到复用的过程,你会怎么说?

    依赖变化会通知对应的惰性观察者,使其进入需要求值的状态;首次读取时重新执行计算并产出结果,随后读取可继续使用缓存。直到相关依赖再次变化前,不应重复计算;前提是这些依赖确实在求值过程中被响应式系统收集。

  • 线上日志显示源字段频繁变化,但依赖某个 computed 的视图并非每次都重渲染;你会先判断这是故障还是优化?

    先比较计算属性最终的新旧值,而不是只看底层依赖是否变化;最终值未变时,不触发渲染观察者属于预期优化。若最终值已变化仍不更新,再检查依赖是否具有响应性、计算属性是否被订阅,以及读取链路是否真正建立。

  • 库存页需要在数量变化后写日志并请求补货,同事想把这些副作用塞进 computed 以利用缓存;你会如何取舍?

    不应把日志和请求作为计算属性的核心职责,computed 更适合根据响应式状态产生可缓存的派生值。副作用应放到侦听器或明确的业务动作中,以便控制触发和失败处理;否则惰性读取会让请求时机取决于“是否有人访问”。

# 13 watch 的理解

⚡ 30 秒速记

  • 用途:监听数据变化后执行副作用(发请求、操作 DOM、写缓存)
  • 常用选项:deep(深度监听,有性能代价)、immediate(立即执行一次)、flush(控制回调时机)
  • watch 需要显式指定源,watchEffect 自动收集回调里用到的依赖 —— 后者更简洁但依赖不够明确
  • 监听对象的某个属性用 getter 写法:watch(() => obj.a, cb),直接传 obj.a 是拿不到响应性的
  • 记得清理:watch 返回一个停止函数,组件卸载时 Vue 会自动清理,但手动创建的要自己收

watch 本质上是观察数据变化,并在变化后执行回调,它本身没有缓存能力。 当监听对象内部的属性时,可以开启 deep: true,让对象中的每一项都被监听。深度监听覆盖得更全面,但也会带来额外的性能开销。明确知道要观察哪个属性时,我一般会使用字符串形式监听,缩小监听范围。

watch没有缓存性,更多的是观察的作用,可以监听某些数据执行回调。当我们需要深度监听对象中的属性时,可以打开deep:true选项,这样便会对对象中的每一项进行监听。这样会带来性能问题,优化的话可以使用字符串形式监听

注意:Watcher : 观察者对象 , 实例分为渲染 watcher (render watcher),计算属性 watcher (computed watcher),侦听器 watcher(user watcher)三种

💬 面试官追问

  • 个人资料页把完整用户对象做成 computed,只为了姓名变化时调用埋点;同事认为 computedwatch 都能依赖收集,你会接受吗?

    不接受,埋点属于数据变化后的副作用,应使用 watch 执行回调,而 computed 应保留给派生值和缓存。混用会让读取动作夹带外部行为,触发时机也更难判断;若只是展示拼接姓名,才适合计算属性。

  • 配置中心监听一个包含大量嵌套字段的表单,任意字段改动都触发自动保存;直接设置 deep: true 有什么工程风险?

    深度监听会遍历对象中的各项以建立监听,结构越大,初始化与变更处理的负担越值得警惕。更稳妥的是只监听确实影响保存的字段,或拆成多个边界明确的对象;若需求就是捕获任意深层修改,精确监听会增加维护清单。

  • 订单页原来只监听 status,产品临时要求地址任一层级变化也重新校验;你会继续写单字段监听,还是改成深度监听?

    若地址结构稳定且只有少数字段参与校验,优先精确监听这些路径,避免无关字段也触发回调。只有规则明确要求捕获整个地址对象的任意深层变化时才考虑 deep: true;范围扩大后必须评估回调频率和对象规模。

  • 线上修改 profile.contact.phone 后校验没有执行,但替换整个 profile 对象时会执行;当前只监听了对象本身,你会怎么定位?

    先确认侦听器是否只观察了对象引用,因为深层属性变化不等于引用被替换。随后核对目标字段路径,并选择精确的字符串路径监听或按需求开启 deep;开启深度监听虽能覆盖该变化,也可能把无关字段纳入回调。

  • 审计模块既要计算账户风险等级,又要在等级变化时发送告警;架构师想全部交给一个侦听器,你会怎样拆分?

    风险等级应由 computed watcher 负责派生和缓存,告警则由 user watcher 观察等级变化后执行回调。组件渲染仍由 render watcher 响应最终展示变化;拆分能区分计算、用户副作用和渲染职责,但要避免告警回调反向修改依赖造成循环。

# 14 vue 渲染过程

⚡ 30 秒速记

  • 完整链路:模板 → 编译成 render 函数 → 执行得到 VNode 树 → patch 成真实 DOM
  • 编译三步:parse(模板转 AST)→ optimize/transform(标记静态节点、生成 Patch Flag)→ generate(生成 render 代码)
  • 更新时:响应式数据变化 → 触发组件的渲染 effect → 重新执行 render 得到新 VNode → 与旧 VNodediffpatch
  • 编译时机:.vue 单文件在构建期编译(推荐),运行时编译需要带编译器的完整版
  • Vue 3 的编译期优化让运行时 diff 只处理动态节点,这是它比 Vue 2 快的主因

Vue 的渲染过程是先把模板编译成 render 函数,再生成 VNode,最后通过 patch 更新真实 DOM 编译时,parse 把模板转成 ASToptimize 标记静态节点,generate 再生成 render 函数字符串。数据变化会触发渲染 Watcher,重新执行 render 得到新的 VNode。随后通过 DOM diff 对比新旧节点,只对真实 DOM 做必要的添加、修改或删除。

  • 调用 compile 函数,生成 render 函数字符串 ,编译过程如下:
    • parse 使用大量的正则表达式对template字符串进行解析,将标签、指令、属性等转化为抽象语法树AST。模板 -> AST (最消耗性能)
    • optimize 遍历AST,找到其中的一些静态节点并进行标记,方便在页面重渲染的时候进行diff比较时,直接跳过这一些静态节点,优化runtime的性能
    • generate 将最终的AST转化为render函数字符串
  • 调用 new Watcher 函数,监听数据的变化,当数据发生变化时,Render 函数执行生成 vnode 对象
  • 调用 patch 方法,对比新旧 vnode 对象,通过 DOM diff 算法,添加、修改、删除真正的 DOM 元素

💬 面试官追问

  • 商品详情页只修改购物车数量,为什么不能理解成 Vue 会重新创建整棵真实 DOM

    数据变化会触发对应的 Watcher,重新执行 render 生成新的 VNode,但不会直接重建整棵真实 DOM。随后 patch 对比新旧 VNode,只对需要变化的真实节点执行添加、修改或删除;若节点结构或标识发生变化,复用范围才会缩小。

  • 后台表格有 3000 行,筛选条件每输入一个字符就明显卡顿,你会把耗时拆到渲染链路的哪些阶段?

    先区分数据变化后执行 render 的脚本耗时,以及 patch 后真实 DOM 更新带来的布局和绘制耗时。若生成大量 VNode 已经很慢,应减少参与渲染的数据;若主要慢在真实节点更新,则应分页或缩小一次更新的节点规模。

  • 运营平台允许用户在线修改 template,而普通业务组件在构建时已有 render,两类组件的渲染链路有什么关键差异?

    在线模板必须先经过 parseoptimizegenerate,得到 render 后才能进入创建 VNodepatch 的流程。构建阶段已经生成 render 的组件可跳过运行时编译;动态模板若频繁变化,编译成本会重新出现,不能只优化后续 diff

  • 线上页面数据已更新,控制台也能看到新值,但视图保持旧内容,你会沿着哪条链路定位断点?

    先确认变化是否触发负责渲染的 Watcher,再检查 render 是否生成了符合预期的新 VNode,最后观察 patch 是否更新了真实 DOM。若新旧 VNode 本身没有差异,应回查渲染依赖;若差异存在但节点未变,再检查节点匹配与补丁过程。

  • 内容团队要求首屏模板高度动态,性能团队要求彻底移除运行时编译,你会如何划分方案边界?

    固定结构的组件应在构建阶段完成模板编译,运行时直接执行 render,避免承担最耗性能的模板到 AST 转换。真正由用户配置结构的区域才保留动态编译,并限制其范围和触发次数;完全动态与完全预编译无法同时满足同一块模板。

# 15 说一说keep-alive实现原理

⚡ 30 秒速记

  • 作用:缓存组件实例,切换回来时不重新创建,保留状态和避免重复渲染
  • 实现:内部用一个 Map 缓存 VNode,配合 LRU 策略按 max 淘汰最久未使用的
  • 三个属性:include/exclude(按组件名匹配)、max(最大缓存数)
  • 被缓存的组件不走 mounted/unmounted,改走 activated/deactivated 两个钩子
  • 它是抽象组件:自身不渲染真实 DOM,也不出现在父组件链中

keep-alive 的核心是缓存组件对应的 VNode,再次命中时直接从缓存中取出。 它通过 includeexclude 按组件的 name 控制哪些需要缓存,参数可以是字符串、正则或数组。max 用来限制缓存数量,超过上限后会删除最先进入的缓存项。这个淘汰过程采用 LRU 思路,让最近访问过的内容更有机会继续保留。

keep-alive组件接受三个属性参数:includeexcludemax

  • include 指定需要缓存的组件name集合,参数格式支持String, RegExp, Array。当为字符串的时候,多个组件名称以逗号隔开。
  • exclude 指定不需要缓存的组件name集合,参数格式和include一样。
  • max 指定最多可缓存组件的数量,超过数量删除第一个。参数格式支持String、Number。

原理

keep-alive实例会缓存对应组件的VNode,如果命中缓存,直接从缓存对象返回对应VNode

LRU(Least recently used) 算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。(墨菲定律:越担心的事情越会发生)

💬 面试官追问

  • 订单列表被 keep-alive 包裹后,从详情页返回仍保留筛选条件,这是否说明它把页面真实 DOM 截图后直接恢复了?

    不是,keep-alive 的核心是缓存对应组件的 VNode,再次命中时从缓存对象返回该 VNode。保留页面状态来自组件实例及其虚拟节点被复用,而不是保存一份页面截图;未命中缓存时仍需按正常组件流程创建。

  • 移动端有商品、订单、消息三类列表,只希望订单页返回时保留状态,你会怎样配置 include 和组件名称?

    给订单页设置稳定的组件 name,并让 include 只匹配该名称,商品页和消息页便不会进入缓存。include 支持 StringRegExpArray,字符串包含多个名称时用逗号分隔;名称不匹配会导致看似配置成功却始终无法命中。

  • 路由扩展到 20 个详情页,产品要求全部缓存,客户端负责人又要求限制缓存数量,你会怎样处理 max

    设置 max 限制缓存组件数量,超出后按最近使用情况淘汰长期未访问的缓存项。max 支持 StringNumber,但它只能控制数量,不能判断业务重要性;关键页面仍应结合 includeexclude 明确缓存范围。

  • 线上反馈用户返回列表时状态偶尔丢失,但同一路由有时又能恢复,你会优先核对哪些缓存条件?

    先核对组件 name 是否稳定且确实命中 include,再检查是否被 exclude 排除或因超过 max 而被淘汰。随后确认访问顺序,因为 LRU 会改变淘汰对象;若组件从未命中缓存,问题不在恢复阶段,而在缓存筛选条件。

  • 中后台有 30 个路由,团队争论是全部套 keep-alive,还是只缓存高频表单页,你会支持哪种方案?

    应只缓存确实需要保留编辑状态或页面上下文的组件,并用 includeexclude 明确名单,同时设置合理的 max。全量缓存会让缓存集合持续扩大并触发淘汰,命中结果更依赖访问历史;选择性缓存更可控,但未缓存页面需要自行恢复状态。

# 16 为什么访问data属性不需要带data

⚡ 30 秒速记

  • 因为初始化时做了代理Vue 遍历 data 的每个键,在实例上用 Object.defineProperty 定义同名访问器
  • 访问 this.msg 实际走的是代理,内部转发到 this._data.msg
  • 同样的代理也用在 propsmethodscomputed 上,所以它们都能直接用 this. 访问
  • 由此推出一个坑:datapropsmethods 不能重名,否则会互相覆盖
  • _$ 开头的属性不会被代理,避免和 Vue 内部属性冲突

访问 data 属性时不需要再写 data,是因为 Vue 把这些属性代理到了实例上。 简单来说,读取 this.xxx 时,代理的 get 会转发到内部对象对应的属性;给 this.xxx 赋值时,set 也会把新值写回去。这个代理通过 Object.defineProperty 实现,并将属性设置为可枚举、可配置,因此模板和实例代码都能用更简洁的方式访问数据。

vue中访问属性代理 this.data.xxx 转换 this.xxx 的实现

 /** 将 某一个对象的属性 访问 映射到 对象的某一个属性成员上 */
function proxy( target, prop, key ) {
  Object.defineProperty( target, key, {
    enumerable: true,
    configurable: true,
    get () {
      return target[ prop ][ key ];
    },
    set ( newVal ) {
      target[ prop ][ key ] = newVal;
    }
  } );
}

💬 面试官追问

  • 组件里写了 data: { count: 1 },模板和方法都能访问 this.count,这是否意味着 count 被复制到实例上了?

    不是复制,而是在实例的 count 属性上通过 Object.defineProperty 建立访问代理。读取 this.count 时,get 返回 this.data.count;写入时,set 再把新值交给对应的数据属性,因此两处不会维护两份独立状态。

  • 表单组件有 80 个字段,团队想手写 this.data.xxx 来减少一层代理,你会如何评价这个改动?

    现有代理让模板和实例方法统一使用 this.xxx,手写完整路径会改变组件的访问约定,却不会改变底层数据仍位于 data 的事实。是否值得改动应以实际测量为依据;仅凭多一次 get 转发,无法断言它就是表单卡顿来源。

  • 基础组件实例上已经存在 status,业务又在 data 中声明同名字段并希望通过 this.status 访问,代理实现需要满足什么约束?

    代理要求在目标实例上为该 key 定义可配置的访问属性,因此同名成员会产生覆盖或定义冲突风险。初始化阶段应检测并避免名称碰撞,必要时重命名业务字段;否则 this.status 指向哪一层会变得不可靠。

  • 线上出现 this.total = 20this.data.total 仍是旧值,你会怎样验证代理是否被破坏?

    先用属性描述符检查实例 total 是否仍具有代理的 getset,再确认它们是否指向预期的 data.total。若该属性后来被重新定义,赋值就可能不再转发;若代理完整,则继续核对实际代理容器和字段名是否一致。

  • 代码评审中有人建议用一次性赋值 this.count = this.data.count 代替访问器代理,两种方案的行为差异是什么?

    一次性赋值只同步初始化时的值,之后修改实例属性或 data.count 都不会自动反映到另一处。访问器代理则让每次读写都落到同一个底层字段;代价是实例属性必须保留这组 getset,不能随意改成普通值属性。

# 17 template预编译是什么

⚡ 30 秒速记

  • 指在构建期就把模板编译成 render 函数,而不是等到运行时再编译
  • 好处:运行时不需要携带编译器,包体积更小(约省 30%);启动更快,没有编译开销
  • .vue 单文件组件由 vue-loader/@vitejs/plugin-vue 在构建期完成预编译
  • 只有用字符串模板(template 选项写在 JS 里)或运行时挂载 el 才需要完整版 Vue
  • Vue 3 的编译期优化(静态提升、Patch Flag)也都发生在这一步

模板预编译,就是在项目构建阶段把 template 转换成 render function 如果留到运行时处理,组件实例化时需要先完成一次模板编译,之后才能执行渲染,这部分工作会带来运行时损耗。提前编译后,组件运行时可以直接使用生成的渲染函数,从而跳过模板编译。它优化的是组件启动和渲染前的准备过程,并不是让模板在运行时反复编译。

对于 Vue 组件来说,模板编译只会在组件实例化的时候编译一次,生成渲染函数之后在也不会进行编译。因此,编译对组件的 runtime 是一种性能损耗。

而模板编译的目的仅仅是将template转化为render function,这个过程,正好可以在项目构建的过程中完成,这样可以让实际组件在 runtime 时直接跳过模板渲染,进而提升性能,这个在项目构建的编译template的过程,就是预编译。

💬 面试官追问

  • 一个组件只实例化一次,负责人因此认为运行时编译 template 没有成本,你会怎样反驳?

    模板即使只在组件实例化时编译一次,也必须完成从 templaterender function 的转换,这仍是一次运行时性能损耗。预编译把这段工作移到项目构建过程,使实际组件运行时直接执行渲染函数;实例少只能降低影响,不能让成本消失。

  • 营销页有 40 个固定组件模板,构建负责人问是否值得做预编译,你会给出什么落地判断?

    这些模板在发布前已经确定,适合在构建阶段统一转换为 render function,运行时无需再次处理模板。落地时应确认产物包含可直接执行的渲染函数;若仍把原始 template 留到浏览器处理,就没有获得预编译的核心收益。

  • 低代码平台的模板由运营发布后即时生效,架构组仍要求所有模板构建期预编译,这个约束为何无法直接满足?

    构建期预编译只能处理构建时已经存在的模板,而运营发布的模板在运行时才确定,无法被先前构建产物完整覆盖。可把固定外壳预编译,将动态区域限制在必要范围;若动态结构也必须即时变化,就仍需承担相应的模板转换成本。

  • 线上首开页面卡顿,火焰图显示组件实例化阶段出现明显模板编译工作,你会检查构建链路的什么结果?

    先检查发布产物中的组件是否已经携带 render function,以及运行时是否仍接收到原始 template。若模板没有在构建阶段转换,实例化时就会再次承担编译;若已有渲染函数,则应继续排查 render 和后续真实节点更新,而非归因于预编译。

  • 插件系统在“构建期预编译”和“运行时接收模板”之间选型,产品强调灵活性,性能团队强调启动速度,你会怎样定边界?

    核心页面和稳定插件应优先预编译,让运行时直接进入渲染流程;必须动态下发结构的插件才保留运行时模板转换。前者牺牲发布后的即时结构变化,后者增加实例化阶段的编译工作,边界应由模板是否能在构建时确定来划分。

# 18 介绍一下Vue中的Diff算法

⚡ 30 秒速记

  • 三个前提假设把复杂度从 O(n³) 降到 O(n):只做同层比较、类型不同直接整棵替换、用 key 标识同层节点身份
  • Vue 2双端比较:新旧列表各设头尾指针,四种组合两两比对,命中就复用并移动指针
  • Vue 3 改用最长递增子序列:先处理头尾相同部分,中间乱序部分求 LIS,不在 LIS 里的才需要移动,DOM 移动次数最少
  • key 的意义:没有 key 时按索引复用,列表中间插入会导致后续节点全部错位重渲染
  • 别用 indexkey —— 那等于没有 key

VueDiff 算法用于比较新旧虚拟 DOM,找出同层节点中真正需要更新的部分。 它先判断节点是否相同,不同就删除旧节点并创建新节点,相同则通过 patchVnode 继续处理子节点。双方都有多个子节点时,才进入 updateChildren 做核心比较,匹配到相同子节点后再递归处理。它不做跨层级比较,因此把原本可能达到 O(n³) 的复杂度降低到了 O(n)

在新老虚拟DOM对比时

  • 首先,对比节点本身,判断是否为同一节点,如果不为相同节点,则删除该节点重新创建节点进行替换
  • 如果为相同节点,进行patchVnode,判断如何对该节点的子节点进行处理,先判断一方有子节点一方没有子节点的情况(如果新的children没有子节点,将旧的子节点移除)
  • 比较如果都有子节点,则进行updateChildren,判断如何对这些新老节点的子节点进行操作(diff核心)。 匹配时,找到相同的子节点,递归比较子节点

在diff中,只对同层的子节点进行比较,放弃跨级的节点比较,使得时间复杂从O(n^3)降低值O(n),也就是说,只有当新旧children都为多个子节点时才需要用核心的Diff算法进行同层级比较。

💬 面试官追问

  • 资讯列表把一个卡片从第三层容器移到相邻栏目,内容完全相同,为什么不能期待 diff 跨层找到并直接复用?

    该算法只比较同层子节点,主动放弃跨级匹配,因此相同内容移动到不同层级时不会按跨层节点处理。旧位置可能被删除,新位置再创建对应节点;这项约束换来了线性的同层比较范围,代价是跨层重排无法直接复用。

  • 商品列表更新 1000 条数据时,只有价格字段变化,你会如何解释从根节点到真实 DOM 的处理路径?

    先比较新旧虚拟节点本身,若判定为同一节点,就进入 patchVnode 处理其子节点。双方都有多个子节点时进入 updateChildren,找到相同子节点后递归比较,最终只把确定的差异落实到真实 DOM;若节点不相同,则会删除并重新创建。

  • 空状态页从“无订单”切换为包含 20 条订单的列表,和两个列表各有 20 条数据时,是否都会进入核心 updateChildren

    不会,只有新旧双方都具有多个子节点时,才需要进入核心的同层子节点比较。由无子节点变为有子节点时可直接添加,新的一方没有子节点时则移除旧子节点;因此应先判断子节点存在形态,再决定是否执行列表 diff

  • 线上更新一条记录后整行输入状态丢失,你怀疑节点被替换,会沿着 diff 的哪个判断排查?

    先检查新旧行节点是否被判定为同一节点,因为一旦不相同,旧节点会被删除并创建新节点,内部状态也无法沿用。若节点相同,再进入 patchVnode 检查子节点处理;排查重点是生成的虚拟节点结构与身份条件,而不只是数据值。

  • 设计器团队希望支持任意节点跨层拖拽,同时要求每次更新都保持现有 diff 的低复杂度,你会如何说明取舍?

    现有算法通过只比较同层子节点,把原本可能达到 O(n^3) 的一般树比较收敛到 O(n) 范围。任意跨层匹配会扩大搜索空间,不能直接沿用这一复杂度承诺;可把拖拽结果视为旧层删除、新层创建,或由业务层额外维护映射。

  • 代码评审时有人把 patchVnodeupdateChildren 当成同一层逻辑,你会用“新节点没有子节点”的场景怎样区分?

    patchVnode 负责同一节点内部该如何更新,其中先判断新旧双方是否存在子节点。只有双方都有多个子节点时才调用 updateChildren 做核心同层比较;若新节点没有子节点,直接移除旧子节点即可,无需进入列表匹配。

# 19 说说Vue2.0和Vue3.0有什么区别

⚡ 30 秒速记

  • 响应式:Object.definePropertyProxy,能检测新增删除、支持数组索引、懒代理
  • 代码组织:Options APIComposition API,按逻辑关注点聚合
  • 性能:编译期静态提升、Patch FlagBlock Treediff 从双端比较换成 LIS
  • 工程:源码用 TypeScript 重写、Tree Shaking 友好、包体更小
  • 新能力:Fragment 多根节点、TeleportSuspense<script setup>;生命周期改名(destroyedonUnmounted

Vue 3 相比 Vue 2,核心变化集中在响应式、代码组织和编译运行效率上。 响应式从 Object.defineProperty 改为 Proxy,可以直接监听数组变化和属性增删,也不必预先遍历对象的每个属性。它新增 Composition API,让相关逻辑更容易组织和复用;同时重构虚拟 DOM,对静态节点、slot 和内联事件做编译优化。代码结构也更利于 Tree shaking,并使用 TypeScript 替代 Flow

  • 重构响应式系统,使用Proxy替换Object.defineProperty,使用Proxy优势:
    • 可直接监听数组类型的数据变化
    • 监听的目标为对象本身,不需要像Object.defineProperty一样遍历每个属性,有一定的性能提升
    • 可拦截apply、ownKeys、has等13种方法,而Object.defineProperty不行
    • 直接实现对象属性的新增/删除
  • 新增Composition API,更好的逻辑复用和代码组织
  • 重构 Virtual DOM
    • 模板编译时的优化,将一些静态节点编译成常量
    • slot优化,将slot编译为lazy函数,将slot的渲染的决定权交给子组件
    • 模板中内联事件的提取并重用(原本每次渲染都重新生成内联函数)
  • 代码结构调整,更便于Tree shaking,使得体积更小
  • 使用Typescript替换Flow

💬 面试官追问

  • 商品编辑页给一个已有响应式对象动态增加 discount 字段,Vue 2 页面不更新,迁到 Vue 3 后却正常了;你怎么解释这种差异,又会提醒团队别得出什么错误结论?

    差异来自响应式实现:Vue 2Object.defineProperty 主要在初始化时逐项劫持属性,后续新增或删除属性不能被直接感知;Vue 3Proxy 代理对象本身,可以拦截这类操作。不能因此认定所有更新都会更快,组件划分、依赖数量和渲染成本仍会决定实际表现。

  • 一个 Vue 2 后台有几十种动态表单,字段和数组项频繁增删;如果准备迁移到 Vue 3,你会优先验证哪些响应式代码,而不是只确认页面能启动?

    应优先检查依赖 Vue.setVue.delete、数组变更补丁以及深层对象初始化的代码,因为迁移后这些路径会由 Proxy 直接跟踪。再用新增字段、删除字段和数组增删等真实操作做回归,确认计算属性与视图同步;响应式改善并不能替代业务状态和表单边界测试。

  • 一个多人维护的复杂业务组件在 Vue 2 中把请求、筛选、分页都塞进 mixins,升级时有人主张只换响应式实现、不引入 Composition API;你会如何取舍?

    只完成兼容迁移是更低风险的路径,Composition API 并不是升级后必须立即采用。若组件长期存在逻辑分散、同名覆盖或复用困难,可按业务能力抽成组合函数并逐段验证;一次性同时改框架、状态结构和组织方式,会扩大回归范围,也更难定位问题。

  • 迁移后列表数据没变,但性能面板显示重复创建了大量模板内联事件函数;你会从 Vue 3 的编译与虚拟 DOM 优化角度检查什么?

    先确认生产构建确实使用了对应的 Vue 3 模板编译器,并检查模板是否让静态节点、内联事件和 slot 获得编译期优化。Vue 3 可提升静态内容、复用内联事件并将 slot 编译为惰性函数;若大量内容本身是动态的,这些优化空间会自然缩小。

  • 团队争论升级收益时,一方只谈 Proxy,另一方只谈包体积;你会怎样给出更完整的技术判断?

    应把收益拆成运行时、开发组织和构建产物三类:Proxy 改善对象与数组变化追踪,Composition API 改善逻辑复用,编译器和虚拟 DOM 重构减少部分重复工作。代码结构也更利于 Tree Shaking,且实现语言由 Flow 转向 TypeScript;最终收益仍受依赖兼容和业务代码形态约束。

# 七、React

# 0 对虚拟DOM的理解

⚡ 30 秒速记

  • 虚拟 DOM 就是用普通 JS 对象描述真实 DOM{ type, props, children, key }
  • 首要价值不是「快」,而是声明式UI = f(state),你只描述结果不写操作步骤)和跨平台(同一套描述可以渲染到浏览器、RNCanvasSSR
  • 性能上的真相:手写精准的 DOM 操作永远更快,虚拟 DOM 保证的是下限
  • 它真正避免的是「频繁分散的 DOM 操作」:把多次修改合并成一次批量 patch
  • 代价是内存占用和 diff 计算;Svelte/Solid 走编译期路线证明了它并非唯一解

虚拟 DOM 的核心价值不是比手写 DOM 操作更快,而是让声明式更新和视图抽象成为可能。 它作为局部更新链路的一环,用更轻量的数据结构承载视图,使新旧视图能够执行 Diff,避免每次都做全量更新。直接、精准地操作真实 DOM 通常仍可能更快,因为人为已经省掉了比较过程。工程上的取舍是接受一定抽象成本,换取 state = UI 的开发方式以及更好的跨平台能力。

虚拟dom从来不是用来和直接操作dom对比的,它们俩最终殊途同归。虚拟dom只不过是局部更新的一个环节而已,整个环节的对比对象是全量更新。虚拟dom对于state=UI的意义是,虚拟dom使diff成为可能(理论上也可以直接用dom对象diff,但是太臃肿),促进了新的开发思想,又不至于性能太差。但是性能再好也不可能好过直接操作dom,人脑连diff都省了。还有一个很重要的意义是,对视图抽象,为跨平台助力

其实我最终希望你明白的事情只有一件:虚拟 DOM 的价值不在性能,而在别处。因此想要从性能角度来把握虚拟 DOM 的优势,无异于南辕北辙。偏偏在面试场景下,10 个人里面有 9 个都走这条歧路,最后9个人里面自然没有一个能自圆其说,实在让人惋惜。

真正理解虚拟DOM (opens new window)

💬 面试官追问

  • 活动页只有一个计数器,工程师说直接写 textContent 肯定比虚拟 DOM 快,所以虚拟 DOM 没价值;你会怎样回应这个反例?

    单点且更新路径已知时,直接操作真实 DOM 通常少了一层创建和比较,虚拟 DOM 并不保证更快。它的价值是让开发者用 state = UI 描述界面,再通过差异计算完成通用的局部更新;若把比较对象限定成一次精准手工操作,结论本身就失去了公平性。

  • 一个表格页面每次筛选都重新生成整棵虚拟节点树,但最终只改了几个单元格;这层虚拟 DOM 在工程上解决了什么,又没有解决什么?

    它提供了轻量的视图描述,使新旧结果能够执行 diff,再把必要变更提交到真实 DOM,而不必由业务代码维护每个更新路径。它没有消除生成对象和比较树的成本,也不会自动修复不合理的全量状态更新;组件边界和状态粒度仍需工程控制。

  • 同一套业务组件要同时运行在浏览器和原生容器中,产品要求交互逻辑复用但宿主控件完全不同;虚拟 DOM 在这里承担哪一层职责?

    虚拟 DOM 抽象的是视图结构与变化,而不是某一种宿主节点,因此同一套组件描述可以交给不同渲染器落地。浏览器渲染器操作 DOM,其他平台可映射到自身视图对象;跨端仍需处理平台能力、布局和交互差异,不能理解为业务代码百分之百无需适配。

  • 线上长列表滚动卡顿,监控只显示主线程繁忙,团队立即把责任归到 diff;你会怎样验证,而不是直接移除虚拟 DOM

    先区分耗时发生在状态计算、虚拟树创建与比较,还是最终布局、绘制和真实 DOM 操作,并用性能轨迹定位长任务。虚拟 DOM 只是局部更新链路中的一环,卡顿也可能来自全量重渲染或节点规模过大;没有分段证据就改渲染模型,往往只会转移成本。

  • 后台搭建器需要支持大量动态条件和组件插槽,一方主张手写 DOM 获得极致性能,另一方坚持虚拟 DOM;你会如何做选型?

    动态结构复杂且更新路径难以人工维护时,虚拟 DOM 能用统一声明式模型换取可组合性和可维护性,通常更适合作为默认方案。只有热点路径稳定、性能瓶颈已被证明确实位于通用比较时,才值得局部采用更直接的更新方式;代价是手工同步状态与视图的复杂度会上升。

# 1 谈谈你对React的理解

⚡ 30 秒速记

  • 核心公式:UI = f(state) —— 你只描述「状态对应什么界面」,怎么更新交给 React
  • 三个基石:组件化、单向数据流、不可变数据
  • JSXJS:没有模板语法这层额外心智,条件和循环都用原生 JS 表达
  • 更新机制:状态变化 → 重新执行组件函数 → 生成新的元素树 → diff → 提交到 DOM
  • 演进主线:StackFiber(可中断)→ 并发特性(Suspense/Transition)→ Server Components(把渲染分到服务端)

React 本质上是一个组件化的视图层框架,用声明式方式把状态映射成界面。 声明式让界面逻辑更直观,组件化便于拆分和复用,也更容易做到高内聚、低耦合。借助虚拟 DOM,同一套组件思想还能用于 WebNativeVR 等场景。它只负责视图层,大型应用仍要结合社区方案处理路由和状态管理,因此会增加选型与学习成本。

React 是一个网页 UI 框架,通过组件化的方式解决视图层开发复用的问题,本质是一个组件化框架。

  • 它的核心设计思路有三点,分别是声明式、组件化与 通用性
  • 声明式的优势在于直观与组合。
  • 组件化的优势在于视图的拆分与模块复用,可以更容易做到高内聚低耦合。
  • 通用性在于一次学习,随处编写。比如 React Native,React 360 等, 这里主要靠虚拟 DOM 来保证实现。
  • 这使得 React 的适用范围变得足够广,无论是 Web、Native、VR,甚至 Shell 应用都可以进行开发。这也是 React 的优势。
  • 但作为一个视图层的框架,React 的劣势也十分明显。它并没有提供完整的一揽子解决方 案,在开发大型前端应用时,需要向社区寻找并整合解决方案。虽然一定程度上促进了社区的繁荣,但也为开发者在技术选型和学习适用上造成了一定的成本。
  • 承接在优势后,可以再谈一下自己对于 React 优化的看法、对虚拟 DOM 的看法

💬 面试官追问

  • 一个静态营销页只有几处按钮交互,候选人说用了 React 就一定比原生实现更先进;作为技术负责人你会怎么判断?

    React 的优势不在于天然更先进,而在于用声明式和组件化组织会持续变化、需要复用的视图。若页面结构简单且长期稳定,引入组件运行时与配套工具未必带来足够收益;选型应看状态复杂度、复用需求和团队维护成本,而不是框架标签。

  • 订单后台把筛选栏、表格和详情弹窗拆成二十多个组件,但改一个字段仍要跨层修改;为什么“拆成组件”没有自动实现高内聚低耦合?

    组件化只提供拆分与组合机制,边界若按标签数量而非业务职责划分,依赖仍会散落在多层。应把相关状态、行为和视图收拢到稳定能力中,并明确输入输出;拆得过细会增加数据传递和理解成本,拆得过粗又会削弱复用与隔离。

  • 同一支团队要把 Web 端的内容流迁到原生端,管理层认为 React Native 意味着整套页面直接复制;你会如何校正预期?

    可复用的核心是声明式组件思维和部分业务逻辑,虚拟 DOM 所代表的视图抽象让不同渲染器成为可能。浏览器 DOM 与原生视图的能力、样式和交互并不相同,因此宿主相关代码仍要适配;“一次学习,随处编写”不等于“一份界面代码,所有平台无差别运行”。

  • 大型 React 项目上线前,路由、数据请求和状态管理分别来自不同社区库,升级时出现版本冲突;这是偶发管理失误,还是框架定位带来的成本?

    这与 React 主要聚焦视图层的定位直接相关,它没有为大型应用提供完整的一揽子方案。社区生态带来选择空间,也把选型、集成和升级责任交给团队;应通过统一技术基线、封装边界和升级策略控制成本,但无法把这部分复杂度完全消除。

  • 架构评审中,一方看重声明式与跨端能力,另一方担心生态拼装成本;什么项目条件下你仍会选择 React

    当界面状态复杂、组件复用价值高,或团队需要在多种渲染目标间共享开发模型时,React 的声明式、组件化和通用性更有吸引力。若项目更需要开箱即用的全套约束,团队又缺少整合生态的能力,则其视图层定位可能成为负担;选择必须连同路由、数据和工程规范一起评估。

# 2 如何避免React生命周期中的坑

⚡ 30 秒速记

  • componentWillMount/WillReceiveProps/WillUpdate 三个已被标记为不安全UNSAFE_ 前缀),因为 Fiber 可中断会导致它们被多次调用
  • 替代:getDerivedStateFromProps(静态、纯函数)和 getSnapshotBeforeUpdate
  • 副作用只放 componentDidMount/DidUpdate,且 DidUpdate 里改 state 必须加条件,否则死循环
  • 一定要在 componentWillUnmount 里清理定时器、监听器、订阅和未完成的请求
  • Hooks 时代对应的坑是 useEffect 的依赖数组:漏依赖拿到旧闭包,缺清理函数就泄漏

避免生命周期的坑,关键是让渲染阶段保持纯净,把副作用和清理放到合适的提交阶段。 Fiber 让渲染可以暂停或重来,因此不要在已废弃的 componentWillMountcomponentWillReceivePropscomponentWillUpdate 中请求数据或绑定事件。异步请求一般放在 componentDidMount,更新前读取 DOM 可结合 getSnapshotBeforeUpdatecomponentDidUpdate。卸载时还要取消定时器和事件监听,并用错误边界避免渲染异常直接白屏。

16.3版本

>=16.4版本

在线查看https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram (opens new window)

  • 避免生命周期中的坑需要做好两件事:不在恰当的时候调用了不该调用的代码;在需要调用时,不要忘了调用。
  • 那么主要有这么 7 种情况容易造成生命周期的坑
    • getDerivedStateFromProps 容易编写反模式代码,使受控组件与非受控组件区分模糊
    • componentWillMount 在 React 16.3 起被标记弃用、React 19 已移除(仅存 UNSAFE_componentWillMount),不要在新代码中使用,主要原因是新的异步渲染架构会导致它被多次调用。所以网络请求及事件绑定代码应移至 componentDidMount 中。
    • componentWillReceiveProps 同样被标记弃用、React 19 已移除,被 getDerivedStateFromProps 所取代,主要原因是性能问题
    • shouldComponentUpdate 通过返回 true 或者 false 来确定是否需要触发新的渲染。主要用于性能优化
    • componentWillUpdate 同样是由于新的异步渲染机制而被标记废弃、React 19 已移除,不要在新代码中使用,原先的逻辑可结合 getSnapshotBeforeUpdatecomponentDidUpdate 改造使用。
    • 如果在 componentWillUnmount 函数中忘记解除事件绑定,取消定时器等清理操作,容易引发 bug
    • 如果没有添加错误边界处理,当渲染发生异常时,用户将会看到一个无法操作的白屏,所以一定要添加

“React 的请求应该放在哪里,为什么?” 这也是经常会被追问的问题。你可以这样回答。

对于异步请求,应该放在 componentDidMount 中去操作。从时间顺序来看,除了 componentDidMount 还可以有以下选择:

  • constructor:可以放,但从设计上而言不推荐。constructor 主要用于初始化 state 与函数绑定,并不承载业务逻辑。而且随着类属性的流行,constructor 已经很少使用了
  • componentWillMount:已被标记废弃、React 19 已移除,在新的异步渲染架构下会触发多次渲染,容易引发 Bug,不利于未来 React 升级后的代码维护。
  • 所以React 的请求放在 componentDidMount 里是最好的选择

透过现象看本质:React 16 缘何两次求变?

Fiber 架构简析

Fiber 是 React 16 对 React 核心算法的一次重写。你只需要 get 到这一个点:Fiber 会使原本同步的渲染过程变成异步的

在 React 16 之前,每当我们触发一次组件的更新,React 都会构建一棵新的虚拟 DOM 树,通过与上一次的虚拟 DOM 树进行 diff,实现对 DOM 的定向更新。这个过程,是一个递归的过程。下面这张图形象地展示了这个过程的特征:

如图所示,同步渲染的递归调用栈是非常深的,只有最底层的调用返回了,整个渲染过程才会开始逐层返回。这个漫长且不可打断的更新过程,将会带来用户体验层面的巨大风险:同步渲染一旦开始,便会牢牢抓住主线程不放,直到递归彻底完成。在这个过程中,浏览器没有办法处理任何渲染之外的事情,会进入一种无法处理用户交互的状态。因此若渲染时间稍微长一点,页面就会面临卡顿甚至卡死的风险。

而 React 16 引入的 Fiber 架构,恰好能够解决掉这个风险:Fiber 会将一个大的更新任务拆解为许多个小任务每当执行完一个小任务时,渲染线程都会把主线程交回去,看看有没有优先级更高的工作要处理,确保不会出现其他任务被“饿死”的情况,进而避免同步渲染带来的卡顿。在这个过程中,渲染线程不再“一去不回头”,而是可以被打断的,这就是所谓的“异步渲染”,它的执行过程如下图所示:

换个角度看生命周期工作流

Fiber 架构的重要特征就是可以被打断的异步渲染模式。但这个“打断”是有原则的,根据“能否被打断”这一标准,React 16 的生命周期被划分为了 render 和 commit 两个阶段,而 commit 阶段又被细分为了 pre-commit 和 commit。每个阶段所涵盖的生命周期如下图所示:

我们先来看下三个阶段各自有哪些特征

  • render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。
  • pre-commit 阶段:可以读取 DOM。
  • commit 阶段:可以使用 DOM,运行副作用,安排更新。

总的来说,render 阶段在执行过程中允许被打断,而 commit 阶段则总是同步执行的。

为什么这样设计呢?简单来说,由于 render 阶段的操作对用户来说其实是“不可见”的,所以就算打断再重启,对用户来说也是零感知。而 commit 阶段的操作则涉及真实 DOM 的渲染,所以这个过程必须用同步渲染来求稳

为什么 React 16 要更改组件的生命周期详解

💬 面试官追问

  • 一个类组件在 componentWillMount 里发起订单请求,开发环境偶尔出现两笔相同请求;为什么把去重逻辑塞进去仍不是可靠修复?

    componentWillMount 属于可能因异步渲染而重复执行的旧生命周期,且在 React 19 已移除,仅遗留 UNSAFE_ 形式用于迁移。请求应移到提交后的 componentDidMount,并按业务需要处理取消或幂等;仅在旧钩子内部去重会继续绑定不稳定的执行时机。

  • 资料编辑页收到新 userId 后,用 getDerivedStateFromProps 把全部表单状态覆盖一遍,结果用户刚输入的内容频繁丢失;你会怎样重构?

    这里混淆了受控数据和组件内部草稿,任何父组件更新都可能触发错误覆盖。应先明确表单由父级完全控制,还是仅在身份变化时重新初始化,再缩小需要派生的状态;getDerivedStateFromProps 必须保持纯净,但纯净并不能消除状态双源带来的反模式。

  • 聊天页卸载后仍持续收到消息并触发更新,线上表现为内存上涨和重复提示;你会从哪个生命周期切入排查?

    应检查 componentWillUnmount 是否对事件订阅、定时器及其他长期资源执行了对称清理,并确认绑定与解绑使用的是同一引用。仅阻止卸载后的状态更新不能终止资源本身;若组件反复挂载,遗漏清理还会累积监听器,使故障越来越明显。

  • 发布后某个商品卡片渲染异常导致整个内容区白屏,但网络和接口都正常;生命周期层面应补什么保护,它又保护不了什么?

    应在合适的组件边界增加错误边界,把渲染异常限制在局部并提供降级界面,避免整棵相关视图不可操作。边界位置要兼顾隔离范围与恢复体验;它不是所有错误的通用捕获器,网络失败和事件回调等场景仍需各自处理。

  • 滚动列表更新前必须记录容器高度,DOM 更新后再恢复滚动位置;有人准备把读取和写入都放进 componentDidUpdate,你会怎么拆?

    更新前需要读取旧 DOM 状态时,应使用 getSnapshotBeforeUpdate 获取快照,再把结果交给 componentDidUpdate 完成更新后的 DOM 操作。这样对应 pre-commit 可读 DOMcommit 执行副作用的阶段划分;若读写时机混在提交后,旧布局信息可能已经丢失。

# 3 React Fiber架构

⚡ 30 秒速记

  • 解决的问题:老的 Stack Reconciler 递归不可中断,大组件树会长时间占用主线程导致掉帧
  • Fiber 把递归改成链表 + 循环,每个 Fiber 节点是一个工作单元,可以做完一个就让出主线程
  • 每个节点有三个指针:childsiblingreturn,靠它们遍历整棵树
  • 两个阶段:render/reconcile 阶段可中断(构建 Fiber 树、打副作用标记),commit 阶段不可中断(一次性提交到 DOM
  • 双缓存:current 树和 workInProgress 树交替,构建完成后直接切换指针

Fiber 是对 React 核心协调算法的重构,本质上把不可中断的递归遍历改成可暂停、可恢复的任务单元遍历。 每个 Fiber 节点通过 childsiblingreturn 连接,使协调工作能够拆分,并按优先级让高优任务先执行。可中断的 reconciliation 阶段负责比较并标记变化,commit 阶段则同步更新真实 DOM。它不一定缩短总计算时间,主要价值是避免长任务持续占用主线程,让交互保持响应。

最主要的思想就是将任务拆分

  • DOM需要渲染时暂停,空闲时恢复。
  • window.requestIdleCallback
  • React内部实现的机制

React 追求的是 “快速响应”,那么,“快速响应“的制约因素都有什么呢

  • CPU的瓶颈:当项目变得庞大、组件数量繁多、遇到大计算量的操作或者设备性能不足使得页面掉帧,导致卡顿。
  • IO的瓶颈:发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。

fiber 架构主要就是用来解决 CPU 和网络的问题,这两个问题一直也是最影响前端开发体验的地方,一个会造成卡顿,一个会造成白屏。为此 react 为前端引入了两个新概念:Time Slicing 时间分片Suspense

1. React 都做过哪些优化

  • React渲染页面的两个阶段
    • 调度阶段(reconciliation):在这个阶段 React 会更新数据生成新的 Virtual DOM,然后通过Diff算法,快速找出需要更新的元素,放到更新队列中去,得到新的更新队列。
    • 渲染阶段(commit):这个阶段 React 会遍历更新队列,将其所有的变更一次性更新到DOM上
  • React 15 架构
    • React15架构可以分为两层
      • Reconciler(协调器)—— 负责找出变化的组件;
      • Renderer(渲染器)—— 负责将变化的组件渲染到页面上;
  • 在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,递归更新时间超过了16ms,用户交互就会卡顿。
  • 为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。
  • React 16 架构

    • 为了解决同步更新长时间占用线程导致页面卡顿的问题,也为了探索运行时优化的更多可能,React开始重构并一直持续至今。重构的目标是实现Concurrent Mode(并发模式)。
    • 从v15到v16,React团队花了两年时间将源码架构中的Stack Reconciler重构为Fiber Reconciler
    • React16架构可以分为三层
      • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler;
      • Reconciler(协调器)—— 负责找出变化的组件:更新工作从递归变成了可以中断的循环过程。Reconciler内部采用了Fiber的架构;
      • Renderer(渲染器)—— 负责将变化的组件渲染到页面上。
  • React 17 优化

    • 使用Lane来管理任务的优先级。Lane用二进制位表示任务的优先级,方便优先级的计算(位运算),不同优先级占用不同位置的“赛道”,而且存在批的概念,优先级越低,“赛道”越多。高优先级打断低优先级,新建的任务需要赋予什么优先级等问题都是Lane所要解决的问题。
    • Concurrent Mode的目的是实现一套可中断/恢复的更新机制。其由两部分组成:
      • 一套协程架构:Fiber Reconciler
      • 基于协程架构的启发式更新算法:控制协程架构工作方式的算法

2. 浏览器一帧都会干些什么以及requestIdleCallback的启示

我们都知道,页面的内容都是一帧一帧绘制出来的,浏览器刷新率代表浏览器一秒绘制多少帧。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。目前浏览器大多是 60Hz(60帧/s),每一帧耗时也就是在 16.6ms 左右。那么在这一帧的(16.6ms) 过程中浏览器又干了些什么呢

通过上面这张图可以清楚的知道,浏览器一帧会经过下面这几个过程:

  1. 接受输入事件
  2. 执行事件回调
  3. 开始一帧
  4. 执行 RAF (RequestAnimationFrame)
  5. 页面布局,样式计算
  6. 绘制渲染
  7. 执行 RIC (RequestIdelCallback)

第七步的 RIC 事件不是每一帧结束都会执行,只有在一帧的 16.6ms 中做完了前面 6 件事儿且还有剩余时间,才会执行。如果一帧执行结束后还有时间执行 RIC 事件,那么下一帧需要在事件执行结束才能继续渲染,所以 RIC 执行不要超过 30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。

requestIdleCallback 的启示:我们以浏览器是否有剩余时间作微任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。

requestIdleCallback((deadline) => {
// deadline 有两个参数
  // timeRemaining(): 当前帧还剩下多少时间
  // didTimeout: 是否超时
// 另外 requestIdleCallback 后如果跟上第二个参数 {timeout: ...} 则会强制浏览器在当前帧执行完后执行。
 if (deadline.timeRemaining() > 0) {
   // TODO
 } else {
  requestIdleCallback(otherTasks);
 }
});
// 用法示例
var tasksNum = 10000

requestIdleCallback(unImportWork)

function unImportWork(deadline) {
  while (deadline.timeRemaining() && tasksNum > 0) {
    console.log(`执行了${10000 - tasksNum + 1}个任务`)
    tasksNum--
  }
  if (tasksNum > 0) { // 在未来的帧中继续执行
    requestIdleCallback(unImportWork)
  }
}

其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,Facebook 抛弃了 requestIdleCallback的原生 API:

  • 浏览器兼容性;
  • 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的requestIdleCallback触发的频率会变得很低。

基于以上原因,在React中实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置

3. React Fiber是什么

React Fiber是对核心算法的一次重新实现。React Fiber把更新过程碎片化,把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会

  1. React Fiber中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来
  2. 因为一个更新过程可能被打断,所以React Fiber一个更新过程被分为两个阶段(Phase):第一个阶段Reconciliation Phase和第二阶段Commit Phase
  3. 在第一阶段Reconciliation PhaseReact Fiber会找出需要更新哪些DOM,这个阶段是可以被打断的;但是到了第二阶段Commit Phase,那就一鼓作气把DOM更新完,绝不会被打断
  4. 这两个阶段大部分工作都是React Fiber做,和我们相关的也就是生命周期函数

React Fiber改变了之前react的组件渲染机制,新的架构使原来同步渲染的组件现在可以异步化,可中途中断渲染,执行更高优先级的任务。释放浏览器主线程

关键特性

  • 增量渲染(把渲染任务拆分成块,匀到多帧)
  • 更新时能够暂停,终止,复用渲染任务
  • 给不同类型的更新赋予优先级
  • 并发方面新的基础能力

增量渲染用来解决掉帧的问题,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前长时间占用

4. 组件的渲染顺序

假如有A,B,C,D组件,层级结构为:

我们知道组件的生命周期为:

下面列的是 React 16 之前的生命周期(历史对照)componentWillMountcomponentWillReceivePropscomponentWillUpdate 在 React 16.3 起被标记废弃,React 19 已移除,仅保留 UNSAFE_ 前缀版本供存量迁移;对应替代分别是 constructor / componentDidMountgetDerivedStateFromPropsgetSnapshotBeforeUpdate

挂载阶段

  • constructor()
  • componentWillMount()(React 19 已移除)
  • render()
  • componentDidMount()

更新阶段为

  • componentWillReceiveProps()(React 19 已移除)
  • shouldComponentUpdate()
  • componentWillUpdate()(React 19 已移除)
  • render()
  • componentDidUpdate

那么在挂载阶段,A,B,C,D的生命周期渲染顺序是如何的呢?

那么在挂载阶段,A,B,C,D的生命周期渲染顺序是如何的呢?

render()函数为分界线。从顶层组件开始,一直往下,直至最底层子组件。然后再往上

组件update阶段同理

前面是react16以前的组建渲染方式。这就存在一个问题

如果这是一个很大,层级很深的组件,react渲染它需要几十甚至几百毫秒,在这期间,react会一直占用浏览器主线程,任何其他的操作(包括用户的点击,鼠标移动等操作)都无法执行

Fiber架构就是为了解决这个问题

看一下fiber架构 组建的渲染顺序

加入fiberreact将组件更新分为两个时期

这两个时期以render为分界

  • render前的生命周期为phase1,
  • render后的生命周期为phase2
  • phase1的生命周期是可以被打断的,每隔一段时间它会跳出当前渲染进程,去确定是否有其他更重要的任务。此过程,ReactworkingProgressTree (并不是真实的virtualDomTree)上复用 current 上的 Fiber 数据结构来一步地(通过requestIdleCallback)来构建新的 tree,标记处需要更新的节点,放入队列中
  • phase2的生命周期是不可被打断的,React 将其所有的变更一次性更新到DOM

这里最重要的是phase1这是时期所做的事。因此我们需要具体了解phase1的机制

  • 如果不被打断,那么phase1执行完会直接进入render函数,构建真实的virtualDomTree
  • 如果组件再phase1过程中被打断,即当前组件只渲染到一半(也许是在willMount,也许是willUpdate~反正是在render之前的生命周期),那么react会怎么干呢? react会放弃当前组件所有干到一半的事情,去做更高优先级更重要的任务(当然,也可能是用户鼠标移动,或者其他react监听之外的任务),当所有高优先级任务执行完之后,react通过callback回到之前渲染到一半的组件,从头开始渲染。(看起来放弃已经渲染完的生命周期,会有点不合理,反而会增加渲染时长,但是react确实是这么干的)

所有phase1的生命周期函数都可能被执行多次,因为可能会被打断重来

这样的话,就和react16版本之前有很大区别了,因为可能会被执行多次,那么我们最好就得保证phase1的生命周期每一次执行的结果都是一样的,否则就会有问题,因此,最好都是纯函数

  • 如果高优先级的任务一直存在,那么低优先级的任务则永远无法进行,组件永远无法继续渲染。这个问题facebook目前好像还没解决
  • 所以,facebook在react16增加fiber结构,其实并不是为了减少组件的渲染时间,事实上也并不会减少,最重要的是现在可以使得一些更高优先级的任务,如用户的操作能够优先执行,提高用户的体验,至少用户不会感觉到卡顿

5 React Fiber架构总结

React Fiber如何性能优化

  • 更新的两个阶段
    • 调度算法阶段-执行diff算法,纯js计算
    • Commit阶段-将diff结果渲染dom
  • 可能会有性能问题
    • JS是单线程的,且和DOM渲染公用一个线程
    • 当组件足够复杂,组件更新时计算和渲染压力都大
    • 同时再有DOM操作需求(动画、鼠标拖拽等),将卡顿
  • 解决方案fiber
    • 将调度算法阶段阶段任务拆分(Commit无法拆分)
    • DOM需要渲染时暂停,空闲时恢复
    • 分散执行: 任务分割后,就可以把小任务单元分散到浏览器的空闲期间去排队执行,而实现的关键是两个新API: requestIdleCallbackrequestAnimationFrame
      • 低优先级的任务交给requestIdleCallback处理,这是个浏览器提供的事件循环空闲期的回调函数,需要 pollyfill,而且拥有 deadline 参数,限制执行事件,以继续切分任务;
      • 高优先级的任务交给requestAnimationFrame处理;

React 的核心流程可以分为两个部分:

  • reconciliation (调度算法,也可称为 render)
    • 更新 stateprops
    • 调用生命周期钩子;
    • 生成 virtual dom
      • 这里应该称为 Fiber Tree 更为符合;
    • 通过新旧 vdom 进行 diff 算法,获取 vdom change
    • 确定是否需要重新渲染
  • commit
    • 如需要,则操作 dom 节点更新

要了解 Fiber,我们首先来看为什么需要它

  • 问题: 随着应用变得越来越庞大,整个更新渲染的过程开始变得吃力,大量的组件渲染会导致主进程长时间被占用,导致一些动画或高频操作出现卡顿和掉帧的情况。而关键点,便是 同步阻塞。在之前的调度算法中,React 需要实例化每个类组件,生成一颗组件树,使用 同步递归 的方式进行遍历渲染,而这个过程最大的问题就是无法 暂停和恢复。
  • 解决方案: 解决同步阻塞的方法,通常有两种: 异步 与 任务分割。而 React Fiber 便是为了实现任务分割而诞生的
  • 简述
    • React V16 将调度算法进行了重构, 将之前的 stack reconciler 重构成新版的 fiber reconciler,变成了具有链表和指针的 单链表树遍历算法。通过指针映射,每个单元都记录着遍历当下的上一步与下一步,从而使遍历变得可以被暂停和重启
    • 这里我理解为是一种 任务分割调度算法,主要是 将原先同步更新渲染的任务分割成一个个独立的 小任务单位,根据不同的优先级,将小任务分散到浏览器的空闲时间执行,充分利用主进程的事件循环机制
  • 核心
    • Fiber 这里可以具象为一个 数据结构
class Fiber {
	constructor(instance) {
		this.instance = instance
		// 指向第一个 child 节点
		this.child = child
		// 指向父节点
		this.return = parent
		// 指向第一个兄弟节点
		this.sibling = previous
	}
}
  • 链表树遍历算法: 通过 节点保存与映射,便能够随时地进行 停止和重启,这样便能达到实现任务分割的基本前提
    • 首先通过不断遍历子节点,到树末尾;
    • 开始通过 sibling 遍历兄弟节点;
    • return 返回父节点,继续执行2;
    • 直到 root 节点后,跳出遍历;
  • 任务分割,React 中的渲染更新可以分成两个阶段
    • reconciliation 阶段: vdom 的数据对比,是个适合拆分的阶段,比如对比一部分树后,先暂停执行个动画调用,待完成后再回来继续比对
    • Commit 阶段: 将 change list 更新到 dom 上,并不适合拆分,才能保持数据与 UI 的同步。否则可能由于阻塞 UI 更新,而导致数据更新和 UI 不一致的情况
  • 分散执行: 任务分割后,就可以把小任务单元分散到浏览器的空闲期间去排队执行,而实现的关键是两个新API: requestIdleCallbackrequestAnimationFrame
    • 低优先级的任务交给requestIdleCallback处理,这是个浏览器提供的事件循环空闲期的回调函数,需要 pollyfill,而且拥有 deadline 参数,限制执行事件,以继续切分任务;
    • 高优先级的任务交给requestAnimationFrame处理;
// 类似于这样的方式
requestIdleCallback((deadline) => {
    // 当有空闲时间时,我们执行一个组件渲染;
    // 把任务塞到一个个碎片时间中去;
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent);
    }
});
  • 优先级策略: 文本框输入 > 本次调度结束需完成的任务 > 动画过渡 > 交互反馈 > 数据更新 > 不会显示但以防将来会显示的任务
  • Fiber 其实可以算是一种编程思想,在其它语言中也有许多应用(Ruby Fiber)。
  • 核心思想是 任务拆分和协同,主动把执行权交给主线程,使主线程有时间空挡处理其他高优先级任务。
  • 当遇到进程阻塞的问题时,任务分割、异步调用 和 缓存策略 是三个显著的解决思路。

💬 面试官追问

  • 搜索页一次更新要遍历上千个组件,接入 Fiber 后总计算时间没有明显下降,团队据此认为架构改造失败;这个判断哪里有问题?

    Fiber 的主要目标不是减少总工作量,而是把协调阶段拆成可暂停、恢复或放弃的小任务,避免长时间独占主线程。总计算甚至可能没有下降,但输入等高优先级工作能更早响应;如果瓶颈是单个不可分割的同步任务,任务拆分也难以发挥作用。

  • 输入框联动一个大型结果列表,用户连续键入时旧列表还没算完;SchedulerReconcilerRenderer 分别应承担什么职责?

    Scheduler 根据优先级安排工作,使更紧急的输入更新先进入处理;采用 FiberReconciler 可中断地找出视图变化,Renderer 再把确定的变更提交给宿主环境。若所有更新都被赋予相同优先级,架构具备中断能力也不等于产品自然获得理想调度。

  • 低优先级报表渲染被新的交互更新打断,日志显示前面一部分协调工作后来没有提交;这是数据丢失还是预期行为?

    这是可中断协调阶段允许出现的预期行为:更高优先级任务到来后,未完成的低优先级工作可能被放弃并在之后重新计算。用户尚未看到这些中间结果,因此不会产生半棵界面;这也要求协调阶段保持纯净,不能把不可重复的副作用放进去。

  • 线上拖拽仍会掉帧,性能记录显示一次 commit 中有大量真实 DOM 更新;为什么 Fiber 没把这段工作切到多帧完成?

    可拆分的是生成和比较更新结果的 reconciliation 阶段,commit 要同步应用变更,以避免数据与可见 UI 长时间处于不一致状态。应继续排查本次提交的节点规模与 DOM 成本,而不是期待提交阶段被任意暂停;过大的 commit 仍可能造成明显卡顿。

  • 有人建议直接用浏览器的 requestIdleCallback 实现全部 React 调度,理由是它能返回剩余时间;你会指出哪些工程约束?

    requestIdleCallback 提供了空闲时间提示,但兼容性和触发频率并不稳定,切换标签页等情况还可能显著降低调用频率。React 因此实现自己的 Scheduler,并额外支持多种任务优先级;单纯等待空闲时间也可能让持续被高优任务挤压的工作长期得不到执行。

  • 代码评审发现类组件在协调阶段的旧 will 生命周期里注册事件,而团队正启用可中断更新;这和 Fiber 的哪条约束直接冲突?

    协调阶段可能暂停、终止或重新开始,因此其中的生命周期存在重复执行的可能,注册事件会产生不可安全重放的副作用。副作用应移到同步提交阶段,并在卸载时清理;这正是旧 componentWillMountcomponentWillUpdate 等生命周期被弃用并最终移除的重要背景。

# 4 createElement过程

⚡ 30 秒速记

  • JSX 是语法糖,编译后变成 React.createElement(type, config, ...children)React 17+ 的新转换改成了 jsx() 函数,不用再手动引入 React
  • createElement 做三件事:分离 key/ref、合并 props(含 defaultProps)、处理 children
  • 返回的是一个普通对象 ReactElement{ $$typeof, type, key, ref, props }
  • 它只是「描述」,不是真实 DOM,也不是组件实例
  • type 可以是字符串(原生标签)或函数/类(组件),这决定了后续怎么渲染

React.createElement 会根据类型、属性和子节点创建一个描述界面的 React 元素。 JSX 编译后本质上就是这类调用,第一个参数是标签或组件类型,第二个参数是 props,后续参数表示子节点。返回结果只是普通的界面描述,并不会在这一步直接操作真实 DOM,实际更新要经过协调和提交。React 18 起挂载通常使用 createRoot(container).render(element),但不影响 createElement 的参数和作用。

React.createElement(): 根据指定的第一个参数创建一个React元素

React.createElement(
  type,
  [props],
  [...children]
)
  • 第一个参数是必填,传入的是似HTML标签名称,eg: ul, li
  • 第二个参数是选填,表示的是属性,eg: className
  • 第三个参数是选填, 子节点,eg: 要显示的文本内容

下面示例里的 ReactDOM.render历史写法:React 18 起改用 ReactDOM.createRoot(container).render(element),React 19 已移除 ReactDOM.render。示例本身讲的是 createElement 的参数形式,挂载 API 换成 createRoot 后结论不变。

//写法一:

var child1 = React.createElement('li', null, 'one');
    var child2 = React.createElement('li', null, 'two');
    var content = React.createElement('ul', { className: 'teststyle' }, child1, child2); // 第三个参数可以分开也可以写成一个数组
      ReactDOM.render(
          content,
        document.getElementById('example')
      );

//写法二:

var child1 = React.createElement('li', null, 'one');
    var child2 = React.createElement('li', null, 'two');
    var content = React.createElement('ul', { className: 'teststyle' }, [child1, child2]);
      ReactDOM.render(
          content,
        document.getElementById('example')
      );

💬 面试官追问

  • 商品列表页把 React.createElement('li', { className: 'item' }, sku) 当成已经插入页面的节点,随后立刻查询 .item 却找不到,误判在哪里?

    误判是把创建 React 元素与挂载到页面混为一谈;这次调用只按 type、属性和子节点构造待渲染内容。还要把返回结果交给根节点渲染,React 18 应使用 ReactDOM.createRoot(container).render(element),否则页面中不会出现对应节点。

  • 后台菜单有 200 项,开发者把子项数组作为第三个参数传给 React.createElement('ul', null, children);评审认为必须展开成 ...children,你怎么判断?

    两种传法在该参数形式下都成立:子节点既能作为后续参数逐个传入,也能以数组形式放在第三个参数。工程上应统一团队写法并保证数组内容确实是合法子节点;不要仅因没有展开数组,就把渲染异常归因于 createElement

  • 老管理台仍用 ReactDOM.render(content, container) 展示 createElement 示例,升级到 React 18 后负责人与维护者对是否重写有分歧,你会怎么处理?

    示例所讲的三个参数形式不需要改变,但挂载代码应迁移为 ReactDOM.createRoot(container).render(content)React 18 起旧写法已经是历史用法,React 19 更移除了 ReactDOM.render;继续保留会把元素创建和版本相关的挂载接口混在一起。

  • 营销页生成的列表没有样式,代码是 React.createElement('ul', { classname: 'teststyle' }, items)DOM 结构和文本都正常,你先查哪里?

    先核对第二个参数中的属性名,React 示例使用的是 className,而不是 classname。第三个参数已经能正常产生文本和列表结构,说明排查重点应落在属性传递及样式选择器;改挂载 API 不能修复属性拼写错误。

  • 低代码渲染器需要同时生成两个 <li>,有人主张先各自调用 createElement,另一人主张直接传配置对象数组;哪种边界必须守住?

    传给父元素的子项应当是已经创建好的 React 子节点,例如两个 React.createElement('li', ...) 的结果,再逐个或以数组传入。普通配置对象不能仅凭形状替代子节点;若选择配置驱动,仍需先把配置映射为元素,代价是多一层转换与校验。

# 5 调和阶段 setState内部干了什么

⚡ 30 秒速记

  • setState 不直接改 state,而是创建一个 update 对象挂到 Fiber 的更新队列上
  • 然后从当前 Fiber 向上标记到根节点(markUpdateLaneFromFiberToRoot),再调度一次渲染
  • 调度器按优先级(Lane 模型)决定什么时候开始 render 阶段
  • render 阶段遍历 Fiber 树、计算新状态、打副作用标记,这一步可中断
  • commit 阶段一次性把变更提交到 DOM,并执行生命周期和 effect

调用 setState 后,React 会合并或排队保存状态更新,并启动调和流程。 调和时会根据新状态构建新的 React 元素树,再与上一次的元素树做 diff,找出真正发生变化的部分。这样最终只更新必要的 UI,而不是每次都重建整个页面;状态计算和界面更新也不一定在调用处立即完成。

  • 当调用 setState 时,React会做的第一件事情是将传递给 setState 的对象合并到组件的当前状态
  • 这将启动一个称为和解(reconciliation)的过程。和解(reconciliation)的最终目标是以最有效的方式,根据这个新的状态来更新UI。 为此,React将构建一个新的 React 元素树(您可以将其视为 UI 的对象表示)
  • 一旦有了这个树,为了弄清 UI 如何响应新的状态而改变,React 会将这个新树与上一个元素树相比较( diff )

通过这样做, React 将会知道发生的确切变化,并且通过了解发生什么变化,只需在绝对必要的情况下进行更新即可最小化 UI 的占用空间

💬 面试官追问

  • 订单页调用 setState({ status: 'paid' }) 后,开发者认为 React 会直接把按钮文本改成“已支付”,这个判断漏掉了什么过程?

    setState 触发的不是一次面向具体 DOM 文本的直接修改,而是基于新状态重新构建 React 元素树。React 还要把新树与上一棵树比较,确定真正发生变化的位置后再更新 UI;因此不能把状态对象与某个 DOM 操作简单画等号。

  • 看板一次收到 300 条状态变化,产品要求只更新发生变化的卡片;你如何用调和过程解释实现判断?

    React 会依据新状态生成新的元素树,再与上一次的元素树进行比较,从而找出需要更新的部分。组件仍应让状态准确表达每张卡片的结果,不能手工假定只有某个节点变化;错误的状态建模会让生成的新树本身就不符合预期。

  • 同一份组件升级后改用并发能力,负责人仍要求“每次调用 setState 后立刻完成整棵树比较”,这个约束合理吗?

    不应把 setState 理解为调用返回前必须完成全部 UI 更新;可靠结论是它会发起状态更新与后续调和。具体调度时机不由本节的树比较模型保证,业务若依赖立即可见结果,就把实现绑到了未承诺的执行时序上。

  • 库存页状态已经变成 0,页面却仍显示“有货”;值班同学只盯着接口响应,你会沿哪条链路排查?

    先确认传入 setState 的新状态是否正确,再检查该状态生成的新元素树是否真的改变了展示分支。随后核对树比较后对应 UI 是否提交;若新旧树表达相同内容,调和没有可更新之处,问题通常在状态到元素的映射而非接口本身。

  • 团队想绕过 React,监听状态后直接改 DOM,以避免元素树比较;在频繁更新的仪表盘里你如何取舍?

    直接改 DOM 会绕开“新状态生成元素树、与旧树比较、只更新必要部分”的一致更新链路。除非该区域明确由外部系统独占管理,否则 React 后续调和可能覆盖手工结果;省下一次声明式计算的同时,会增加状态与页面失配的风险。

# 6 setState

⚡ 30 秒速记

  • setState异步表现的:React 会把同一批次的多次调用合并,避免重复渲染
  • 想拿到更新后的值:用函数式写法 setState(prev => prev + 1),或在第二个参数的回调里读
  • 连续 setState(count + 1) 三次只加 1,因为读的都是同一个旧闭包值 —— 用函数式写法才会累加
  • React 18 之前只有 React 事件和生命周期里会批处理,setTimeout/原生事件里是同步的
  • React 18自动批处理覆盖所有场景;需要同步刷新时用 flushSync

setState 的核心是排队和批量更新,不能简单理解成一个异步函数。 对象写法连续读取旧 state 时,同名字段可能互相覆盖;依赖前一次结果时,应使用函数式写法,让更新按顺序计算。React 18 的 createRootReact 19 会在定时器、原生事件等场景自动批处理,React 17 及以前则可能同步刷新。需要在提交后读取结果,可以使用回调;确需强制同步时可用 flushSync

先看版本结论(React 17 / 18 / 19 三档)

版本与根节点 React 事件 / 生命周期内 setTimeout / 原生事件 / Promise.then 强制同步的出口
React 17 及以前(ReactDOM.render 批量合并 逐次同步刷新,能立刻读到新值 本来就是同步的
React 18(ReactDOM.render 兼容模式) 批量合并 逐次同步刷新(与 17 一致) 本来就是同步的
React 18(createRoot 批量合并 同样批量合并(automatic batching) flushSync
React 19 批量合并 批量合并(ReactDOM.render 已移除,无法退回旧行为) flushSync

本节余下的 Transaction(事务)与 isBatchingUpdates 分析描述的是 React 16 / 17 的内部实现,属于历史对照:现在 Fiber 走的是 lane 优先级调度,不再依赖单个全局布尔标志。这些内容仍然值得读——它解释了「为什么当年定时器里会变成同步」——但不要当作 React 18 及以后的现状。

下面凡是出现「在原生事件和 setTimeout 中 setState 是同步的」这类表述,都请补上前提:React 17 及以前,或 React 18 的 ReactDOM.render 兼容模式

在了解setState之前,我们先来简单了解下 React 一个包装结构: Transaction:

事务 (Transaction)

是 React 中的一个调用结构,用于包装一个方法,结构为: initialize - perform(method) - close。通过事务,可以统一管理一个方法的开始与结束;处于事务流中,表示进程正在执行一些操作

  • setState: React 中用于修改状态,更新视图。它具有以下特点:

异步与同步: setState并不是单纯的异步或同步,这其实与调用时的环境相关:

  • 合成事件生命周期钩子(除 componentDidUpdate) 中,setState是"异步"的;
    • 原因: 因为在setState的实现中,有一个判断: 当更新策略正在事务流的执行中时,该组件更新会被推入dirtyComponents队列中等待执行;否则,开始执行batchedUpdates队列更新;
      • 在生命周期钩子调用中,更新策略都处于更新之前,组件仍处于事务流中,而componentDidUpdate是在更新之后,此时组件已经不在事务流中了,因此则会同步执行;
      • 在合成事件中,React 是基于 事务流完成的事件委托机制 实现,也是处于事务流中;
    • 问题: 无法在setState后马上从this.state上获取更新后的值。
    • 解决: 如果需要马上同步去获取新值,setState其实是可以传入第二个参数的。setState(updater, callback),在回调中即可获取最新值;
  • 原生事件 和 setTimeout 中,setState是同步的,可以马上获取更新后的值(仅限 React 17 及以前 / React 18 兼容模式;React 18 createRoot 起这些场景同样批量合并);
    • 原因: 原生事件是浏览器本身的实现,与事务流无关,自然是同步;而setTimeout是放置于定时器线程中延后执行,此时事务流已结束,因此也是同步;
  • 批量更新: 在 合成事件 和 生命周期钩子 中,setState更新队列时,存储的是 合并状态(Object.assign)。因此前面设置的 key 值会被后面所覆盖,最终只会执行一次更新;
  • 函数式: 由于 Fiber 及 合并 的问题,官方推荐可以传入 函数 的形式。setState(fn),在fn中返回新的state对象即可,例如this.setState((state, props) => newState);
    • 使用函数式,可以用于避免setState的批量更新的逻辑,传入的函数将会被 顺序调用;

注意事项:

  • setState 合并,在 合成事件 和 生命周期钩子 中多次连续调用会被优化为一次;
  • 当组件已被销毁,如果再次调用setState,React 会报错警告,通常有两种解决办法
    • 将数据挂载到外部,通过 props 传入,如放到 Redux 或 父级中;
    • 在组件内部维护一个状态量 (isUnmounted),componentWillUnmount中标记为 true,在setState前进行判断;

总结

setState 并非真异步,只是看上去像异步。在源码中,通过 isBatchingUpdates 来判断

  • setState 是先存进 state 队列还是直接更新,如果值为 true 则执行异步操作,为 false 则直接更新。
  • 那么什么情况下 isBatchingUpdates 会为 true 呢?在 React 可以控制的地方,就为 true,比如在 React 生命周期事件和合成事件中,都会走合并操作,延迟更新的策略。
  • 但在 React 无法控制的地方,比如原生事件,具体就是在 addEventListener 、setTimeoutsetInterval 等事件中,就只能同步更新。这一条到 React 18 createRoot 为止:automatic batching 之后 React 不再依赖「是否在自己的调用栈里」来决定批处理,上述场景也会合并。

一般认为,做异步设计是为了性能优化、减少渲染次数,React 团队还补充了两点。

  • 保持内部一致性。如果将 state 改为同步更新,那尽管 state 的更新是同步的,但是 props不是。
  • 启用并发更新,完成异步渲染。

  1. setState 只有在 React 自身的合成事件和钩子函数中是异步的,在原生事件和 setTimeout 中都是同步的——这是 React 17 及以前的结论;React 18 createRoot 与 React 19 里全部场景都是批处理的异步更新
  2. setState 的异步并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和钩子函数中没法立马拿到更新后的值,形成了所谓的异步。当然可以通过 setState 的第二个参数中的 callback 拿到更新后的结果
  3. setState 的批量更新优化也是建立在异步(合成事件、钩子函数)之上的,在原生事件和 setTimeout 中不会批量更新,在异步中如果对同一个值进行多次 setState,setState 的批量更新策略会对其进行覆盖,去最后一次的执行,如果是同时 setState 多个不同的值,在更新时会对其进行合并批量更新

React 17 及以前

  • 合成事件中是异步
  • 钩子函数中的是异步
  • 原生事件中是同步
  • setTimeout中是同步

React 18 createRoot 与 React 19:上面四条统一为「都是批处理的异步更新」,只有 flushSync 能强制同步。

这是一道经常会出现的 React setState 笔试题:下面的代码输出什么呢?

class Test extends React.Component {
  state  = {
      count: 0
  };

    componentDidMount() {
    this.setState({count: this.state.count + 1});
    console.log(this.state.count);

    this.setState({count: this.state.count + 1});
    console.log(this.state.count);

    setTimeout(() => {
      this.setState({count: this.state.count + 1});
      console.log(this.state.count);

      this.setState({count: this.state.count + 1});
      console.log(this.state.count);
    }, 0);
  }

  render() {
    return null;
  }
};

我们可以进行如下的分析:

  • 首先第一次和第二次的 console.log,都在 React 的生命周期事件中,所以是异步的处理方式,则输出都为 0
  • 而在 setTimeout 中的 console.log 处于原生事件中,所以会同步的处理再输出结果,但需要注意,虽然 count 在前面经过了两次的 this.state.count + 1,但是每次获取的 this.state.count 都是初始化时的值,也就是 0
  • 所以此时 count1,那么后续在 setTimeout中的输出则是 23

所以完整答案是 0,0,2,3

版本提醒:这个 0,0,2,3 只在 React 17 及以前(或 React 18 兼容模式)成立。在 React 18 createRoot 与 React 19 下,setTimeout 里两次 setState 也会被批处理并互相覆盖,输出变成 0,0,1,1,最终 count2

同步场景

异步场景中的案例使我们建立了这样一个认知:setState 是异步的,但下面这个案例又会颠覆你的认知。如果我们将 setState 放在 setTimeout 事件中,那情况就完全不同了。

class Test extends Component {
    state = {
        count: 0
    }

    componentDidMount(){
        this.setState({ count: this.state.count + 1 });
        console.log(this.state.count);
        setTimeout(() => {
          this.setState({ count: this.state.count + 1 });
          console.log("setTimeout: " + this.state.count);
        }, 0);
    }

    render(){
        ...
    }
}

那这时输出的应该是什么呢?如果你认为是 0,0,那么又错了。

React 17 及以前,正确的结果是 0,2setState 并不是真正的异步函数,它通过队列延迟执行,源码里用 isBatchingUpdates 判断是先入队还是直接更新——值为 true 走延迟合并,false 直接同步更新。

React 18 createRoot 与 React 19 下,结果是 0,1:定时器里的更新也被批处理,console.log 读到的是提交前的 1(第一次 setState 已在 componentDidMount 的批次里提交),而不是 2

接下来这个案例的答案是什么呢

class Test extends Component {
    state = {
        count: 0
    }

    componentDidMount(){
        this.setState({
           count: this.state.count + 1
         }, () => {
            console.log(this.state.count)
         })
         this.setState({
           count: this.state.count + 1
         }, () => {
            console.log(this.state.count)
         })
    }

    render(){
        ...
    }
}

如果你觉得答案是 1,2,那肯定就错了。这种迷惑性极强的考题在面试中非常常见,因为它反直觉。

如果重新仔细思考,你会发现当前拿到的 this.state.count 的值并没有变化,都是 0,所以输出结果应该是 1,1

当然,也可以在 setState 函数中获取修改后的 state 值进行修改。

class Test extends Component {
    state = {
        count: 0
    }

    componentDidMount(){
        this.setState(
          preState=> ({
            count:preState.count + 1
        }),()=>{
           console.log(this.state.count)
        })
        this.setState(
          preState=>({
            count:preState.count + 1
        }),()=>{
           console.log(this.state.count)
        })
    }

    render(){
        ...
    }
}

这些通通是异步的回调,如果你以为输出结果是 1,2,那就又错了,实际上是 2,2

为什么会这样呢?当调用 setState 函数时,就会把当前的操作放入队列中。React 根据队列内容,合并 state 数据,完成后再逐一执行回调,根据结果更新虚拟 DOM,触发渲染。所以回调时,state 已经合并计算完成了,输出的结果就是 2,2 了。

💬 面试官追问

  • 类组件计数器在一次点击里连续执行两次 this.setState({ count: this.state.count + 1 }),两个回调都打印 1;候选人说队列丢了一次更新,你同意吗?

    不同意,两次对象式更新读取的都是当时相同的 this.state.count,同一批次里同名字段会被后一次结果覆盖。改成 this.setState(state => ({ count: state.count + 1 })) 才会顺序消费前一次结果;两个提交后回调看到的是合并完成的最终值。

  • React 19 的搜索页在 Promise.then 里连续更新筛选条件和页码,产品担心产生两次渲染;你如何落地并验证?

    React 19 会对该异步回调中的更新进行批处理,不应沿用旧版“异步回调逐次同步刷新”的判断。把相互依赖的状态更新写成函数式形式,并用渲染日志或 Profiler 验证实际提交;批处理只减少提交次数,不会替你修正错误的状态依赖。

  • 遗留系统一部分用 React 18 的 ReactDOM.render,新页面使用 createRoot,同样在 setTimeout 中两次 setState 却表现不同,怎么解释?

    React 18 的兼容根沿用 React 17 行为,定时器中的更新会逐次同步刷新;createRoot 则启用 automatic batching,将这些更新一起处理。排查时必须先确认根节点创建方式,不能只看 React 主版本;迁移根节点还可能改变依赖同步读值的旧代码。

  • 线上弹窗执行 setState 后马上读取 this.state.open,在 React 18 createRoot 下仍是旧值,开发者准备套一层 setTimeout,你会怎么改?

    不要依赖定时器碰运气,因为 automatic batching 已覆盖 setTimeout,回调中的读取也未必对应预想的提交边界。类组件可使用 setState(updater, callback) 在更新完成后读取,或在 componentDidUpdate 中响应变化;若新值依赖旧值,应同时改用函数式更新。

  • 展开面板后必须立刻测量新 DOM 高度,架构师禁止所有同步刷新,动画负责人坚持使用 flushSync;你支持哪边?

    该场景确实需要在状态更新后立即读取已提交的 DOM,可把最小范围的更新包在 flushSync 中。它是 React 18 createRootReact 19 打破批处理的出口,但会牺牲批处理收益;应限制在必要测量点,不能作为普通状态更新的默认写法。

  • 组件卸载后请求回调仍调用 setState,监控出现警告;有人提议每个组件都维护 isUnmounted,另一人要把数据提升到父层,你怎么选?

    先让异步任务在卸载时停止回写;若数据生命周期确实长于组件,再考虑放到父级或外部状态中通过 props 传入。isUnmounted 判断是局部防线,会在大量组件中形成重复状态;无论选哪种方案,都不能让已销毁实例继续进入更新队列。

# 7 setState原理分析

⚡ 30 秒速记

  • 内部维护一个更新队列(环状链表),每次 setState 往队列里推一个 update
  • 通过 isBatchingUpdates / Lane 优先级判断当前是否处于批处理上下文
  • 处于批处理时只入队不立即渲染,等本批次结束统一处理
  • 计算新 state 时按顺序遍历队列:对象写法后者覆盖前者,函数写法则依次累加
  • React 18Lane 模型替代了旧的 expirationTime,能表达更细的优先级和并发

setState 本质上会把状态变更放进更新队列,再由 React 在合适的时机统一计算和渲染。 React 16、17 的旧实现通过 isBatchingUpdatesdirtyComponents 控制批处理,React 18 起则改用 Fiberlane 调度和自动批处理。直接修改 this.state 不会进入这套更新流程,因此不能可靠触发重新渲染。若新值依赖旧值,应使用函数式更新;提交后的值可在回调或 componentDidUpdate 中读取。

本节是 React 16 / 17 内部实现的历史对照:下面的 pre / post 钩子、isBatchingUpdatesdirtyComponents 属于旧的 Transaction 批处理实现。React 18 起改为 Fiber 的 lane 优先级调度 + automatic batching,「是否在 React 调用栈里」不再决定批处理与否。版本结论见上一节开头的三档对照表。

1. setState异步更新

  • 我们都知道,React通过this.state来访问state,通过this.setState()方法来更新state。当this.setState()方法被调用的时候,React会重新调用render方法来重新渲染UI
  • 首先如果直接在setState后面获取state的值是获取不到的。在React内部机制能检测到的地方, setState就是异步的;在React检测不到的地方,例如setInterval,setTimeoutsetState就是同步更新的(React 17 及以前;React 18 createRoot 起这些场景同样批量合并,React 19 无法退回旧行为)

img

因为setState是可以接受两个参数的,一个state,一个回调函数。因此我们可以在回调函数里面获取值

img

  • setState方法通过一个队列机制实现state更新,当执行setState的时候,会将需要更新的state合并之后放入状态队列,而不会立即更新this.state
  • 如果我们不使用setState而是使用this.state.key来修改,将不会触发组件的re-render
  • 如果将this.state赋值给一个新的对象引用,那么其他不在对象上的state将不会被放入状态队列中,当下次调用setState并对状态队列进行合并时,直接造成了state丢失

1.1 setState批量更新的过程

react生命周期和合成事件执行前后都有相应的钩子,分别是pre钩子和post钩子,pre钩子会调用batchedUpdate方法将isBatchingUpdates变量置为true,开启批量更新,而post钩子会将isBatchingUpdates置为false

  • isBatchingUpdates变量置为true,则会走批量更新分支,setState的更新会被存入队列中,待同步代码执行完后,再执行队列中的state更新。 isBatchingUpdatestrue,则把当前组件(即调用了 setState的组件)放入 dirtyComponents 数组中;否则 batchUpdate 所有队列中的更新
  • 而在原生事件和异步操作中,不会执行pre钩子,或者生命周期的中的异步操作之前执行了pre钩子,但是pos钩子也在异步操作之前执行完了,isBatchingUpdates必定为false,也就不会进行批量更新

img

enqueueUpdate包含了React避免重复render的逻辑。mountComponentupdateComponent方法在执行的最开始,会调用到batchedUpdates进行批处理更新,此时会将isBatchingUpdates设置为true,也就是将状态标记为现在正处于更新阶段了。 isBatchingUpdatestrue,则把当前组件(即调用了 setState 的组件)放入dirtyComponents 数组中;否则 batchUpdate 所有队列中的更新

1.2 为什么直接修改this.state无效

  • 要知道setState本质是通过一个队列机制实现state更新的。 执行setState时,会将需要更新的state合并后放入状态队列,而不会立刻更新state,队列机制可以批量更新state
  • 如果不通过setState而直接修改this.state,那么这个state不会放入状态队列中,下次调用setState时对状态队列进行合并时,会忽略之前直接被修改的state,这样我们就无法合并了,而且实际也没有把你想要的state更新上去

1.3 什么是批量更新 Batch Update

在一些mv*框架中,,就是将一段时间内对model的修改批量更新到view的机制。比如那前端比较火的ReactvuenextTick机制,视图的更新以及实现)

1.4 setState之后发生的事情

  • setState操作并不保证是同步的,也可以认为是异步的
  • ReactsetState之后,会经对state进行diff,判断是否有改变,然后去diff dom决定是否要更新UI。如果这一系列过程立刻发生在每一个setState之后,就可能会有性能问题
  • 在短时间内频繁setStateReact会将state的改变压入栈中,在合适的时机,批量更新state和视图,达到提高性能的效果

1.5 如何知道state已经被更新

传入回调函数

setState({
    index: 1
}}, function(){
    console.log(this.state.index);
})

在钩子函数中体现

componentDidUpdate(){
    console.log(this.state.index);
}

2. setState循环调用风险

  • 当调用setState时,实际上会执行enqueueSetState方法,并对partialState以及_pending-StateQueue更新队列进行合并操作,最终通过enqueueUpdate执行state更新
  • performUpdateIfNecessary方法会获取_pendingElement,_pendingStateQueue_pending-ForceUpdate,并调用receiveComponentupdateComponent方法进行组件更新
  • 如果在shouldComponentUpdate或者componentWillUpdate方法中调用setState,此时this._pending-StateQueue != null,就会造成循环调用,使得浏览器内存占满后崩溃

3 事务

  • 事务就是将需要执行的方法使用wrapper封装起来,再通过事务提供的perform方法执行,先执行wrapper中的initialize方法,执行完perform之后,在执行所有的close方法,一组initializeclose方法称为一个wrapper
  • 那么事务和setState方法的不同表现有什么关系,首先我们把4setState简单归类,前两次属于一类,因为它们在同一调用栈中执行,setTimeout中的两次setState属于另一类
  • setState调用之前,已经处在batchedUpdates执行的事务中了。那么这次batchedUpdates方法是谁调用的呢,原来是ReactMount.js中的_renderNewRootComponent方法。也就是说,整个将React组件渲染到DOM中的过程就是处于一个大的事务中。而在componentDidMount中调用setState时,batchingStrategyisBatchingUpdates已经被设为了true,所以两次setState的结果没有立即生效
  • 再反观setTimeout中的两次setState,因为没有前置的batchedUpdates调用,所以导致了新的state马上生效

4. 总结

  • 通过setState去更新this.state,不要直接操作this.state,请把它当成不可变的
  • 调用setState更新this.state不是马上生效的,它是异步的,所以不要天真以为执行完setStatethis.state就是最新的值了
  • 多个顺序执行的setState不是同步地一个一个执行滴,会一个一个加入队列,然后最后一起执行,即批处理

💬 面试官追问

  • 资料编辑页直接执行 this.state.profile.name = value,调试器里值已变化但页面没重渲染;开发者认为 Reactdiff 失效了,你怎么纠正?

    直接修改 this.state 没有通过 setState 进入状态更新队列,因此不能据此期待 React 重新调用 render。应把状态视为不可直接修改的数据,通过 setState 提交新值;否则后续队列合并还可能忽略这次私改,造成页面与内存观察结果不一致。

  • 表格一次编辑三个字段,代码连续调用对象式 setState,评审担心每次都重绘整页;你会怎样实现?

    这些更新会先进入状态队列,并在合适时机批量合并后更新视图,而不是把每次调用都等同于一次立即 DOM 修改。若字段互不覆盖,可提交各自的部分状态;若新值依赖旧状态,则使用函数式更新,避免对象式更新在队列中覆盖结果。

  • 老代码用 isBatchingUpdatesdirtyComponents 解释 React 19 的所有更新时序,面试中你会指出什么版本边界?

    这套 prepostisBatchingUpdatesdirtyComponents 描述的是 React 16、17 的 Transaction 实现,只适合作为历史对照。React 18 起采用 Fiberlane 优先级调度与 automatic batching,触发点是否位于 React 调用栈内已不再决定是否批处理。

  • 支付页调用 setState 后日志仍是旧金额,值班同学认定更新失败;你要求他按什么顺序定位?

    先确认日志是否紧跟在 setState 后,因为状态更新入队并不保证当场改写 this.state。再把观察点移到 setState 回调或 componentDidUpdate,确认提交后的值;若那里仍异常,继续检查对象式更新是否被后续同字段更新覆盖。

  • 类组件需要在价格更新完成后上报一次埋点,有人放在 render,有人放进 setState 第二个回调;你怎么取舍?

    不要在 render 中上报,因为重新渲染不等于这次价格更新的专属完成通知,容易产生重复副作用。针对单次类组件更新可使用 setState 的第二个回调,跨多次价格变化的统一响应则放在 componentDidUpdate 并比较前后值;两者都要防止回调再次无条件更新状态。

  • 遗留组件在 shouldComponentUpdate 中调用 setState 修正数据,线上出现持续更新和内存上涨;根因与改法是什么?

    该生命周期本就在判断一次待处理更新,内部再次调用 setState 会继续向待处理队列追加更新,可能形成循环调用。应把数据修正移到事件入口、状态计算阶段或受条件约束的更新位置;仅加日志或扩大内存不能消除循环更新链路。

# 8 React事务机制

⚡ 30 秒速记

  • 事务是 React 16 之前(Stack 架构)的机制:给方法包一层 initializemethodclose
  • 批处理就靠它实现:initialize 时把 isBatchingUpdatestrueclose 时置回并统一刷新
  • 这解释了为什么老版本里 setTimeout 中的 setState 是同步的 —— 它跑在事务之外
  • Fiber 架构之后事务机制已被移除,改用 Lane 优先级模型和调度器
  • 面试遇到这题:说清它解决了什么、以及现在已被什么取代,比背实现细节有价值

React 事务机制是 Stack 架构时期用来包裹更新流程、实现批处理的一套机制。 它会在目标方法执行前运行 initialize,执行完成后再运行 close,从而统一开启和结束批量更新。处于事务中的多次 setState 会先被收集,所以调用后看起来没有立即生效。需要注意,这属于旧版实现;Fiber 架构下已经由 lane 优先级调度等机制接替。

💬 面试官追问

  • 遗留后台在 React 合成事件里连续调用三次 setState,开发者用“每次调用都立刻刷新一次”解释页面行为;结合事务图你会怎么反驳?

    旧实现会在受 React 管理的调用过程外包一层事务,在开始阶段进入批量更新状态,结束阶段再统一处理队列。因而三次调用不能简单等同于三次即时提交;但该解释属于旧版内部机制,不应直接套到 React 18、19 的 lane 调度上。

  • 维护者准备把旧 Transaction 图直接写进 React 19 的故障手册,用它判断 setTimeout 一定逃离批处理;你批准吗?

    不批准,这组图更适合解释旧实现中事务边界与批处理的关系,不能证明现代版本的定时器更新仍逐次刷新。React 18 createRoot 起已有 automatic batchingReact 19 也保持批处理;手册必须先标注根节点和版本边界。

  • React 17 页面在原生事件回调里两次更新状态,迁移 createRoot 后日志时序改变;值班人员只检查业务代码,漏了什么?

    他漏查了根节点迁移带来的批处理边界变化:旧事务机制无法覆盖的原生回调,在 React 18 createRoot 下也会进入 automatic batching。应对照迁移前后的根节点、触发来源和提交日志;不能继续用旧图中的事务包裹范围推断新版本时序。

  • 数据大屏要求更新状态后立即读取新 DOM,团队想修改事务开关强制提交;你会给出什么工程方案?

    不要操作内部事务状态,这些标志属于旧实现细节,也不是稳定公共接口。现代 React 应在确有同步 DOM 读取需求时,以最小范围使用 flushSync 包住更新;它会打破批处理并增加同步工作,因此只适合作为受控逃生口。

  • 培训中有人把 Transactionautomatic batchingreconciliation 说成同一件事,导致新人认为合并更新就是跳过树比较;你如何串起来?

    Transaction 描述旧版如何包裹一段执行并在边界处理更新,automatic batching 描述现代版本如何合并一批状态更新;它们都不等于 reconciliation。批次形成后,React 仍需根据新状态构建元素树并与旧树比较,再决定必要的 UI 更新。

# 9 React组件和渲染更新过程

⚡ 30 秒速记

  • 挂载:createElement 生成元素树 → 构建 Fiber 树 → commit 到真实 DOM
  • 更新:setState 入队 → 调度 → render 阶段生成新 Fiberdiffcommit 提交变更
  • render 阶段的产物是副作用链表(哪些节点要增删改),commit 阶段按链表执行
  • commit 分三小步:before mutation(读旧 DOM)→ mutation(改 DOM)→ layout(执行 didMount/didUpdateuseLayoutEffect
  • useEffect异步执行的,排在浏览器绘制之后,不阻塞渲染

React 组件的渲染分为计算差异的 render 阶段和提交变更的 commit 阶段,前者可中断,后者不可中断。 JSX 本质上会调用 createElement 生成虚拟节点,组件再根据 propsstate 执行 render(),最终把结果更新到页面。调用 setState 后,相关组件会被标记为待更新,重新生成新节点并与旧节点比较,因此副作用应留到提交阶段处理。

渲染和更新过程

  • jsx如何渲染为页面
  • setState之后如何更新页面
  • 面试考察全流程

JSX本质和vdom

  • JSX即createElement函数
  • 执行生成vnode
  • patch(elem,vnode)patch(vnode,newNode)

组件渲染过程

  • props state
  • render()生成vnode
  • patch(elem, vnode)

组件更新过程

  • setState-->dirtyComponents(可能有子组件)
  • render生成newVnode
  • patch(vnode, newVnode)

💬 面试官追问

  • 商品详情页只修改了购物车数量,父组件和多个商品推荐子组件的 render 都执行了,但 DOM 几乎没变;你会把这次更新描述成整页重绘吗?

    不会,组件重新执行 render 只是在依据新的 propsstate 生成 newVnode,不等于重新创建整页真实 DOM。随后 patch(vnode, newVnode) 会找出变化并更新对应节点;若只看到函数执行次数,不能直接判定真实 DOM 操作过多。

  • 后台表格点击“刷新状态”后调用了 setState,面试官要求你从这一行代码一直讲到页面文字变化,中间链路怎么说?

    setState 会使相关组件进入待更新集合,可理解为被标记到 dirtyComponents,其中也可能涉及子组件。React 随后重新执行组件渲染得到 newVnode,再与旧 vnodepatch,最终把差异落实到真实 DOM;它不是直接修改页面节点。

  • 一个组件首次挂载和后续调用 setState 时都执行了 render,前端同学认为两条链路完全相同,你会指出哪处关键差异?

    首次挂载是由 propsstate 生成 vnode,再执行类似 patch(elem, vnode) 的过程,把描述落实到容器。更新阶段已有旧 vnode,所以重点变成生成 newVnode 并执行 patch(vnode, newVnode);两者都渲染,但比较基准不同。

  • 订单页调用 setState 后数据已经变了,界面却仍显示旧金额;你会按哪几层定位,而不是直接怀疑浏览器缓存?

    先确认更新是否让目标组件进入待更新流程,再检查 render 生成的 newVnode 是否真的包含新金额。若虚拟节点正确,再沿 patch(vnode, newVnode) 检查差异是否落到对应真实节点;若渲染输入仍旧,问题通常在状态或属性传递链路,而非 DOM 补丁阶段。

  • 团队想绕过虚拟节点,在库存变化时直接找到节点改 textContent,理由是只有一个数字变化;你如何评估这种取舍?

    直接改 DOM 可能立即显示结果,但会绕开由 propsstate 生成 vnode 再执行 patch 的一致更新链路。后续组件再次渲染时,声明结果可能覆盖手工修改,也会增加状态与页面不一致的排查成本;除非明确隔离该节点,否则不宜作为常规方案。

# 10 如何解释 React 的渲染流程

⚡ 30 秒速记

  • 触发 → 调度 → rendercommit 四步
  • 触发:首次渲染或 setState/useState 更新
  • 调度:按 Lane 优先级排队,高优先级(如用户输入)可以打断低优先级(如数据渲染)
  • render:递归遍历 Fiber 树,对比新旧,标记副作用;可中断可重来,所以这一步必须
  • commit:一次性同步提交 DOM 变更并执行生命周期,不可中断

React 的渲染流程可以概括为触发更新、调度任务、计算差异和提交结果。 React 16 以前的 Stack Reconciler 以递归和事务为核心,执行期间容易持续占用主线程;之后的 Fiber Reconciler 把节点组织成可遍历的 Fiber 结构,并支持按优先级让出执行权。render 阶段构造 workInProgress 树,可中断且不应产生副作用;commit 阶段同步更新 DOM、执行生命周期和处理 ref,不可中断。普通管理后台里两者差距未必明显,但动画、画布或手势场景更容易体现 Fiber 调度的价值。

  • React 的渲染过程大致一致,但协调并不相同,以 React 16 为分界线,分为 Stack ReconcilerFiber Reconciler。这里的协调从狭义上来讲,特指 React 的 diff 算法,广义上来讲,有时候也指 React 的 reconciler 模块,它通常包含了 diff 算法和一些公共逻辑。
  • 回到 Stack Reconciler 中,Stack Reconciler核心调度方式是递归调度的基本处理单位是事务,它的事务基类是 Transaction,这里的事务是 React 团队从后端开发中加入的概念。在 React 16 以前,挂载主要通过 ReactMount 模块完成,更新通过 ReactUpdate 模块完成,模块之间相互分离,落脚执行点也是事务。
  • React 16 及以后,协调改为了 Fiber Reconciler。它的调度方式主要有两个特点,第一个是协作式多任务模式,在这个模式下,线程会定时放弃自己的运行权利,交还给主线程,通过requestIdleCallback 实现。第二个特点是策略优先级,调度任务通过标记 tag 的方式分优先级执行,比如动画,或者标记为 high 的任务可以优先执行。Fiber Reconciler的基本单位是 FiberFiber 基于过去的 React Element 提供了二次封装,提供了指向父、子、兄弟节点的引用,为 diff 工作的双链表实现提供了基础。
  • 在新的架构下,整个生命周期被划分为 Render 和 Commit 两个阶段Render 阶段的执行特点是可中断、可停止、无副作用,主要是通过构造 workInProgress 树计算出 diff。以 current 树为基础,将每个 Fiber作为一个基本单位,自下而上逐个节点检查并构造 workInProgress 树。这个过程不再是递归,而是基于循环来完成
  • 在执行上通过 requestIdleCallback 来调度执行每组任务,每组中的每个计算任务被称为 work,每个 work 完成后确认是否有优先级更高的 work 需要插入,如果有就让位,没有就继续。优先级通常是标记为动画或者 high 的会先处理。每完成一组后,将调度权交回主线程,直到下一次 requestIdleCallback 调用,再继续构建 workInProgress
  • commit 阶段需要处理 effect 列表,这里的 effect 列表包含了根据 diff 更新 DOM 树回调生命周期响应 ref 等。
  • 但一定要注意,这个阶段是同步执行的,不可中断暂停,所以不要在 componentDidMountcomponentDidUpdatecomponentWiilUnmount中去执行重度消耗算力的任务
  • 如果只是一般的应用场景,比如管理后台、H5 展示页等,两者性能差距并不大,但在动画、画布及手势等场景下,Stack Reconciler 的设计会占用占主线程,造成卡顿,而 fiber reconciler 的设计则能带来高性能的表现

💬 面试官追问

  • 动画面板更新时主线程连续被占用,同事说 React 只是在递归整棵组件树;放到 React 16 之后的架构里,这个说法哪里不准确?

    React 16 之后使用 Fiber Reconciler,基本处理单位是带父、子、兄弟引用的 Fiber,工作过程不再等同于旧架构的连续递归。协调工作可被拆分、暂停和恢复,并结合任务优先级让位;但后续 commit 仍是同步且不可中断的。

  • 画布编辑器同时收到拖拽更新和低优先级列表刷新,产品要求拖拽优先响应;渲染流程中的哪一层负责体现这种优先级?

    优先级体现在 Fiber Reconciler 的调度与 render 工作阶段,任务会被标记并按策略安排,高优先级工作可插入并获得执行机会。它改善的是协调过程对主线程的占用方式,不代表所有工作都会异步;真正应用变更的 commit 仍要同步完成。

  • 管理后台只有少量表单和列表,架构评审却把从 Stack ReconcilerFiber Reconciler 当成必然的大幅性能收益;你会接受吗?

    不会直接接受,一般管理后台或 H5 展示页中,两种协调方式的体验差距未必明显。Fiber 的优势更容易在动画、画布、手势或大批量更新等主线程敏感场景体现;是否有收益仍要结合更新规模和交互特征判断。

  • 线上弹窗打开时页面长时间不响应,日志显示节点差异已经算完,随后生命周期回调里执行了重计算;你会把故障定位到哪个阶段?

    应重点定位 commit 阶段,因为它会同步处理 effect 列表,包括更新 DOM、生命周期回调和 ref。该阶段不可中断,在 componentDidMountcomponentDidUpdate 或卸载回调中执行重计算会直接延长阻塞;把渲染阶段做成可中断并不能抵消这类开销。

  • 面试官让你用 current 树、workInProgress 树和真实 DOM 串起一次更新,不能只背“渲染与提交”,你会怎么讲?

    render 阶段以 current 树为基础,逐个处理 Fiber 并构造 workInProgress 树,在此期间计算差异且应避免副作用。完成后进入同步 commit,按照 effect 信息更新真实 DOM、触发生命周期并处理 ref;前一阶段可调度,后一阶段不能暂停。

# 11 diff算法是怎么运作

⚡ 30 秒速记

  • 三个前提假设把复杂度从 O(n³) 降到 O(n):只做同层比较、类型不同直接卸载重建整棵子树、用 key 标识同层身份
  • 单节点:先比 key 再比 type,都相同才复用 Fiber
  • 多节点分两轮:第一轮按顺序比对,遇到不能复用就跳出;第二轮把剩余旧节点放进 Map,用 key 查找并通过 lastPlacedIndex 判断要不要移动
  • key 的意义:没有 key 时按索引复用,列表头部插入会导致后面全部重渲染
  • 别用 indexkey —— 列表顺序变化时等于没有 key

Reactdiff 通过只比较同层节点、按组件类型判断复用,并用 key 识别同层元素,把树比较从 O(n³) 简化到 O(n) 节点类型不同会直接删除旧子树并创建新子树,类型相同才继续比较属性和子节点,所以跨层移动通常也会被当成删除与新增。列表更新时会优先处理可复用节点,剩余节点再执行插入、删除或移动。key 应保持唯一和稳定,否则在头部插入或顺序变化时,可能让后续节点失去正确的复用关系;用索引作 key 也有同样风险。

每一种节点类型有自己的属性,也就是prop,每次进行diff的时候,react会先比较该节点类型,假如节点类型不一样,那么react会直接删除该节点,然后直接创建新的节点插入到其中,假如节点类型一样,那么会比较prop是否有更新,假如有prop不一样,那么react会判定该节点有更新,那么重渲染该节点,然后在对其子节点进行比较,一层一层往下,直到没有子节点

  • 把树形结构按照层级分解,只比较同级元素。
  • 给列表结构的每个单元添加唯一的key属性,方便比较。
  • React 只会匹配相同 classcomponent(这里面的class指的是组件的名字)
  • 合并操作,调用 componentsetState 方法的时候, React 将其标记为 - dirty.到每一个事件循环结束, React 检查所有标记 dirtycomponent重新绘制.
  • 选择性子树渲染。开发人员可以重写shouldComponentUpdate提高diff的性能

优化⬇️

为了降低算法复杂度,Reactdiff会预设三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:

Diff的思路

该如何设计算法呢?如果让我设计一个Diff算法,我首先想到的方案是:

  1. 判断当前节点的更新属于哪种情况
  2. 如果是新增,执行新增逻辑
  3. 如果是删除,执行删除逻辑
  4. 如果是更新,执行更新逻辑
  • 按这个方案,其实有个隐含的前提——不同操作的优先级是相同的
  • 但是React团队发现,在日常开发中,相较于新增删除更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新

基于以上原因,Diff算法的整体逻辑会经历两轮遍历:

  • 第一轮遍历:处理更新的节点。
  • 第二轮遍历:处理剩下的不属于更新的节点。

diff算法的作用

计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。

传统diff算法

通过循环递归对节点进行依次对比,算法复杂度达到 O(n^3) ,n是树的节点数,这个有多可怕呢?——如果要展示1000个节点,得执行上亿次比较。。即便是CPU快能执行30亿条命令,也很难在一秒内计算出差异。

React的diff算法

  1. 什么是调和?

将Virtual DOM树转换成actual DOM树的最少操作的过程 称为 调和 。

  1. 什么是React diff算法?

diff算法是调和的具体实现。

diff策略

React用 三大策略 将O(n^3)复杂度 转化为 O(n)复杂度

策略一(tree diff):

  • Web UI中DOM节点跨层级的移动操作特别少,可以忽略不计。

策略二(component diff):

  • 拥有相同类的两个组件 生成相似的树形结构,
  • 拥有不同类的两个组件 生成不同的树形结构。

策略三(element diff):

对于同一层级的一组子节点,通过唯一id区分。

tree diff

  • React通过updateDepth对Virtual DOM树进行层级控制。
  • 对树分层比较,两棵树 只对同一层次节点 进行比较。如果该节点不存在时,则该节点及其子节点会被完全删除,不会再进一步比较。
  • 只需遍历一次,就能完成整棵DOM树的比较。

image-20210307224725566

那么问题来了,如果DOM节点出现了跨层级操作,diff会咋办呢?

答:diff只简单考虑同层级的节点位置变换,如果是跨层级的话,只有创建节点和删除节点的操作。

image-20210307224829092

如上图所示,以A为根节点的整棵树会被重新创建,而不是移动,因此 官方建议不要进行DOM节点跨层级操作,可以通过CSS隐藏、显示节点,而不是真正地移除、添加DOM节点

component diff

React对不同的组件间的比较,有三种策略

  1. 同一类型的两个组件,按原策略(层级比较)继续比较Virtual DOM树即可。
  2. 同一类型的两个组件,组件A变化为组件B时,可能Virtual DOM没有任何变化,如果知道这点(变换的过程中,Virtual DOM没有改变),可节省大量计算时间,所以 用户 可以通过 shouldComponentUpdate() 来判断是否需要 判断计算。
  3. 不同类型的组件,将一个(将被改变的)组件判断为dirty component(脏组件),从而替换 整个组件的所有节点。

注意:如果组件D和组件G的结构相似,但是 React判断是 不同类型的组件,则不会比较其结构,而是删除 组件D及其子节点,创建组件G及其子节点。

element diff

当节点处于同一层级时,diff提供三种节点操作:删除、插入、移动。

  • 插入:组件 C 不在集合(A,B)中,需要插入
  • 删除:
    • 组件 D 在集合(A,B,D)中,但 D的节点已经更改,不能复用和更新,所以需要删除 旧的 D ,再创建新的。
    • 组件 D 之前在 集合(A,B,D)中,但集合变成新的集合(A,B)了,D 就需要被删除。
  • 移动:组件D已经在集合(A,B,C,D)里了,且集合更新时,D没有发生更新,只是位置改变,如新集合(A,D,B,C),D在第二个,无须像传统diff,让旧集合的第二个B和新集合的第二个D 比较,并且删除第二个位置的B,再在第二个位置插入D,而是 (对同一层级的同组子节点) 添加唯一key进行区分,移动即��。

总结

  1. tree diff:只对比同一层的 dom 节点,忽略 dom 节点的跨层级移动

如下图,react 只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点不存在时,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。

这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

image-20210302195610674

这就意味着,如果 dom 节点发生了跨层级移动,react 会删除旧的节点,生成新的节点,而不会复用。

  1. component diff:如果不是同一类型的组件,会删除旧的组件,创建新的组件

image-20210302195654736

  1. element diff:对于同一层级的一组子节点,需要通过唯一 id 进行来区分
  • 如果没有 id 来进行区分,一旦有插入动作,会导致插入位置之后的列表全部重新渲染
  • 这也是为什么渲染列表时为什么要使用唯一的 key。

diff的不足与待优化的地方

尽量减少类似将最后一个节点移动到列表首部的操作,当节点数量过大或更新操作过于频繁时,会影响React的渲染性能

与其他框架相比,React 的 diff 算法有何不同?

diff 算法探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两株虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新

具体的流程是这样的:

  • 真实 DOM 与虚拟 DOM 之间存在一个映射关系。这个映射关系依靠初始化时的 JSX 建立完成;
  • 当虚拟 DOM 发生变化后,就会根据差距计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除等;
  • 最后再根据 patch 去更新真实的 DOM,反馈到用户的界面上。

在回答有何不同之前,首先需要说明下什么是 diff 算法。

  • diff 算法是指生成更新补丁的方式,主要应用于虚拟 DOM 树变化后,更新真实 DOM。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁
  • React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。
    • 树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。
    • 组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。
    • 元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。同一层级的子节点,可以通过标记 key 的方式进行列表对比。
  • 以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构fiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点
  • 然后拿 Vue 和 Preact 与 React 的 diff 算法进行对比
    • PreactDiff 算法相较于 React,整体设计思路相似,但最底层的元素采用了真实 DOM 对比操作,也没有采用 Fiber 设计。Vue 的 Diff 算法整体也与 React 相似,同样未实现 Fiber 设计
  • 然后进行横向比较,React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力,在大批量节点更新的极端情况下,拥有更友好的交互体验。
  • Preact 可以在一些对性能要求不高,仅需要渲染框架的简单场景下应用。
  • Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。

**学习原理的目的就是应用。那如何根据 React diff 算法原理优化代码呢?**这个问题其实按优化方式逆向回答即可。

  • 根据 diff 算法的设计原则,应尽量避免跨层级节点移动。
  • 通过设置唯一 key 进行优化,尽量减少组件层级深度。因为过深的层级会加深遍历深度,带来性能问题。
  • 设置 shouldComponentUpdate 或者 React.pureComponet 减少 diff 次数。

💬 面试官追问

  • 消息列表头部插入一条新消息,开发者没有提供稳定 key,结果插入点后的多行都被当成变化;为什么 diff 没识别出只是新增一项?

    同层子节点缺少稳定标识时,React 主要按位置判断对应关系,头部插入会改变后续节点的位置映射。为每条消息使用唯一且稳定的业务 key,才能帮助 element diff 区分插入、删除和移动;key 只在同一组兄弟节点中参与匹配。

  • 可拖拽看板把一个带输入状态的卡片从 A 列移到 B 列,业务希望组件实例和状态原样保留;仅让两边使用相同 key 能做到吗?

    不能保证,因为 React 只在同一层级、同一组子节点中利用 key 建立对应关系,不会把跨父节点移动当作可复用的同一次移动。旧子树会被删除,新位置会创建节点,局部状态可能随之丢失;需要保持层级不变并调整展示,或把需保留的状态移到更稳定的位置。

  • 设计系统把列表项根节点从 div 改成 p,内部子组件和属性看起来完全一致;为什么上线后仍出现整段节点重建?

    根元素类型不同会触发不同树的假设,React 会销毁旧 div 及其子孙节点,再创建新的 p 子树,而不是继续比较内部结构。只有类型相同时才会比较 prop 并向下处理子节点;结构相似并不足以跨类型复用。

  • 千行表格更新一行后出现大量节点操作,你拿到新旧虚拟树时会先查哪些破坏复用关系的线索?

    先核对同层列表是否有唯一、稳定的 key,以及更新前后元素或组件类型是否被意外替换。再检查节点是否跨父级移动,因为这会表现为旧子树删除和新子树创建;这些线索比单纯统计组件执行次数更能解释异常补丁规模。

  • 前端负责人建议实现完整的跨层树匹配,认为能减少任何移动场景的节点创建;你如何回应复杂度与收益冲突?

    完整树比较的传统复杂度可达到 O(n^3)React 通过只比较同层节点、按组件类型判断、用 key 区分同组元素,把常见比较约束到 O(n)。支持任意跨层复用会破坏这些前提并显著增加成本;更稳妥的是调整页面结构,减少跨层移动。

  • 一个类组件渲染代价较高,但其输入在多数更新中不变;shouldComponentUpdatediff 的关系该怎样向评审解释?

    shouldComponentUpdate 可在已知组件输出无需变化时阻止继续计算其子树,从而减少不必要的 diff。它依赖开发者正确判断 propsstate 是否影响输出,判断错误会让页面漏更新;因此应针对明确稳定的子树使用,而不是无条件阻断。

# 12 合成事件原理

⚡ 30 秒速记

  • React 不把事件绑在具体元素上,而是统一委托到根容器(React 17 起是 root 节点,16 及以前是 document
  • 收到原生事件后,React 构造一个跨浏览器一致的 SyntheticEvent 对象,再沿 Fiber 树模拟捕获和冒泡
  • 好处:抹平浏览器差异、减少监听器数量、便于批处理和优先级调度
  • 坑一:合成事件和原生事件混用时,原生事件先触发e.stopPropagation() 拦不住已经触发的原生监听
  • 坑二:React 17 之前 SyntheticEvent 有事件池复用,异步访问会拿到被清空的对象(要 e.persist());17 起已移除事件池

合成事件本质上是 React 对原生事件的统一封装,事件通常委托到顶层容器,而不是直接绑定在每个子元素上。 原生事件冒泡到委托节点后,React 会创建 SyntheticEvent,找到对应组件,并按组件树分发处理函数。这样既能屏蔽浏览器差异,也便于统一管理事件并减少大量监听器带来的内存开销。需要注意版本边界:React 17 以前主要委托到 document,之后改为各自的根容器;混用原生事件时也要留意两套传播路径的差别。

为了解决跨浏览器兼容性问题,React 会将浏览器原生事件(Browser Native Event)封装为合成事件(SyntheticEvent)传入设置的事件处理器中。这里的合成事件提供了与原生事件相同的接口,不过它们屏蔽了底层浏览器的细节差异,保证了行为的一致性。另外有意思的是,React 并没有直接将事件附着到子元素上,而是以单一事件监听器的方式将所有的事件发送到顶层进行处理。这样 React 在更新 DOM 的时候就不需要考虑如何去处理附着在 DOM 上的事件监听器,最终达到优化性能的目的

  • 所有的事件挂在document上,DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。
  • event不是原生的,是SyntheticEvent合成事件对象
  • 和Vue事件不同,和DOM事件也不同

React 17 之前的事件冒泡流程图

所以这就造成了,在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。值得一提的是,这个问题在 React 17 中得到了解决,事件委托不再挂在 document 上,而是挂在 DOM 容器上,也就是 ReactDom.Render 所调用的节点上。

React 17 后的事件冒泡流程图

那到底哪些事件会被捕获生成合成事件呢?可以从 React 的源码测试文件中一探究竟。下面的测试快照中罗列了大量的事件名,也只有在这份快照中的事件,才会被捕获生成合成事件。

// react/packages/react-dom/src/__tests__/__snapshots__/ReactTestUtils-test.js.snap
Array [
	  "abort",
	  "animationEnd",
	  "animationIteration",
	  "animationStart",
	  "auxClick",
	  "beforeInput",
	  "blur",
	  "canPlay",
	  "canPlayThrough",
	  "cancel",
	  "change",
	  "click",
	  "close",
	  "compositionEnd",
	  "compositionStart",
	  "compositionUpdate",
	  "contextMenu",
	  "copy",
	  "cut",
	  "doubleClick",
	  "drag",
	  "dragEnd",
	  "dragEnter",
	  "dragExit",
	  "dragLeave",
	  "dragOver",
	  "dragStart",
	  "drop",
	  "durationChange",
	  "emptied",
	  "encrypted",
	  "ended",
	  "error",
	  "focus",
	  "gotPointerCapture",
	  "input",
	  "invalid",
	  "keyDown",
	  "keyPress",
	  "keyUp",
	  "load",
	  "loadStart",
	  "loadedData",
	  "loadedMetadata",
	  "lostPointerCapture",
	  "mouseDown",
	  "mouseEnter",
	  "mouseLeave",
	  "mouseMove",
	  "mouseOut",
	  "mouseOver",
	  "mouseUp",
	  "paste",
	  "pause",
	  "play",
	  "playing",
	  "pointerCancel",
	  "pointerDown",
	  "pointerEnter",
	  "pointerLeave",
	  "pointerMove",
	  "pointerOut",
	  "pointerOver",
	  "pointerUp",
	  "progress",
	  "rateChange",
	  "reset",
	  "scroll",
	  "seeked",
	  "seeking",
	  "select",
	  "stalled",
	  "submit",
	  "suspend",
	  "timeUpdate",
	  "toggle",
	  "touchCancel",
	  "touchEnd",
	  "touchMove",
	  "touchStart",
	  "transitionEnd",
	  "volumeChange",
	  "waiting",
	  "wheel",
	]

如果DOM上绑定了过多的事件处理函数,整个页面响应以及内存占用可能都会受到影响。React为了避免这类DOM事件滥用,同时屏蔽底层不同浏览器之间的事件系统的差异,实现了一个中间层 - SyntheticEvent

  1. 当用户在为onClick添加函数时,React并没有将Click绑定到DOM上面
  2. 而是在document处监听所有支持的事件,当事件发生并冒泡至document处时,React将事件内容封装交给中间层 SyntheticEvent (负责所有事件合成)
  3. 所以当事件触发的时候, 对使用统一的分发函数 dispatchEvent 将指定函数执行

为何要合成事件

  • 兼容性和跨平台
  • 挂在统一的document上,减少内存消耗,避免频繁解绑
  • 方便事件的统一管理(事务机制)
  • dispatchEvent事件机制

💬 面试官追问

  • 列表里有一千个按钮,实习生认为每个 onClick 都会对应一个直接挂在按钮上的原生监听器;结合 React 的事件链路,你会如何纠正?

    React 不会按这种理解把每个处理器直接附着到对应子节点,而是统一监听支持的事件,再在事件到达委托层后定位组件并分发。传给业务处理器的是 SyntheticEvent,这种中间层减少了逐节点管理监听器的负担,也统一了浏览器差异。

  • 同一页面嵌入两个独立 React 应用,旧方案都把事件交给 document,团队担心事件互相影响;React 17 的委托位置变化解决了什么?

    React 17 将事件委托从 document 下移到各自的 React 根容器,也就是渲染所使用的容器节点。这样多个应用或版本能在各自边界内处理合成事件,降低顶层事件系统相互干扰的风险;跨容器的原生冒泡规则仍需单独考虑。

  • 埋点 SDK 用原生 addEventListener,业务组件使用 onClick,一次点击出现顺序与组件树直觉不一致;排查时为什么不能只看 JSX 嵌套?

    业务 onClick 经过 React 的统一监听、合成和 dispatchEvent 分发,并非直接绑定在 JSX 对应节点上。应同时确认原生监听器所在节点、捕获或冒泡阶段,以及 React 版本对应的委托层;混用两套事件系统时,原生传播路径与组件树模拟分发不能混为一谈。

  • 线上只有部分事件能进入组件处理器,click 正常但某个自定义事件名称毫无响应;你会怎样判断是不是合成事件系统的问题?

    先确认该事件是否属于 React 支持并会捕获生成 SyntheticEvent 的事件集合,不能假设任意事件名都会被统一分发。若不在支持范围,应改用合适的原生监听或封装桥接;若属于支持事件,再沿原生冒泡、根容器监听和 dispatchEvent 定位中断点。

  • 基础设施团队主张全部改用原生事件,理由是 SyntheticEvent 只是多包了一层;你会保留它的哪些工程价值?

    合成事件统一了不同浏览器的事件接口,并让 React 集中管理监听和分发,避免组件更新时反复处理大量节点监听器。全部改用原生事件会获得更直接的底层控制,但需要自行承担兼容性、解绑和与 React 委托边界协作的成本;两套体系混用还会增加传播顺序的复杂度。

# 13 JSX语法糖本质

⚡ 30 秒速记

  • JSX 不是模板,它是 JS 的语法扩展,编译后变成函数调用
  • React 17 之前编译成 React.createElement(...),所以必须 import React
  • React 17 起用新的 JSX 转换,编译成 jsx()/jsxs() 并自动从 react/jsx-runtime 引入,不用再手动 import React
  • 大写开头当组件(变量),小写开头当原生标签(字符串)—— 这是编译器的约定
  • 因为 JSXJS,所以条件、循环、抽取变量都用原生语法,不需要 v-if/v-for 这类指令

JSX 本质上是 JavaScript 的语法扩展,也是创建 ReactElement 的语法糖。 经典转换中,Babel 会先把它解析成 AST,再转换为 React.createElement 调用,最终得到描述节点类型、属性和子元素的普通对象。这个对象组成虚拟 DOM 树,后续经过对比和更新渲染到页面。实际开发选择 JSX,是因为它比手写嵌套函数更直观,也不需要额外引入模板指令。

JSX是语法糖,通过babel转成React.createElement函数,在babel官网上可以在线把JSX转成React的JS语法

  • 首先解析出来的话,就是一个createElement函数
  • 然后这个函数执行完后,会返回一个vnode
  • 通过vdom的patch或者是其他的一个方法,最后渲染一个页面

script标签中不添加text/babel解析jsx语法的情况下

<script>
  const ele = React.createElement("h2", null, "Hello React!");
  // 历史写法:React 18 起用 ReactDOM.createRoot(...).render(ele),React 19 已移除 ReactDOM.render
  ReactDOM.render(ele, document.getElementById("app"));
</script>

JSX的本质是React.createElement()函数

createElement函数返回的对象是ReactEelement对象。

createElement的写法如下

class App extends React.Component {
  constructor() {
    super()
    this.state = {}
  }

  render() {
    return React.createElement("div", null,
        /*第一个子元素,header*/
        React.createElement("div", { className: "header" },
                            React.createElement("h1", { title: "\u6807\u9898" }, "\u6211\u662F\u6807\u9898")
                          ),
        /*第二个子元素,content*/
        React.createElement("div", { className: "content" },
                            React.createElement("h2", null, "\u6211\u662F\u9875\u9762\u7684\u5185\u5BB9"),
                            React.createElement("button", null, "\u6309\u94AE"),
                            React.createElement("button", null, "+1"),
                            React.createElement("a", { href: "http://www.baidu.com" },
                                                "\u767E\u5EA6\u4E00\u4E0B")
                          ),
        /*第三个子元素,footer*/
        React.createElement("div", { className: "footer" },
                            React.createElement("p", null, "\u6211\u662F\u5C3E\u90E8\u7684\u5185\u5BB9")
                          )
      );
  }
}

// 历史写法:React 18 起用 ReactDOM.createRoot(document.getElementById("app")).render(<App />)
ReactDOM.render(<App />, document.getElementById("app"));

实际开发中不会使用createElement来创建ReactElement的,一般都是使用JSX的形式开发。

ReactElement在程序中打印一下

render() {
  let ele = (
    <div>
      <div className="header">
        <h1 title="标题">我是标题</h1>
      </div>
      <div className="content">
        <h2>我是页面的内容</h2>
        <button>按钮</button>
        <button>+1</button>
        <a href="http://www.baidu.com">百度一下</a>
      </div>
      <div className="footer">
        <p>我是尾部的内容</p>
      </div>
    </div>
  )
  console.log(ele);
  return ele;
}

react通过babel把JSX转成createElement函数,生成ReactElement对象,然后通过ReactDOM.render函数把ReactElement渲染成真实的DOM元素

为什么 React 使用 JSX

  • 在回答问题之前,我首先解释下什么是 JSX 吧。JSX 是一个 JavaScript 的语法扩展,结构类似 XML。
  • JSX 主要用于声明 React 元素,但 React 中并不强制使用 JSX。即使使用了 JSX,也会在构建过程中,通过 Babel 插件编译为 React.createElement。所以 JSX 更像是 React.createElement 的一种语法糖
  • 接下来与 JSX 以外的三种技术方案进行对比
    • 首先是模板,React 团队认为模板不应该是开发过程中的关注点,因为引入了模板语法、模板指令等概念,是一种不佳的实现方案
    • 其次是模板字符串,模板字符串编写的结构会造成多次内部嵌套,使整个结构变得复杂,并且优化代码提示也会变得困难重重
    • 所以 React 最后选用了 JSX,因为 JSX 与其设计思想贴合,不需要引入过多新的概念,对编辑器的代码提示也极为友好。

Babel 插件如何实现 JSX 到 JS 的编译? 在 React 面试中,这个问题很容易被追问,也经常被要求手写。

它的实现原理是这样的。Babel 读取代码并解析,生成 AST,再将 AST 传入插件层进行转换,在转换时就可以将 JSX 的结构转换为 React.createElement 的函数。如下代码所示:

module.exports = function (babel) {
  var t = babel.types;
  return {
    name: "custom-jsx-plugin",
    visitor: {
      JSXElement(path) {
        var openingElement = path.node.openingElement;
        var tagName = openingElement.name.name;
        var args = []; 
        args.push(t.stringLiteral(tagName)); 
        var attribs = t.nullLiteral(); 
        args.push(attribs); 
        var reactIdentifier = t.identifier("React"); //object
        var createElementIdentifier = t.identifier("createElement");
        var callee = t.memberExpression(reactIdentifier, createElementIdentifier)
        var callExpression = t.callExpression(callee, args);
        callExpression.arguments = callExpression.arguments.concat(path.node.children);
        path.replaceWith(callExpression, path.node); 
      },
    },
  };
};

React.createElement源码分析

/**
 101. React的创建元素方法
 */
export function createElement(type, config, children) {
  // propName 变量用于储存后面需要用到的元素属性
  let propName;
  // props 变量用于储存元素属性的键值对集合
  const props = {};
  // key、ref、self、source 均为 React 元素的属性,此处不必深究
  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  // config 对象中存储的是元素的属性
  if (config != null) {
    // 进来之后做的第一件事,是依次对 ref、key、self 和 source 属性赋值
    if (hasValidRef(config)) {
      ref = config.ref;
    }
    // 此处将 key 值字符串化
    if (hasValidKey(config)) {
      key = '' + config.key;
    }
    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source;
    // 接着就是要把 config 里面的属性都一个一个挪到 props 这个之前声明好的对象里面
    for (propName in config) {
      if (
        // 筛选出可以提进 props 对象里的属性
        hasOwnProperty.call(config, propName) &&
        !RESERVED_PROPS.hasOwnProperty(propName)
      ) {
        props[propName] = config[propName];
      }
    }
  }
  // childrenLength 指的是当前元素的子元素的个数,减去的 2 是 type 和 config 两个参数占用的长度
  const childrenLength = arguments.length - 2;
  // 如果抛去type和config,就只剩下一个参数,一般意味着文本节点出现了
  if (childrenLength === 1) {
    // 直接把这个参数的值赋给props.children
    props.children = children;
    // 处理嵌套多个子元素的情况
  } else if (childrenLength > 1) {
    // 声明一个子元素数组
    const childArray = Array(childrenLength);
    // 把子元素推进数组里
    for (let i = 0; i < childrenLength; i++) {
      childArray[i] = arguments[i + 2];
    }
    // 最后把这个数组赋值给props.children
    props.children = childArray;
  }

  // 处理 defaultProps(React 16/17 源码逻辑,属历史对照)
  // React 19 已移除函数组件的 defaultProps,改用函数默认参数;类组件的 defaultProps 仍可用
  if (type && type.defaultProps) {
    const defaultProps = type.defaultProps;
    for (propName in defaultProps) {
      if (props[propName] === undefined) {
        props[propName] = defaultProps[propName];
      }
    }
  }

  // 最后返回一个调用ReactElement执行方法,并传入刚才处理过的参数
  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

入参解读:创造一个元素需要知道哪些信息

export function createElement(type, config, children)

createElement 有 3 个入参,这 3 个入参囊括了 React 创建一个元素所需要知道的全部信息。

  • type:用于标识节点的类型。它可以是类似“h1”“div”这样的标准 HTML 标签字符串,也可以是 React 组件类型或 React fragment 类型。
  • config:以对象形式传入,组件所有的属性都会以键值对的形式存储在 config 对象中。
  • children:以对象形式传入,它记录的是组件标签之间嵌套的内容,也就是所谓的“子节点”“子元素”
React.createElement("ul", {
  // 传入属性键值对
  className: "list"
   // 从第三个入参开始往后,传入的参数都是 children
}, React.createElement("li", {
  key: "1"
}, "1"), React.createElement("li", {
  key: "2"
}, "2"));

这个调用对应的 DOM 结构如下:

<ul className="list">
  <li key="1">1</li>
  <li key="2">2</li>
</ul>

createElement 函数体拆解

createElement 中并没有十分复杂的涉及算法或真实 DOM 的逻辑,它的每一个步骤几乎都是在格式化数据。

现在看来,createElement 原来只是个“参数中介”。此时我们的注意力自然而然地就聚焦在了 ReactElement

出参解读:初识虚拟 DOM

createElement 执行到最后会 return 一个针对 ReactElement 的调用。这里关于 ReactElement,我依然先给出源码 + 注释形式的解析

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // REACT_ELEMENT_TYPE是一个常量,用来标识该对象是一个ReactElement
    $$typeof: REACT_ELEMENT_TYPE,

    // 内置属性赋值
    type: type,
    key: key,
    ref: ref,
    props: props,

    // 记录创造该元素的组件
    _owner: owner,
  };

  //
  if (__DEV__) {
    // 这里是一些针对 __DEV__ 环境下的处理,对于大家理解主要逻辑意义不大,此处我直接省略掉,以免混淆视听
  }

  return element;
};

ReactElement 其实只做了一件事情,那就是“创建”,说得更精确一点,是“组装”:ReactElement 把传入的参数按照一定的规范,“组装”进了 element 对象里,并把它返回给了 eact.createElement,最终 React.createElement 又把它交回到了开发者手中

const AppJSX = (<div className="App">
  <h1 className="title">I am the title</h1>
  <p className="content">I am the content</p>
</div>)

console.log(AppJSX)

你会发现它确实是一个标准的 ReactElement 对象实例

这个 ReactElement 对象实例,本质上是以 JavaScript 对象形式存在的对 DOM 的描述,也就是老生常谈的“虚拟 DOM”(准确地说,是虚拟 DOM 中的一个节点)

💬 面试官追问

  • 代码评审里有人说 <UserCard name="Ada" /> 会在浏览器里直接创建一个名为 UserCardDOM 标签;你会用编译产物怎样反驳?

    JSX 会先经 Babel 转换为类似 React.createElement(UserCard, {name: "Ada"}) 的调用,其中 type 是组件本身而不是浏览器标签字符串。该调用组装并返回 ReactElement 描述,之后才由 React 的渲染流程把它落实到真实 DOM

  • 低代码平台不能使用 JSX,只允许执行普通 JavaScript;要生成一个带两个 liul,最少需要向 createElement 提供哪些信息?

    需要提供节点 type、保存属性的 config,以及从第三个参数开始传入的一个或多个 children。例如父节点类型为 "ul",子项分别是嵌套的 React.createElement("li", {key: "..."}, "...");返回值仍是元素描述,不会在调用时直接操作 DOM

  • 调试台打印 JSX 变量时只看到带 typekeyrefprops 的普通对象,开发者以为渲染失败了;这个现象正常吗?

    正常,createElement 的职责主要是整理参数并组装 ReactElement,所以 JSX 表达式的结果本来就是 JavaScript 对象形式的节点描述。真实节点要等后续渲染与补丁流程处理;仅打印元素对象看不到浏览器 DOM,不能据此判断失败。

  • 组件传入 keyrefclassName 和多个子节点后,业务代码发现 props 的形状与原始配置对象不同;createElement 做了哪些整理?

    createElement 会单独提取 keyref 等保留字段,把普通配置项放入 props,并将子节点整理到 props.children。一个子节点通常直接保存,多个子节点会组成数组;因此不能把所有 JSX 属性都假设为组件可读取的普通 props

  • 团队准备自己写 Babel 插件,把内部 XML 风格语法转换成 React 元素;最核心的 AST 转换动作是什么?

    插件需要在 Babel 解析得到 AST 后访问 JSXElement,提取标签、属性和子节点,再把该节点替换为 React.createElement 调用表达式。难点不只是替换函数名,还要完整保留组件类型、属性表达式和嵌套子节点;只处理字符串标签会在组件或复杂属性场景失效。

  • 模板方案和 JSX 方案选型时,有人认为 JSX 是运行时模板解释器;基于其工作链路,你会怎样说明取舍?

    JSXJavaScript 的语法扩展,通常在构建阶段转换为 React.createElement,并不依赖浏览器把它当模板解释。它减少了额外模板指令概念,并有利于编辑器提示,但仍需要编译工具处理;若运行环境没有对应转换,就必须直接编写普通 JavaScript 调用。

# 14 为什么 React 元素有一个 $$typeof 属性

⚡ 30 秒速记

  • $$typeof 的值是一个 SymbolSymbol.for('react.element')),用来标记「这是一个合法的 React 元素」
  • 目的是XSS:如果服务端把用户输入的 JSON 直接当元素渲染,攻击者可以伪造 {type: 'script'} 这样的对象
  • Symbol 无法被 JSON 序列化,所以从 JSON 来的假元素一定没有这个属性,React 会拒绝渲染
  • 不支持 Symbol 的环境降级为数字 0xeac7(看起来像 React
  • 这是个很能体现「读过源码」的细节题,答出「Symbol 不能被 JSON 序列化」是关键

React 元素上的 $$typeof 是身份标记,用来区分可信的 ReactElement 与可能从外部混入的普通对象。 它的值使用 Symbol,而 Symbol 不能被 JSON 序列化,因此服务端或数据库返回的 JSON 很难直接伪造成合法元素。React 检查不到这个标记时会拒绝按元素处理,从而降低旧式元素注入导致 XSS 的风险。它是一层安全护栏,但不能替代对 dangerouslySetInnerHTML 内容本身的谨慎处理。

image-20210302200213923

目的是为了防止 XSS 攻击。因为 Synbol 无法被序列化,所以 React 可以通过有没有 $$typeof 属性来断出当前的 element 对象是从数据库来的还是自己生成的。

  • 如果没有 $$typeof 这个属性,react 会拒绝处理该元素。
  • 在 React 的古老版本中,下面的写法会出现 XSS 攻击:
// 服务端允许用户存储 JSON
let expectedTextButGotJSON = {
  type: 'div',
  props: {
    dangerouslySetInnerHTML: {
      __html: '/* 把你想的搁着 */'
    },
  },
  // ...
};
let message = { text: expectedTextButGotJSON };

// React 0.13 中有风险
<p>
  {message.text}
</p>

💬 面试官追问

  • 评论页原本只显示后端返回的文本,但接口把一段带 typepropsdangerouslySetInnerHTMLJSON 对象交给了 {message.text};没有 $$typeof 校验时会发生什么?

    旧版 React 可能把这个普通对象误认成元素,并按其中的 props 创建节点,最终让攻击者控制的 HTML 进入页面。$$typeof 相当于元素身份标记;缺少该标记的对象不会作为合法元素处理,但普通文本输出是否安全仍取决于具体渲染入口。

  • 消息中心既接收服务端 JSON,又允许业务代码通过 React.createElement 动态生成提示组件,你会怎样划分两类数据的信任边界?

    服务端 JSON 只能作为文本或经过校验的业务数据,不能直接充当待渲染的元素对象;动态组件则应在前端通过 JSXReact.createElement 创建。这样生成的元素带有 React 认可的 $$typeof,但组件类型和危险属性仍不能由用户输入任意决定。

  • 如果运行环境支持把 Symbol 转成自定义字符串并写入数据库,攻击者能否仅靠伪造同名的 "$$typeof" 字段绕过检查?

    仅写入同名字符串通常不能等价于 React 内部使用的 Symbol 值,因为标准 JSON 无法表达 Symbol。不过若应用在反序列化后主动把外部标记转换为真实标记,就重新打开了信任通道;因此不要为用户数据补造元素身份。

  • 一次发布后,富文本详情页仍出现脚本注入,而监控显示所有可疑对象都没有通过元素身份校验,你会先排查哪些渲染入口?

    应先搜索 dangerouslySetInnerHTML 以及把外部内容交给组件类型或属性的路径,确认注入是否绕开了伪造元素这一入口。$$typeof 只阻止普通 JSON 冒充 React 元素,不能替代 HTML 净化、URL 协议校验或服务端输入治理。

  • 安全评审有人主张删掉 $$typeof,改为递归检查对象是否只有 typepropschildren,这种方案为什么不可靠?

    字段白名单只能判断对象的形状,攻击者仍可构造完全符合形状约束的 JSON,并把危险内容放进允许的属性中。使用 JSON 无法序列化的 Symbol 作为身份标记,能区分前端创建的元素和数据库对象;代价是它只提供来源识别,不负责内容安全。

# 15 Virtual DOM 的工作原理是什么

⚡ 30 秒速记

  • 三步:用 JS 对象描述界面(createElement)→ 状态变化后生成新树并与旧树 diff → 把差异批量 patch 到真实 DOM
  • diff 只做同层比较,靠 key 判断节点身份,复杂度 O(n)
  • 批量提交避免了「改一次 DOM 触发一次重排」的性能陷阱
  • 抽象层带来的额外收益:可以渲染到非浏览器环境(RNSSRCanvas
  • 它不保证比手写 DOM 操作快,保证的是「不会写得太差」

虚拟 DOM 的工作流程可以概括为用对象描述界面、比较前后差异,再把 patch 应用到真实 DOM JSX 经编译并执行后会生成包含节点类型、propschildren 的普通对象,这些对象共同组成树。状态变化时,框架对新旧树执行 diff,只根据差异更新真实页面,从而减少人为操作 DOM 的风险。它还便于服务端渲染和跨平台复用,但会占用额外内存,也不意味着所有高性能场景都更合适。

  • 虚拟 DOM 的工作原理是通过 JS 对象模拟 DOM 的节点。在 Facebook 构建 React 初期时,考虑到要提升代码抽象能力、避免人为的 DOM 操作、降低代码整体风险等因素,所以引入了虚拟 DOM
  • 虚拟 DOM 在实现上通常是 Plain Object,以 React 为例,在 render 函数中写的 JSX 会在 Babel 插件的作用下,编译为 React.createElement 执行 JSX 中的属性参数
  • React.createElement 执行后会返回一个 Plain Object,它会描述自己的 tag 类型、props 属性以及 children 情况等。这些 Plain Object 通过树形结构组成一棵虚拟 DOM 树。当状态发生变更时,将变更前后的虚拟 DOM 树进行差异比较,这个过程称为 diff,生成的结果称为 patch。计算之后,会渲染 Patch 完成对真实 DOM 的操作。
  • 虚拟 DOM 的优点主要有三点:改善大规模DOM操作的性能规避 XSS 风险能以较低的成本实现跨平台开发
  • 虚拟 DOM 的缺点在社区中主要有两点
    • 内存占用较高,因为需要模拟整个网页的真实 DOM
    • 高性能应用场景存在难以优化的情况,类似像 Google Earth 一类的高性能前端应用在技术选型上往往不会选择 React

除了渲染页面,虚拟 DOM 还有哪些应用场景?

这个问题考验面试者的想象力。通常而言,我们只是将虚拟 DOM 与渲染绑定在一起,但实际上虚拟 DOM 的应用更为广阔。比如,只要你记录了真实 DOM 变更,它甚至可以应用于埋点统计与数据记录等。

SSR原理

借助虚拟dom,服务器中没有dom概念的,react巧妙的借助虚拟dom,然后可以在服务器中nodejs可以运行起来react代码。

💬 面试官追问

  • 商品页只更新购物车角标,有人却说 Virtual DOM 会直接把整页真实 DOM 重建一遍;你会怎样纠正这个判断?

    状态变化后会先得到新的虚拟 DOM 树,再与旧树执行 diff,计算出描述差异的 patch,最后把必要变更应用到真实 DOM。虚拟树本身通常是描述标签、props 和子节点的普通 JS 对象,但比较和保存这些对象也有计算与内存成本。

  • 你接手一个用 JSX 写成的活动页,评审要求画出从 render 到浏览器节点更新的链路,关键步骤应该是什么?

    JSX 会经编译转换为创建元素的调用,执行后产生描述节点的普通对象,并按子节点关系组成虚拟 DOM 树。状态更新时生成新树,diff 得出 patch,渲染阶段再据此操作真实 DOM;这条链路把声明式描述与具体 DOM 操作隔开。

  • 同一套资讯卡片既要在浏览器渲染,也计划输出到非 DOM 宿主环境,虚拟 DOM 在这个约束变化下提供了什么能力?

    虚拟 DOM 是与真实 DOM 分离的对象描述,因此上层组件逻辑不必直接依赖浏览器节点,底层可以针对不同平台实现对应渲染过程。跨平台并非自动免费获得,各宿主仍需处理属性、事件和生命周期差异,平台特有能力也需要适配。

  • 一个包含大量节点的地图式页面持续掉帧,Profiler 显示 JS 比较和对象分配都很重,但真实 DOM 更新并不多,你会如何解释并推进排查?

    瓶颈可能位于虚拟树创建和 diff,而不是最终的真实 DOM 写入;虚拟 DOM 不保证所有高性能场景都更快。应缩小参与更新的树和更新频率,并评估局部直接绘制或更专用的渲染技术;类似高性能地图场景可能并不适合由 React 主导全部渲染。

  • 服务端没有浏览器 DOM,却要为文章详情页生成首屏 HTML,虚拟 DOM 为什么仍能参与这条链路?

    组件先生成与浏览器 DOM 无关的虚拟节点描述,因此相关代码可以在 Node.js 环境运行,再由服务端渲染器把描述转换为 HTML。它解决的是渲染描述与宿主解耦,浏览器接管后的事件绑定和状态衔接仍需另一阶段完成。

# 16 React有哪些优化性能的手段

⚡ 30 秒速记

  • 减少重渲染:React.memo(组件)、useMemo(值)、useCallback(函数引用),但别滥用,比较本身也有成本
  • 状态下沉、内容提升(把不变的部分作为 children 传入),从结构上减少影响范围
  • 列表加稳定的 key,别用 index
  • 长列表虚拟滚动、路由级代码分割(lazy + Suspense
  • 并发特性:useTransition 把非紧急更新降优先级、useDeferredValue 延迟大列表更新
  • React 19 的编译器(React Compiler)能自动做记忆化,未来手写 memo 会越来越少

React 性能优化的核心是避免无意义的重渲染,并把暂时用不到的代码延后加载。 类组件可以用 PureComponentshouldComponentUpdate 控制更新,函数组件则常用 React.memo;局部计算和回调再按需要交给 useMemouseCallback。列表更新时要使用稳定且唯一的 key,避免用数组下标造成错误复用。体积较大的页面还可以通过 lazy 配合 Suspense 懒加载,但这些手段应在确有重复渲染或加载成本时使用。

类组件中的优化手段

  • 使用纯组件 PureComponent 作为基类。
  • 使用 shouldComponentUpdate 生命周期函数来自定义渲染逻辑。

方法组件中的优化手段

  • 使用 React.memo 高阶函数包装组件,React.memo 可以实现类似于 shouldComponentUpdate 或者 PureComponent 的效果
  • 使用 useMemo
    • 使用React.useMemo精细化的管控,useMemo 控制的则是是否需要重复执行某一段逻辑,而React.memo 控制是否需要重渲染一个组件
  • 使用 useCallBack

其他方式

  • 在列表需要频繁变动时,使用唯一 id 作为 key,而不是数组下标。
  • 必要时通过改变 CSS 样式隐藏显示组件,而不是通过条件判断显示隐藏组件。
  • 使用 Suspense 和 lazy 进行懒加载,例如:
import React, { lazy, Suspense } from "react";

export default class CallingLazyComponents extends React.Component {
  render() {
    var ComponentToLazyLoad = null;

    if (this.props.name == "Mayank") {
      ComponentToLazyLoad = lazy(() => import("./mayankComponent"));
    } else if (this.props.name == "Anshul") {
      ComponentToLazyLoad = lazy(() => import("./anshulComponent"));
    }

    return (
      <div>
        <h1>This is the Base User: {this.state.name}</h1>
        <Suspense fallback={<div>Loading...</div>}>
          <ComponentToLazyLoad />
        </Suspense>
      </div>
    )
  }
}

💬 面试官追问

  • 订单列表父组件每次输入筛选词都会创建新数组,团队给所有子组件套了 React.memo,但渲染次数没有下降;你会指出哪里判断错了?

    React.memo 控制的是组件在属性可比较为未变化时跳过重渲染,新数组会破坏引用稳定性,使包装失去预期效果。应先确认筛选计算和传入属性是否需要 useMemo,但缓存本身也有维护成本,简单计算或持续变化的依赖未必受益。

  • 后台表格有数千条可编辑记录,某个单元格更新会带动整页刷新;在不改业务功能的前提下,你会怎样落地优化?

    先用 React.memo 或类组件的 PureComponent 隔离无关行,并保证列表使用稳定且唯一的业务 id 作为 key。对确实昂贵的派生计算使用 useMemo,对需要稳定传递的回调用 useCallback;若属性始终变化,这些优化仍会失效。

  • 仪表盘组件切换后必须保留内部输入和滚动位置,产品同时要求隐藏期间减少开销;你会选择条件卸载还是 CSS 隐藏?

    需要保留组件实例和内部状态时,可以通过 CSS 切换显示状态,避免反复卸载与挂载。代价是隐藏组件仍占用 DOM 和内存,也可能继续响应状态更新;若组件长期不用或资源昂贵,条件卸载通常更合适。

  • 一次重构把列表 key 从业务 id 换成数组下标,排序后出现输入框内容跟着错误行移动;你会如何定位并修复?

    排序改变了下标与业务记录的对应关系,React 会按旧 key 复用节点,从而把局部状态保留在错误条目上。恢复稳定且唯一的业务 id,并检查新增、删除和排序链路;只有列表顺序和成员永久不变时,下标风险才相对有限。

  • 首屏包体过大,设置页中的两个低频模块只有用户进入特定分支才需要,你会怎样使用 lazySuspense,又要防什么问题?

    可用 lazy(() => import(...)) 延迟加载分支组件,并在外层用 Suspense 提供加载期间的占位内容。这样把非首屏代码推迟到需要时下载,但首次进入会产生等待;加载边界、失败处理以及组件引用是否有效都需要单独设计。

# 17 Redux实现原理解析

⚡ 30 秒速记

  • 三大原则:单一数据源、state 只读、只能通过纯函数 reducer 修改
  • 数据流单向:dispatch(action)reducer(state, action) 返回新 state → 通知订阅者
  • createStore 内部就是一个闭包:维护 currentState 和监听器数组,暴露 getState/dispatch/subscribe
  • 中间件靠 applyMiddleware 层层包裹 dispatch,形成洋葱模型(compose 组合)
  • reducer 必须是纯函数且返回新对象,否则浅比较发现不了变化,界面不更新
  • 现代推荐 Redux Toolkit:内置 Immer 可以写「看起来可变」的代码,样板代码大幅减少

Redux 本质上用单向数据流和唯一的 store,把状态变化收口到 dispatch(action) 与纯函数 reducer createStore 内部维护 state 和监听器,派发后调用 reducer 生成新状态,再通知订阅者更新视图。异步请求、日志等副作用由中间件拦截 action,通过逐层包装 dispatch 扩展流程。使用时不能直接修改 state,否则状态变化会变得不可预测。

在 Redux 的整个工作过程中,数据流是严格单向的。这一点一定一定要背下来,面试的时候也一定一定要记得说

为什么要用redux

React中,数据在组件中是单向流动的,数据从一个方向父组件流向子组件(通过props),所以,两个非父子组件之间通信就相对麻烦,redux的出现就是为了解决state里面的数据问题

Redux设计理念

Redux是将整个应用状态存储到一个地方上称为store,里面保存着一个状态树store tree,组件可以派发(dispatch)行为(action)给store,而不是直接通知其他组件,组件内部通过订阅store中的状态state来刷新自己的视图

如果你想对数据进行修改,只有一种途径:派发 action。action 会被 reducer 读取,进而根据 action 内容的不同对数据进行修改、生成新的 state(状态),这个新的 state 会更新到 store 对象里,进而驱动视图层面做出对应的改变。

Redux三大原则

  • 唯一数据源

整个应用的state都被存储到一个状态树里面,并且这个状态树,只存在于唯一的store中

  • 保持只读状态

state是只读的,唯一改变state的方法就是触发actionaction是一个用于描述以发生时间的普通对象

  • 数据改变只能通过纯函数来执行

使用纯函数来执行修改,为了描述action如何改变state的,你需要编写reducers

从编码的角度理解 Redux 工作流

  1. 使用 createStore 来完成 store 对象的创建
// 引入 redux
import { createStore } from 'redux'
// 创建 store
const store = createStore(
    reducer,
    initial_state,
    applyMiddleware(middleware1, middleware2, ...)
);

createStore 方法是一切的开始,它接收三个入参:

  • reducer;
  • 初始状态内容;
  • 指定中间件
  1. reducer 的作用是将新的 state 返回给 store

一个 reducer 一定是一个纯函数,它可以有各种各样的内在逻辑,但它最终一定要返回一个 state:

const reducer = (state, action) => {
    // 此处是各种样的 state处理逻辑
    return new_state
}

当我们基于某个 reducer 去创建 store 的时候,其实就是给这个 store 指定了一套更新规则:

// 更新规则全都写在 reducer 里
const store = createStore(reducer)
  1. action 的作用是通知 reducer “让改变发生”

要想让 state 发生改变,就必须用正确的 action 来驱动这个改变。

const action = {
  type: "ADD_ITEM",
  payload: '<li>text</li>'
}

action 对象中允许传入的属性有多个,但只有 type 是必传的。type 是 action 的唯一标识,reducer 正是通过不同的 type 来识别出需要更新的不同的 state,由此才能够实现精准的“定向更新”。

  1. 派发 action,靠的是 dispatch

action 本身只是一个对象,要想让 reducer 感知到 action,还需要“派发 action”这个动作,这个动作是由 store.dispatch 完成的。这里我简单地示范一下:

import { createStore } from 'redux'
// 创建 reducer
const reducer = (state, action) => {
    // 此处是各种样的 state处理逻辑
    return new_state
}
// 基于 reducer 创建 state
const store = createStore(reducer)
// 创建一个 action,这个 action 用 “ADD_ITEM” 来标识
const action = {
  type: "ADD_ITEM",
  payload: '<li>text</li>'
}
// 使用 dispatch 派发 action,action 会进入到 reducer 里触发对应的更新
store.dispatch(action)

以上这段代码,是从编码角度对 Redux 主要工作流的概括,这里我同样为你总结了一张对应的流程图:

Redux源码

let createStore = (reducer) => {
    let state;
    //获取状态对象
    //存放所有的监听函数
    let listeners = [];
    let getState = () => state;
    //提供一个方法供外部调用派发action
    let dispath = (action) => {
        //调用管理员reducer得到新的state
        state = reducer(state, action);
        //执行所有的监听函数
        listeners.forEach((l) => l())
    }
    //订阅状态变化事件,当状态改变发生之后执行监听函数
    let subscribe = (listener) => {
        listeners.push(listener);
    }
    dispath();
    return {
        getState,
        dispath,
        subscribe
    }
}
let combineReducers=(renducers)=>{
    //传入一个renducers管理组,返回的是一个renducer
    return function(state={},action={}){
        let newState={};
        for(var attr in renducers){
            newState[attr]=renducers[attr](state[attr],action)

        }
        return newState;
    }
}
export {createStore,combineReducers};

聊聊 Redux 和 Vuex 的设计思想

  • 共同点

首先两者都是处理全局状态的工具库,大致实现思想都是:全局state保存状态---->dispatch(action)------>reducer(vuex里的mutation)----> 生成newState; 整个状态为同步操作;

  • 区别

最大的区别在于处理异步的不同,vuex里面多了一步commit操作,在action之后commit(mutation)之前处理异步,而redux里面则是通过中间件处理

redux 中间件

中间件提供第三方插件的模式,自定义拦截 action -> reducer 的过程。变为 action -> middlewares -> reducer 。这种机制可以让我们改变数据流,实现如异步 action ,action 过 滤,日志输出,异常报告等功能

常见的中间件:

  • redux-logger:提供日志输出;
  • redux-thunk:处理异步操作;
  • redux-promise: 处理异步操作;
  • actionCreator 的返回值是 promise

redux中间件的原理是什么

applyMiddleware

为什么会出现中间件?

  • 它只是一个用来加工dispatch的工厂,而要加工什么样的dispatch出来,则需要我们传入对应的中间件函数
  • 让每一个中间件函数,接收一个dispatch,然后返回一个改造后的dispatch,来作为下一个中间件函数的next,以此类推。
function applyMiddleware(middlewares) {
  middlewares = middlewares.slice()
  middlewares.reverse()

  let dispatch = store.dispatch
  middlewares.forEach(middleware =>
    dispatch = middleware(store)(dispatch)
  )
  return Object.assign({}, store, { dispatch })
}

上面的middleware(store)(dispatch) 就相当于是 const logger = store => next => {},这就是构造后的dispatch,继续向下传递。这里middlewares.reverse(),进行数组反转的原因,是最后构造的dispatch,实际上是最先执行的。因为在applyMiddleware串联的时候,每个中间件只是返回一个新的dispatch函数给下一个中间件,实际上这个dispatch并不会执行。只有当我们在程序中通过store.dispatch(action),真正派发的时候,才会执行。而此时的dispatch是最后一个中间件返回的包装函数。然后依次向前递推执行。

浅析中间件 (opens new window)

action、store、reducer分析

redux的核心概念就是store、action、reducer,从调用关系来看如下所示

store.dispatch(action) --> reducer(state, action) --> final state
// reducer方法, 传入的参数有两个
// state: 当前的state
// action: 当前触发的行为, {type: 'xx'}
// 返回值: 新的state
var reducer = function(state, action){
    switch (action.type) {
        case 'add_todo':
            return state.concat(action.text);
        default:
            return state;
    }
};

// 创建store, 传入两个参数
// 参数1: reducer 用来修改state
// 参数2(可选): [], 默认的state值,如果不传, 则为undefined
var store = redux.createStore(reducer, []);

// 通过 store.getState() 可以获取当前store的状态(state)
// 默认的值是 createStore 传入的第二个参数
console.log('state is: ' + store.getState());  // state is:

// 通过 store.dispatch(action) 来达到修改 state 的目的
// 注意: 在redux里,唯一能够修改state的方法,就是通过 store.dispatch(action)
store.dispatch({type: 'add_todo', text: '读书'});
// 打印出修改后的state
console.log('state is: ' + store.getState());  // state is: 读书

store.dispatch({type: 'add_todo', text: '写作'});
console.log('state is: ' + store.getState());  // state is: 读书,写作
  1. store、reducer、action关联

store

  • store在这里代表的是数据模型,内部维护了一个state变量
  • store有两个核心方法,分别是getStatedispatch。前者用来获取store的状态(state),后者用来修改store的状态
// 创建store, 传入两个参数
// 参数1: reducer 用来修改state
// 参数2(可选): [], 默认的state值,如果不传, 则为undefined
var store = redux.createStore(reducer, []);

// 通过 store.getState() 可以获取当前store的状态(state)
// 默认的值是 createStore 传入的第二个参数
console.log('state is: ' + store.getState());  // state is:

// 通过 store.dispatch(action) 来达到修改 state 的目的
// 注意: 在redux里,唯一能够修改state的方法,就是通过 store.dispatch(action)
store.dispatch({type: 'add_todo', text: '读书'});

action

  • 对行为(如用户行为)的抽象,在redux里是一个普通的js对象
  • action必须有一个type字段来标识这个行为的类型
{type:'add_todo', text:'读书'}
{type:'add_todo', text:'写作'}
{type:'add_todo', text:'睡觉', time:'晚上'}

reducer

  • 一个普通的函数,用来修改store的状态。传入两个参数 stateaction
  • 其中,state为当前的状态(可通过store.getState()获得),而action为当前触发的行为(通过store.dispatch(action)调用触发)
  • reducer(state, action) 返回的值,就是store最新的state
// reducer方法, 传入的参数有两个
// state: 当前的state
// action: 当前触发的行为, {type: 'xx'}
// 返回值: 新的state
var reducer = function(state, action){
    switch (action.type) {
        case 'add_todo':
            return state.concat(action.text);
        default:
            return state;
    }
};
  1. 关于actionCreator
actionCreator(args) => action
var addTodo = function(text){
    return {
        type: 'add_todo',
        text: text
    };
};

addTodo('睡觉');  // 返回:{type: 'add_todo', text: '睡觉'}

异步Action及操作

  1. 创建同步Action

Action是数据从应用传递到 store/state 的载体,也是开启一次完成数据流的开始

普通的action对象

const action = {
	type:'ADD_TODO',
	name:'poetries'
}

dispatch(action)

封装action creator

function actionCreator(data){
    return {
    	type:'ADD_TODO',
    	data:data
    }
}

dispatch(actionCreator('poetries'))

bindActionCreators合并

function a(name,id){
	reurn {
		type:'a',
		name,
		id
	}
}
function b(name,id){
	reurn {
		type:'b',
		name,
		id
	}
}

let actions = Redux.bindActionCreators({a,b},store.dispatch)

//调用
actions.a('poetries','id001')
actions.b('jing','id002')

action创建的标准

在Flux的架构中,一个Action要符合 FSA(Flux Standard Action) 规范,需要满足如下条件

  • 是一个纯文本对象
  • 只具备 typepayloaderrormeta中的一个或者多个属性。type 字段不可缺省,其它字段可缺省
  • Action 报错,error 字段不可缺省,切必须为 true

payload 是一个对象,用作Action携带数据的载体

标准action示例

  • A basic Flux Standard Action:
{
  type: 'ADD_TODO',
  payload: {
    text: 'Do something.'
  }
}
  • An FSA that represents an error, analogous to a rejected Promise
{
  type: 'ADD_TODO',
  payload: new Error(),
  error: true
}

https://github.com/acdlite/flux-standard-action

  • 可以采用如下一个简单的方式检验一个Action是否符合FSA标准
// every有一个匹配不到返回false
let isFSA = Object.keys(action).every((item)=>{
   return  ['payload','type','error','meta'].indexOf(item) >  -1
})
  1. 创建异步action的多种方式

最简单的方式就是使用同步的方式来异步,将原来同步时一个action拆分成多个异步的action的,在异步开始前、异步请求中、异步正常返回(异常)操作分别使用同步的操作,从而模拟出一个异步操作了。这样的方式是比较麻烦的,现在已经有redux-saga等插件来解决这些问题了

异步action的实现方式一:setTimeout

redux-thunk中间处理解析

function thunkAction(data) {
    reutrn (dispatch)=>{
        setTimeout(function(){
            dispatch({
                type:'ADD_TODO',
                data
            })
        },3000)
    }
}

异步action的实现方式二:promise实现异步action

redux-promise中间处理这种action

function promiseAction(name){
    return new Promise((resolve,reject) => {
        setTimeout((param)=>{
            resolve({
                type:'ADD_TODO',
                name
            })
        },3000)
    }).then((param)=>{
        dispatch(action("action2"))
        return;
    }).then((param)=>{
        dispatch(action("action3"))
    })
}
  1. redux异步流程

  • 首先发起一个action,然后通过中间件,这里为什么要用中间件呢,因为这样dispatch的返回值才能是一个函数。
  • 通过store.dispatch,将状态的的改变传给store的小弟reducerreducer根据action的改变,传递新的状态state
  • 最后将所有的改变告诉给它的大哥,storestore保存着所有的数据,并将数据注入到组件的顶部,这样组件就可以获得它需要的数据了
  1. Redux异步方案选型

redux-thunk

Redux本身只能处理同步的Action,但可以通过中间件来拦截处理其它类型的action,比如函数(Thunk),再用回调触发普通Action,从而实现异步处理

  • 发送异步的action其实是被中间件捕获的,函数类型的action就被middleware捕获。至于怎么定义异步的action要看你用哪个中间件,根据他们的实例来定义,这样才会正确解析action

Redux 本身不处理异步行为,需要依赖中间件。结合 redux-actions 使用,Redux 有两个推荐的异步中间件

  • redux-thunk
  • redux-promise

redux-thunk 的源码如下

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

源码可知,action creator 需要返回一个函数给 redux-thunk 进行调用,示例如下

export let addTodoWithThunk = (val) => async (dispatch, getState)=>{
    //请求之前的一些处理

    let value = await Promise.resolve(val + ' thunk');
    dispatch({
        type:CONSTANT.ADD_TO_DO_THUNK,
        payload:{
            value
        }
    });
};
  • 而它使用起来最大的问题,就是重复的模板代码太多
//action types
const GET_DATA = 'GET_DATA',
    GET_DATA_SUCCESS = 'GET_DATA_SUCCESS',
    GET_DATA_FAILED = 'GET_DATA_FAILED';

//action creator
const getDataAction = (id) => (dispatch, getState) => {
        dispatch({
            type: GET_DATA,
            payload: id
        })
        api.getData(id) //注:本文所有示例的api.getData都返回promise对象
            .then(response => {
                dispatch({
                    type: GET_DATA_SUCCESS,
                    payload: response
                })
            })
            .catch(error => {
                dispatch({
                    type: GET_DATA_FAILED,
                    payload: error
                })
            })
    }
}

//reducer
const reducer = (oldState, action) => {
    switch(action.type) {
    case GET_DATA :
        return oldState;
    case GET_DATA_SUCCESS :
        return successState;
    case GET_DATA_FAILED :
        return errorState;
    }
}

这已经是最简单的场景了,请注意:我们甚至还没写一行业务逻辑,如果每个异步处理都像这样,重复且无意义的工作会变成明显的阻碍

  • 另一方面,像GET_DATA_SUCCESSGET_DATA_FAILED这样的字符串声明也非常无趣且易错 上例中,GET_DATA这个action并不是多数场景需要的

redux-promise

由于redux-thunk写起来实在是太麻烦了,社区当然会有其它轮子出现。redux-promise则是其中比较知名的

  • 它自定义了一个middleware,当检测到有actionpayload属性是Promise对象时,就会
    • resolve,触发一个此action的拷贝,但payloadpromisevalue,并设status属性为"success"
    • reject,触发一个此action的拷贝,但payloadpromisereason,并设status属性为"error"
//action types
const GET_DATA = 'GET_DATA';

//action creator
const getData = function(id) {
    return {
        type: GET_DATA,
        payload: api.getData(id) //payload为promise对象
    }
}

//reducer
function reducer(oldState, action) {
    switch(action.type) {
        case GET_DATA:
            if (action.status === 'success') {
                return successState
            } else {
                   return errorState
            }
        }
}

redux-promise为了精简而做出的妥协非常明显:无法处理乐观更新

场景解析之:乐观更新

多数异步场景都是悲观更新的,即等到请求成功才渲染数据。而与之相对的乐观更新,则是不等待请求成功,在发送请求的同时立即渲染数据

  • 由于乐观更新发生在用户操作时,要处理它,意味着必须有action表示用户的初始动作
  • 在上面redux-thunk的例子中,我们看到了GET_DATA, GET_DATA_SUCCESSGET_DATA_FAILED三个action,分别表示初始动作、异步成功和异步失败,其中第一个action使得redux-thunk具备乐观更新的能力
  • 而在redux-promise中,最初触发的action被中间件拦截然后过滤掉了。原因很简单,redux认可的action对象是 plain JavaScript objects,即简单对象,而在redux-promise中,初始actionpayload是个Promise

redux-promise-middleware

redux-promise-middleware相比redux-promise,采取了更为温和和渐进式的思路,保留了和redux-thunk类似的三个action

//action types
const GET_DATA = 'GET_DATA',
    GET_DATA_PENDING = 'GET_DATA_PENDING',
    GET_DATA_FULFILLED = 'GET_DATA_FULFILLED',
    GET_DATA_REJECTED = 'GET_DATA_REJECTED';

//action creator
const getData = function(id) {
    return {
        type: GET_DATA,
        payload: {
            promise: api.getData(id),
            data: id
        }
    }
}

//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case GET_DATA_PENDING :
        return oldState; // 可通过action.payload.data获取id
    case GET_DATA_FULFILLED :
        return successState;
    case GET_DATA_REJECTED :
        return errorState;
    }
}
  1. redux异步操作代码演示
  • 根据官网的async例子分析 https://github.com/lewis617/react-redux-tutorial/tree/master/redux-examples/async

action/index.js

import fetch from 'isomorphic-fetch'
export const RECEIVE_POSTS = 'RECEIVE_POSTS'

//获取新闻成功的action
function receivePosts(reddit, json) {
  return {
    type: RECEIVE_POSTS,
    reddit: reddit,
    posts: json.data.children.map(child =>child.data)
  }
}

function fetchPosts(subreddit) {

  return function (dispatch) {

    return fetch(`http://www.subreddit.com/r/${subreddit}.json`)
      .then(response => response.json())
      .then(json =>
        dispatch(receivePosts(subreddit, json))
      )
  }
}

//如果需要则开始获取文章
export function fetchPostsIfNeeded(subreddit) {

  return (dispatch, getState) => {

      return dispatch(fetchPosts(subreddit))

    }
}

fetchPostsIfNeeded这里就是一个中间件。redux-thunk会拦截fetchPostsIfNeeded这个action,会先发起数据请求,如果成功,就将数据传给action从而到达reducer那里

reducers/index.js

import { combineReducers } from 'redux'
import {
  RECEIVE_POSTS
} from '../actions'


function posts(state = {
  items: []
}, action) {
  switch (action.type) {

    case RECEIVE_POSTS:
      // Object.assign是ES6的一个语法。合并对象,将对象合并为一个,前后相同的话,后者覆盖强者。详情可以看这里
      //  https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
      return Object.assign({}, state, {
        items: action.posts //数据都存在了这里
      })
    default:
      return state
  }
}


// 将所有的reducer结合为一个,传给store
const rootReducer = combineReducers({
  postsByReddit
})

export default rootReducer

这个跟正常的reducer差不多。判断action的类型,从而根据action的不同类型,返回不同的数据。这里将数据存储在了items这里。这里的reducer只有一个。最后结合成rootReducer,传给store

store/configureStore.js

import { createStore, applyMiddleware } from 'redux'
import thunkMiddleware from 'redux-thunk'
import createLogger from 'redux-logger'
import rootReducer from '../reducers'

const createStoreWithMiddleware = applyMiddleware(
  thunkMiddleware,
  createLogger()
)(createStore)

export default function configureStore(initialState) {
  const store = createStoreWithMiddleware(rootReducer, initialState)

  if (module.hot) {
    // Enable Webpack hot module replacement for reducers
    module.hot.accept('../reducers', () => {
      const nextRootReducer = require('../reducers')
      store.replaceReducer(nextRootReducer)
    })
  }

  return store
}
  • 我们是如何在 dispatch 机制中引入 Redux Thunk middleware 的呢? 我们使用了applyMiddleware()
  • 通过使用指定的 middlewareaction creator 除了返回 action 对象外还可以返回函数
  • 这时,这个 action creator 就成为了 thunk

界面上的调用:在containers/App.js

//初始化渲染后触发
  componentDidMount() {
    const { dispatch} = this.props
    // 这里可以传两个值,一个是 reactjs 一个是 frontend
    dispatch(fetchPostsIfNeeded('frontend'))
  }

改变状态的时候也是需要通过dispatch来传递的

  • 数据的获取是通过provider,将store里面的数据注入给组件。让顶级组件提供给他们的子孙组件调用。代码如下:
import 'babel-core/polyfill'
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import App from './containers/App'
import configureStore from './store/configureStore'
const store = configureStore()
render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

这样就完成了redux的异步操作。其实最主要的区别还是action里面还有中间件的调用,其他的地方基本跟同步的redux差不多的。搞懂了中间件,就基本搞懂了redux的异步操作

💬 面试官追问

  • 购物车页面有人绕过 dispatch,直接修改从 store.getState() 取得的数组,视图偶尔更新、调试记录却没有对应事件;为什么这违背了 Redux 的工作流?

    Redux 规定状态只读,变更必须由普通 actionstore.dispatch 送入 reducer,再生成新的 state。直接修改既绕开了订阅通知和行为记录,也破坏了可预测性;即使页面偶然显示正确,时间回溯和问题定位也会失真。

  • 请你在代码评审中补全一个最小 createStore:它要支持读取状态、派发行为和订阅更新,dispatch 内部的执行顺序应是什么?

    闭包保存 state 和监听函数集合,getState 返回当前状态,subscribe 登记监听者。dispatch(action) 先执行 reducer(state, action) 得到新状态,再依次通知监听者;初始化时还需触发一次 reducer,使默认状态得以建立。

  • 支付流程要求 dispatch 接受函数来发起异步请求,但现有 reducer 只能消费带 type 的普通对象;你会把扩展点放在哪里?

    应通过中间件包装 dispatch,让函数类型的 action 先被类似 redux-thunk 的逻辑拦截,并在异步完成后派发普通 actionreducer 继续保持纯函数,只负责根据当前状态和 action 生成新状态;请求等副作用进入 reducer 会破坏结果可复现性。

  • 线上接入两个日志与异步中间件后,执行顺序和配置顺序相反,排查 applyMiddleware 时你会关注哪段函数组合?

    中间件本质是加工 dispatch 的工厂,形态可表示为 store => next => action,每层把包装后的函数交给下一层。组合时常通过反向构造形成嵌套,最终得到的最外层会最先执行;若某层未调用 next(action),后续中间件和 reducer 都会被截断。

  • 大型表单拆成多个领域 reducer 后,团队打算让每个 reducer 都返回完整状态树;combineReducers 应怎样避免这种耦合?

    combineReducers 可遍历 reducer 映射,把各自对应的 state[attr] 和同一个 action 传入,再将返回值组装成新的总状态。每个 reducer 只管理自己的状态切片,边界更清晰;切片命名或状态结构调整会影响选择器和持久化数据,需要同步迁移。

  • 同事认为 action 自己会修改状态,因此在 actionCreator 里直接操作 store;你会怎样区分 actionreducerstore 的职责?

    action 只是描述已发生行为的普通对象,至少需要用于识别行为的 typeactionCreator 只是生成该对象。store.dispatch 负责把它交给 reducer,后者计算新状态并由 store 保存、通知订阅者;把修改逻辑藏进 actionCreator 会破坏单向数据流。

# 18 谈谈你对状态管理的理解

⚡ 30 秒速记

  • 先分层:服务端状态(接口数据,有缓存和同步问题)和客户端状态UI 状态、表单、主题)
  • 服务端状态用 React Query/SWR 这类数据请求库,它们自带缓存、去重、重试、失效重取
  • 客户端状态按范围选:组件内 useState、跨几层 Context、全局用 Zustand/Jotai/Redux
  • 别滥用全局状态:只有跨组件共享生命周期长的才值得提升
  • Context 的坑:值变化会让所有消费者重渲染,大型应用要拆分 Context 或用专门的状态库

状态管理的核心,是用明确的数据流统一保存和修改跨组件状态,让变化可预测、可追踪。 Flux 通过 ActionDispatcherStore 到视图的单向流动,解决了数据流向不清的问题;Redux 则进一步用单一数据源、只读状态和纯函数 reducer 约束更新。异步请求等副作用通常交给中间件处理。若更看重少量样板代码和自动响应更新,也可以选择 Mobx

  • 首先介绍 Flux,Flux 是一种使用单向数据流的形式来组合 React 组件的应用架构。
  • Flux 包含了 4 个部分,分别是 DispatcherStoreViewActionStore 存储了视图层所有的数据,当 Store 变化后会引起 View 层的更新。如果在视图层触发一个 Action,就会使当前的页面数据值发生变化。Action 会被 Dispatcher 进行统一的收发处理,传递给 Store 层,Store 层已经注册过相关 Action 的处理逻辑,处理对应的内部状态变化后,触发 View 层更新。
  • Flux 的优点是单向数据流,解决了 MVC 中数据流向不清的问题,使开发者可以快速了解应用行为。从项目结构上简化了视图层设计,明确了分工,数据与业务逻辑也统一存放管理,使在大型架构的项目中更容易管理、维护代码。
  • 其次是 Redux,Redux 本身是一个 JavaScript 状态容器,提供可预测化状态的管理。社区通常认为 Redux 是 Flux 的一个简化设计版本,它提供的状态管理,简化了一些高级特性的实现成本,比如撤销、重做、实时编辑、时间旅行、服务端同构等。
  • Redux 的核心设计包含了三大原则:单一数据源、纯函数 Reducer、State 是只读的
  • Redux 中整个数据流的方案与 Flux 大同小异
  • Redux 中的另一大核心点是处理“副作用”,AJAX 请求等异步工作,或不是纯函数产生的第三方的交互都被认为是 “副作用”。这就造成在纯函数设计的 Redux 中,处理副作用变成了一件至关重要的事情。社区通常有两种解决方案:
    • 第一类是在 Dispatch 的时候会有一个 middleware 中间件层,拦截分发的 Action 并添加额外的复杂行为,还可以添加副作用。第一类方案的流行框架有 Redux-thunk、Redux-Promise、Redux-Observable、Redux-Saga 等。
    • 第二类是允许 Reducer 层中直接处理副作用,采取该方案的有 React LoopReact Loop 在实现中采用了 Elm 中分形的思想,使代码具备更强的组合能力。
    • 除此以外,社区还提供了更为工程化的方案,比如 rematch 或 dva,提供了更详细的模块架构能力,提供了拓展插件以支持更多功能。
  • Redux 的优点很多:
    • 结果可预测;
    • 代码结构严格易维护;
    • 模块分离清晰且小函数结构容易编写单元测试;
    • Action 触发的方式,可以在调试器中使用时间回溯,定位问题更简单快捷;
    • 单一数据源使服务端同构变得更为容易;社区方案多,生态也更为繁荣。
  • 最后是 Mobx,Mobx 通过监听数据的属性变化,可以直接在数据上更改触发UI 的渲染。在使用上更接近 Vue,比起 Flux 与 Redux 的手动挡的体验,更像开自动挡的汽车。Mobx 的响应式实现原理与 Vue 相同,以 Mobx 5 为分界点,5 以前采用 Object.defineProperty 的方案,5 及以后使用 Proxy 的方案。它的优点是样板代码少、简单粗暴、用户学习快、响应式自动更新数据让开发者的心智负担更低。
  • Mobx 在开发项目时简单快速,但应用 Mobx 的场景 ,其实完全可以用 Vue 取代。如果纯用 Vue,体积还会更小巧

💬 面试官追问

  • 一个小型资料页只有父子组件传值,架构师却要求所有输入都先经过 Dispatcher 和全局 Store;你会如何评价这种选型?

    Flux 的价值在于用 ActionDispatcherStoreView 建立清晰的单向数据流,尤其适合状态关系复杂的应用。简单父子传值强行全局化会增加结构和行为跳转成本;是否引入应由共享范围、变更复杂度和维护需求决定。

  • 协作编辑页面需要统一管理文档、选区和工具栏状态,并希望每次变化都能定位到触发行为;你会怎样组织 Flux 数据流?

    视图触发 Action,由 Dispatcher 统一分发给已注册处理逻辑的 StoreStore 更新内部状态后通知 View 刷新。这样数据沿单一方向流动,行为入口容易追踪;若把所有局部交互都塞入同一 Store,模块边界仍可能变得模糊。

  • 产品新增撤销、重做和服务端同构要求,现有响应式状态允许任意位置直接赋值;为什么团队可能转向 Redux

    Redux 以单一数据源、只读状态和纯函数 Reducer 约束变化,使同一组 action 更容易重放,因此更适合撤销、重做、时间回溯和同构等需求。代价是更新路径更严格,副作用不能随意混入 reducer,还需额外的中间件或工程封装。

  • 线上偶发状态无法复现,日志中同一个 action 重放后得到不同结果,而 reducer 内部读取了时间并发起 AJAX;你会怎样修正?

    应把时间读取和 AJAX 等副作用移出纯函数 reducer,通过 dispatch 前的中间件处理,再把确定结果封装成普通 actionReducer 只依据传入的 stateaction 计算新状态;否则时间旅行、单元测试和故障重放都会失去可信度。

  • 团队在 ReduxMobx 之间争论:大型权限后台强调变更审计,原型团队更看重少样板和直接赋值,你会怎样取舍?

    强调可预测、严格结构和行为追踪时,Reduxaction 与纯 reducer 更契合,也便于测试和时间回溯。追求快速开发和自动响应更新时,Mobx 的属性监听与直接修改更轻量;代价是隐式依赖增多后,变化来源通常不如显式 action 清晰。

  • 维护一个旧项目时发现 Mobx 的响应式层依赖属性拦截,新模块却使用 Proxy;这与它和 Vue 的相邻原理有什么关系?

    MobxVue 的响应式思路相近,都是监听数据属性变化后驱动 UI 更新;资料所述的分界是 Mobx 5 以前使用 Object.defineProperty,之后使用 Proxy。两种机制的能力边界不同,混合维护时应以项目实际版本和构建环境为准,不能只按写法推断。

# 19 connect组件原理分析

⚡ 30 秒速记

  • connect(mapStateToProps, mapDispatchToProps)(Component) 是一个高阶组件
  • 内部通过 Context 拿到 store,订阅它的变化
  • store 变化时执行 mapStateToProps 得到新 props,与上次做浅比较,不同才触发重渲染
  • 所以 mapStateToProps 里不能每次返回新对象/新数组,否则浅比较永远不等,白白重渲染
  • 现代写法用 useSelector/useDispatch 两个 Hook 替代 connect,逻辑一样但更直观

connect 是连接 React 组件与 Redux store 的高阶函数,负责订阅状态并把数据和派发能力映射成组件的 props Provider 先通过 context 向下传递 storeconnect 再用 mapStateToPropsmapDispatchToProps 计算组件真正需要的内容。状态或组件自身 props 变化时,它会重新计算并判断是否需要渲染。组件卸载时还要取消订阅,避免遗留监听。

1. connect用法

作用:连接React组件与 Redux store

connect([mapStateToProps], [mapDispatchToProps], [mergeProps],[options])
// 这个函数允许我们将 store 中的数据作为 props 绑定到组件上
const mapStateToProps = (state) => {
  return {
    count: state.count
  }
}
  • 这个函数的第一个参数就是 Reduxstore,我们从中摘取了 count 属性。你不必将 state 中的数据原封不动地传入组件,可以根据 state 中的数据,动态地输出组件需要的(最小)属性
  • 函数的第二个参数 ownProps,是组件自己的 props

state 变化,或者 ownProps 变化的时候,mapStateToProps 都会被调用,计算出一个新的 stateProps,(在与 ownProps merge 后)更新给组件

mapDispatchToProps(dispatch, ownProps): dispatchProps

connect 的第二个参数是 mapDispatchToProps,它的功能是,将 action 作为 props绑定到组件上,也会成为 MyComp 的 `props

2. 原理解析

首先connect之所以会成功,是因为Provider组件

  • 在原应用组件上包裹一层,使原来整个应用成为Provider的子组件
  • 接收Reduxstore作为props,通过context对象传递给子孙组件上的connect

connect做了些什么

它真正连接 ReduxReact,它包在我们的容器组件的外一层,它接收上面 Provider提供的 store 里面的 statedispatch,传给一个构造函数,返回一个对象,以属性形式传给我们的容器组件

3. 源码

connect是一个高阶函数,首先传入mapStateToPropsmapDispatchToProps,然后返回一个生产Component的函数(wrapWithConnect),然后再将真正的Component作为参数传入wrapWithConnect,这样就生产出一个经过包裹的Connect组件,该组件具有如下特点

  • 通过props.store获取祖先Componentstore props包括statePropsdispatchPropsparentProps,合并在一起得到nextState,作为props传给真正的Component
  • componentDidMount时,添加事件this.store.subscribe(this.handleChange),实现页面交互
  • shouldComponentUpdate时判断是否有避免进行渲染,提升页面性能,并得到nextState
  • componentWillUnmount时移除注册的事件this.handleChange
// 主要逻辑

export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
  return function wrapWithConnect(WrappedComponent) {
    class Connect extends Component {
      constructor(props, context) {
        // 从祖先Component处获得store
        this.store = props.store || context.store
        this.stateProps = computeStateProps(this.store, props)
        this.dispatchProps = computeDispatchProps(this.store, props)
        this.state = { storeState: null }
        // 对stateProps、dispatchProps、parentProps进行合并
        this.updateState()
      }
      shouldComponentUpdate(nextProps, nextState) {
        // 进行判断,当数据发生改变时,Component重新渲染
        if (propsChanged || mapStateProducedChange || dispatchPropsChanged) {
          this.updateState(nextProps)
            return true
          }
        }
        componentDidMount() {
          // 改变Component的state
          this.store.subscribe(() = {
            this.setState({
              storeState: this.store.getState()
            })
          })
        }
        render() {
          // 生成包裹组件Connect
          return (
            <WrappedComponent {...this.nextState} />
          )
        }
      }
      Connect.contextTypes = {
        store: storeShape
      }
      return Connect;
    }
}

💬 面试官追问

  • 商品列表页把 store 直接作为一个 data 属性传给展示组件,团队说这样比写 mapStateToProps 省事;当无关的登录状态变化时,列表也收到新属性,这种接法的问题在哪?

    connect 的职责不是原样转发整个 state,而是通过 mapStateToProps 摘取组件真正需要的最小数据。传递范围过大会扩大组件与全局状态的耦合,也让无关状态变化更容易进入更新链路;代价是后续拆分、测试和定位渲染来源都会更困难。

  • 订单容器既要读取 state.orders,又要根据父组件传入的 userId 过滤数据,还要暴露 reload 操作;你会怎样组织 connect 的几个参数?

    mapStateToProps(state, ownProps) 中结合 userId 计算订单属性,在 mapDispatchToProps(dispatch, ownProps) 中封装需要派发的操作。最后由默认或自定义 mergeProps 合并 statePropsdispatchProps 与父级属性;合并规则越复杂,越要防止同名属性被意外覆盖。

  • 同一个详情组件要挂在两个不同的 Provider 下,父组件还可能通过 props.store 显式传入另一个 store;此时包装层应该从哪里取状态,更新订阅又跟着谁?

    按给出的实现,包装层优先读取 props.store,否则才从 context 获取祖先 Provider 提供的 store。状态读取、dispatchsubscribe 必须绑定同一个实例,否则页面可能展示一个仓库的数据却监听另一个仓库;显式覆盖因此需要非常谨慎。

  • 线上出现“Redux DevTools 里状态已经变化,但被 connect 包装的库存组件没有刷新”,你会沿着包装组件的哪条链路排查?

    先确认 Provider 传入的 store 是否正确,再检查挂载时是否执行了 store.subscribe,订阅回调有没有通过 setState 触发包装层更新。随后核对 mapStateToProps 是否产出变化,以及 shouldComponentUpdate 是否错误拦截;卸载过早或订阅错误实例也会造成同样现象。

  • 团队想把所有 mapStateToPropsmapDispatchToProps 和父级属性都手工塞进一个对象再传下去,认为这样更可控;什么时候值得写 mergeProps,什么时候应保持默认合并?

    只有属性需要重命名、组合或根据父级参数形成特定接口时,才值得提供 mergeProps。普通场景保留默认合并更容易追踪数据来源,也减少每次更新时的额外计算;自定义合并若持续产生不稳定结果,还会削弱包装层避免无效渲染的能力。

  • 类组件页面不能直接读取 context 中的 Redux 数据,架构师建议每个组件都手写订阅;connect 相比这种方案补齐了哪些生命周期责任?

    connect 把取 store、计算状态与派发属性、订阅变化、判断更新和向真实组件传参集中在高阶组件中。组件挂载时注册订阅,卸载时还应移除订阅,避免遗留监听;手写方案若漏掉清理或更新判断,很容易产生泄漏或冗余渲染。

# 20 React Hooks

⚡ 30 秒速记

  • 解决的问题:类组件里逻辑被生命周期拆散、复用只能靠 HOC/render props(嵌套地狱)
  • 两条铁律:只在顶层调用只在函数组件或自定义 Hook 里调用 —— 因为 Hook调用顺序匹配状态(内部是链表)
  • 常用:useState/useEffect/useMemo/useCallback/useRef/useContext/useReducer
  • useEffect 的依赖数组是最大的坑:漏依赖会拿到旧闭包,缺清理函数会泄漏
  • useLayoutEffect 在浏览器绘制同步执行(用于读布局避免闪烁),useEffect 在绘制后异步执行

React Hooks 让函数组件能够使用状态和副作用,并把相关逻辑抽成可复用的自定义 Hook 它解决了类组件中生命周期拆散业务逻辑、this 使用复杂,以及 HOCrender props 容易造成嵌套的问题。由于内部按调用顺序保存 Hook,只能在函数组件或自定义 Hook 顶层调用,不能放进条件、循环或嵌套函数。普通副作用优先用 useEffect,需要同步读取或调整 DOM、避免闪烁时再用 useLayoutEffect

  • 代码逻辑聚合,逻辑复用
  • HOC嵌套地狱
  • 代替class

React 中通常使用 类定义 或者 函数定义 创建组件:

在类定义中,我们可以使用到许多 React 特性,例如 state、 各种组件生命周期钩子等,但是在函数定义中,我们却无能为力,因此 React 16.8 版本推出了一个新功能 (React Hooks),通过它,可以更好的在函数定义组件中使用 React 特性。

函数组件与类组件的对比:无关“优劣”,只谈“不同”

  • 类组件需要继承 class,函数组件不需要;
  • 类组件可以访问生命周期方法,函数组件不能;
  • 类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
  • 类组件中可以定义并维护 state(状态),而函数组件不可以;

但是类组件它太重了,对于解决许多问题来说,编写一个类组件实在是一个过于复杂的姿势。复杂的姿势必然带来高昂的理解成本,这也是我们所不想看到的

react hooks的好处:

  1. 跨组件复用: 其实 render props / HOC 也是为了复用,相比于它们,Hooks 作为官方的底层 API,最为轻量,而且改造成本小,不会影响原来的组件层次结构和传说中的嵌套地狱;
  2. 类定义更为复杂
  • 不同的生命周期会使逻辑变得分散且混乱,不易维护和管理;
  • 时刻需要关注this的指向问题;
  • 代码复用代价高,高阶组件的使用经常会使整个组件树变得臃肿;
  1. 状态与UI隔离: 正是由于 Hooks 的特性,状态逻辑会变成更小的粒度,并且极容易被抽象成一个自定义 Hooks,组件中的状态和 UI 变得更为清晰和隔离。

注意:

  • 避免在 循环/条件判断/嵌套函数 中调用 hooks,保证调用顺序的稳定;
  • 只有 函数定义组件 和 hooks 可以调用 hooks,避免在 类组件 或者 普通函数 中调用;
  • 不能在useEffect中使用useState,React 会报错提示;
  • 类组件不会被替换或废弃,不需要强制改造类组件,两种方式能并存;

重要钩子

  1. 状态钩子 (useState): 用于定义组件的 State,其到类定义中this.state的功能;
// useState 只接受一个参数: 初始状态
// 返回的是组件名和更改该组件对应的函数
const [flag, setFlag] = useState(true);
// 修改状态
setFlag(false)

// 上面的代码映射到类定义中:
this.state = {
	flag: true
}
const flag = this.state.flag
const setFlag = (bool) => {
    this.setState({
        flag: bool,
    })
}
  1. 生命周期钩子 (useEffect):

类定义中有许多生命周期函数,而在 React Hooks 中也提供了一个相应的函数 (useEffect),这里可以看做componentDidMount、componentDidUpdate和componentWillUnmount的结合。

useEffect(callback, [source])接受两个参数

  • callback: 钩子回调函数;
  • source: 设置触发条件,仅当 source 发生改变时才会触发;
  • useEffect钩子在没有传入[source]参数时,默认在每次 render 时都会优先调用上次保存的回调中返回的函数,后再重新调用回调;
useEffect(() => {
	// 组件挂载后执行事件绑定
	console.log('on')
	addEventListener()

	// 组件 update 时会执行事件解绑
	return () => {
		console.log('off')
		removeEventListener()
	}
}, [source]);


// 每次 source 发生改变时,执行结果(以类定义的生命周期,便于大家理解):
// --- DidMount ---
// 'on'
// --- DidUpdate ---
// 'off'
// 'on'
// --- DidUpdate ---
// 'off'
// 'on'
// --- WillUnmount ---
// 'off'

通过第二个参数,我们便可模拟出几个常用的生命周期:

  • componentDidMount: 传入[]时,就只会在初始化时调用一次
const useMount = (fn) => useEffect(fn, [])
  • componentWillUnmount: 传入[],回调中的返回的函数也只会被最终执行一次
const useUnmount = (fn) => useEffect(() => fn, [])
  • mounted: 可以使用 useState 封装成一个高度可复用的 mounted 状态;
const useMounted = () => {
    const [mounted, setMounted] = useState(false);
    useEffect(() => {
        !mounted && setMounted(true);
        return () => setMounted(false);
    }, []);
    return mounted;
}
  • componentDidUpdate: useEffect每次均会执行,其实就是排除了 DidMount 后即可;
const mounted = useMounted()
useEffect(() => {
    mounted && fn()
})
  1. 其它内置钩子:
  • useContext: 获取 context 对象
  • useReducer: 类似于 Redux 思想的实现,但其并不足以替代 Redux,可以理解成一个组件内部的 redux:
    • 并不是持久化存储,会随着组件被销毁而销毁;
    • 属于组件内部,各个组件是相互隔离的,单纯用它并无法共享数据;
    • 配合useContext`的全局性,可以完成一个轻量级的 Redux;(easy-peasy)
  • useCallback: 缓存回调函数,避免传入的回调每次都是新的函数实例而导致依赖组件重新渲染,具有性能优化的效果;
  • useMemo: 用于缓存传入的 props,避免依赖的组件每次都重新渲染;
  • useRef: 获取组件的真实节点;
  • useLayoutEffect
    • DOM更新同步钩子。用法与useEffect类似,只是区别于执行时间点的不同
    • useEffect属于异步执行,并不会等待 DOM 真正渲染后执行,而useLayoutEffect则会真正渲染后才触发;
    • 可以获取更新后的 state;
  1. 自定义钩子(useXxxxx): 基于 Hooks 可以引用其它 Hooks 这个特性,我们可以编写自定义钩子,如上面的useMounted。又例如,我们需要每个页面自定义标题:
function useTitle(title) {
  useEffect(
    () => {
      document.title = title;
    });
}

// 使用:
function Home() {
	const title = '我是首页'
	useTitle(title)

	return (
		<div>{title}</div>
	)
}

React Hooks 的限制

  • 不要在循环、条件嵌套函数中调用 Hook
  • 在 React 的函数组件中调用 Hook

那为什么会有这样的限制呢?就得从 Hooks 的设计说起。Hooks 的设计初衷是为了改进 React 组件的开发模式。在旧有的开发模式下遇到了三个问题。

  • 组件之间难以复用状态逻辑。过去常见的解决方案是高阶组件、render props 及状态管理框架。
  • 复杂的组件变得难以理解。生命周期函数与业务逻辑耦合太深,导致关联部分难以拆分。
  • 常见的有 this 的问题,但在 React 团队中还有类难以优化的问题,他们希望在编译优化层面做出一些改进。

这三个问题在一定程度上阻碍了 React 的后续发展,所以为了解决这三个问题,Hooks 基于函数组件开始设计。然而第三个问题决定了 Hooks 只支持函数组件。

那为什么不要在循环、条件或嵌套函数中调用 Hook 呢?因为 Hooks 的设计是基于数组实现。在调用时按顺序加入数组中,如果使用循环、条件或嵌套函数很有可能导致数组取值错位,执行错误的 Hook。当然,实质上 React 的源码里不是数组,是链表

这些限制会在编码上造成一定程度的心智负担,新手可能会写错,为了避免这样的情况,可以引入 ESLint 的 Hooks 检查插件进行预防。

useEffect 与 useLayoutEffect 区别在哪里

  • 它们的共同点很简单,底层的函数签名是完全一致的,都是调用的 mountEffectImpl,在使用上也没什么差异,基本可以直接替换,也都是用于处理副作用。
  • 那不同点就很大了,useEffect 在 React 的渲染过程中是被异步调用的,用于绝大多数场景,而 LayoutEffect 会在所有的 DOM 变更之后同步调用,主要用于处理 DOM 操作、调整样式、避免页面闪烁等问题。也正因为是同步处理,所以需要避免在 LayoutEffect 做计算量较大的耗时任务从而造成阻塞。
  • 在未来的趋势上,两个 API 是会长期共存的,暂时没有删减合并的计划,需要开发者根据场景去自行选择。React 团队的建议非常实用,如果实在分不清,先用 useEffect,一般问题不大;如果页面有异常,再直接替换为 useLayoutEffect 即可。

💬 面试官追问

  • 筛选面板只有展开时才执行 useEffect 订阅快捷键,收起时跳过;测试发现后面的 useState 偶尔读到别的状态,为什么不能把 Hook 放进 if

    Hook 依赖每次渲染中稳定的调用顺序,条件分支会使内部链表的取值位置发生错位。应始终调用 Hook,把条件放到副作用内部,或把展开区域拆成独立组件;循环、嵌套函数中的条件调用同样存在风险。

  • 一个报表页把订阅、标题更新和请求状态全塞进类组件的多个生命周期,产品又要求三个页面复用;你会怎样用自定义 Hook 重组?

    把彼此关联的状态和副作用分别抽成 useSubscriptionuseTitle 等自定义 Hook,让每段业务逻辑在一个位置维护并跨组件复用。自定义 Hook 内仍可调用 useStateuseEffect,但自身也必须遵守顶层调用规则;拆得过碎会增加依赖关系的理解成本。

  • 聊天室的 useEffect 原来依赖 [roomId],有人为减少请求改成 [];用户在同一页面切换房间后仍收到旧房间消息,你会如何修正执行与清理时机?

    订阅确实随 roomId 变化,因此依赖项应保留 [roomId]。每次切换时,React 会先执行上一次回调返回的退订函数,再建立新订阅,卸载时也会清理;改成 [] 只适用于整个挂载期间参数不会变化的副作用。

  • 线上弹窗打开时偶发绑定两次滚动监听,关闭后仍触发回调;相关代码用了没有依赖数组的 useEffect,你会先查什么?

    先检查副作用是否每次渲染都重新绑定,以及回调是否返回了与本次绑定对应的移除函数。没有依赖数组时,每次渲染会先清理旧副作用再执行新副作用;若清理缺失、函数引用不匹配或绑定逻辑不对称,就会残留监听。

  • 可视化页面需要读取更新后的 DOM 尺寸并立即调整样式,useEffect 下会短暂闪动;同事提议所有副作用统一换成 useLayoutEffect,你怎么取舍?

    这类必须在 DOM 变更后同步测量并调整样式的逻辑适合 useLayoutEffect,可减少绘制后的视觉闪动。普通订阅、日志和数据副作用仍优先使用 useEffectuseLayoutEffect 会同步阻塞后续绘制,放入耗时计算会拖慢页面呈现。

  • 编辑器把回调传给多个依赖组件,父组件每次渲染都创建新函数;评审同时提出 useCallbackuseMemouseRef,三者在这里分别解决什么?

    需要稳定回调引用时使用 useCallback,需要缓存计算结果或传入属性时使用 useMemo,访问真实节点或保存可变引用则使用 useRef。它们用途不同,不能互相替代;缓存本身也增加依赖维护成本,应围绕明确的重复渲染或计算问题使用。

# 21 受控组件和非受控组件

⚡ 30 秒速记

  • 受控组件:值由 Reactstate 驱动,value + onChange 配对,React 是唯一数据源
  • 非受控组件:值由 DOM 自己维护,用 ref 读取,可以配 defaultValue 给初始值
  • 受控的好处:能实时校验、格式化、联动禁用;代价是每次输入都触发渲染
  • 非受控的好处:代码少、性能好;代价是拿不到实时值、不好做联动
  • 选型:需要实时响应输入就受控;文件上传(<input type="file"> 只能非受控)、简单表单、集成第三方库用非受控

受控组件的值由 React 管理,非受控组件的值则由 DOM 自己保存。 同时传入 valueonChange 时,组件的输入与更新都由外部控制;只传 defaultValue,通常就是给非受控组件设置初始值。受控方式更方便统一管理和同步数据,非受控方式脱离了组件自身的 stateprops 控制,一般不建议作为常规方案。

<FInput value = {x} onChange = {fn} />
// 上面的是受控组件 下面的是非受控组件
<FInput defaultValue = {x} />
  • 当你一个组件同时传递一个value以及onChange事件时,它就是一个受控组件,收入输出都是我来控制的。
  • 第二个只是传递了默认的初时值,并没有传onchange事件,
  • 非受控组件是一种反模式,它的值不受组件自身的state或props控制

💬 面试官追问

  • 登录页给 <FInput value={name} /> 却没有传 onChange,测试说输入框怎么敲都不变;开发认为删掉 value 就算修好,你怎么看?

    传入 value 意味着显示值由外部控制,没有对应的 onChange 更新来源时,用户输入无法形成新的受控值。应补上变更回调并更新状态;若改用 defaultValue,则只是交给组件内部维护,父级也就失去持续控制能力。

  • 后台编辑页有姓名、部门和角色三个字段,保存按钮必须随任一字段变化更新可用状态;你会让输入组件接收哪些属性?

    这类实时联动适合受控方式:父级持有字段值,向每个输入传入 valueonChange,变更后统一计算保存条件。数据入口和出口都由父级掌握,校验与提交更直接;代价是父级需要维护完整的状态更新链路。

  • 搜索框原来只需要一个初始关键词,改版后路由参数变化时必须立即覆盖输入内容;继续使用 defaultValue={keyword} 能满足吗?

    defaultValue 只表达初始值,后续路由参数变化不应依赖它持续驱动输入框。需要外部参数随时覆盖显示内容时,应改为 value={keyword} 并提供 onChange 更新来源;切换模式时还要避免同时混用两套值来源。

  • 线上表单从接口数据未返回时的 undefined,切换成返回后的字符串,控制台提示输入组件控制方式发生变化;你会怎样定位?

    先检查首屏是否没有传 value,数据返回后才开始传入,导致组件从内部维护切到外部控制。若决定受控,应从首次渲染就提供稳定值,例如空字符串,并始终配套 onChange;若决定非受控,则只传 defaultValue,不要中途切换。

  • 内容发布页只在点击提交时读取标题,产品没有实时校验、联动或外部重置要求;受控和非受控该怎么选?

    仅需初始值且提交时再读取的场景,可以采用 defaultValue,让输入值不受父级 stateprops 持续控制。若随后加入实时校验、跨字段联动或外部覆盖,就更适合受控方式;选择取决于谁必须成为稳定的数据源。

# 22 如何避免ajax数据请求重新获取

⚡ 30 秒速记

  • 根因通常是组件重渲染导致 useEffect 重新执行 —— 检查依赖数组里有没有每次都变的引用
  • 把请求结果提升到更上层(父组件或全局状态),避免组件卸载后数据丢失
  • 用请求库的缓存能力:React Query/SWR 自带缓存、去重、失效重取,是最省事的方案
  • 手写方案:用 Map 做请求缓存,相同 key 直接返回已有的 Promise(同时解决了并发重复请求)
  • 组件卸载时用 AbortController 取消未完成的请求,避免在已卸载组件上 setState

避免重复请求的核心是缓存数据并对相同请求去重,通常优先交给 React QuerySWR 处理。 它们能按请求键复用缓存、合并并发请求,并在数据过期后重新验证,比把服务端数据塞进 ReduxZustand 更省维护成本。组件里还要正确设置 useEffect 依赖,并用 AbortController 取消过期请求;不常变化的接口也可以配合 Cache-ControlETag

核心思路是缓存 + 请求去重——把已经拿到的数据缓存起来,相同的请求不重复发。

  1. 用数据请求库管理缓存(现代主线)React Query / SWR 内置了缓存、请求去重(同一时刻多个组件请求同一 key 只发一次)、后台重新验证(stale-while-revalidate)、失效控制。这是现在处理「服务端状态」的标准做法——避免重复请求本就是它们的核心能力,不用自己造轮子
  2. 放全局状态(旧做法):把数据存进 Redux/Zustand,组件先读缓存、没有再请求。这是过去常见的做法,但要自己处理缓存失效、去重、更新,样板多——现在更推荐用 React Query 管服务端状态、全局 store 只放真正的客户端状态
  3. 请求去重(in-flight dedup):对「正在进行中」的相同请求做去重——用一个 Map 缓存进行中的 Promise,相同 key 直接返回同一个 Promise,避免并发的重复请求。
  4. HTTP 缓存:对可缓存的接口配 Cache-Control / ETag,让浏览器层面命中缓存(适合不常变的数据)。
  5. 组件层面:用 useEffect 的依赖数组控制只在必要时请求、用 AbortController 取消过期请求避免竞态。

一句话:优先用 React Query/SWR(自带缓存 + 去重 + 重验证),它们专门解决「服务端状态」的重复获取;全局 store 只放客户端状态;再配合请求去重和 HTTP 缓存。

💬 面试官追问

  • 商品详情页三个区域同时请求 /product?id=42,抓包看到首屏并发三次;只把最后一次结果存进缓存,为什么仍挡不住重复请求?

    结果缓存只有请求完成后才能命中,三个调用在完成前仍会各自发出请求。请求层应按包含参数的 key 把进行中的 Promise 放进 Map,后续调用复用它;结束后还要按缓存策略清理或转存结果,避免把失败请求永久保留。

  • 列表页返回时产品要求先展示旧数据、再静默校准库存,团队准备自己维护过期时间、重试和并发合并;你会怎么落地?

    优先使用 React QuerySWR,利用缓存先呈现已有数据,再通过后台重新验证更新库存。它们也覆盖请求去重与失效控制,能减少自建状态机;团队仍需定义合理的 key 和数据失效边界,否则不同筛选条件可能误用同一缓存。

  • 新闻接口从“几乎不变”改成“运营每分钟修订”,后端却设置了长时间 Cache-Control;前端继续依赖浏览器缓存会有什么风险,怎么调整?

    浏览器缓存适合不常变化的数据,更新频率提高后,过长的缓存时间会让用户持续看到旧内容。应由服务端调整 Cache-Control,或使用 ETag 让浏览器重新验证;前端请求库的失效策略也要同步,否则两层缓存会产生不同的新鲜度判断。

  • 用户快速切换 status 筛选,慢的旧请求最后返回并覆盖了新结果;页面还偶发重复请求,你会按什么顺序排查?

    先核对 useEffect 依赖是否准确反映 status,避免无关渲染重复触发或漏掉必要请求。再用 AbortController 取消已经过期的请求,防止旧响应覆盖新状态;请求去重只能合并相同 key,不能替代不同筛选条件间的竞态处理。

  • 架构评审争论把所有接口结果放进 Redux,还是引入 React Query;订单数据需要缓存、失效、重试,主题和侧栏状态只在客户端存在,你怎么划分?

    订单数据属于服务端状态,优先交给 React QuerySWR 管理缓存、去重、重验证与失效。主题和侧栏这类客户端状态再放入全局 store;把接口数据统一塞进 Redux 也能工作,但团队必须自行补齐更新和缓存生命周期。

  • 接口已经支持 ETag,前端又用了 SWR,同事认为两者重复只能留一个;在一次返回列表页的流程里,它们各自拦截什么?

    SWR 在应用层按 key 复用数据和进行中的请求,并可先展示旧数据后重新验证;ETag 则让重新访问服务器时验证资源是否变化,可能避免重复传输完整响应。两层可以协作,但缓存时效要协调,否则应用层不发请求时不会触发 HTTP 验证。

# 23 组件之间通信

⚡ 30 秒速记

  • 父传子:props
  • 子传父:父把回调函数当 props 传下去,子调用它
  • 兄弟:状态提升到共同父组件,或用全局状态
  • 跨多层:Context(避免 prop drilling),但要注意值变化会让所有消费者重渲染
  • 全局:Zustand/Redux/Jotai;组件实例方法用 ref + useImperativeHandle 暴露
  • 选型原则:能用 props 就别上 Context,能用 Context 就别上全局状态库

组件通信通常按关系远近选择:父子之间用 props 和回调,跨多层用 Context,复杂公共状态再考虑状态管理库。 父组件可以把状态传给子组件,子组件则通过回调把输入或事件交回父组件,兄弟组件一般把状态提升到共同父级。主题、语言这类跨层信息适合 Context;组件树两侧共享的复杂数据可用 Redux 等方案,但会增加学习和结构成本。需要父组件直接调用子组件方法时,也可以通过 ref 获取实例,不过这是比较特殊的通信方式。

  • 父子组件通信
  • 自定义事件
  • redux和context

context如何运用

  • 父组件向其下所有子孙组件传递信息
  • 如一些简单的信息:主题、语言
  • 复杂的公共信息用redux

在跨层级通信中,主要分为一层或多层的情况

  • 如果只有一层,那么按照 React 的树形结构进行分类的话,主要有以下三种情况:父组件向子组件通信子组件向父组件通信以及平级的兄弟组件间互相通信
  • 在父与子的情况下,因为 React 的设计实际上就是传递 Props 即可。那么场景体现在容器组件与展示组件之间,通过 Props 传递 state,让展示组件受控。
  • 在子与父的情况下,有两种方式,分别是回调函数与实例函数。回调函数,比如输入框向父级组件返回输入内容,按钮向父级组件传递点击事件等。实例函数的情况有些特别,主要是在父组件中通过 React 的 ref API 获取子组件的实例,然后是通过实例调用子组件的实例函数。这种方式在过去常见于 Modal 框的显示与隐藏
  • 多层级间的数据通信,有两种情况。第一种是一个容器中包含了多层子组件,需要最底部的子组件与顶部组件进行通信。在这种情况下,如果不断透传 Props 或回调函数,不仅代码层级太深,后续也很不好维护。第二种是两个组件不相关,在整个 React 的组件树的两侧,完全不相交。那么基于多层级间的通信一般有三个方案。
    • 第一个是使用 React 的 Context API,最常见的用途是做语言包国际化
    • 第二个是使用全局变量与事件。
    • 第三个是使用状态管理框架,比如 Flux、Redux 及 Mobx。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高

💬 面试官追问

  • 商品卡片的收藏按钮点击后要通知父列表更新计数,开发准备用 ref 调子组件实例再反向取值;这条只有一层的通信为什么不合适?

    一层子到父通信应由父组件传入回调,子组件在点击时把结果作为参数回传,数据流更清晰。通过 ref 获取实例并调用方法属于更特殊的命令式场景,过去常见于弹窗显隐;用它传普通业务数据会增加父子实现耦合。

  • 结算页的优惠金额由父组件计算,价格展示组件只负责渲染,数量组件还要把修改结果传回去;你会怎样设计属性边界?

    父组件通过 props 把优惠金额和数量传给展示组件,再把数量变更回调传给数量组件。子组件触发回调后由父级更新状态并重新下发,形成单向数据流;不要让两个子组件私下同步副本,否则容易出现价格与数量不一致。

  • 主题值要从应用根节点传到第六层按钮,中间五层都不用它;设计师又要求切换主题后整棵页面同步,你会选 Context 还是逐层 props

    这种面向一批子孙组件的简单公共信息适合 Context,根节点提供主题,深层按钮直接消费,避免无意义的逐层透传。若信息演变为复杂且频繁变化的业务状态,则应重新评估状态管理框架;Context 不等于所有全局状态的默认容器。

  • 两个位于组件树两侧的面板通过全局事件同步筛选条件,线上偶尔出现页面已卸载但监听仍执行;你会查哪里并考虑怎样替换?

    先检查事件监听是否在挂载时注册、卸载时解除,以及同一组件是否重复注册。对于跨层级且互不相交的复杂共享状态,可改用 ReduxMobx 等状态管理框架,让更新路径受到统一约束;全局事件虽直接,但生命周期和数据来源更难追踪。

  • 小型后台只有语言和主题两个公共值,架构师要求一开始就上 Redux,另一位坚持所有层级都传 props;你如何裁决?

    语言和主题属于 Context 的典型用途,可由上层统一提供并让后代消费,不必为了简单信息引入完整状态管理框架。逐层 props 在层级很浅时仍最直观;只有公共状态复杂度和协作约束明显上升时,Redux 等方案的结构化收益才更突出。

  • 父组件需要控制第三方弹窗的打开和关闭,但该弹窗只暴露实例方法;团队规定所有子到父通信都必须用回调,这里是否需要例外?

    若第三方弹窗确实只提供命令式实例方法,父组件可通过 Reactref 获取实例并调用显示或隐藏方法。它适用于这类特殊控制,不应扩展成常规数据通信机制;能通过 props 和回调表达的状态,仍应优先保持声明式传递。

# 24 类组件与函数组件有什么区别呢?

⚡ 30 秒速记

  • 表面差异:类组件有实例、有 this、有生命周期;函数组件没有实例,靠 Hooks 管状态和副作用
  • 本质差异:函数组件捕获了渲染时的值(每次渲染都是独立的闭包快照),类组件的 this.props 总是读最新的
  • 这个差异决定了异步回调里的行为不同 —— 函数组件更可预测,也是「函数式心智模型」的体现
  • 函数组件更容易做逻辑复用(自定义 Hook)、代码更少、Tree Shaking 更友好
  • 现在官方推荐一律用函数组件;类组件只在需要 Error Boundary 时还有不可替代性(getDerivedStateFromError

类组件和函数组件在展示及现代浏览器中的性能没有明显差异,真正不同的是开发时的心智模型。 类组件围绕面向对象、this 和生命周期组织代码,函数组件则更强调不可变数据、减少副作用,并通过 Hooks 组织和复用逻辑。性能优化时,类组件常用 shouldComponentUpdate,函数组件可用 React.memo;随着 Hooks 出现,函数组件已经能覆盖原先依赖生命周期的场景。组件设计也更推崇组合而非继承,因此函数组件通常更符合后续的并发与细粒度逻辑复用方向。

  • 作为组件而言,类组件与函数组件在使用与呈现上没有任何不同,性能上在现代浏览器中也不会有明显差异
  • 它们在开发时的心智模型上却存在巨大的差异。类组件是基于面向对象编程的,它主打的是继承、生命周期等核心概念;而函数组件内核是函数式编程,主打的是 immutable、没有副作用、引用透明等特点。
  • 之前,在使用场景上,如果存在需要使用生命周期的组件,那么主推类组件;设计模式上,如果需要使用继承,那么主推类组件。
  • 但现在由于 React Hooks 的推出,生命周期概念的淡出,函数组件可以完全取代类组件。
  • 其次继承并不是组件最佳的设计模式,官方更推崇“组合优于继承”的设计概念,所以类组件在这方面的优势也在淡出。
  • 性能优化上,类组件主要依靠 shouldComponentUpdate 阻断渲染来提升性能,而函数组件依靠 React.memo 缓存渲染结果来提升性能。
  • 从上手程度而言,类组件更容易上手,从未来趋势上看,由于React Hooks 的推出,函数组件成了社区未来主推的方案。
  • 类组件在未来时间切片与并发模式中,由于生命周期带来的复杂度,并不易于优化。而函数组件本身轻量简单,且在 Hooks 的基础上提供了比原先更细粒度的逻辑组织与复用,更能适应 React 的未来发展。

💬 面试官追问

  • 商品详情页把类组件改成函数组件后,产品认为页面表现和性能都会明显提升,你会怎样纠正这个判断?

    单纯改写组件形态通常不会带来可感知的展示或性能提升,两者作为组件时的使用结果并无本质差异。迁移价值主要来自 Hooks 带来的细粒度逻辑组织与复用,以及更适合后续并发能力的模型;若现有代码稳定且没有复用诉求,不应把重写包装成性能优化。

  • 一个类组件同时处理请求、订阅和表单校验,多个生命周期里散落着同一业务逻辑,准备迭代时你会怎样落地改造?

    适合在本次业务迭代中改成函数组件,并按业务关注点拆出自定义 Hook,让请求、订阅和校验各自聚合相关状态与清理逻辑。不要机械地把每个生命周期翻译成一个 useEffect,否则只是换了语法;依赖项和副作用边界不清时,迁移后仍会难以维护。

  • 后台管理系统大量使用基类继承权限校验,新模块坚持继续继承,另一位开发主张全面改成组合,你会支持哪一边?

    新模块应优先使用组合,把权限能力做成包装组件、Context 或自定义 Hook,而不是继续扩大组件继承层级。React 的组件复用更适合“组合优于继承”,函数组件也因此削弱了类组件的传统优势;旧基类可渐进兼容,但一次性替换会扩大回归范围。

  • 函数组件接入实时行情后偶发重复订阅,类组件版本却正常;日志显示每次渲染后订阅数量都增加,你先查哪里?

    先检查副作用是否写在渲染过程,或 useEffect 是否缺少正确依赖与清理函数,确保每次重新订阅前都会解除旧订阅。函数组件强调渲染无副作用,副作用应集中在对应的 Hook 中;若依赖对象每次都重新创建,还需稳定引用,否则清理正确也可能频繁重订阅。

  • 数据表格频繁刷新,类组件方案要写 shouldComponentUpdate,函数组件方案准备套 React.memo,你会如何决定并验证?

    两种方案都能阻断不必要的渲染:类组件使用 shouldComponentUpdate,函数组件通常使用 React.memo 缓存渲染结果。应先确认瓶颈来自重复渲染,再保证传入的对象和回调引用足够稳定;盲目增加比较逻辑也有成本,且无法修复组件内部昂贵计算。

# 25 如何设计React组件

⚡ 30 秒速记

  • 单一职责:一个组件只干一件事,超过就拆
  • 接口最小化:props 命名语义化、给合理默认值、受控与非受控都支持
  • 组合优于配置:用 children/插槽而不是无限加配置项(复合组件模式)
  • 分层:基础组件(纯 UI 无业务)→ 业务组件 → 页面,只有基础组件才追求高度通用
  • 别忘非功能需求:无障碍(role/aria/键盘操作)、国际化、暗色模式、SSR 兼容
  • 配套:TypeScript 类型定义 + Storybook 文档 + 单测

设计 React 组件的核心是单一职责、接口精简,并根据业务边界合理分层。 我一般把纯展示和通用能力放进基础组件,把状态与业务逻辑留给容器组件或页面,避免为了复用而过度抽象。组件之间优先通过 propschildren 等方式组合,接口命名要有语义,也要考虑受控与非受控场景。基础组件还应补齐 TypeScript 类型、无障碍能力、单测和 Storybook 文档。

React 组件应从设计与工程实践两个方向进行探讨

从设计上而言,社区主流分类的方案是展示组件与灵巧组件

  • 展示组件内部没有状态管理,仅仅用于最简单的展示表达。展示组件中最基础的一类组件称作代理组件。代理组件常用于封装常用属性、减少重复代码。很经典的场景就是引入 Antd 的 Button 时,你再自己封一层。如果未来需要替换掉 Antd 或者需要在所有的 Button 上添加一个属性,都会非常方便。基于代理组件的思想还可以继续分类,分为样式组件与布局组件两种,分别是将样式与布局内聚在自己组件内部。
  • 从工程实践而言,通过文件夹划分的方式切分代码。我初步常用的分割方式是将页面单独建立一个目录,将复用性略高的 components 建立一个目录,在下面分别建立 basic、container 和 hoc 三类。这样可以保证无法复用的业务逻辑代码尽量留在 Page 中,而可以抽象复用的部分放入 components 中。其中 basic 文件夹放展示组件,由于展示组件本身与业务关联性较低,所以可以使用 Storybook 进行组件的开发管理,提升项目的工程化管理能力

💬 面试官追问

  • 订单页把请求、权限判断和几十行表格结构都写进一个所谓“展示组件”,评审时你会指出什么设计矛盾?

    它已经不是纯展示组件,因为数据获取和权限判断属于业务协调或状态管理,应该由页面或容器层承担。展示组件只接收明确数据并表达界面,才能低成本复用和独立验证;如果强行追求绝对无状态,也可能把紧密相关的交互拆得过散。

  • 六个页面都直接使用 Antd Button,现在设计要求统一补埋点并可能更换组件库,你会怎样调整目录和依赖?

    应增加项目自己的按钮代理组件,集中封装常用属性、样式和埋点,再让业务页面依赖这层稳定接口。通用展示组件可放入 components/basic 并用 Storybook 管理;代理层若暴露过多底层专有属性,未来替换组件库仍会产生大面积修改。

  • 团队规定所有组件必须放进公共 components,结果两百多个只服务单一页面的组件也被共享,你会如何重新划分?

    无法复用且高度依赖业务流程的代码应留在对应 Page 目录,只有抽象稳定、复用性较高的内容才进入公共组件目录。可进一步按 basiccontainerhoc 等职责分类,使依赖方向清楚;过早公共化会固化尚未稳定的接口,并增加跨页面修改风险。

  • 改动会员卡样式后十几个页面错位,排查发现每页都复制了相同布局和间距,你会怎样止损并定位抽象边界?

    先把重复的样式与布局收敛为样式组件或布局组件,再逐页替换,避免继续复制并分别修补。组件内部应内聚稳定的视觉规则,页面只提供业务内容;若不同页面的结构差异已经超过共同点,硬合并会制造大量条件属性,应保留少量明确变体。

  • 搜索页组件已经拆成三十个文件,阅读一次交互要跨越多层 props,但架构师仍要求继续细拆,你会用什么标准取舍?

    拆分应围绕职责、复用价值和封装边界,而不是文件数量或代码行数。稳定的展示单元可进入 basic,负责数据与状态协调的部分留在容器或页面层;粒度过细会让父子关系和数据传递更难理解,粒度过粗则会把业务逻辑与展示再次绑死。

# 26 组件的协同及(不)可控组件

⚡ 30 秒速记

  • 组件协同的三种模式:props 传递、复合组件(Compound Components,用 Context 在父子间隐式传值)、render props/children as function
  • 复合组件的典型例子:<Tabs><Tab/><TabPanel/></Tabs>,父组件管状态,子组件通过 Context 消费
  • 可控组件:值由外部 props 驱动,组件自身不存状态
  • 不可控组件:值存在组件内部,只在初始化时接受 defaultValue
  • 最佳实践是两者都支持:传了 value 就走受控,没传就用内部 stateuseControllableValue 模式)

组件协同本质上是组织父子关系和复用公共逻辑,而可控与不可控的区别在于状态由谁管理。 父组件可以通过嵌套和 props 统一管理子组件,也可以抽离多个组件共有的方法,减少重复代码,但抽象过多会让逻辑分散、维护难度上升。可控组件的值来自外部状态,符合 React 数据流,也更方便读取和处理。不可控组件通常用 defaultValue 初始化,后续需要借助 ref 获取实际值。

为什么要进行组件的协同

  • 我们在实际的开发项目的时候,不会只用几个组件,有时候遇到大型的项目,可能会有成千上百的组件,难免会遇到有功能重复的组件。要进行修改,就会修改大部分的文件。所以我们需要进行组件的协同开发。

什么是组件的协同使用?

  • 组件的协同本质上是对组件的一种组织、管理的方式。
  • 目的:
    • 逻辑清晰:这是组件与组件之间的逻辑
    • 代码模块化
    • 封装细节:像面向对象一样将常用的方法以及数据封装起来
    • 提高代码的复用性:因为是组件,相当于一个封装好的东西,用的时候直接调用

如何实现组件的协同使用

  • 第一种:增加一个父组件,将其他的组件进行嵌套,更多的是实现代码的封装
  • 第二种:通过一些操作从后台获取数据,React中的Mixin,更多的是实现代码的复用

组件嵌套的含义

  • 组件嵌套的本质是父子关系

组件嵌套的优缺点

  • 优点:
    • 逻辑清晰:父子关系类似于人类中的父子关系
    • 模块化开发:每个模块对应一个功能,不同的模块可以同步开发
    • 封装细节:开发者必须要关注组件的功能,不需要了解细节
  • 缺点:
    • 编写难度高:父子组件的关系需要经过深思熟虑,贸然编写可能导致关系混乱,代码难以维护
    • 无法掌握所有细节:使用者只知道组件的用法,不知道实现细节,遇到问题难以修复

Mixin

Mixin的含义

  • Mixin=一组方法
  • 他的目的是横向抽离出组件的相似代码,把组件的共同作用以及效果的代码提出来

Mixin的优缺点

  • 优点
    • 代码复用:抽离出通用的代码,减少开发成本,提高开发效率
    • 即插即用:可以使用许多现有的Mixin来开发自己的代码
    • 适应性强:改动一次代码,影响多个组件
  • 缺点
    • 编写难度高:Mixin可能被用在各种环境中,想要兼容多种环境就需要更多的 - 码与逻辑,通用的代价是提高复杂度
    • 降低代码的可读性:组件的优势在于将逻辑与是界面直接结合在一起,Mixin本质上会分散逻辑,理解起来难度大

不可控组件

  • 上图:defaultValue的值是固定的,这就是一个不可控组件
  • 如果要获取inputvalue值,只有使用ref获取节点来获取值

可控组件

  • defaultValue的值是根据状态确定了,只需要拿到this.state.value的值就可以了
  • 这里需要注意一下:使用value的值是不可修改的,defaultValue的值是可以修改的

可控组件的优点

  • 符合React的数据流
  • 数据存储在state中,便于获取
  • 便于处理数据

💬 面试官追问

  • 注册页的手机号输入框传了 defaultValue,用户编辑后父组件更新默认值,界面却没有跟着变化,为什么?

    defaultValue 只负责非受控输入框的初始值,挂载后输入内容由 DOM 自己维护,父组件随后修改默认值不会持续驱动界面。若业务要求外部状态始终决定内容,应改用 value 配合 onChange;这样也意味着父组件必须及时更新状态,否则输入框会表现为不可编辑。

  • 结算页要求地址输入变化后立即联动运费、校验和提交按钮状态,你会选受控还是非受控组件?

    应优先选择受控组件,把输入值保存在 Reactstate 中,并通过 value 和变更回调形成单向数据流。这样联动逻辑能读取同一份确定数据,也便于统一校验;字段数量很大或变更非常频繁时,需要控制状态作用域,避免无关区域跟随渲染。

  • 一个文件上传控件需要读取原生节点状态,团队却要求所有表单项都必须用 value 控制,你会怎样处理这组选型冲突?

    不必把所有输入都强行设计成受控模式,无法由普通 value 完整驱动或更适合读取 DOM 状态的控件可使用非受控方案,并通过 ref 获取节点值。组件应封装这些实现细节,对外暴露清晰事件;代价是数据不再始终位于 React 状态中,联动和即时校验会更困难。

  • 筛选面板上线后偶发出现“输入框显示值与提交参数不一致”,代码同时通过 refDOM,又在 state 中保存一份值,你先怎么排查?

    先确认界面的唯一数据源,检查渲染使用的是 valuedefaultValue 还是 DOM 当前值,并对照提交路径读取了哪一份数据。受控和非受控机制混用容易产生两个不同步的状态源;应统一为 state 驱动,或明确只在提交时通过 ref 读取,避免双向同步。

  • 十几个组件都要复用请求和日志逻辑,旧方案准备继续追加 Mixin,新方案主张父组件协调,你会如何取舍?

    组件间的结构协作可由父组件嵌套和组织,使父子关系、数据来源与封装边界保持清晰。Mixin 能横向复用一组方法,但会把逻辑分散到组件之外,兼容场景越多复杂度越高;遗留代码可维护,新增逻辑则应优先选择边界更显式的组合方式。

# 27 React-Router 的实现原理及工作方式分别是什么

⚡ 30 秒速记

  • 核心:监听 URL 变化 → 匹配路由配置 → 渲染对应组件
  • 两种模式:BrowserRouterhistory.pushStateURL 干净,但刷新会真请求该路径,服务端要配回退);HashRouterhash# 后内容不发服务器,刷新不会 404
  • 内部通过 Context 向下传递 locationnavigate,所以任意层级都能拿到路由信息
  • pushState 不会触发 popstate,所以路由库在 push 时要手动触发更新
  • v6 的变化:SwitchRoutes、支持嵌套路由和相对路径、useNavigate 替代 useHistoryv6.4+ 引入 loader/action 数据路由

React Router 的实现原理是监听地址变化并匹配对应组件,工作时则通过上下文连接路由容器、匹配组件和导航组件。 Hash 模式依赖哈希变化,History 模式使用 pushStatereplaceState 等浏览器 API,后者还要求服务端配置 historyApiFallback。浏览器端使用 History API,原生端可以使用基于数组维护记录的内存历史。像 Router 提供上下文,Route 负责匹配,Link 则完成平台相关的导航。

  • React Router 路由的基础实现原理分为两种,如果是切换 Hash 的方式,那么依靠浏览器 Hash 变化即可;如果是切换网址中的 Path,就要用到 HTML5 History API 中的 pushStatereplaceState 等。在使用这个方式时,还需要在服务端完成 historyApiFallback 配置
  • React Router 内部主要依靠 history 库完成,这是由 React Router 自己封装的库,为了实现跨平台运行的特性,内部提供两套基础 history,一套是直接使用浏览器的 History API,用于支持 react-router-dom;另一套是基于内存实现的版本,这是自己做的一个数组,用于支持 react-router-native
  • React Router 的工作方式可以分为设计模式与关键模块两个部分。从设计模式的角度出发,在架构上通过 Monorepo进行库的管理。Monorepo 具有团队间透明、迭代便利的优点。其次在整体的数据通信上使用了 Context API 完成上下文传递。
  • 在关键模块上,主要分为三类组件:第一类是 Context 容器,比如 Router 与 MemoryRouter;第二类是消费者组件,用以匹配路由,主要有 Route、Redirect、Switch 等;第三类是与平台关联的功能组件,比如 Link、NavLink、DeepLinking 等。

React router原理分析 (opens new window)

💬 面试官追问

  • 运营把商品详情从 /product/42 改成 #/product/42 后,认为两种地址只是写法不同,你会怎样解释底层差异?

    Hash 路由依赖地址中散列部分的变化,而普通 Path 路由依赖 HTML5 History APIpushStatereplaceState 等能力。两者都能驱动客户端匹配组件,但服务器参与程度不同;选择 Path 后若没有回退配置,直接访问深层地址可能由服务器返回 404

  • 内容站准备从 HashRouter 切到正常路径,上线前除了替换路由容器,你会要求服务端完成什么配置?

    服务端需要配置 historyApiFallback 或等价回退规则,让未知的前端路径仍返回应用入口,再由 React Router 完成匹配。还要排除静态资源和真实接口路径,不能把所有请求无条件重写;否则深层链接虽能打开,资源请求或后端错误却可能被错误地返回 HTML

  • 同一套路由配置既要跑浏览器端管理后台,又要跑不具备浏览器 History API 的测试环境,历史记录层怎么选?

    浏览器环境使用基于 History API 的实现,非浏览器场景可使用内存历史记录,以数组维护位置变化。React Router 通过不同的 history 基础实现支持跨平台,上层匹配逻辑可以保持接近;内存实现不会天然改变真实地址栏,不能把测试结果直接等同于浏览器导航行为。

  • 用户直接打开 /orders/1001 得到服务器 404,但先访问首页再点 Link 却正常,你会如何定位?

    现象说明客户端路由匹配大概率正常,故障位于服务器对深层路径的处理,应先检查入口回退配置和部署平台重写规则。Link 在应用内通过 History API 切换,因此不会重新请求对应文档;修复时需保留 API、资源和真实文件的优先匹配,避免过度回退。

  • 微前端页面需要共享当前位置,团队争论是层层传 location,还是让路由组件直接读取,你会怎么设计?

    路由容器应通过 Context API 传递位置和导航上下文,负责匹配的消费者组件按需读取,避免每层手工转发。整体可区分 RouterMemoryRouter 等容器,路由匹配组件,以及 LinkNavLink 等平台功能组件;多个应用根并存时仍要明确各自上下文边界。

# 28 React 17 带来了哪些改变

⚡ 30 秒速记

  • 它是一个没有新特性的版本,定位是「为后续升级铺路」,支持渐进式升级(一个页面可以同时跑两个版本的 React
  • 事件委托从 document 改到 root 容器 —— 这样多版本共存和微前端嵌套才不会互相干扰
  • 移除事件池:不再需要 e.persist(),异步访问事件对象也没问题
  • 新的 JSX 转换:不用再手动 import React,包体也更小
  • 副作用清理函数改为异步执行,卸载不再阻塞屏幕更新;useEffect 的清理时机更一致

React 17 的关键变化是更新 JSX 转换、重构事件系统,并引入更细致的 Lane 优先级模型。 新转换会从 react/jsx-runtime 自动引入解析能力,因此使用 JSX 时不再必须手动导入 React,编译输出也有简化空间。事件委托从全局 document 下移到各自的根容器,让多个 React 应用之间的影响更可控,同时取消事件池,异步访问事件不再需要 e.persist()。源码调度方面,Lane 用二进制位表达优先级,比原来的 expirationTime 能覆盖更多边界情况。

最重要的是以下三点:

  • 新的 JSX 转换逻辑
  • 事件系统重构
  • Lane 模型的引入

1. 重构 JSX 转换逻辑

在过去,如果我们在 React 项目中写入下面这样的代码:

function MyComponent() {
  return <p>这是我的组件</p>
}

React 是会报错的,原因是 React 中对 JSX 代码的转换依赖的是 React.createElement 这个函数。因此但凡我们在代码中包含了 JSX,那么就必须在文件中引入 React,像下面这样:

import React from 'react';
function MyComponent() {
  return <p>这是我的组件</p>
}

React 17 则允许我们在不引入 React 的情况下直接使用 JSX。这是因为在 React 17 中,编译器会自动帮我们引入 JSX 的解析器,也就是说像下面这样一段逻辑:

function MyComponent() {
  return <p>这是我的组件</p>
}

会被编译器转换成这个样子:

import {jsx as _jsx} from 'react/jsx-runtime';
function MyComponent() {
  return _jsx('p', { children: '这是我的组件' });
}

react/jsx-runtime 中的 JSX 解析器将取代 React.createElement 完成 JSX 的编译工作,这个过程对开发者而言是自动化、无感知的。因此,新的 JSX 转换逻辑带来的最显著的改变就是降低了开发者的学习成本。

react/jsx-runtime 中的 JSX 解析器看上去似乎在调用姿势上和 React.createElement 区别不大,那么它是否只是 React.createElement 换了个马甲呢?当然不是,它在内部实现了 React.createElement 无法做到的性能优化和简化。在一定情况下,它可能会略微改善编译输出内容的大小

2. 事件系统重构

事件系统在 React 17 中的重构要从以下两个方面来看:

  • 卸掉历史包袱
  • 拥抱新的潮流

2.1 卸掉历史包袱:放弃利用 document 来做事件的中心化管控

React 16.13.x 版本中的事件系统会通过将所有事件冒泡到 document 来实现对事件的中心化管控

这样的做法虽然看上去已经足够巧妙,但仍然有它不聪明的地方——document 是整个文档树的根节点,操作 document 带来的影响范围实在是太大了,这将会使事情变得更加不可控

在 React 17 中,React 团队终于正面解决了这个问题:事件的中心化管控不会再全部依赖 document,管控相关的逻辑被转移到了每个 React 组件自己的容器 DOM 节点中。比如说我们在 ID 为 root 的 DOM 节点下挂载了一个 React 组件,像下面代码这样:

const rootElement = document.getElementById("root");
// 这里沿用 React 17 的写法说明事件挂载位置的变化;React 18 起写作
// ReactDOM.createRoot(rootElement).render(<App />),事件同样挂在这个容器节点上
ReactDOM.render(<App />, rootElement);

那么事件管控相关的逻辑就会被安装到 root 节点上去。这样一来, React 组件就能够自己玩自己的,再也无法对全局的事件流构成威胁了

2.2 拥抱新的潮流:放弃事件池

在 React 17 之前,合成事件对象会被放进一个叫作“事件池”的地方统一管理。这样做的目的是能够实现事件对象的复用,进而提高性能:每当事件处理函数执行完毕后,其对应的合成事件对象内部的所有属性都会被置空,意在为下一次被复用做准备。这也就意味着事件逻辑一旦执行完毕,我们就拿不到事件对象了,React 官方给出的这个例子就很能说明问题,请看下面这个代码

function handleChange(e) {
  // This won't work because the event object gets reused.
  setTimeout(() => {
    console.log(e.target.value); // Too late!
  }, 100);
}

异步执行的 setTimeout 回调会在 handleChange 这个事件处理函数执行完毕后执行,因此它拿不到想要的那个事件对象 e

要想拿到目标事件对象,必须显式地告诉 React——我永远需要它,也就是调用 e.persist() 函数,像下面这样:

function handleChange(e) {
  // Prevents React from resetting its properties:
  e.persist();
  setTimeout(() => {
    console.log(e.target.value); // Works
  }, 100);
}

在 React 17 中,我们不需要 e.persist(),也可以随时随地访问我们想要的事件对象。

3. Lane 模型的引入

初学 React 源码的同学由此可能会很自然地认为:优先级就应该是用 Lane 来处理的。但事实上,React 16 中处理优先级采用的是 expirationTime 模型

expirationTime 模型使用 expirationTime(一个时间长度) 来描述任务的优先级;而 Lane 模型则使用二进制数来表示任务的优先级

lane 模型通过将不同优先级赋值给一个位,通过 31 位的位运算来操作优先级。

Lane 模型提供了一个新的优先级排序的思路,相对于 expirationTime 来说,它对优先级的处理会更细腻,能够覆盖更多的边界条件。

💬 面试官追问

  • 一个只写 <Card /> 的文件删掉 import React from 'react' 后仍能编译,同事认为浏览器已经原生支持 JSX,你会怎样纠正?

    不是浏览器直接执行 JSX,而是新的 JSX 转换由编译器自动引入 react/jsx-runtime,再生成相应的函数调用。它因此不再要求每个 JSX 文件显式导入 React,并可能简化编译输出;若构建工具尚未启用新转换,删除导入仍可能导致编译失败。

  • 大型页面里同时挂载两个独立 React 根节点,升级到 React 17 后事件隔离为什么比旧版本更容易控制?

    React 17 将事件的中心化管控从全局 document 移到各自的根容器节点,因此不同 React 根对全局事件流的影响更小。工程上仍要检查原生事件监听、冒泡边界和第三方脚本;容器级委托降低了相互干扰,并不代表所有跨根事件冲突都会自动消失。

  • 表单代码在事件回调结束后通过 setTimeout 读取 e.target.valueReact 16 需要 e.persist(),升级到 React 17 后要怎么处理?

    React 17 放弃了合成事件池,异步回调可以继续访问事件对象,因此不再需要为此调用 e.persist()。迁移时可清理依赖事件复用机制的兼容代码,但仍应注意目标 DOM 是否已被移除;取消事件池只保证对象不被统一置空,不保证相关节点永远存在。

  • 升级 React 17 后点击事件完全不触发,控制台也没有业务异常;页面由脚本动态替换过 root 容器,你先查什么?

    先确认 React 实际挂载的根容器是否仍在文档中,以及动态替换节点后是否重新完成挂载,因为事件委托现在安装在该容器上。再检查容器附近的原生监听器是否阻止传播;继续只排查 document 上的 React 事件监听会偏离方向,但全局第三方监听仍可能造成干扰。

  • 调度模块准备继续用单一过期时间表达所有任务优先级,源码维护者建议切到 Lane 模型,你如何说明取舍?

    React 17 引入的 Lane 模型使用二进制位表示不同优先级,并通过位运算组合和处理任务,比单一 expirationTime 能表达更细腻的优先级关系。它为更多调度边界提供了空间,但属于内部复杂度的增加;业务代码不应依赖具体位值,也不能把升级直接等同于页面必然变快。

# 八、性能

# 1 DNS 预解析

⚡ 30 秒速记

  • <link rel="dns-prefetch" href="//cdn.example.com"> 让浏览器提前做域名解析
  • 收益:省掉一次 DNS 查询的往返(通常 20~120ms),跨域资源多的页面收益明显
  • 更进一步用 preconnect:不只解析 DNS,还提前完成 TCP 握手和 TLS 协商
  • 别滥用:每个 preconnect 都占用连接资源,只给关键的 2~4 个域名
  • 现代补充:HTTP/2 之后应该收敛域名而不是拆分,预连接只留给真正必要的第三方域

DNS 预解析就是提前把域名解析成对应的 IP,减少真正请求资源时等待解析的时间。 浏览器访问外部域名之前通常需要先完成解析,因此预先处理可以让后续请求更早发出。比如页面确定会访问 blog.poetries.top,可以通过 <link rel="dns-prefetch" href="//blog.poetries.top"> 提前解析。

  • DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP
<link rel="dns-prefetch" href="//blog.poetries.top">

💬 面试官追问

  • 商品页接入地图、客服、埋点和两个 CDN,产品要求给全部第三方域名都加 dns-prefetch,你会直接照做吗?

    不会直接给所有域名都加,而是只保留当前页面较可能请求的域名。dns-prefetch 只是提前完成域名到 IP 的解析,若用户根本不会触发客服或地图,请求前的准备工作就可能白做;候选域名过多时,还应通过实际请求链路筛选。

  • 首屏图片来自 img.example.com,你准备怎样在页面里落地 DNS 预解析,并确认写法指向正确域名?

    可在文档头部加入 <link rel="dns-prefetch" href="//img.example.com">,让浏览器提前解析该图片域名。配置必须写资源实际访问的主机名,不能只写主站域名;若图片后来改到其他域名,这条提示不会替新域名完成解析。

  • 同一套站点内网环境直接访问固定 IP,公网环境则通过多个域名加载资源,平台团队想统一保留 dns-prefetch,是否都有收益?

    固定 IP 的请求不存在域名到 IP 的解析过程,因此 dns-prefetch 对它没有对应收益;公网域名请求才可能减少后续等待 DNS 的时间。统一模板可以保留条件配置,但不能把该提示当成所有网络环境都有效的加速手段。

  • 活动页已经加了 dns-prefetch,线上瀑布图里接口请求仍长时间等待,开发因此认定预解析失效,你会先查什么?

    先确认 <link> 中的主机名是否与接口最终请求域名一致,并区分耗时究竟发生在 DNS、连接还是服务端响应阶段。dns-prefetch 只预先获得域名对应的 IP,无法消除连接建立、重定向或接口处理耗时;若 DNS 本身不慢,整体变化可能并不明显。

  • 支付页只有一个首屏接口域名,但架构师希望用 dns-prefetch 解决整条请求链路延迟,你如何界定这个方案?

    它只能针对请求前的 DNS 解析环节,适合域名确定且稍后会访问的场景,不能替代接口、网络连接或服务端优化。应先按请求阶段定位瓶颈,再决定是否加入预解析;若主要耗时不在 DNS,继续堆叠该标签只会掩盖真正原因。

# 2 缓存

⚡ 30 秒速记

  • 两层:强缓存(不发请求)和协商缓存(发请求但可能拿 304
  • 强缓存看 Cache-Controlmax-age/no-cache/no-store/immutable),优先级高于 Expires
  • 协商缓存看 ETag/If-None-Match(精确)和 Last-Modified/If-Modified-Since(秒级)
  • 工程实践:HTMLno-cache,带 hash 的静态资源用 max-age=31536000, immutable,靠文件名变化更新
  • 易错点:no-cache 是「可以存但每次要验证」,no-store 才是真不缓存

浏览器缓存主要分为强缓存和协商缓存,区别在于资源复用前是否还要向服务器发请求。 强缓存命中时直接使用本地资源,常用 Cache-Control,其优先级高于 Expires;协商缓存则携带 ETag 或修改时间验证,未变化时返回 304。频繁变化的资源可用 no-cache 配合 ETag,代码文件通常设置长期缓存并通过文件指纹更新,不应缓存的资源则用 no-store

  • 缓存对于前端性能优化来说是个很重要的点,良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度
  • 通常浏览器缓存策略分为两种:强缓存和协商缓存

强缓存

实现强缓存可以通过两种响应头实现:ExpiresCache-Control 。强缓存表示在缓存期间不需要请求,state code200

Expires: Wed, 22 Oct 2018 08:41:00 GMT

ExpiresHTTP / 1.0 的产物,表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT 后过期,需要再次请求。并且 Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效

Cache-control: max-age=30

Cache-Control 出现于 HTTP / 1.1,优先级高于 Expires 。该属性表示资源会在 30 秒后过期,需要再次请求

协商缓存

  • 如果缓存过期了,我们就可以使用协商缓存来解决问题。协商缓存需要请求,如果缓存有效会返回 304
  • 协商缓存需要客户端和服务端共同实现,和强缓存一样,也有两种实现方式

Last-ModifiedIf-Modified-Since

  • Last-Modified 表示本地文件最后修改日期,If-Modified-Since 会将 Last-Modified的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来
  • 但是如果在本地打开缓存文件,就会造成 Last-Modified 被修改,所以在 HTTP / 1.1 出现了 ETag

ETagIf-None-Match

  • ETag 类似于文件指纹,If-None-Match 会将当前 ETag 发送给服务器,询问该资源 ETag 是否变动,有变动的话就将新的资源发送回来。并且 ETag 优先级比 Last-Modified

选择合适的缓存策略

对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略

  • 对于某些不需要缓存的资源,可以使用 Cache-control: no-store ,表示该资源不需要缓存
  • 对于频繁变动的资源,可以使用 Cache-Control: no-cache 并配合 ETag 使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。
  • 对于代码文件来说,通常使用 Cache-Control: max-age=31536000 并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件

💬 面试官追问

  • 运营后台的配置接口每天变更多次,后端却给它设置一年 max-age,前端说再配 ETag 就能保证实时,你认可吗?

    不认可,因为强缓存有效期内浏览器通常不会发起请求,也就没有机会用 ETag 校验。频繁变动且仍允许保存副本的资源更适合 Cache-Control: no-cache 配合 ETag;若内容根本不应落入缓存,则应使用 no-store,代价是每次都要完整请求。

  • 构建后的脚本文件名带内容指纹,首页文档仍可能更新,你会怎样分别配置两类资源的缓存?

    带指纹的代码文件可设置 Cache-Control: max-age=31536000,内容变化后通过新文件名触发重新下载。入口文档若频繁变化,可采用 no-cache 并配合 ETag 做协商校验;若入口也长期强缓存,用户可能持续拿到引用旧资源的文档。

  • 公司代理仍会经过只理解部分旧缓存规则的链路,服务端同时返回 ExpiresCache-Control,浏览器应以谁为准?

    在支持 HTTP/1.1 缓存语义的客户端中,Cache-Control 的优先级高于 Expires,应以其指令和 max-age 为主要依据。Expires 依赖绝对时间且受本地时间影响,可作为兼容信息保留;两者冲突会增加排查成本,配置时应保持意图一致。

  • 用户反馈刚发布的 app.js 仍是旧内容,网络面板显示 200 且请求没有发到服务器,你会怎样定位?

    这更像资源命中了未过期的强缓存,应先检查响应中的 Cache-ControlExpires、实际 URL 和文件名是否变化。强缓存期间不会发起校验请求,状态表现仍可能是 200;若固定 URL 配了长 max-age,应改用内容指纹,而不是期待 ETag 立即纠正。

  • 图片接口返回内容很大但一周才变化一次,团队在 Last-ModifiedETag 之间争论,你会如何选择?

    两者都能用于协商缓存,但 ETag 更像资源指纹,且校验优先级高于 Last-Modified,更适合需要准确判断内容是否变化的资源。Last-Modified 依赖修改时间,本地打开缓存文件还可能改变该时间;采用 ETag 会增加服务端生成和维护标识的工作。

  • 安全团队要求登录后的敏感响应不能保存,性能团队却想用 no-cache 减少传输,你如何裁决?

    若要求是不允许存储响应,应使用 Cache-Control: no-store,因为 no-cache 仍允许缓存,只是每次使用前都要向服务器验证。性能团队可把协商缓存留给允许保存且频繁变化的普通资源;敏感数据场景需要接受重复传输带来的成本。

# 3 使用 HTTP / 2.0

⚡ 30 秒速记

  • 四个改进:二进制分帧、多路复用(一条连接跑多个请求)、头部压缩 HPACK、服务端推送(已被主流浏览器废弃)
  • 多路复用解决了 HTTP/1.1 的队头阻塞,也让雪碧图、域名分片、资源合并这些老优化失效甚至变成负优化
  • 前提是 HTTPS:浏览器只在 TLS 上启用 HTTP/2
  • 它没解决的问题:多路复用只解决 HTTP 层队头阻塞,TCP 层丢包后面还是全等着
  • 所以有了 HTTP/3:底层换成基于 UDPQUIC

HTTP/2 的核心改进是多路复用,让多个请求可以共用同一个 TCP 连接。HTTP/1.1 下,请求受浏览器并发限制,连接建立和断开会消耗多个 RTT,大文件还会受到 TCP 慢启动影响。HTTP/2 通过复用连接减少这类等待,同时使用头部压缩降低请求数据量,因此更适合资源请求较多的页面。

  • 因为浏览器会有并发请求限制,在 HTTP / 1.1 时代,每个请求都需要建立和断开,消耗了好几个 RTT 时间,并且由于 TCP 慢启动的原因,加载体积大的文件会需要更多的时间
  • HTTP / 2.0 中引入了多路复用,能够让多个请求使用同一个 TCP 链接,极大的加快了网页的加载速度。并且还支持 Header 压缩,进一步的减少了请求的数据大小

💬 面试官追问

  • 新闻首页把二十个接口迁到 HTTP/2 后,开发认为每个请求从此都不再有网络成本,你会怎样纠正?

    HTTP/2 的收益在于多个请求可复用同一条 TCP 连接,并通过头部压缩减少传输数据,并不意味着请求本身没有成本。接口响应体、服务端处理和网络往返仍然存在;若瓶颈不在连接并发或请求头,迁移后的体感提升可能有限。

  • 一个首屏要并发加载十个脚本和六张图片,你会用什么证据确认线上确实按 HTTP/2 复用了连接?

    应在浏览器网络面板或抓包结果中核对协议,并观察同一主机的多个请求是否共享连接,而不是只看服务器配置文件。只有客户端与服务端实际使用 HTTP/2,多路复用和头部压缩才会生效;若链路中间环节回落,页面仍会表现为旧协议路径。

  • 站点只有一个体积较大的下载文件,没有并发小请求,负责人仍把升级 HTTP/2 当作唯一优化项,你会同意吗?

    不会把它视为唯一方案,因为多路复用主要改善多个请求共享连接的场景,单个大文件仍受传输速率和 TCP 慢启动等因素影响。升级协议可能保留其他收益,但还应检查文件体积与传输链路;否则协议变化不一定解决主要耗时。

  • 网关宣称已启用 HTTP/2,但页面仍出现大量连接建立时间和并发等待,你会按什么顺序排查?

    先核对浏览器实际协商出的协议,再检查资源是否分散在不同主机,因为连接复用依赖实际使用的连接范围。随后区分等待来自旧协议并发限制、连接建立还是服务端响应;仅网关支持不代表每个资源请求都已通过同一条 HTTP/2 连接。

  • 老项目为绕过 HTTP/1.1 并发限制,把静态资源拆到多个域名;迁移 HTTP/2 后是否应立即合并?

    多域名会形成不同连接,无法让所有资源共享同一条 TCP 连接,因此应重新评估拆分是否仍有价值。是否合并还要结合部署、缓存和故障隔离约束验证,不能只改域名;若资源仍跨主机,多路复用的覆盖范围自然会受限。

# 4 预加载

⚡ 30 秒速记

  • <link rel="preload">:告诉浏览器本页马上要用的关键资源,提高其加载优先级
  • 必须写 as 属性(script/style/font/image),否则会重复下载
  • 字体预加载还要加 crossorigin,否则同样会请求两次
  • prefetch 的区别:preload 是「本页立刻要用」,prefetch 是「下个页面可能用」(低优先级,空闲时下)
  • 别滥用:preload 会抢占带宽,标记太多等于没有优先级

预加载适合那些暂时不用、但希望浏览器尽早获取的重要资源。 它本质上是一种声明式的 fetch,通过 <link rel="preload"> 强制浏览器提前发起请求,而且不会阻塞 onload 事件。合理使用可以降低首屏加载时间,例如提前获取后续会用到的重要文件;不过需要留意浏览器兼容性,不能把它当成所有环境都可靠的默认方案。

  • 在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载
  • 预加载其实是声明式的 fetch ,强制浏览器请求资源,并且不会阻塞 onload 事件,可以使用以下代码开启预加载
<link rel="preload" href="http://example.com">

预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好

💬 面试官追问

  • 详情页有一个用户可能点击后才播放的视频,产品要求首屏用 preload 强制下载,理由是它不会阻塞 onload,你接受吗?

    不能仅凭不阻塞 onload 就预加载,因为 preload 会强制浏览器请求资源,用户不播放时可能产生无效下载。它更适合当前页面确定需要且希望尽早取得的资源;对低概率交互内容,应结合使用概率和资源成本再决定。

  • 首屏确定要执行的脚本发现得很晚,你准备如何用 HTML 声明预加载,并避免链接指向错误资源?

    可在文档中加入 <link rel="preload" href="http://example.com/app.js">,让浏览器更早发起该资源请求。落地时必须保证 href 与页面最终使用的资源一致,并在目标浏览器中验证兼容性;地址不一致会形成额外请求,兼容性不足时也不能依赖它保证效果。

  • 内嵌 WebView 的浏览器版本分布复杂,业务负责人仍要求把首屏达标完全押在 preload 上,你如何评估?

    不应把结果完全押在 preload 上,因为其兼容性存在限制,部分运行环境可能无法获得预期收益。应先验证目标 WebView 是否支持并正确执行,再保留正常资源发现与加载路径;预加载只能作为提前请求的优化,不能成为资源可用性的唯一前提。

  • 上线后网络面板出现两次相同脚本请求,页面还加了对应 preload,你会先核对哪些地方?

    先核对预加载的 href 与脚本实际 URL 是否完全一致,包括协议、主机、路径和参数,再确认页面最终确实使用该资源。preload 是一次声明式请求,若声明目标与实际目标不同,就可能产生两份下载;还应检查是否有重复标签或重定向。

  • 团队把下一页可能使用的模块也全部标成 preload,架构师建议改成 prefetch,你站哪一边?

    下一页未来可能使用的资源更符合 prefetch,而 preload 面向当前页面所需的脚本、样式等资源。把大量不确定资源设为 preload 会强制提前请求,可能浪费传输资源;若下一页并非高概率访问,连 prefetch 也应谨慎使用。

  • 页面把非首屏但确定重要的文件延后加载,开发担心这会拖慢首次呈现,preloadasyncdefer 分别解决什么?

    preload 解决资源尽早获取的问题,本身不会阻塞 onloadasyncdefer 描述脚本异步读取后的执行方式。defer 保持脚本顺序依赖,async 则加载完成后执行;不能用执行属性替代资源优先发现,也不能用预加载替代脚本执行控制。

# 5 预渲染

⚡ 30 秒速记

  • 广义的「预渲染」有两类,别混:构建期预渲染(SSG)和浏览器预渲染(prerender
  • SSG:构建时生成静态 HTML,首屏最快且对 SEO 友好,适合内容不常变的页面
  • 浏览器的 <link rel="prerender"> 已被废弃,现代方案是 Speculation Rules APIspeculationrules
  • 骨架屏也算一种「感知层面的预渲染」:先给结构占位,减少主观等待
  • 代价:预渲染会消耗额外的 CPU 和流量,只对高概率访问的下一页做

预渲染就是让浏览器提前在后台渲染用户接下来大概率会访问的页面,从而缩短真正打开时的等待时间。 可以通过 <link rel="prerender" href="http://poetries.com"> 指定目标页面,本质上是用当前的网络和计算资源换取后续访问速度。这个优化只适合用户几乎确定会打开的页面,否则提前下载和渲染的内容没有被使用,反而会白白消耗资源。

可以通过预渲染将下载的文件预先在后台渲染,可以使用以下代码开启预渲染

<link rel="prerender" href="http://poetries.com">
  • 预渲染虽然可以提高页面的加载速度,但是要确保该页面百分百会被用户在之后打开,否则就白白浪费资源去渲染

总结

  • deferasync在网络读取的过程中都是异步解析
  • defer是有顺序依赖的,async只要脚本加载完后就会执行
  • preload 可以对当前页面所需的脚本、样式等资源进行预加载
  • prefetch 加载的资源一般不是用于当前页面的,是未来很可能用到的这样一些资源

💬 面试官追问

  • 电商首页列出一百个商品链接,运营想给每个详情页都加 <link rel="prerender">,说这样点击一定更快,你会批准吗?

    不会批准批量预渲染,因为它会在后台提前渲染目标页面,用户未访问的页面会白白消耗资源。预渲染只适合之后几乎确定会打开的页面;面对一百个候选目标,应缩小到确定性极高的路径,否则加速收益建立在大量浪费之上。

  • 支付成功页之后固定进入订单详情页,你会怎样写预渲染声明,并说明它何时值得保留?

    可在当前文档中加入 <link rel="prerender" href="http://poetries.com/order">,让目标页面预先在后台渲染。只有用户后续几乎必然进入该地址时才值得保留;若流程允许关闭页面、跳转其他入口或目标 URL 不确定,就应重新评估。

  • 同一流程从“付款后必进订单页”改成“付款后可能去抽奖、订单页或首页”,原有 prerender 还应保留吗?

    原有预渲染的成立条件已经变弱,不应继续按必达路径处理。它的代价不仅是提前获取,还包括后台渲染目标页面;当三个去向都可能发生时,应根据真实导航确定性缩减或移除,避免为未打开页面消耗资源。

  • 上线预渲染后,低端设备在当前页出现额外资源消耗,用户却很少进入目标页,你会怎样确认根因?

    先检查页面中的 prerender 声明及其目标地址,再对照用户实际后续导航,确认目标页是否真的高概率打开。若大多数用户没有访问,后台渲染就是直接浪费;应移除或只在路径高度确定时声明,不能用当前页加载完成来证明配置合理。

  • 结算页的下一步几乎确定,但团队在 prefetchprerender 之间争论,你如何选择?

    若只想提前取得未来可能使用的资源,prefetch 更贴近需求;若目标页面之后确定会打开,并希望连后台渲染也提前完成,才考虑 prerender。后者工作更重,对导航确定性要求更高;一旦用户改走其他路径,已完成的渲染就会浪费。

  • 首屏脚本、下一页资源和下一页完整页面都想提前处理,评审会上有人建议统一使用 preload,你会怎么拆分?

    当前页面所需的脚本或样式使用 preload,未来很可能用到的资源对应 prefetch,几乎确定会访问的完整页面才适合 prerender。三者提前处理的对象和成本不同,统一使用 preload 会混淆当前页资源与未来导航,也无法等同于后台渲染。

# 6 懒执行与懒加载

⚡ 30 秒速记

  • 懒加载:资源用到时才下载 —— 图片 loading="lazy"、路由级 import() 动态分包、组件级 React.lazy
  • 懒执行:代码已下载但推迟执行 —— 用 requestIdleCallbacksetTimeout 把非关键逻辑挪到空闲时
  • 图片懒加载的现代做法是原生 loading="lazy",不需要再写 IntersectionObserver
  • 但首屏主图要反过来:fetchpriority="high" 提高优先级,别让它被懒加载拖慢
  • 懒加载的坑:必须给占位尺寸(width/heightaspect-ratio),否则加载完顶动布局,CLS 变差

懒执行是推迟计算逻辑,懒加载则是推迟非关键资源的下载,两者优化的对象并不一样。 耗时逻辑如果首屏用不到,可以等定时器或用户事件触发后再执行;图片、视频等资源则可以等进入指定区域时再加载。图片场景里常先展示占位图,把真实地址放在自定义属性中,满足加载条件后再赋给 src,但要确保触发逻辑可靠。

懒执行

  • 懒执行就是将某些逻辑延迟到使用时再计算。该技术可以用于首屏优化,对于某些耗时逻辑并不需要在首屏就使用的,就可以使用懒执行。懒执行需要唤醒,一般可以通过定时器或者事件的调用来唤醒

懒加载

  • 懒加载就是将不关键的资源延后加载

懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片懒加载

  • 懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等

💬 面试官追问

  • 商品详情页把价格计算、推荐列表和首屏主图都改成进入视口后执行,结果页面先出现空壳,用户还没滚动就无法下单;这里把“懒”用错在哪了?

    首屏成交链路不应被统一延后,价格、主图等当前区域必需内容应立即执行或加载。懒执行适合暂时不用的耗时逻辑,懒加载适合非关键资源;判断标准是当前区域与当前操作是否依赖它,延后关键内容只会把成本变成可感知等待。

  • 一个长列表有 500 张商品图,接口一次返回了全部图片地址;你会怎样落地图片懒加载,同时避免未加载区域把页面高度撑乱?

    先为图片保留明确尺寸并展示占位图,把真实地址放在自定义属性中,再用 IntersectionObserver 监听图片进入或接近可视区域时替换 src。加载完成后应取消观察,失败时保留占位或展示重试入口;没有预留尺寸会在图片出现时引发布局跳动。

  • 资讯页的下一个视频刚进入可视区域才开始下载并播放,用户在弱网下总看到黑屏;产品又不接受进入页面就加载全部视频,你怎么调整触发边界?

    把触发区域从“已经可见”扩大为“即将进入可视区域”,提前加载下一段视频,但仍只播放当前可见内容。预加载距离应结合滚动速度、资源体积和网络状况控制;范围过大会退化为批量加载,范围过小则无法掩盖下载等待。

  • 图片懒加载上线后,部分图片滚到屏幕内仍保持占位图,而手动刷新又恢复;你会按什么顺序定位?

    先确认观察器是否绑定到真实图片节点,以及列表更新后新增节点是否被继续观察,再检查真实地址写入和 src 替换逻辑。随后查看图片请求是否发出、是否失败,并检查容器滚动场景下观察区域是否设置正确;只监听初始节点会让动态列表稳定复现漏加载。

  • 筛选面板包含一段耗时统计逻辑:用 setTimeout 延后执行,还是等用户点击“展开”再执行?如果结果还要用于首屏角标,你怎么选?

    若统计只服务于展开后的面板,应由展开事件唤醒,避免定时器在用户从不使用时仍消耗主线程。若首屏角标依赖同一结果,就不能整体懒执行,应拆出角标所需的最小计算,其余逻辑继续延后;拆分会增加状态与缓存一致性的维护成本。

# 7 文件优化

⚡ 30 秒速记

  • 图片:换 WebP/AVIF、按展示尺寸裁剪、srcset 响应式、图标用 SVG
  • JS:代码分割、Tree Shaking、按需引入(别整包引 lodash)、压缩混淆
  • CSS:关键 CSS 内联、去无用样式(PurgeCSS)、压缩
  • 字体:woff2、子集化(中文字体不做子集是灾难)、font-display: swappreload
  • 传输层:开 gzip/brotlibrotli 通常再省 15%~20%)、CDN 分发

文件优化的核心是减少资源体积、避免阻塞渲染,并把静态资源更高效地送到用户手里。 图片应按设备尺寸请求裁剪版本,照片可用 JPEG,小图可用 PNGbase64,图标优先考虑 SVG,支持时也可选压缩效果更好的 WebPCSS 放在 head,脚本按依赖选择 deferasync,耗时计算可交给 Webworker。服务端还应开启压缩并使用不同于主站的 CDN 域名,避免静态资源请求携带主站 Cookie

图片优化

对于如何优化图片,有 2 个思路

  • 减少像素点
  • 减少每个像素点能够显示的颜色

图片加载优化

  • 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
  • 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片
  • 小图使用 base64格式
  • 将多个图标文件整合到一张图片中(雪碧图)
  • 选择正确的图片格式:
    • 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
    • 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
    • 照片使用 JPEG

其他文件优化

  • CSS文件放在 head
  • 服务端开启文件压缩功能
  • script 标签放在 body 底部,因为 JS 文件执行会阻塞渲染。当然也可以把 script 标签放在任意位置然后加上 defer ,表示该文件会并行下载,但是会放到 HTML 解析完成后顺序执行。对于没有任何依赖的 JS文件可以加上 async ,表示加载和渲染后续文档元素的过程将和 JS 文件的加载与执行并行无序进行。 执行 JS代码过长会卡住渲染,对于需要很多时间计算的代码
  • 可以考虑使用 WebworkerWebworker可以让我们另开一个线程执行脚本而不影响渲染。

CDN

静态资源尽量使用 CDN 加载,由于浏览器对于单个域名有并发请求上限,可以考虑使用多个 CDN 域名。对于 CDN 加载静态资源需要注意 CDN 域名要与主站不同,否则每次请求都会带上主站的 Cookie

💬 面试官追问

  • 活动页为了减少请求,把一张 2MB 照片转成 base64 塞进主 CSS,请求数下降了但首屏更慢;你会如何判断这次优化?

    这是把小图内联策略错误地用于大图,图片体积会进入阻塞渲染的 CSS,还失去独立资源缓存和按需加载空间。base64 只适合足够小且确实值得减少请求的资源;大照片应选择合适尺寸与格式,并交给静态资源缓存或 CDN

  • 移动商品页拿到的是 1920px 原图,而内容区最多只有 375px;设计要求清晰,后端又已有图片裁剪服务,你会怎样改造请求?

    根据展示宽度和设备需求向图片服务请求相应裁剪尺寸,避免移动端下载远超实际像素需求的原图。照片可优先评估 WebP,并为不适用的环境保留 JPEG 等格式;裁剪过小或压缩过度会损害清晰度,需要在体积与观感之间验收。

  • 后台管理页有 80 个小图标,团队在雪碧图、SVG 和逐个 PNG 之间争论;哪些约束会改变你的选择?

    可缩放、需要换色的图标更适合 SVG,固定的小位图可考虑合并为雪碧图以减少请求,少量独立资源则未必值得增加维护复杂度。选择还要看兼容要求和更新频率;雪碧图中单个图标频繁变更会使整张图失效,独立文件则增加请求数量。

  • 线上首页的 CSS 已放在 head,但主体仍长期空白,网络面板显示一个同步 script 位于文档前部且执行时间很长;你会怎么处理?

    将非首屏脚本移到 body 底部或使用 defer,让它并行下载并在 HTML 解析完成后按顺序执行。没有依赖关系的脚本才适合评估 async,因为执行顺序不受保证;若真正瓶颈是长时间计算,还应拆分任务或交给 Web Worker

  • 架构师想给图片、脚本拆出多个 CDN 域名提升并发,安全同学担心请求携带登录 Cookie;你会怎样落方案?

    静态资源域名应与主站区分,并确保请求不携带主站 Cookie,否则每次资源请求都会增加无效传输并扩大暴露面。多域名能够缓解单域并发限制,但也会增加域名解析和运维成本;资源数量不多时,不应只为理论并发继续拆域。

# 8 其他

⚡ 30 秒速记

  • 减少重排重绘:动画只改 transform/opacity,批量 DOM 操作先读后写
  • 长任务拆分:超过 50ms 的任务会影响 INP,用时间切片或 Web Worker
  • 事件优化:委托代替逐个绑定,滚动和 resize 节流,touch/wheelpassive: true
  • content-visibility: auto 跳过屏幕外元素的渲染,长页面首屏提升明显
  • 别凭感觉优化:先用 Performance 面板抓火焰图定位瓶颈,再动手

除了资源本身,构建产物和线上错误监控也是性能优化不能漏掉的两块。 使用 Webpack4 时可启用 production、基于 ES6 模块做 tree shaking,再按路由拆包并给文件名加哈希,兼顾按需加载与浏览器缓存。线上错误可用 window.onerrorPromise.catchtry catch 捕获,跨域脚本还要配置 crossorigin。由于生产代码通常经过压缩,构建时应生成 sourceMap 方便定位,并把捕获结果上传到服务端。

使用 Webpack 优化项目

  • 对于 Webpack4,打包项目使用 production 模式,这样会自动开启代码压缩
  • 使用 ES6 模块来开启 tree shaking,这个技术可以移除没有使用的代码
  • 优化图片,对于小图可以使用 base64 的方式写入文件中
  • 按照路由拆分代码,实现按需加载
  • 给打包出来的文件名添加哈希,实现浏览器缓存文件

监控

对于代码运行错误,通常的办法是使用 window.onerror 拦截报错。该方法能拦截到大部分的详细报错信息,但是也有例外

  • 对于跨域的代码运行错误会显示 Script error. 对于这种情况我们需要给 script 标签添加 crossorigin 属性
  • 对于某些浏览器可能不会显示调用栈信息,这种情况可以通过 arguments.callee.caller 来做栈递归
  • 对于异步代码来说,可以使用 catch 的方式捕获错误。比如 Promise 可以直接使用 catch 函数,async await 可以使用 try catch
  • 但是要注意线上运行的代码都是压缩过的,需要在打包时生成 sourceMap 文件便于 debug
  • 对于捕获的错误需要上传给服务器,通常可以通过 img 标签的 src发起一个请求

💬 面试官追问

  • 项目切到 Webpack4production 后,负责人认为包体一定已经最优,不再做任何配置检查;你会拿什么反例追问他?

    production 会开启代码压缩,但不代表未使用代码、路由资源和图片都已按目标优化。还要确认源码使用 ES6 模块以支持 tree shaking、路由是否按需拆分,以及小图是否适合内联;动态引用或存在副作用时,未使用代码也未必能安全移除。

  • 一个管理系统有 30 条路由,首屏只进入工作台;你会如何组织构建产物,并让浏览器可靠复用未变化的文件?

    按路由拆分代码,让工作台只加载当前必需资源,其余路由在访问时再获取。输出文件名加入内容相关哈希,使未变化文件继续命中浏览器缓存、变化文件获得新地址;拆得过细会增加请求和运行时管理成本,需要结合页面依赖调整粒度。

  • 公司要求错误监控尽量不依赖业务接口,前端负责人提出所有异常都交给 window.onerror;遇到 Promiseasync/await 异常时,你会怎样补齐?

    window.onerror 可覆盖大部分运行时错误,但异步链路仍应在 Promise 上使用 catch,在 async/await 周围使用 try...catch。捕获后统一整理并上报,必要时可通过图片 src 发起轻量请求;遗漏异步出口会形成未记录的失败。

  • 线上监控只收到 Script error,故障来自另一个域名上的脚本,本地却有完整堆栈;你先检查哪两处配置?

    先检查跨域 script 是否添加了 crossorigin,再确认资源服务是否允许相应的跨域访问,否则浏览器不会暴露详细错误。即使拿到文件名和行列号,线上代码仍是压缩产物,还必须保留与该版本匹配的 sourceMap 才能映射回源码。

  • 安全团队不允许公开部署 sourceMap,研发又必须定位压缩代码报错;构建和监控之间应该怎样取舍?

    构建阶段仍应生成与发布版本对应的 sourceMap,供受控的调试或错误解析流程使用,不必把映射文件作为普通静态资源公开。监控事件还要携带可定位构建版本的信息;若映射文件与线上哈希不匹配,即使保留了文件也无法可靠还原位置。

# 9 如何根据chrome的timing优化

⚡ 30 秒速记

  • Network 面板的 Timing 分几段:Queueing(排队)、Stalled(阻塞)、DNS LookupInitial connectionSSLRequest sentWaiting (TTFB)Content Download
  • Queueing/Stalled 长 → 并发连接数被占满 → 收敛域名、升 HTTP/2、减少请求数
  • DNS/Connection/SSL 长 → 用 preconnect、开启连接复用(keep-alive
  • TTFB 长 → 服务端问题:加缓存、优化数据库、上 CDN 边缘节点
  • Content Download 长 → 资源太大:压缩、拆包、换更优的图片格式

我会先用 Chrome DevToolsperformance.timing 拆开各阶段耗时,再针对最长的一段优化。 DNS 慢就减少域名并配置 dns-prefetch,请求和下载慢则优先检查缓存、压缩、资源体积及并发阻塞。首屏资源可通过异步、预加载或懒加载后置,图片则压缩并选择 WebP 等格式。单页应用里直接依赖传统时间点不一定合适,还要结合图片 onload 等事件判断首屏完成。

性能优化API

  • Performanceperformance.now()new Date()区别,它是高精度的,且是相对时间,相对于页面加载的那一刻。但是不一定适合单页面场景
  • window.addEventListener("load", ""); window.addEventListener("domContentLoaded", "");
  • Imgonload事件,监听首屏内的图片是否加载完成,判断首屏事件
  • RequestFrameAnmationRequestIdleCallback
  • IntersectionObserverMutationObserverPostMessage
  • Web Worker,耗时任务放在里面执行

检测工具

  • Chrome Dev Tools
  • Page Speed
  • Jspref

前端指标

image-20210307184052955

window.onload = function(){
    setTimeout(function(){
        let t = performance.timing
        console.log('DNS查询耗时 :' + (t.domainLookupEnd - t.domainLookupStart).toFixed(0))
        console.log('TCP链接耗时 :' + (t.connectEnd - t.connectStart).toFixed(0))
        console.log('request请求耗时 :' + (t.responseEnd - t.responseStart).toFixed(0))
        console.log('解析dom树耗时 :' + (t.domComplete - t.domInteractive).toFixed(0))
        console.log('白屏时间 :' + (t.responseStart - t.navigationStart).toFixed(0))
        console.log('domready时间 :' + (t.domContentLoadedEventEnd - t.navigationStart).toFixed(0))
        console.log('onload时间 :' + (t.loadEventEnd - t.navigationStart).toFixed(0))

        if(t = performance.memory){
            console.log('js内存使用占比 :' + (t.usedJSHeapSize / t.totalJSHeapSize * 100).toFixed(2) + '%')
        }
    })
}

DNS预解析优化

dns解析是很耗时的,因此如果解析域名过多,会让首屏加载变得过慢,可以考虑dns-prefetch优化

DNS Prefetch 应该尽量的放在网页的前面,推荐放在 后面。具体使用方法如下:

<meta http-equiv="x-dns-prefetch-control" content="on">
<link rel="dns-prefetch" href="//www.zhix.net">
<link rel="dns-prefetch" href="//api.share.zhix.net">
<link rel="dns-prefetch" href="//bdimg.share.zhix.net">

request请求耗时

  • 不请求,用cache(最好的方式就是尽量引用公共资源,同时设置缓存,不去重新请求资源,也可以运用PWA的离线缓存技术,可以帮助wep实现离线使用)
  • 前端打包时压缩
  • 服务器上的zip压缩
  • 图片压缩(比如tiny),使用webp等高压缩比格式
  • 把过大的包,拆分成多个较少的包,防止单个资源耗时过大
  • 同一时间针对同一域名下的请求有一定数量限制,超过限制数目的请求会被阻塞。如果资源来自于多个域下,可以增大并行请求和下载速度
  • 延迟、异步、预加载、懒加载
  • 对于非首屏的资源,可以使用 defer 或 async 的方式引入
  • 也可以按需加载,在逻辑中,只有执行到时才做请求
  • 对于多屏页面,滚动时才动态载入图片

💬 面试官追问

  • 单页应用用 new Date() 记录按钮操作耗时,连续测量出现精度不足和系统时间调整带来的跳变;你会换成什么,并提醒什么边界?

    短时耗时测量应使用高精度、相对页面加载时刻的 performance.now(),避免依赖系统绝对时间变化。它适合计算同一页面生命周期内的时间差,但单页应用长期运行时不能把页面初始时刻直接当成每次路由的起点,应为各次操作单独记录基准。

  • 首页同时引用主站、接口、图片和分享服务四个域名,Chrome DevTools 显示首次访问的域名解析拖慢首屏;你会把优化写在哪里?

    对确定会在首屏使用的外部域名,在文档前部加入 dns-prefetch,并通过 x-dns-prefetch-control 开启相应控制,让解析尽早发生。只应声明高概率访问的域名;预解析过多不会减少资源体积,还可能引入无意义的解析开销。

  • 瀑布图显示一个首屏包下载时间很长,团队一方主张继续拆包,另一方主张合并以减少请求;你依据哪些现象裁决?

    先判断耗时来自单文件过大还是同域请求超过并发限制:前者适合压缩并拆出非首屏代码,后者可能需要减少首屏请求、利用缓存或调整资源域。拆包后仍应把非关键部分延迟或按需加载;仅把大包切成大量同时请求的小包,可能把下载耗时变成排队耗时。

  • 监控代码在 window.onload 回调里立刻读取 loadEventEnd,线上偶尔得到不完整结果;你会如何修正采集时机,并区分哪些阶段?

    应在 load 回调后再延迟到当前事件处理结束读取,避免 loadEventEnd 尚未写入。采集时分别计算 DNS、连接、响应、DOM 解析、白屏、DOMContentLoadedload 时间,先定位阶段再优化;单个总耗时无法说明瓶颈属于网络还是渲染。

  • 一个多屏图文页首屏资源已压缩,但用户流量仍很高;产品要求滚动顺滑,研发准备把所有图片都预加载,你会怎么取舍?

    非首屏图片应随滚动按需加载,必要时在即将进入视口时提前触发,而不是一次预加载全部资源。首屏资源可继续使用缓存、压缩和合适图片格式;触发太晚会出现占位等待,触发太早则重新增加请求竞争与流量。

# 10 移动端优化

⚡ 30 秒速记

  • 网络:移动网络延迟高,请求数比体积更致命 —— 合并请求、上 HTTP/2、离线包预下载
  • 渲染:减少重排、动画走合成层、长列表虚拟滚动;低端机 CPU 弱,长任务代价更大
  • 交互:点击热区不小于 44px、避免依赖 hovertouch 事件加 passive
  • 适配:viewport 必写、安全区用 env(safe-area-inset-*)100vh100dvh 避开地址栏
  • 首屏:SSR/骨架屏、图片按 DPR 给合适尺寸(别给 2x 屏发 4x 图)

移动端优化既要控制资源加载,也要降低脚本执行和页面渲染的开销。 我一般把首屏目标控制在三秒左右,静态资源使用缓存和 GZip,图片按尺寸与格式压缩,非首屏内容按需或异步加载。交互阶段要减少 DOM 操作、重绘和回流,高频的 scrolltouchmove 可配合 requestAnimationFrame 限制渲染频率。懒加载可能带来额外重绘,GPU 加速也应合理使用,不能为了指标全部开启。

img

1. 概述

  • PC优化手段在Mobile侧同样适用
  • Mobile侧我们提出三秒种渲染完成首屏指标
  • 基于第二点,首屏加载3秒完成或使用Loading
  • 基于联通3G网络平均338KB/s(2.71Mb/s),所以首屏资源不应超过1014KB
  • Mobile侧因手机配置原因,除加载外渲染速度也是优化重点
  • 基于第五点,要合理处理代码减少渲染损耗
  • 基于第二、第五点,所有影响首屏加载和渲染的代码应在处理逻辑中后置
  • 加载完成后用户交互使用时也需注意性能

2. 加载优化

加载过程是最为耗时的过程,可能会占到总耗时的80%时间,因此是优化的重点

2.1 缓存

使用缓存可以减少向服务器的请求数,节省加载时间,所以所有静态资源都要在服务器端设置缓存,并且尽量使用长Cache(长Cache资源的更新可使用时间戳)

2.2 压缩HTML、CSS、JavaScript

减少资源大小可以加快网页显示速度,所以要对HTMLCSSJavaScript等进行代码压缩,并在服务器端设置GZip

  • a) 压缩(例如,多余的空格、换行符和缩进)
  • b) 启用GZip

2.3 无阻塞

写在HTML头部的JavaScript(无异步),和写在HTML标签中的Style会阻塞页面的渲染,因此CSS放在页面头部并使用Link方式引入,避免在HTML标签中写StyleJavaScript放在页面尾部或使用异步方式加载

2.4 使用首屏加载

首屏的快速显示,可以大大提升用户对页面速度的感知,因此应尽量针对首屏的快速显示做优化。

2.5 按需加载

将不影响首屏的资源和当前屏幕资源不用的资源放到用户需要时才加载,可以大大提升重要资源的显示速度和降低总体流量。

PS:按需加载会导致大量重绘,影响渲染性能

  • a) LazyLoad
  • b) 滚屏加载
  • c) 通过Media Query加载

2.6 预加载

大型重资源页面(如游戏)可使用增加Loading的方法,资源加载完成后再显示页面。但Loading时间过长,会造成用户流失。

对用户行为分析,可以在当前页加载下一页资源,提升速度。

  • a)可感知Loading
  • b)不可感知的Loading(如提前加载下一页)

2.7 压缩图片

图片是最占流量的资源,因此尽量避免使用他,使用时选择最合适的格式(实现需求的前提下,以大小判断),合适的大小,然后使用智图压缩,同时在代码中用Srcset来按需显示

PS:过度压缩图片大小影响图片显示效果

  • a)使用智图( http://zhitu.tencent.com/ )
  • b)使用其它方式代替图片(1. 使用CSS3 2. 使用SVG 3. 使用IconFont
  • c)使用Srcset
  • d)选择合适的图片(1. webP优于JPG2. PNG8优于GIF
  • e)选择合适的大小(1. 首次加载不大于1014KB 2. 不宽于640(基于手机屏幕一般宽度))

2.8 减少Cookie

Cookie会影响加载速度,所以静态资源域名不使用Cookie

2.9 避免重定向

重定向会影响加载速度,所以在服务器正确设置避免重定向。

2.10 异步加载第三方资源

第三方资源不可控会影响页面的加载和显示,因此要异步加载第三方资源

2.11 减少HTTP请求

因为手机浏览器同时响应请求为4个请求(Android支持4个,iOS 5后可支持6个),所以要尽量减少页面的请求数,首次加载同时请求数不能超过4个

  • a)合并CSSJavaScript
  • b)合并小图片,使用雪碧图

3. 三、脚本执行优化

脚本处理不当会阻塞页面加载、渲染,因此在使用时需当注意

  • CSS写在头部,JavaScript写在尾部或异步
  • 避免图片和iFrame等的空Src,空Src会重新加载当前页面,影响速度和效率。
  • 尽量避免重设图片大小
  • 重设图片大小是指在页面、CSS、JavaScript等中多次重置图片大小,多次重设图片大小会引发图片的多次重绘,影响性能
  • 图片尽量避免使用DataURLDataURL图片没有使用图片的压缩算法文件会变大,并且要解码后再渲染,加载慢耗时长

4. CSS优化

尽量避免写在HTML标签中写Style属性

4.1 css3过渡动画开启硬件加速

.translate3d{
   -webkit-transform: translate3d(0, 0, 0);
   -moz-transform: translate3d(0, 0, 0);
   -ms-transform: translate3d(0, 0, 0);
   transform: translate3d(0, 0, 0);
 }

4.2 避免CSS表达式

CSS表达式的执行需跳出CSS树的渲染,因此请避免CSS表达式。

4.3 不滥用Float

Float在渲染时计算量比较大,尽量减少使用

4.4 值为0时不需要任何单位

为了浏览器的兼容性和性能,值为0时不要带单位

5. JavaScript执行优化

5.1 减少重绘和回流

  • 避免不必要的Dom操作
  • 尽量改变Class而不是Style,使用classList代替className
  • 避免使用document.write
  • 减少drawImage

5.2 TOUCH事件优化

使用touchstarttouchend代替click,因快影响速度快。但应注意Touch响应过快,易引发误操作

6. 渲染优化

6.1 HTML使用Viewport

Viewport可以加速页面的渲染,请使用以下代码

<meta name=”viewport” content=”width=device-width, initial-scale=1″>

6.2 动画优化

  • 尽量使用CSS3动画
  • 合理使用requestAnimationFrame动画代替setTimeout
  • 适当使用Canvas动画 5个元素以内使用css动画,5个以上使用Canvas动画(iOS8可使用webGL

6.3 高频事件优化

TouchmoveScroll 事件可导致多次渲染

  • 使用requestAnimationFrame监听帧变化,使得在正确的时间进行渲染
  • 增加响应变化的时间间隔,减少重绘次数

6.4 GPU加速

CSS中以下属性(CSS3 transitionsCSS3 3D transformsOpacityCanvasWebGLVideo)来触发GPU渲染,请合理使用

💬 面试官追问

  • 移动活动页为了满足“三秒首屏”,在资源没完成时一直显示全屏 Loading,弱网用户等很久仍看不到任何内容;你如何评价这个方案?

    Loading 只能让等待可感知,不能替代首屏资源与渲染优化,持续过久仍会造成用户流失。应先后置不影响首屏的代码和资源,尽快展示可用的首屏结构;大型重资源页面才适合等待资源完成后整体呈现,也要控制等待范围。

  • 移动首页预算约 1014KB,现在一张横幅原图就占了大半,设计不允许去掉;你会怎样压缩加载成本而不直接牺牲视觉需求?

    先按实际展示宽度生成合适尺寸,通过 srcset 按需选择,并在满足兼容与观感时采用 WebP 等压缩率更高的格式。还应使用图片压缩工具并避免超过手机所需尺寸;过度压缩会产生明显失真,最终必须以目标设备上的显示效果验收。

  • 页面首屏有 10 个脚本和样式请求,旧移动浏览器同时只能处理较少请求;团队又希望所有模块独立发布,你怎么处理这个冲突?

    首屏应优先减少必要请求数,可合并关键 CSSJavaScript 和小图片,并把非关键模块异步或按需加载。模块独立发布不等于必须在首屏分别请求全部产物;合并过度会扩大缓存失效范围,因此只聚合稳定且共同使用的关键资源。

  • 商品列表滚动时明显掉帧,埋点显示 scroll 回调频繁修改多个元素的内联 style;你会先改哪一段执行链?

    把高频 scroll 更新合并到 requestAnimationFrame 中,并增加响应间隔,避免同一帧内反复触发渲染。批量切换 class,减少零散 DOM 操作和不必要的重绘回流;若每帧计算本身仍然过重,仅限频不能消除卡顿。

  • 动画负责人要求给页面所有卡片加 translate3d 强制开启 GPU,测试发现低端机内存压力反而上升;你会怎么划定使用范围?

    硬件加速应只用于确实需要平滑过渡的动画元素,并优先使用 transformopacity 配合 requestAnimationFrame。大量元素长期占用独立合成资源会增加设备负担,因此要在真实移动设备上验证;静态卡片或偶发变化不应统一强制提升。

  • 第三方统计脚本偶尔超时并拖住移动首页,业务方又要求不能删除;你如何隔离它与首屏渲染?

    将第三方资源改为异步加载,并确保首屏内容不依赖其下载或执行结果,因为外部服务的时延不可控。脚本还应避免放在会阻塞解析的位置;异步化可能改变埋点初始化时序,业务事件需要在脚本就绪前暂存或允许安全丢弃。

# 九、工程化

# 1 介绍一下 webpack 的构建流程

⚡ 30 秒速记

  • 五步:初始化参数(合并配置)→ 开始编译(创建 Compiler)→ 确定入口 → 编译模块(递归调 loader 转换、解析依赖)→ 输出资源(生成 chunk 写文件)
  • 核心对象:Compiler(整个构建生命周期,只有一个)、Compilation(单次编译,热更新每次都新建)
  • 插件通过 Tapable 在各个钩子上挂回调,介入这条流程
  • loader 作用在「编译模块」这一步,负责单个文件的内容转换
  • 最终产物是依赖图 → 按 chunk 拆分 → 经 plugin 处理后写入 output

Webpack 的构建流程本质上是读取配置,从入口递归生成依赖图,再组织为 chunk 并输出资源。 启动时它会合并配置和命令行参数,创建全局的 Compiler,加载插件并开始一次 Compilation。模块经过对应 loader 转换后,再通过 AST 分析依赖,直到所有入口依赖都处理完成。最后按依赖关系组装产物并写入文件系统,插件则通过 Tapable 的生命周期事件介入并修改构建结果。

核心概念

  • entry:入口。webpack是基于模块的,使用webpack首先需要指定模块解析入口(entry),webpack从入口开始根据模块间依赖关系递归解析和处理所有资源文件。
  • output:输出。源代码经过webpack处理之后的最终产物。
  • loader:模块转换器。本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。
  • plugin:扩展插件。基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
  • module:模块。除了js范畴内的es module、commonJs、AMD等,css @import、url(...)、图片、字体等在webpack中都被视为模块。

解释几个 webpack 中的术语

  • module:指在模块化编程中我们把应用程序分割成的独立功能的代码模块
  • chunk:指模块间按照引用关系组合成的代码块,一个 chunk 中可以包含多个 module
  • chunk group:指通过配置入口点(entry point)区分的块组,一个 chunk group 中可包含一到多个 chunk
  • bundling:webpack 打包的过程
  • asset/bundle:打包产物

webpack 的打包思想可以简化为 3 点:

  • 一切源代码文件均可通过各种 Loader 转换为 JS 模块 (module),模块之间可以互相引用。
  • webpack 通过入口点(entry point)递归处理各模块引用关系,最后输出为一个或多个产物包 js(bundle) 文件。
  • 每一个入口点都是一个块组(chunk group),在不考虑分包的情况下,一个 chunk group 中只有一个 chunk,该 chunk 包含递归分析后的所有模块。每一个 chunk 都有对应的一个打包后的输出文件(asset/bundle

打包流程

  1. 初始化参数:从配置文件和 Shell 语句中读取并合并参数,得出最终的配置参数。
  2. 开始编译:从上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译。
  3. 确定入口:根据配置中的 entry 找出所有的入口文件。
  4. 编译模块:从入口文件出发,调用所有配置的 loader 对模块进行翻译,再找出该模块依赖的模块,这个步骤是递归执行的,直至所有入口依赖的模块文件都经过本步骤的处理。
  5. 完成模块编译:经过第 4 步使用 loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系。
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 chunk,再把每个 chunk 转换成一个单独的文件加入到输出列表,这一步是可以修改输出内容的最后机会。
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。

简版

  • Webpack CLI 启动打包流程;
  • 载入 Webpack 核心模块,创建 Compiler 对象;
  • 使用 Compiler 对象开始编译整个项目;
  • 从入口文件开始,解析模块依赖,形成依赖关系树;
  • 递归依赖树,将每个模块交给对应的 Loader 处理;
  • 合并 Loader 处理完的结果,将打包结果输出到 dist 目录。

在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到相关事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果

构建流程核心概念:

  • Tapable:一个基于发布订阅的事件流工具类,CompilerCompilation 对象都继承于 Tapable
  • Compiler:compiler对象是一个全局单例,他负责把控整个webpack打包的构建流程。在编译初始化阶段被创建的全局单例,包含完整配置信息、loaders、plugins以及各种工具方法
  • Compilation:代表一次 webpack 构建和生成编译资源的的过程,在watch模式下每一次文件变更触发的重新编译都会生成新的 Compilation 对象,包含了当前编译的模块 module, 编译生成的资源,变化的文件, 依赖的状态等
  • 而每个模块间的依赖关系,则依赖于AST语法树。每个模块文件在通过Loader解析完成之后,会通过acorn库生成模块代码的AST语法树,通过语法树就可以分析这个模块是否还有依赖的模块,进而继续循环执行下一个模块的编译解析。

最终Webpack打包出来的bundle文件是一个IIFE的执行函数。

// webpack 5 打包的bundle文件内容

(() => { // webpackBootstrap
    var __webpack_modules__ = ({
        'file-A-path': ((modules) => { // ... })
        'index-file-path': ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => { // ... })
    })

    // The module cache
    var __webpack_module_cache__ = {};

    // The require function
    function __webpack_require__(moduleId) {
        // Check if module is in cache
        var cachedModule = __webpack_module_cache__[moduleId];
        if (cachedModule !== undefined) {
                return cachedModule.exports;
        }
        // Create a new module (and put it into the cache)
        var module = __webpack_module_cache__[moduleId] = {
                // no module.id needed
                // no module.loaded needed
                exports: {}
        };

        // Execute the module function
        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

        // Return the exports of the module
        return module.exports;
    }

    // startup
    // Load entry module and return exports
    // This entry module can't be inlined because the eval devtool is used.
    var __webpack_exports__ = __webpack_require__("./src/index.js");
})

webpack详细工作流程

💬 面试官追问

  • 一个后台项目只有一个 entry,最终却生成了入口包、公共包和异步包;同事坚持“一个入口只会对应一个文件”,你怎么纠正?

    entry 对应的是入口点和 chunk group,并不保证最终只有一个输出文件。Webpack 会从入口递归建立模块依赖图,再根据代码分割等规则组装多个 chunk,每个 chunk 才通常对应一个输出 asset;不考虑分包时,才可近似理解为一个入口产生一个包。

  • 团队要让 Webpack 支持 TypeScriptLess、字体和图片,你会把这些处理分别放到构建流程的什么位置?

    这些资源都应先作为 module 进入依赖图,再由匹配的 loader 完成转换,例如 ts-loaderless-loader 和文件资源加载器。Webpackentry 递归解析引用,转换完成后才汇总模块关系并生成 chunk;若处理需要观察整轮构建或修改最终产物,则应交给 plugin

  • 同一套配置由开发服务器以 watch 模式运行,连续改动三个文件时,CompilerCompilation 会怎样变化?

    Compiler 在初始化阶段创建并贯穿整个构建生命周期,配置、插件和工具方法都由它持有。每次文件变化触发重新编译时会新建一个 Compilation,记录该轮模块、资源、变更文件和依赖状态;把轮次状态长期挂在 Compiler 上,容易造成数据串轮或缓存失真。

  • 一个新增的 loader 能输出合法代码,但它引用的子模块始终没有进入产物;你会沿构建链路查哪些环节?

    先确认入口是否实际到达该文件,以及 loader 的匹配范围和输出是否保留了可被解析的模块引用。随后检查转换结果生成的 AST 能否识别依赖,再核对依赖解析、模块编译和 chunk 组装结果;若依赖是在运行时动态拼接的,静态分析可能无法发现。

  • 产品要求在写入 dist 前改写所有资源名,同时还要转换单个源码文件;你会怎样划分 loaderplugin

    单文件内容转换放在 loader,因为它处于模块编译阶段,只需接收并返回可继续处理的内容。资源名改写应由 plugin 在输出资源阶段监听合适的 Tapable 钩子,利用 Compilation 操作完整产物列表;若等文件写入后再处理,会增加额外文件操作并可能破坏引用关系。

# 2 介绍 Loader

⚡ 30 秒速记

  • 本质是一个函数:接收源文件内容(字符串或 Buffer),返回处理后的内容
  • 执行顺序:从右到左、从下到上use 数组里最后一个先执行)
  • 关键 APIthis.callback(err, content, sourceMap) 返回多值、this.async() 处理异步、this.getOptions() 取配置
  • 必须是纯函数、无状态,不要在 loader 之间用全局变量传数据
  • 分类:prenormalinlinepost;常见如 babel-loadercss-loaderstyle-loader

LoaderWebpack 的模块转换器,负责把它原本不能直接处理的资源转换成可继续构建的内容。 它本质上是接收内容并返回转换结果的函数,多个 loader 可以按配置形成链式处理。比如 babel-loader 转换新语法,css-loader 解析 @importurl()style-loader 再把样式插入页面。编写时应保持单一职责和相互独立,否则转换链过重,也会让配置与问题定位变复杂。

常用 Loader:

  • file-loader: 加载文件资源,如 字体 / 图片 等,具有移动/复制/命名等功能;
  • url-loader: 通常用于加载图片,可以将小图片直接转换为 Data Url,减少请求;
  • babel-loader: 加载 js / jsx 文件, 将 ES6 / ES7 代码转换成 ES5,抹平兼容性问题;
  • ts-loader: 加载 ts / tsx 文件,编译 TypeScript;
  • style-loader: 将 css 代码以<style>标签的形式插入到 html 中;
  • css-loader: 分析@import和url(),引用 css 文件与对应的资源;
  • postcss-loader: 用于 css 的兼容性处理,具有众多功能,例如 添加前缀,单位转换 等;
  • less-loader / sass-loader: css预处理器,在 css 中新增了许多语法,提高了开发效率;

编写原则:

  • 单一原则: 每个 Loader 只做一件事;
  • 链式调用: Webpack 会按顺序链式调用每个 Loader;
  • 统一原则: 遵循 Webpack制定的设计规则和结构,输入与输出均为字符串,各个 Loader 完全独立,即插即用;

💬 面试官追问

  • 一个 Less 文件先后经过 less-loadercss-loaderstyle-loader,同事把三者都理解成“把样式塞进页面”,你怎么指出反例?

    三者承担的是连续但不同的转换职责:less-loader 处理预处理语法,css-loader 分析 @importurl() 依赖,style-loader 才把样式以 <style> 形式注入页面。把职责合进一个 loader 会削弱复用和可组合性,也违背单一职责原则。

  • 项目同时有 src 下的业务代码和大量已编译依赖,配置 babel-loader 时你会怎样控制匹配范围?

    babel-loader 只处理确实需要转换的 jsjsx,通常通过 test 配合 includeexclude 缩小到业务源码。这样既避免重复转换已编译的第三方代码,也减少无效搜索;若依赖包确实发布了目标环境不支持的语法,则要针对该包单独放开范围。

  • 设计一个图片规则时,小图希望内联,大图需要复制并重命名;你如何解释 url-loaderfile-loader 的职责边界?

    小图片可由 url-loader 转成 Data URL,从而减少独立资源请求;超过限定条件的文件则交给文件资源处理逻辑完成复制、命名和输出。内联会增大承载它的代码或样式体积,因此不能只追求减少请求而无条件处理所有图片。

  • 新增自定义 loader 后,构建报“下一个 loader 无法处理输入”,但单独执行转换函数结果正常,你先查什么?

    先检查整条 loader 链的执行顺序,以及当前输出是否符合下一个 loader 可识别的内容形式。每个 loader 都必须保持独立且可链式组合,不能只验证自身转换结果;若某一步输出了二进制或特殊结构,还需按 Webpack 的接口约定明确声明和适配。

  • 团队要实现“转换每个 SVG 文件”和“汇总全部图片生成资源清单”,负责人想统一写成一个 loader,你怎么取舍?

    SVG 到可识别模块内容的转换适合 loader,因为处理对象是单个文件,输入输出边界清晰。全量资源清单依赖整次编译的输出集合,应由 plugin 监听生命周期并读取产物;强行放进 loader 会让单文件转换产生跨模块状态和执行顺序风险。

# 3 介绍 plugin

⚡ 30 秒速记

  • 本质是一个带 apply(compiler) 方法的,在 apply 里注册生命周期钩子
  • 钩子基于 Tapable,分同步 tap 和异步 tapAsync/tapPromise
  • 两个核心对象:Compiler(全局唯一)和 Compilation(单次编译的模块和产物)
  • 常用钩子:entryOptioncompilecompilationemit(产物写盘前,最常用)、done
  • loader 的分工:loader 管「怎么读一个文件」,plugin 管「构建过程中还要做什么」

Plugin 是通过钩子参与 Webpack 整个构建生命周期、并在特定阶段修改打包结果的插件。 注册时,Webpack 会调用它的 apply 方法并传入全局唯一的 Compiler,插件再借助 Tapable 的事件机制订阅编译钩子。每轮编译会创建新的 Compilation,所以插件既能处理全局流程,也能介入单次编译。相比之下,loader 主要负责转换单个模块,Plugin 更适合生成页面、抽离样式或分析产物。

插件系统是 Webpack 成功的一个关键性因素。在编译的整个生命周期中,Webpack 会触发许多事件钩子,Plugin 可以监听这些事件,根据需求在相应的时间点对打包内容进行定向的修改。

一个最简单的 plugin 是这样的:

class Plugin{
  	// 注册插件时,会调用 apply 方法
  	// apply 方法接收 compiler 对象
  	// 通过 compiler 上提供的 Api,可以对事件进行监听,执行相应的操作
  	apply(compiler){
  		// compilation 是监听每次编译循环
  		// 每次文件变化,都会生成新的 compilation 对象并触发该事件
    	compiler.plugin('compilation',function(compilation) {})
  	}
}

注册插件:

// webpack.config.js
module.export = {
	plugins:[
		new Plugin(options),
	]
}

事件流机制:

Webpack 就像工厂中的一条产品流水线。原材料经过 Loader 与 Plugin 的一道道处理,最后输出结果。

  • 通过链式调用,按顺序串起一个个 Loader;
  • 通过事件流机制,让 Plugin 可以插入到整个生产过程中的每个步骤中;

Webpack 事件流编程范式的核心是基础类 Tapable,是一种 观察者模式 的实现事件的订阅与广播:

const { SyncHook } = require("tapable")

const hook = new SyncHook(['arg'])

// 订阅
hook.tap('event', (arg) => {
	// 'event-hook'
	console.log(arg)
})

// 广播
hook.call('event-hook')

Webpack 中两个最重要的类 CompilerCompilation 便是继承于 Tapable,也拥有这样的事件流机制。

  • Compiler: 可以简单的理解为 Webpack 实例,它包含了当前 Webpack 中的所有配置信息,如 options, loaders, plugins 等信息,全局唯一,只在启动时完成初始化创建,随着生命周期逐一传递;

  • Compilation: 可以称为 编译实例。当监听到文件发生改变时,Webpack 会创建一个新的 Compilation 对象,开始一次新的编译。它包含了当前的输入资源,输出资源,变化的文件等,同时通过它提供的 api,可以监听每次编译过程中触发的事件钩子;

  • 区别:

    • Compiler 全局唯一,且从启动生存到结束;
    • Compilation对应每次编译,每轮编译循环均会重新创建;
  • 常用 Plugin:

    • UglifyJsPlugin: 压缩、混淆代码;
    • CommonsChunkPlugin: 代码分割;
    • ProvidePlugin: 自动加载模块;
    • html-webpack-plugin: 加载 html 文件,并引入 css / js 文件;
    • extract-text-webpack-plugin / mini-css-extract-plugin: 抽离样式,生成 css 文件; DefinePlugin: 定义全局变量;
    • optimize-css-assets-webpack-plugin: CSS 代码去重;
    • webpack-bundle-analyzer: 代码分析;
    • compression-webpack-plugin: 使用 gzip 压缩 js 和 css;
    • happypack: 使用多进程,加速代码构建;
    • EnvironmentPlugin: 定义环境变量;
  • 调用插件 apply 函数传入 compiler 对象

  • 通过 compiler 对象监听事件

loader和plugin有什么区别?

webapck默认只能打包JS和JOSN模块,要打包其它模块,需要借助loader,loader就可以让模块中的内容转化成webpack或其它loader可以识别的内容。

  • loader就是模块转换化,或叫加载器。不同的文件,需要不同的loader来处理。
  • plugin是插件,可以参与到整个webpack打包的流程中,不同的插件,在合适的时机,可以做不同的事件。

webpack中都有哪些插件,这些插件有什么作用?

  • html-webpack-plugin 自动创建一个HTML文件,并把打包好的JS插入到HTML文件中
  • clean-webpack-plugin 在每一次打包之前,删除整个输出文件夹下所有的内容
  • mini-css-extrcat-plugin 抽离CSS代码,放到一个单独的文件中
  • optimize-css-assets-plugin 压缩css

💬 面试官追问

  • 一个插件只修改单个 .js 文件源码,同事却说“只要用了 apply 就一定比 loader 合适”,你怎么反驳?

    是否适合 plugin 取决于是否需要介入构建生命周期,而不是有没有 apply 方法。单文件、可链式的内容转换通常属于 loader;只有需要观察编译事件、访问整轮资源或定向修改打包结果时,plugin 的事件钩子才体现价值。

  • 你要写一个插件,在每轮构建中读取所有输出资源并生成清单,apply 里应该抓住哪些对象?

    插件注册时由 Webpack 调用 apply(compiler),先通过 compiler 订阅与编译相关的钩子,再在回调中取得当轮 Compilation。资源清单应从该轮编译实例的输出资源生成,避免把上一轮数据留在全局状态中;具体钩子还要服从资源是否已完整生成的时机要求。

  • 开发服务器持续运行并触发 20 次增量编译,插件把资源列表缓存到 Compiler 上且从不清空,会有什么问题?

    Compiler 全局唯一并持续到进程结束,而每轮文件变化都会创建新的 Compilation。把轮次资源长期累计在 Compiler 上,可能让已删除或改名的资源混入后续结果;缓存若必须跨轮保留,就要明确失效条件,并以当前 Compilation 为事实来源。

  • 自定义插件在首次构建正常,文件改动后却重复执行多次并生成重复内容,你会先排查哪里?

    先确认钩子是否在每次 compilation 回调里被重复注册,以及插件实例是否被配置了多次。再区分订阅的是全局 Compiler 生命周期还是单轮 Compilation 生命周期,检查状态是否随新编译重置;错误的注册层级会在 watch 模式下不断叠加监听。

  • 团队要自动生成 HTML 并注入打包后的 JS,另一个方案是在源码里手写固定文件名,你会选哪个?

    应让类似 html-webpack-plugin 的插件在产物确定后生成或修改 HTML,依据实际资源名完成注入。手写文件名无法可靠跟随代码分割、内容哈希和资源增删变化;代价是插件必须选择正确钩子,并避免覆盖其他插件已经完成的修改。

# 4 webpack 热更新实现原理

⚡ 30 秒速记

  • 四方参与:webpack 编译 → webpack-dev-server 起服务 → WebSocket 长连接推消息 → HMR runtime 在浏览器里替换模块
  • 流程:文件变化触发重新编译 → 生成 hash 和更新清单 → 通过 WebSocket 把新 hash 推给浏览器
  • 浏览器收到后用 JSONP 拉取变更的 chunk.hot-update.js)和清单(.hot-update.json
  • HMR runtime 替换模块并沿依赖树向上冒泡,找到有 module.hot.accept 的地方就停止;找不到就整页刷新
  • 框架的 HMRReact Fast Refresh/vue-loader)就是帮你写好了 accept 回调,还能保留组件状态

Webpack 热更新本质上是重新编译变更模块,再由 HMR 运行时把补丁替换到当前页面中。 文件变化后,文件系统通知 Webpack,它只为相关模块生成新的编译结果,并通知 HMR Server。服务端通过 WebSocket 告知浏览器存在更新,浏览器再用 HTTP 请求对应的更新内容。运行时能够接收更新就替换模块;如果判断无法更新,则会退化为整页刷新。

HMR 的基本流程图

  • 当修改了一个或多个文件;
  • 文件系统接收更改并通知 webpack
  • webpack 重新编译构建一个或多个模块,并通知 HMR 服务器进行更新;
  • HMR Server 使用 webSocket 通知 HMR runtime 需要更新,HMR 运行时通过 HTTP 请求更新 jsonp
  • HMR 运行时替换更新中的模块,如果确定这些模块无法更新,则触发整个页面刷新

💬 面试官追问

  • 修改一个样式模块后浏览器收到了 WebSocket 消息,同事便认定新代码也通过 WebSocket 传输,这个判断哪里不对?

    WebSocket 主要负责由 HMR Server 通知浏览器端运行时存在新更新,并不等于承载完整更新代码。HMR runtime 收到通知后还会通过 HTTP 请求更新内容,再执行模块替换;因此通知链路正常,只能证明更新事件已送达。

  • 一个包含 300 个模块的管理后台只改了其中一个文件,HMR 从磁盘变化到页面更新会经过哪些关键环节?

    文件系统先通知 WebpackWebpack 针对受影响模块重新编译并把更新信息交给 HMR Server。服务端经 WebSocket 通知浏览器,HMR runtime 再通过 HTTP 获取更新并替换对应模块;是否能保留页面状态,还取决于更新边界能否被安全接受。

  • 公司代理允许普通 HTTP 请求却拦截 WebSocket,页面初次加载正常,但修改文件后一直没有热更新,你如何判断影响范围?

    初始资源仍可通过 HTTP 加载,但浏览器收不到 HMR Server 的更新通知,因此不会主动请求增量内容。应先检查连接建立、代理升级配置和服务端通知是否到达;即使更新文件可直接访问,缺少通知链路也无法自动触发替换。

  • 开发环境改文件后服务端已重新编译,浏览器也收到通知,却最终整页刷新;你按什么顺序排查?

    先确认 HMR runtime 是否成功发起更新请求以及返回内容是否可执行,再检查变更模块能否被当前热更新边界接受。运行时若判断模块无法安全替换,就会退化为整页刷新;若请求本身失败,则应优先排查资源路径、服务端输出和网络代理。

  • 有人提议每次文件变化都直接刷新页面,认为这与 HMR 没有本质差异;在复杂表单页面里你怎么取舍?

    整页刷新会重新加载整个应用,并丢失未持久化的界面状态;HMR 只重新编译和获取受影响模块,成功时可局部替换。复杂表单更适合保留 HMR,但必须接受无法更新时仍会刷新页面,并确保状态不能只依赖热更新来保护。

# 5 webpack 层面如何做性能优化

⚡ 30 秒速记

  • 先分析再优化:webpack-bundle-analyzer 看清谁占体积,别凭感觉
  • 减体积:splitChunks 拆公共依赖、动态 import() 懒加载、Tree Shaking、按需引入、压缩
  • 提速度:webpack 5 内置 cache: { type: 'filesystem' }(提升最明显)、thread-loader 多进程、loaderinclude 缩小范围
  • 换更快的工具链:esbuild-loader/swc-loader 替代 babel-loader,速度提升数倍
  • 终极方案:新项目直接上 ViteRspack

Webpack 优化要分成构建速度和产物体积两条线,并先用数据找到真正的瓶颈。 我一般用 speed-measure-webpack-plugin 看编译耗时,用 webpack-bundle-analyzer 检查包里哪些内容占空间。构建侧可以通过缓存、限制 loader 处理范围和减少模块搜索来提速;产物侧则依靠 Tree ShakingSplitChunksPlugin 和代码压缩减少下载量。多进程、DLLPluginexternals 都有配置成本,应结合项目依赖和构建情况选择。

优化前的准备工作

  • 准备基于时间的分析工具:我们需要一类插件,来帮助我们统计项目构建过程中在编译阶段的耗时情况。speed-measure-webpack-plugin 分析插件加载的时间
  • 使用 webpack-bundle-analyzer 分析产物内容

代码优化:

无用代码消除,是许多编程语言都具有的优化手段,这个过程称为 DCE (dead code elimination),即 删除不可能执行的代码;

例如我们的 UglifyJs,它就会帮我们在生产环境中删除不可能被执行的代码,例如:

var fn = function() {
	return 1;
	// 下面代码便属于 不可能执行的代码;
	// 通过 UglifyJs (Webpack4+ 已内置) 便会进行 DCE;
	var a = 1;
	return a;
}

摇树优化 (Tree-shaking),这是一种形象比喻。我们把打包后的代码比喻成一棵树,这里其实表示的就是,通过工具 "摇" 我们打包后的 js 代码,将没有使用到的无用代码 "摇" 下来 (删除)。即 消除那些被 引用了但未被使用 的模块代码。

  • 原理: 由于是在编译时优化,因此最基本的前提就是语法的静态分析,ES6的模块机制 提供了这种可能性。不需要运行时,便可进行代码字面上的静态分析,确定相应的依赖关系。
  • 问题: 具有 副作用 的函数无法被 tree-shaking
    • 在引用一些第三方库,需要去观察其引入的代码量是不是符合预期;
    • 尽量写纯函数,减少函数的副作用;
    • 可使用 webpack-deep-scope-plugin,可以进行作用域分析,减少此类情况的发生,但仍需要注意;

code-spliting: 代码分割技术,将代码分割成多份进行 懒加载 或 异步加载,避免打包成一份后导致体积过大,影响页面的首屏加载;

  • Webpack 中使用 SplitChunksPlugin 进行拆分;
  • 按 页面 拆分: 不同页面打包成不同的文件;
  • 按 功能 拆分:
    • 将类似于播放器,计算库等大模块进行拆分后再懒加载引入;
    • 提取复用的业务代码,减少冗余代码;
  • 按 文件修改频率 拆分: 将第三方库等不常修改的代码单独打包,而且不改变其文件 hash 值,能最大化运用浏览器的缓存;

scope hoisting: 作用域提升,将分散的模块划分到同一个作用域中,避免了代码的重复引入,有效减少打包后的代码体积和运行时的内存损耗;

编译性能优化:

  • 升级至 最新 版本的 webpack,能有效提升编译性能;
  • 使用 dev-server / 模块热替换 (HMR) 提升开发体验;
    • 监听文件变动 忽略 node_modules 目录能有效提高监听时的编译效率;
  • 缩小编译范围
    • modules: 指定模块路径,减少递归搜索;
    • mainFields: 指定入口文件描述字段,减少搜索;
    • noParse: 避免对非模块化文件的加载;
    • includes/exclude: 指定搜索范围/排除不必要的搜索范围;
    • alias: 缓存目录,避免重复寻址;
  • babel-loader
    • 忽略node_moudles,避免编译第三方库中已经被编译过的代码
    • 使用cacheDirectory,可以缓存编译结果,避免多次重复编译
  • 多进程并发
    • webpack-parallel-uglify-plugin: 可多进程并发压缩 js 文件,提高压缩速度;
    • HappyPack: 多进程并发文件的 Loader 解析;
  • 第三方库模块缓存:
    • DLLPluginDLLReferencePlugin 可以提前进行打包并缓存,避免每次都重新编译;
  • 使用分析
    • Webpack Analyse / webpack-bundle-analyzer 对打包后的文件进行分析,寻找可优化的地方
    • 配置profile:true,对各个编译阶段耗时进行监控,寻找耗时最多的地方
  • source-map:
    • 开发: cheap-module-eval-source-map
    • 生产: hidden-source-map

优化webpack打包速度

  • 减少文件搜索范围
    • 比如通过别名
    • loadertestinclude & exclude
  • Webpack4 默认压缩并行
  • Happypack 并发调用
  • babel 也可以缓存编译
  • Resolve 在构建时指定查找模块文件的规则
  • 使用DllPlugin,不用每次都重新构建
  • externalsDllPlugin 解决的是同一类问题:将依赖的框架等模块从构建过程中移除。它们的区别在于
    • 在 Webpack 的配置方面,externals 更简单,而 DllPlugin 需要独立的配置文件。
    • DllPlugin 包含了依赖包的独立构建流程,而 externals 配置中不包含依赖框架的生成方式,通常使用已传入 CDN 的依赖包
    • externals 配置的依赖包需要单独指定依赖模块的加载方式:全局对象、CommonJS、AMD 等
    • 在引用依赖包的子模块时,DllPlugin 无须更改,而 externals 则会将子模块打入项目包中

优化打包体积

  • 提取第三方库或通过引用外部文件的方式引入第三方库
  • 代码压缩插件UglifyJsPlugin
  • 服务器启用gzip压缩
  • 按需加载资源文件 require.ensure
  • 优化devtool中的source-map
  • 剥离css文件,单独打包
  • 去除不必要插件,通常就是开发环境与生产环境用同一套配置文件导致
  • Tree Shaking 在构建打包过程中,移除那些引入但未被使用的无效代码
  • 开启 scope hosting
    • 体积更小
    • 创建函数作用域更小
    • 代码可读性更好

💬 面试官追问

  • 产物分析显示某个库被引用却未使用,团队开启 Tree Shaking 后体积仍没下降;你会指出哪些反例边界?

    Tree Shaking 依赖 ES6 模块的静态结构来判断引用关系,并不等同于删除所有看似无用的代码。带副作用的模块或函数可能无法安全移除,应检查导入方式、第三方库实际引入量和副作用声明;强行删除会改变运行结果。

  • 一个项目构建耗时明显上升,但团队还没拿到阶段数据,你会怎样安排第一轮优化?

    先用 speed-measure-webpack-pluginprofile 定位耗时阶段,再用 webpack-bundle-analyzer 检查产物组成,避免把构建速度和包体积混为一谈。确认瓶颈后再缩小 loader 范围、启用 babel-loader 缓存或调整解析规则;并发工具并非对所有任务都有收益。

  • 首屏包过大,但业务要求播放器只有点击后才加载,第三方框架还要尽量命中长期缓存,你怎么拆?

    播放器适合按功能使用异步加载,避免进入首屏;复用业务代码可抽成公共块,低频变更的第三方库可单独拆分以稳定文件哈希。拆分应通过 SplitChunksPlugin 等规则落地,但块过多会增加加载调度成本,仍需结合实际依赖关系验证。

  • 开发构建慢,日志显示大量时间花在模块搜索和 node_modules 转译上,你会改哪些配置并防什么风险?

    testincludeexclude 缩小 loader 搜索范围,并通过 modulesmainFieldsalias 等解析配置减少递归寻址;babel-loader 可排除无须转译的依赖并启用 cacheDirectory。范围收得过窄可能漏掉含不兼容语法的第三方模块,因此要以目标运行环境验证。

  • 架构组在 externalsDllPlugin 之间争论如何移出大型依赖,你会依据什么选择?

    已有稳定 CDN 资源且能明确全局对象、CommonJSAMD 等加载方式时,externals 配置更直接。需要独立构建并缓存依赖、且业务仍会引用依赖子模块时,DllPlugin 更完整,但配置和维护成本更高;两者都要防止外部依赖版本与业务构建不一致。

  • 生产包已经做了代码分割,运维又要求开启 gzip,前端同事认为二者只能选一个,你怎么解释它们的关系?

    代码分割控制哪些代码在何时加载,gzip 控制传输时的压缩体积,两者解决的不是同一层问题,可以同时使用。还可配合代码压缩、样式抽离、Tree Shaking 和作用域提升减少产物;最终仍需分别检查包结构、网络传输和运行时开销。

# 6 介绍一下 Tree Shaking

⚡ 30 秒速记

  • 依赖 ESM静态结构import/export 在编译期就能确定依赖,不像 require 可以动态拼字符串
  • 流程:标记未被使用的导出(usedExports)→ 压缩阶段由 Terser 真正删掉死代码
  • 生效前提:用 ESM 语法(Babel 要设 modules: false 别转成 CJS)、production 模式、package.jsonsideEffects
  • sideEffects 的作用:告诉打包器「删掉这个文件不会有副作用」,否则不敢删(比如只 import 一个 CSS 文件)
  • 常见失效原因:被转成 CJS、库只提供 CJS 产物、代码有隐式副作用(顶层改原型、注册全局)

Tree Shaking 是在打包阶段分析并删除已引入但实际没有使用的代码。 它依赖 ES Moduleimportexport 能被静态分析,因此工具不运行程序也能判断模块和变量的引用关系;CommonJS 具有动态加载特性,就不适合这样处理。代码存在副作用时,打包器不能贸然删除,可以用 package.jsonsideEffects 明确标记。还要避免让 Babel 把模块转成不利于静态分析的形式,否则优化可能无法生效。

对tree-shaking的了解

作用:

它表示在打包的时候会去除一些无用的代码

原理

  • ES6的模块引入是静态分析的,所以在编译时能正确判断到底加载了哪些模块
  • 分析程序流,判断哪些变量未被使用、引用,进而删除此代码

特点:

  • 在生产模式下它是默认开启的,但是由于经过babel编译全部模块被封装成IIFE,它存在副作用无法被tree-shaking
  • 可以在package.json中配置sideEffects来指定哪些文件是有副作用的。它有两种值,一个是布尔类型,如果是false则表示所有文件都没有副作用;如果是一个数组的话,数组里的文件路径表示改文件有副作用
  • rollupwebpack中对tree-shaking的层度不同,例如对babel转译后的class,如果babel的转译是宽松模式下的话(也就是loosetrue),webpack依旧会认为它有副作用不会tree-shaking掉,而rollup会。这是因为rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。

原理

  • ES6 Module 引入进行静态分析,故而编译的时候正确判断到底加载了那些模块
  • 静态分析程序流,判断那些模块和变量未被使用或者引用,进而删除对应代码

依赖于import/export

通过导入所有的包后再进行条件获取。如下:

import foo from "foo";
import bar from "bar";

if(condition) {
    // foo.xxxx
} else {
    // bar.xxx
}

ES6的import语法完美可以使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码

CommonJS的动态特性模块意味着tree shaking不适用。因为它是不可能确定哪些模块实际运行之前是需要的或者是不需要的。在ES6中,进入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

// 不可行,ES6 的import是完全静态的
if(condition) {
    myDynamicModule = require("foo");
} else {
    myDynamicModule = require("bar");
}

💬 面试官追问

  • 商品页只引用了工具包导出的 formatPrice,构建产物里却仍保留同文件导出的 formatDate;既然用了 import,为什么不能直接认定 Tree Shaking 一定生效?

    import/export 只是静态分析的必要条件,不等于未引用代码必然可删。打包器还要结合程序流和副作用判断;如果模块经过转译后被封装成 IIFE,或相关代码可能产生副作用,就会保守地保留。

  • 团队准备发布一个包含几十个工具函数的 ESM 包,希望业务只打入实际引用的函数,package.json 应怎样配置?

    确认所有文件确实没有导入即执行的副作用后,可配置 "sideEffects": false,帮助打包器安全删除未使用模块。若只有样式或初始化文件有副作用,应改用数组逐项声明,否则误标可能把业务依赖的执行逻辑一起删掉。

  • 同一套源码直接交给打包器时能摇掉未使用导出,经 Babel 处理后却全部保留,你会先检查哪一层?

    先检查转译产物是否仍保留 ESM 的静态 import/export,以及模块是否被包装成带潜在副作用的 IIFETree Shaking 依赖编译期可分析的模块关系;结构被改写后,打包器无法证明代码无副作用,只能停止删除。

  • 订单页根据运行时条件在 require("foo")require("bar") 之间选择,产品要求构建时移除永远不会走到的分支,Tree Shaking 能完成吗?

    仅凭这种动态 CommonJS 加载通常不能完成,因为构建阶段无法确定运行时究竟需要哪个模块。应把可静态确定的依赖改成顶层 import,再让程序流分析处理未引用部分;若条件只能运行时确定,两份依赖都可能需要保留。

  • 一个转译后的 classwebpack 里未被删除,而同样产物交给 Rollup 却能删除,你会如何向构建负责人解释差异?

    两者对副作用和程序流的判断能力并不完全相同,不能只凭“都支持 Tree Shaking”推断结果一致。对于 Babel 宽松模式转译的 classwebpack 可能保守认定有副作用,而 Rollup 可借助程序流分析判断其实际影响;切换工具也要重新验证产物语义。

# 7 介绍一下 webpack scope hosting

⚡ 30 秒速记

  • 全称 Scope Hoisting(作用域提升):把多个模块的代码合并到同一个函数作用域
  • 解决的问题:默认打包会给每个模块包一个函数(__webpack_require__),模块多了函数调用和闭包开销大
  • 收益:产物体积更小、运行时更快(少了大量函数包裹和 require 调用)
  • 生效前提同样需要 ESM(静态分析才能确定模块间的引用关系)
  • production 模式默认开启(ModuleConcatenationPlugin);有模块被 CJS 引用或被多处引用时会自动放弃合并

Scope Hoisting 会把原本分散在多个模块函数里的代码合并到同一作用域,减少函数包裹和模块间的调用。 webpack 默认会为每个模块创建独立函数,模块多了会增加产物体积和运行时开销。它依赖 ESM 的静态导入导出关系,动态的 CommonJS require 无法完成这种提升。webpack 4+mode: 'production' 下默认开启,对应 optimization.concatenateModules

作用域提升(Scope Hoisting),将分散的模块划分到同一个作用域中,避免了代码的重复引入,有效减少打包后的代码体积和运行时的内存损耗。

原理:webpack 默认会把每个模块包裹进一个独立的函数((function(module, exports, require){...})),模块越多,这类包裹函数和 __webpack_require__ 调用越多,产物体积和运行时开销都变大。Scope Hoisting 会分析模块间的依赖关系,把可以合并的模块「提升」到同一个函数作用域里,减少函数包裹和模块间的 require 调用。

要点

  1. 依赖 ESM 的静态结构:Scope Hoisting 只能对 ES Module(import/export)生效——因为它需要在编译期静态分析模块的导入导出关系;CommonJS 的动态 require 无法静态分析,不能提升。这也是「用 ESM 才能享受更好的打包优化」的又一个理由(同 Tree Shaking)。
  2. 生产模式默认开启:webpack 4+ 在 mode: 'production' 下默认启用(对应 optimization.concatenateModules),开发模式默认关闭。
  3. 收益:更小的产物、更少的闭包和函数调用、更快的运行时。

一句话:Scope Hoisting 把多个 ESM 模块合并到同一作用域,减少函数包裹和 require 调用,从而减小体积、提升运行时性能;它依赖 ESM 的静态结构,生产模式默认开启。

💬 面试官追问

  • 一个后台页面拆成上百个小模块后,产物里出现大量 (function(module, exports, require){...})__webpack_require__ 调用;Tree Shaking 已经开启,为什么这些包装还在?

    Tree Shaking 负责移除未使用代码,并不负责消除仍需执行模块的包装。Scope Hoisting 才会分析依赖关系,把可合并的 ESM 模块提升到同一函数作用域,从而减少包装函数和模块间的 require 调用。

  • 构建负责人想在生产包中启用作用域提升,webpack 4+ 项目需要增加什么配置,开发环境又该怎么处理?

    mode: "production" 下通常无需额外配置,optimization.concatenateModules 默认启用。开发模式默认关闭;若手动开启,应先确认调试体验和构建行为符合预期,因为它会改变模块在产物中的组织方式。

  • 组件库全部使用 requiremodule.exports,业务方要求仅打开 concatenateModules 就把模块合并到一个作用域,能达到吗?

    不能据此保证合并,因为 Scope Hoisting 依赖 ESM 的静态导入导出关系。动态 require 无法在编译期完整确定依赖图,应先评估迁移到 import/export;否则打包器只能保留独立模块包装。

  • 生产包明明开启了 concatenateModules,入口附近仍有很多独立函数模块,你会怎样判断是配置失效还是模块不可提升?

    先确认实际构建使用了生产模式,并检查输入模块是否仍是可静态分析的 ESM。若前置转译已改写为 CommonJS,依赖关系就无法满足提升条件;此时继续调整压缩参数不会解决根因。

  • 性能评审会上,有人主张只做 Scope Hoisting、不做 Tree Shaking,因为两者都能缩小包体;你会怎么裁决?

    两者解决的冗余不同,不能互相替代:Tree Shaking 删除未引用代码,Scope Hoisting 合并仍需保留的模块作用域。生产构建可同时利用二者,但都依赖 ESM 的静态结构;输入是动态模块时,两类优化都会受限。

# 8 Webpack Proxy工作原理?为什么能解决跨域

⚡ 30 秒速记

  • 原理:devServer 起了一个本地 Node 服务,前端请求先打到它,再由它转发给真实后端
  • 为什么能解决跨域:同源策略是浏览器的限制,服务端之间通信没有这个约束
  • 关键配置:target(目标地址)、changeOrigin: true(把请求头的 Host 改成 target,很多后端会校验)、pathRewrite(改写路径)
  • 底层是 http-proxy-middleware
  • 只在开发环境有效,生产环境要靠 Nginx 反向代理或后端配 CORS

Webpack Proxy 本质上是让本地开发服务器代替浏览器向目标服务转发请求,从而绕开浏览器的跨域限制。 浏览器只请求同源的 webpack-dev-server,后者通过 http-proxy-middleware 请求后端,再把响应转回浏览器;服务器之间通信不受浏览器同源策略约束。配置时通常关注 targetpathRewritechangeOrigin。它只用于开发阶段,生产环境仍要通过服务端代理或后端跨域配置处理。

1. 是什么

webpack proxy,即webpack提供的代理服务

基本行为就是接收客户端发送的请求后转发给其他服务器

其目的是为了便于开发者在开发模式下解决跨域问题(浏览器安全策略限制)

想要实现代理首先需要一个中间服务器,webpack中提供服务器的工具为webpack-dev-server

2. webpack-dev-server

webpack-dev-serverwebpack 官方推出的一款开发工具,将自动编译和自动刷新浏览器等一系列对开发友好的功能全部集成在了一起

目的是为了提高开发者日常的开发效率,「只适用在开发阶段」

关于配置方面,在webpack配置对象属性中通过devServer属性提供,如下:

// ./webpack.config.js
const path = require('path')

module.exports = {
    // ...
    devServer: {
        contentBase: path.join(__dirname, 'dist'),
        compress: true,
        port: 9000,
        proxy: {
            '/api': {
                target: 'https://api.github.com'
            }
        }
        // ...
    }
}

devServetr里面proxy则是关于代理的配置,该属性为对象的形式,对象中每一个属性就是一个代理的规则匹配

属性的名称是需要被代理的请求路径前缀,一般为了辨别都会设置前缀为/api,值为对应的代理匹配规则,对应如下:

  • target:表示的是代理到的目标地址
  • pathRewrite:默认情况下,我们的 /api-hy 也会被写入到URL中,如果希望删除,可以使用pathRewrite
  • secure:默认情况下不接收转发到https的服务器上,如果希望支持,可以设置为false
  • changeOrigin:它表示是否更新代理后请求的 headershost地址

2. 工作原理

proxy工作原理实质上是利用http-proxy-middleware 这个http代理中间件,实现请求转发给其他服务器

举个例子:

在开发阶段,本地地址为http://localhost:3000,该浏览器发送一个前缀带有/api标识的请求到服务端获取数据,但响应这个请求的服务器只是将请求转发到另一台服务器中

const express = require('express');
const proxy = require('http-proxy-middleware');

const app = express();

app.use('/api', proxy({target: 'http://www.example.org', changeOrigin: true}));
app.listen(3000);

// http://localhost:3000/api/foo/bar -> http://www.example.org/api/foo/bar

3. 跨域

在开发阶段, webpack-dev-server 会启动一个本地开发服务器,所以我们的应用在开发阶段是独立运行在 localhost的一个端口上,而后端服务又是运行在另外一个地址上

所以在开发阶段中,由于浏览器同源策略的原因,当本地访问后端就会出现跨域请求的问题

通过设置webpack proxy实现代理请求后,相当于浏览器与服务端中添加一个代理者

当本地发送请求的时候,代理服务器响应该请求,并将请求转发到目标服务器,目标服务器响应数据后再将数据返回给代理服务器,最终再由代理服务器将数据响应给本地

在代理服务器传递数据给本地浏览器的过程中,两者同源,并不存在跨域行为,这时候浏览器就能正常接收数据

注意:「服务器与服务器之间请求数据并不会存在跨域行为,跨域行为是浏览器安全策略限制」

💬 面试官追问

  • 本地页面运行在 http://localhost:3000,直接请求另一地址的接口被浏览器拦截;把请求改成 /api/users 后由开发服务器转发,为什么响应就能被页面读取?

    浏览器此时只请求同源的 localhost:3000,跨地址转发由 webpack-dev-server 代理在服务器侧完成。同源策略限制的是浏览器中的请求,服务器之间转发不受该限制;代理收到目标响应后再以同源响应返回页面。

  • 前端请求 /api/users,后端实际只接受 /users,代理目标已配成 https://service.example.com,你会改哪项配置?

    应通过 pathRewrite 在转发时移除或改写 /api 前缀,使目标服务器收到它认可的路径。target 只决定请求发往哪里,不会自动修正业务路径;规则写错会表现为后端路由不存在,而不是浏览器跨域。

  • 公司测试环境使用自签名的 https 服务,本地代理转发时证书校验失败;把 secure 设为 false 是否适合直接带到正式部署?

    在明确是开发测试证书的前提下,可用 secure: false 允许代理转发到该 https 服务。它降低了证书校验要求,只应作为受控开发配置;webpack-dev-server 本身也仅用于开发阶段,不能替代正式环境的网关。

  • 接口通过代理后仍返回错误页面,后端网关日志显示收到的 Hostlocalhost:3000,但它只按目标域名路由,你会如何排查?

    检查代理规则是否设置 changeOrigin: true,它会把转发请求头中的 Host 更新为目标地址。若路径也带有 /api 前缀,还要同时核对 pathRewrite;两者分别处理主机头和路径,不能相互替代。

  • 上线评审中有人提议把 devServer.proxy 配置原样用于生产跨域治理,你会接受吗?

    不会,webpack-dev-server 的代理是为本地开发效率提供的中间服务器,只适用于开发阶段。生产环境仍需部署独立的反向代理或由服务端处理跨域策略;否则前端构建结束后并不存在这台开发代理服务器。

# 9 介绍一下 babel原理

⚡ 30 秒速记

  • 三个阶段:解析(Parse)→ 转换(Transform)→ 生成(Generate
  • 解析:@babel/parser 把源码变成 AST(先词法分析成 token,再语法分析成树)
  • 转换:@babel/traverse 遍历 AST,插件通过 visitor 模式匹配节点类型并修改 —— 这是 Babel 的核心
  • 生成:@babel/generatorAST 转回代码并生成 source map
  • 关键区分:语法转换由 Babel 做(箭头函数、解构),API 补齐要靠 core-jspolyfillPromiseincludes);preset-envuseBuiltIns: 'usage' 可按需注入

Babel 的核心流程是把源码解析成 AST,修改这棵语法树,再生成目标代码。ES6ES5 为例,解析器先识别源码结构,插件随后遍历 AST,把需要兼容的语法节点转换成新的节点。最后,代码生成器根据转换后的 AST 输出 ES5 代码。简单来说,插件决定“改什么”,解析和生成阶段负责“读懂代码”和“重新写出代码”。

babel 的编译过程分为三个阶段:parsingtransforminggenerating,以 ES6 编译为 ES5 作为例子:

  1. ES6 代码输入;
  2. babylon 进行解析得到 AST;
  3. pluginbabel-traverseAST树进行遍历编译,得到新的 AST树;
  4. babel-generator 通过 AST树生成 ES5 代码。

Babel原理及其使用 (opens new window)

💬 面试官追问

  • 一个页面源码使用箭头函数,交给 Babel 后输出变成普通函数;有人说这是直接做字符串替换,你如何用编译链路反驳?

    它不是针对源码文本做简单替换,而是先由解析器把代码转换为 AST。插件遍历并改写树上的语法节点,再由生成器根据新 AST 输出目标代码,因此转换依据是语法结构而非字符形状。

  • 团队要写一个插件,把项目中的某类调用统一改写;在 Babel 的三个阶段里,核心逻辑应该放在哪里?

    核心逻辑应位于 transforming 阶段,通过遍历 AST 定位目标节点并生成或替换相应节点。解析阶段只负责得到语法树,生成阶段负责把变换后的树输出为代码;若节点匹配过宽,可能改到语义不同的同名调用。

  • 构建工具准备把现有解析器替换掉,但仍沿用原来的遍历插件和代码生成器,你会要求验证什么约束?

    要验证新解析结果的 AST 节点结构是否与遍历插件和生成器所期待的结构兼容。Babel 的解析、转换、生成虽分阶段协作,但中间树是接口契约;节点类型或字段不一致时,转换可能漏掉代码或直接失败。

  • 某个 Babel 插件运行后生成了语法错误,源码解析本身没有报错,你会按什么顺序定位?

    先保留解析得到的原始 AST,再对比插件遍历前后的目标节点,确认错误是在转换阶段引入。随后检查生成器接收到的新树是否合法;源码能成功解析只证明 parsing 正常,不能证明插件改写或 generating 正确。

  • 两个团队争论兼容旧环境应改源码还是维护一套 Babel 转换插件,你会依据什么划分责任?

    若差异能表达为稳定的语法树变换,可交给插件在 AST 层统一处理,并由生成器输出目标语法。若转换无法保持原语义,单靠 parsing-transforming-generating 流程也不能消除运行环境差异,必须另外评估实现边界。

# 10 介绍一下Rollup

⚡ 30 秒速记

  • 定位:面向 ESM 的打包器,产物干净、Tree Shakingwebpack 更彻底
  • 适合打(输出 ESM/CJS/UMD 多种格式),webpack 更适合打应用(生态全、HMR 和代码分割成熟)
  • Vite 生产构建底层就是 Rollup(开发环境则用原生 ESM 免打包)
  • 插件机制比 webpack 简单:一组生命周期钩子函数,没有 loader/plugin 的二元划分
  • 选型一句话:打库用 Rollup,打应用用 webpack/Vite/Rspack

Rollup 是面向 ES Modules 的打包器,通常更适合构建类库或框架。 它生成的代码比较扁平、可读性好,并能自动移除未引用代码,因此类库产物通常更干净。相比之下,应用往往更依赖第三方模块、HMR 和代码拆分,这些场景更适合功能更全面的 Webpack。所以我一般把“应用用 Webpack、库用 Rollup”当作经验选择,而不是绝对规则。

Rollup 是一款 ES Modules 打包器。它也可以将项目中散落的细小模块打包为整块代码,从而使得这些划分的模块可以更好地运行在浏览器环境或者 Node.js 环境。

Rollup优势:

  • 输出结果更加扁平,执行效率更高;
  • 自动移除未引用代码;
  • 打包结果依然完全可读。

缺点

  • 加载非 ESM 的第三方模块比较复杂;
  • 因为模块最终都被打包到全局中,所以无法实现 HMR
  • 浏览器环境中,代码拆分功能必须使用 Require.js 这样的 AMD
  • 我们发现如果我们开发的是一个应用程序,需要大量引用第三方模块,同时还需要 HMR 提升开发体验,而且应用过大就必须要分包。那这些需求 Rollup 都无法满足。
  • 如果我们是开发一个 JavaScript 框架或者库,那这些优点就特别有必要,而缺点呢几乎也都可以忽略,所以在很多像 React 或者 Vue 之类的框架中都是使用的 Rollup 作为模块打包器,而并非 Webpack

总结一下Webpack 大而全,Rollup 小而美

在对它们的选择上,我的基本原则是:应用开发使用 Webpack,类库或者框架开发使用 Rollup

不过这并不是绝对的标准,只是经验法则。因为 Rollup 也可用于构建绝大多数应用程序,而 Webpack 同样也可以构建类库或者框架。

💬 面试官追问

  • 一个工具库由许多 ESM 小文件组成,最终产物却带着大量模块包装;负责人考虑换成 Rollup,它能解决的核心问题是什么?

    Rollup 会把散落的 ESM 模块整合成更扁平、可读的输出,并自动移除未引用代码。它适合追求干净类库产物的场景;若依赖大量非 ESM 第三方模块,接入复杂度会随之上升。

  • 团队正在发布一个供浏览器和 Node.js 使用的 JavaScript 框架,为什么 Rollup 通常比面向应用的全功能方案更契合?

    框架或类库更看重扁平输出、执行效率、未引用代码移除和产物可读性,这些正是 Rollup 的优势。它能把细小模块合并为适合浏览器或 Node.js 运行的代码,但仍需核查第三方模块格式。

  • 原本只发布类库的项目准备扩成大型应用,需要大量第三方模块、开发期 HMR 和按页面分包,还应坚持只用 Rollup 吗?

    不应仅因已有配置就坚持,来源给出的这些需求更符合 Webpack 的应用构建定位。Rollup 对非 ESM 依赖处理较复杂,且该方案下 HMR 与浏览器代码拆分存在约束;切换工具的代价则是重建构建配置。

  • 构建迁移后,部分 CommonJS 第三方包无法像项目内 ESM 一样顺利打入产物,你会先判断哪里?

    先确认失败依赖是否属于非 ESM 模块,因为这正是 Rollup 处理起来较复杂的边界。不要把它误判成未引用代码删除故障;应分别检查模块格式兼容和依赖加载方式,必要时重新评估工具选择。

  • 架构会上有人用“应用必须选 Webpack、类库必须选 Rollup”否决其他方案,你会怎样纠正这个选型结论?

    这只是经验法则,不是强制边界:Rollup 也能构建多数应用,Webpack 同样可以构建类库。选型应看第三方依赖、HMR、分包需求与产物可读性之间的权重;任何一边的优势都要以当前约束为前提。

# 十、HTTP

# HTTP状态码

⚡ 30 秒速记

  • 五类:1xx 信息、2xx 成功、3xx 重定向、4xx 客户端错、5xx 服务端错
  • 面试真正考的是三组对比,不是背表
  • 301 vs 302301 永久、会被浏览器缓存住、权重转移;302 临时、不缓存、权重保留。要严格保持请求方法用 308/307
  • 304 vs 强缓存:304 是发了请求才知道没变(省带宽);强缓存压根不发请求(省往返)
  • 401 vs 403401 没登录,403 登录了但没权限
  • 加分点:停服维护要返 503 而不是 404,搜索引擎才知道是临时的

HTTP 状态码要结合浏览器或客户端的后续行为来理解,而不是只背一张码表。 2xx 表示请求成功,例如 204 没有响应体,206 用于范围请求;3xx 重点区分重定向和缓存,301 是永久跳转,304 表示资源未修改。客户端错误里,401 是未登录,403 是已登录但没权限。服务端异常则常见 500502503504,排查时要分清是应用内部错误、网关错误、服务不可用还是网关超时。

  • 1xx 信息性状态码 websocket upgrade
  • 2xx 成功状态码
    • 200 服务器已成功处理了请求
    • 204(没有响应体)
    • 206(范围请求 暂停继续下载)
  • 3xx 重定向状态码
    • 301(永久) :请求的页面已永久跳转到新的url
    • 302(临时) :允许各种各样的重定向,一般情况下都会实现为到 GET 的重定向,但是不能确保 POST 会重定向为 POST
    • 303 只允许任意请求到 GET 的重定向
    • 304 未修改:自从上次请求后,请求的网页未修改过
    • 307:307302 一样,除了不允许 POSTGET 的重定向
  • 4xx 客户端错误状态码
    • 400 客户端参数错误
    • 401 没有登录
    • 403 登录了没权限 比如管理系统
    • 404 页面不存在
    • 405 禁用请求中指定的方法
  • 5xx 服务端错误状态码
    • 500 服务器错误:服务器内部错误,无法完成请求
    • 502 错误网关:服务器作为网关或代理出现错误
    • 503 服务不可用:服务器目前无法使用
    • 504 网关超时:网关或代理服务器,未及时获取请求

💬 面试官追问

  • 订单页提交参数缺失,接口仍返回 200,只在响应体里写 { "code": 400 },网关和监控都记录成功;你会接受这种约定吗?

    不接受把所有结果都包装成 200,因为 HTTP 层已经无法表达请求是否成功。参数错误应返回 400,业务错误码再放进 body 细分原因;否则只观察状态码的代理、监控和调用方会得到失真的成功率。

  • 下载页要支持大文件暂停后继续,前端带上范围请求后,服务端仍返回完整文件和 200;这里应该怎样落地?

    服务端应在确认支持范围传输后返回 206,并只发送客户端请求的那一段内容,而不是继续用 200 返回完整资源。若服务端无法正确处理范围请求,就不应伪装成断点续传,否则客户端可能重复接收或错误拼接数据。

  • 支付表单通过 POST 提交后需要临时跳转,产品要求“直接用 302 就行,还要保证下一跳仍是 POST”;你会怎么选?

    不能用 302 承诺保留 POST,因为它通常会被实现为转向 GET,但并不保证原方法得到保留。若下一跳必须继续使用 POST,应选择 307;若明确希望任意请求转为 GET,则使用 303,两者语义不能混用。

  • 用户访问管理后台得到空白页,抓包显示先返回 401,登录后重试又返回 403;你如何判断这两次失败分别发生了什么?

    第一次 401 表示请求方尚未完成登录或身份认证,前端应进入登录流程,而不是展示无权限页面。第二次 403 表示身份已经确认但没有资源权限,应停止重复登录并提示授权问题;把两者统一处理会造成循环跳转或误导用户。

  • 线上接口经代理后返回 502,运维却把它当成应用代码抛出的 500;你会怎样缩小排查范围?

    502 表示充当网关或代理的服务器处理上游响应时出错,应先检查代理到源站的链路及上游响应。500 才直接指向服务器内部无法完成请求;若代理等待上游超过时限,则更接近 504,不能仅凭页面文案归因。

  • 新闻详情页带条件请求后收到 304,前端同学认为“不是 2xx 就算失败”并展示错误页;这个判断错在哪里?

    304 表示资源自上次请求后未修改,客户端应继续使用已有副本,而不是把它当作业务失败。它属于重定向类状态,但承担缓存协商语义;前提是客户端确实持有可用缓存,否则无法仅靠该响应恢复正文。

# 1 HTTP前生今世

⚡ 30 秒速记

  • HTTP/0.9(1991):只有 GET,只能传 HTML,没有头部
  • HTTP/1.0(1996):加入头部、状态码、Content-Type,但默认每次请求都要重新建连
  • HTTP/1.1(1997):默认长连接(keep-alive)、管线化、Host 头(支持虚拟主机)、缓存机制完善 —— 统治了近 20 年
  • HTTP/2(2015):二进制分帧、多路复用、头部压缩
  • HTTP/3(2022 成为标准):底层从 TCP 换成基于 UDPQUIC
  • 主线是一句话:每一代都在解决上一代的「阻塞」问题

HTTP 的演进主线是不断完善能力,并逐步改善传输性能。 HTTP/0.9 只是能获取文本资源的简单文本协议,HTTP/1.0 奠定了大部分现代技术,但并不是正式标准。HTTP/1.1 功能更完善,也是目前使用最广泛的版本;HTTP/2 基于 SPDY,重点改善性能。再往后,HTTP/3 基于 QUIC,代表协议后续的发展方向。

  • HTTP 协议始于三十年前蒂姆·伯纳斯 - 李的一篇论文
  • HTTP/0.9 是个简单的文本协议,只能获取文本资源;
  • HTTP/1.0 确立了大部分现在使用的技术,但它不是正式标准;
  • HTTP/1.1 是目前互联网上使用最广泛的协议,功能也非常完善;
  • HTTP/2 基于 Google 的 SPDY 协议,注重性能改善,但还未普及;
  • HTTP/3 基于 Google 的 QUIC 协议,是将来的发展方向

💬 面试官追问

  • 有人看见接口返回文本,就断言它使用的是 HTTP/0.9;面对现代商品详情页的请求,你会怎样反驳?

    响应内容是文本不能证明协议版本,因为后续 HTTP 版本同样能够传输文本资源。HTTP/0.9 的关键限制是协议非常简单且只能获取文本资源;判断现代请求所用版本,应依据实际协商或网络记录,而不是资源外观。

  • 团队维护一个同时承载页面、图片和接口的站点,架构评审只写“使用 HTTP”却没有版本信息;这会遗漏什么工程判断?

    版本不能省略,因为 HTTP/1.1HTTP/2HTTP/3 的能力基础和性能方向并不相同。应记录客户端到服务端实际采用的版本及其部署范围;仅写 HTTP 会让性能分析和兼容性判断缺少必要前提。

  • 旧系统负责人说 HTTP/1.0 已经确立了多数现用技术,因此可以把它视为完整正式标准继续扩建;你同意吗?

    不同意把“确立多数技术”推导成“正式且适合继续扩建”,资料明确指出 HTTP/1.0 并非正式标准。评估旧系统时可以承认其历史贡献,但新方案仍要根据现行部署能力选型,不能只凭版本年代或名称下结论。

  • 主站升级后仍然加载缓慢,项目经理认定“已经写了 HTTP/2,协议层就不可能有问题”;你会先核实什么?

    先核实请求链路是否真的使用 HTTP/2,以及页面涉及的各个资源和服务是否都落在同一部署条件下。HTTP/2 源自 SPDY 并侧重性能改善,但写进方案不等于已经普及或生效;协议之外的服务处理也仍可能成为瓶颈。

  • 基础设施选型会上,一方要求所有业务立即切到 HTTP/3,理由是它代表未来;另一方坚持永远保留 HTTP/1.1,你怎么裁决?

    两种绝对结论都站不住:HTTP/1.1 功能完善且使用广泛,HTTP/3 基于 QUIC,代表后续发展方向。应按客户端、服务器和中间链路的实际支持情况渐进选择,并保留可工作的兼容路径;不能把趋势直接当成现状。

  • 新人把 HTTP/2HTTP/3 都说成“Google 提出的同一个加速协议改名”,在技术评审里你会怎样纠正?

    两者来源不同:HTTP/2 基于 GoogleSPDY,而 HTTP/3 基于 GoogleQUIC。它们都关注 HTTP 的演进不代表底层依据相同;若把二者混为一谈,后续讨论传输基础、部署条件和故障边界都会失去准确前提。

# 2 HTTP世界全览

⚡ 30 秒速记

  • HTTP应用层协议,无状态、无连接(1.1 起默认长连接)、基于请求-响应模型
  • 周边生态:TCP/IP(传输)、DNS(域名解析)、URI(资源定位)、HTTPSTLS 加密)
  • 参与角色:浏览器、Web 服务器、CDN、代理(正向/反向)、WAF
  • 无状态是设计优点(易水平扩展),cookie/session/Token 是在它之上补出来的状态机制
  • 相关规范由 IETF 制定(RFC),W3C 管的是 HTML/CSS 那一层

HTTP 是连接浏览器、服务器和网络中间节点的应用层传输协议。 浏览器和爬虫都可以作为 User Agent 发起请求,服务器负责应答,CDN 与代理位于传输途中,可承担缓存加速、负载均衡等工作。访问资源时,DNS 先把域名映射成 IP,再通过由协议名、主机名和路径组成的 URI 定位资源。普通 HTTP 通常运行在 TCP/IP 的可靠传输之上,需要安全传输时,则使用由 HTTPSSL/TLSTCP/IP 组成的 HTTPS

  • 互联网上绝大部分资源都使用 HTTP 协议传输;
  • 浏览器是 HTTP 协议里的请求方,即 User Agent
  • 服务器是 HTTP 协议里的应答方,常用的有 ApacheNginx
  • CDN 位于浏览器和服务器之间,主要起到缓存加速的作用;
  • 爬虫是另一类 User Agent,是自动访问网络资源的程序。
  • TCP/IP 是网络世界最常用的协议,HTTP 通常运行在 TCP/IP 提供的可靠传输基础上
  • DNS 域名是 IP 地址的等价替代,需要用域名解析实现到 IP 地址的映射;
  • URI 是用来标记互联网上资源的一个名字,由“协议名 + 主机名 + 路径”构成,俗称 URL;
  • HTTPS 相当于“HTTP+SSL/TLS+TCP/IP”,为 HTTP 套了一个安全的外壳;
  • 代理是 HTTP 传输过程中的“中转站”,可以实现缓存加速、负载均衡等功能

💬 面试官追问

  • SEO 爬虫抓取商品页后,开发说“它不是浏览器,所以不属于 HTTP 请求方”;你会怎么纠正这个角色判断?

    爬虫同样属于 User Agent,只是它通过程序自动访问网络资源,而不是由用户操作浏览器。服务器仍作为 HTTP 应答方处理请求;若按“只有浏览器才是请求方”设计策略,会漏掉爬虫这一类合法或需治理的客户端。

  • 图片站准备在浏览器与 Nginx 源站之间接入 CDN,业务方问它除了多一跳还有什么价值;你怎样说明落地边界?

    CDN 位于浏览器和源站之间,主要价值是缓存资源并加速访问,因此适合承担可缓存内容的分发。它仍是传输链路中的中间节点,并不会替代源站处理所有请求;缓存规则不合适时,请求仍需回到服务器。

  • 内网服务迁移后保留原域名,只更换后端 IP,有人认为页面里的 URI 没变就无需关注 DNS;这成立吗?

    不成立,域名需要经 DNS 解析映射到 IP,后端地址变化仍要求解析结果正确指向新地址。URI 负责标记资源,通常由协议名、主机名和路径构成;名称保持不变,并不意味着名称到网络地址的映射会自动正确。

  • 用户报告 https:// 页面完全连不上,应用日志也没有请求;值班同学只检查业务路由,你会如何沿链路排查?

    应用没有收到请求时,应先检查域名能否经 DNS 解析到预期 IP,再核对网络传输和中间代理是否可达。HTTPS 还在 HTTP 外增加了 SSL/TLS 安全层;在这些前置环节确认之前,直接归因于应用路由证据不足。

  • 高峰期需要在浏览器与多台应用服务器之间增加一层设施,团队在“只做缓存”与“同时负载均衡”之间争论;代理能承担哪些职责?

    代理作为 HTTP 传输中的中转站,既可以用于缓存加速,也可以承担负载均衡,因此两种诉求并不天然冲突。具体是否同时启用取决于链路设计和代理能力;增加中转层也意味着请求路径变长,配置错误会影响源站可达性。

  • 安全评审中有人把 HTTPS 描述成“完全替换了 HTTPTCP/IP 的新协议栈”,你会怎样校正?

    HTTPS 更准确的关系是 HTTP + SSL/TLS + TCP/IP,即为 HTTP 增加安全外壳,而不是把 HTTP 与底层网络全部替换。HTTP 通常仍依赖 TCP/IP 提供的可靠传输;混淆这些角色会导致证书、应用响应和网络故障被错误归层。

# 3 HTTP分层

⚡ 30 秒速记

  • TCP/IP 四层模型:应用层(HTTP/DNS/FTP)→ 传输层(TCP/UDP)→ 网络层(IP)→ 链路层
  • OSI 七层是理论模型,实际用的是四层;面试报四层更实际
  • 分层的价值:各层职责独立、可替换 —— HTTP/3 把传输层从 TCP 换成 QUIC 而应用层几乎不用改,就是分层的红利
  • HTTPS 不是新协议,是在应用层和传输层之间插了一层 TLS
  • 数据向下逐层封装(加头部),向上逐层解封装

网络分层的核心价值是职责隔离,上层使用下层能力时不必看到逐层传输的细节。 TCP/IP 分为四层,IP 位于网际层,TCP 位于传输层,HTTP 位于应用层;映射到七层 OSI 模型时,TCP 在第四层,HTTP 在第七层。数据发送和接收时,会沿协议栈逐层打包再逐层拆包。日常交流常说“四层”和“七层”,可以粗略理解为操作系统负责的通常在四层或以下,而应用程序处理的通常在七层,但这不是绝对判断。

  • 第一层:物理层,TCP/IP 里无对应;
  • 第二层:数据链路层,对应 TCP/IP 的链接层;
  • 第三层:网络层,对应 TCP/IP 的网际层;
  • 第四层:传输层,对应 TCP/IP 的传输层;
  • 第五、六、七层:统一对应到 TCP/IP 的应用层

总结

  • TCP/IP 分为四层,核心是二层的 IP 和三层的 TCPHTTP 在第四层;
  • OSI 分为七层,基本对应 TCP/IPTCP 在第四层,HTTP 在第七层;
  • OSI 可以映射到 TCP/IP,但这期间一、五、六层消失了;
  • 日常交流的时候我们通常使用 OSI 模型,用四层、七层等术语;
  • HTTP 利用 TCP/IP协议栈逐层打包再拆包,实现了数据传输,但下面的细节并不可见

有一个辨别四层和七层比较好的(但不是绝对的)小窍门,“两个凡是”:凡是由操作系统负责处理的就是四层或四层以下,否则,凡是需要由应用程序(也就是你自己写代码)负责处理的就是七层

💬 面试官追问

  • 评审会上有人说“HTTP 在第四层,因为它运行在 TCP/IP 上”,另一人说“它在第七层”;面对这场术语冲突你怎么裁定?

    两种说法采用了不同模型:在四层 TCP/IP 模型中,HTTP 位于应用层,也就是第四层;在七层 OSI 模型中,它位于第七层。交流时必须先说明模型,否则层号相同或不同都可能造成假冲突。

  • 前端页面请求经过网卡、网络和服务器后得到 JSON,业务同学问数据为何能跨网络传输;你会怎样用分层解释而不陷入实现细节?

    HTTP 数据会沿 TCP/IP 协议栈逐层封装,经链路传输后在接收端逐层拆包,最终交给应用处理。HTTP 使用底层能力,但其应用代码通常看不到下层细节;分层的价值正是隔离职责,而不是让业务直接操纵每一层。

  • 客户端从浏览器换成操作系统内置网络组件后,负责人断言所有逻辑都从七层降到了四层;这个判断为什么不可靠?

    是否由操作系统处理只能作为辨别四层与七层的经验线索,并非绝对规则。TCPOSI 第四层,HTTP 仍属于应用层;实现代码放在哪里不会自动改变协议职责,必须结合处理的实际协议内容判断。

  • 线上接口无法建立连接,但应用服务日志完全为空;开发立即修改 HTTP 路由,你会按分层把排查重点放在哪里?

    应用日志没有请求时,不宜先认定是 HTTP 路由错误,应先检查链接层、网络层和传输层是否完成了数据送达。只有下层传输成立后,HTTP 才能在应用层处理请求;不过分层只能缩小范围,最终仍需结合各层证据定位。

  • 网关团队计划只依据端口转发连接,业务团队却要求按 HTTP 路径分流;这两个能力分别更接近哪一层?

    依据连接与传输信息工作的能力更接近四层,而解析 HTTP 路径后分流需要应用程序理解七层协议。后者能做更细的业务路由,但也承担 HTTP 解析和配置成本;若只做四层转发,就无法直接使用路径语义决策。

  • 新人画协议栈时把 OSI 的会话层、表示层逐一对应成 TCP/IP 的独立层,还把 TCP 放到了第三层;你会怎样修正?

    OSI 的第五、六、七层在 TCP/IP 中统一归入应用层,并不存在逐层独立对应;TCP 位于 OSI 第四层,对应 TCP/IP 的传输层。映射是概念上的归并,不是编号平移,否则会误判协议职责。

# 4 HTTP报文是什么样子的

⚡ 30 秒速记

  • 请求报文四段:请求行(方法 + 路径 + 版本)+ 请求头 + 空行 + 请求体
  • 响应报文四段:状态行(版本 + 状态码 + 短语)+ 响应头 + 空行 + 响应体
  • 空行是分隔符,缺了整个报文就没法解析
  • 常见请求头:Host(必需)、User-AgentAcceptContent-TypeCookieAuthorization
  • 注意 HTTP/2 之后改成二进制分帧,头部走 HPACK 压缩,上面这套是 HTTP/1.1 的文本格式

HTTP 请求报文和响应报文结构基本一致,都由起始行、头部字段、空行和消息正文组成。 起始行负责说明请求或响应的基本信息,也是两者最明显的区别;头部则用 key-value 形式补充细节。空行用于分隔头部和正文,正文才是实际传输的文本、图片、视频等数据,而且正文并不一定存在。

HTTP 协议的请求报文和响应报文的结构基本相同,由三大部分组成

  • 起始行(start line):描述请求或响应的基本信息;
  • 头部字段集合(header):使用 key-value 形式更详细地说明报文;
  • 消息正文(entity):实际传输的数据,它不一定是纯文本,可以是图片、视频等二进制数据

这其中前两部分起始行和头部字段经常又合称为“请求头”或“响应头”,消息正文又称为“实体”,但与“header”对应,很多时候就直接称为“body”。

一个完整的 HTTP 报文就像是下图的这个样子,注意在 header 和 body 之间有一个“空行”

💬 面试官追问

  • 抓包里请求只有起始行和若干头部字段,没有 JSON 正文,同事因此认定它不是完整的 HTTP 报文;你认可吗?

    不认可,HTTP 报文由起始行、头部字段集合和可选的消息正文构成,正文并非每次都必须承载数据。没有 body 不能单独证明报文不完整;应继续检查起始行、header 以及两者之后的报文边界是否符合格式。

  • 上传接口既要发送文件二进制,也要附带描述信息,产品担心 HTTP 正文只能放纯文本;你会怎样设计认知边界?

    消息正文可以承载图片、视频等二进制数据,并不限于纯文本,因此文件能够放入 body 传输。头部字段负责补充说明报文,实际编码和内容解释仍需双方约定;仅证明能传二进制,不代表任意格式都能被服务端识别。

  • 团队日志把起始行和全部 header 统称为“请求头”,新人随后误以为起始行也是一个 key-value 字段;你怎么纠正?

    工程交流中确实常把起始行与头部字段合称为“请求头”或“响应头”,但结构上两者仍不同。起始行描述请求或响应的基本信息,只有 header 使用 key-value 形式;日志展示可以合并,解析规则不能混淆。

  • 线上代理转发后,服务端把正文首行当成 header,接口持续报格式错误;查看原始报文时你首先核对什么?

    首先核对 headerbody 之间是否保留了规定的空行,因为它承担两部分的分隔作用。若代理改写报文时丢失这一边界,接收端就可能继续按头部解析正文;还应比较代理前后的原始报文,避免只查业务字段。

  • 埋点接口要传一批事件,架构师要求把所有事件内容拆成自定义 header,理由是“headerbody 都能携带数据”;你会接受吗?

    不应把实际业务载荷全部塞进 header,头部字段的职责是以 key-value 形式说明报文,事件数据更符合消息正文的定位。即使某些值技术上能写入 header,也会模糊元信息与实体边界,并增加各节点解析约定的复杂度。

  • 调试工具只展示“请求头”和“响应体”两个面板,实习生据此认为请求报文与响应报文结构完全不同;你会如何串联两者?

    请求报文和响应报文的基本结构相同,都包含起始行、头部字段集合以及可能存在的消息正文。工具只是按调试习惯拆分展示,不能替代协议结构;分析异常时仍要分别核对起始行、header、空行和 body

# 5 HTTP之URL

⚡ 30 秒速记

  • 完整结构:协议://用户信息@主机:端口/路径?查询串#片段
  • URI 是统一资源标识符(上位概念),URL(定位)和 URN(命名)都是它的子集
  • # 后的片段不会发给服务器,只在浏览器端使用 —— 这是 hash 路由的基础
  • 编码:encodeURI 不编码 :/?#[]@ 等结构字符(编码整个 URL 用),encodeURIComponent 全编码(编码参数值用)
  • 解析首选原生 new URL()URLSearchParams,别手写正则

URL 本质上是标记资源位置的字符串,通常由 schemehost:portpathquery 组成。 scheme 决定用什么协议访问,主机和端口定位服务器,path 定位资源,query 再附加具体要求。部分结构可以省略,但像 @&/ 这类特殊字符以及汉字需要正确编码,否则服务器可能无法正确解析报文。

  • URI 是用来唯一标记服务器上资源的一个字符串,通常也称为 URL;
  • URI 通常由 schemehost:portpathquery 四个部分组成,有的可以省略;
  • scheme 叫“方案名”或者“协议名”,表示资源应该使用哪种协议来访问;
  • host:port”表示资源所在的主机名和端口号;
  • path 标记资源所在的位置;
  • query 表示对资源附加的额外要求;
  • URI 里对“@&/”等特殊字符和汉字必须要做编码,否则服务器收到 HTTP报文后会无法正确处理

💬 面试官追问

  • 商品列表页把筛选条件写成 /search?keyword=手机&配件&page=1,后端拿到的关键词只剩“手机”,前端同学却说汉字能显示就不用编码,你怎么判断?

    参数边界已经被 & 破坏,汉字是否可见不能证明 URI 合法。应使用 URLSearchParams,或仅对参数值调用 encodeURIComponent,让特殊字符和汉字按规则编码;不要对整条 URL 再编码,否则 schemehost 和分隔符也会失去结构含义。

  • 订单详情页要生成 /orders/用户输入值?from=售后#记录,开发直接把两个动态值拼进字符串;你会要求怎样拆分和编码?

    先区分 pathqueryURI 组成部分,再分别处理动态数据,不能把用户输入当作结构字符直接拼接。查询参数交给 URLSearchParams,路径段应单独编码;若把整段内容统一处理,输入中的 /&# 可能改变资源位置、参数边界或片段含义。

  • 搜索页既要支持同名标签 tag=前端&tag=性能,又要让链接可复制分享,评审中有人主张把数组先用逗号连接,你选哪种实现?

    优先用 URLSearchParams.append 保留同名多值,使每个值独立编码,复制后的 URI 仍能明确表达多个筛选条件。逗号拼接只有在前后端明确约定且值本身不会引起歧义时才可用,否则解析规则会混入业务层,后续扩展和兼容成本更高。

  • 线上跳转链接偶发落到错误页面,日志里出现参数值包含 %252F,而原始输入只是 /docs/api;你会先查哪一层?

    %252F 通常意味着 %2F 中的百分号又被编码了一次,应沿着表单提交、路由构造、请求封装和服务端解析逐层确认编码次数。原则是动态值在进入对应 URI 组件时编码一次、解析一次;重复调用编码函数或对已生成的完整 URL 再处理都会造成双重编码。

  • 报表页需要保存大量组合筛选,产品要求地址栏可分享,前端提议把全部条件都塞进 query;你如何在 URI 可读性和功能完整性之间取舍?

    少量、稳定且适合公开的筛选条件可以放入 query,因为它本来就用于表达对资源的附加要求,也便于复制链接。条件复杂时应评估浏览器、代理和服务器对请求目标的实现限制,可改为服务端保存条件并在 URL 中放短标识;代价是分享链接依赖后端状态及权限控制。

# 6 HTTP实体数据

⚡ 30 秒速记

  • Content-Type 说明数据类型:text/htmlapplication/jsonapplication/x-www-form-urlencodedmultipart/form-data(文件上传)
  • Content-Encoding 说明压缩方式:gzip/brbrotli 通常再省 15%~20%
  • Accept 系列是客户端的「我想要什么」,Content- 系列是服务端的「我给的是什么」—— 这就是内容协商
  • Content-Length 声明长度;不确定长度时用 Transfer-Encoding: chunked 分块传输
  • 字符集统一用 UTF-8,写在 Content-Type: text/html; charset=utf-8

HTTP 实体数据通过内容类型、压缩方式、语言和字符集来描述,客户端与服务器用对应头字段完成内容协商。 客户端用 Accept 表示能接收的 MIME type,服务器用 Content-Type 说明实际返回的类型;压缩则对应 Accept-EncodingContent-Encoding。多个候选项可以用 ;q= 设置权重,例如优先返回 HTML,其次返回 XML。缓存场景还要关注 Vary,因为同一 URI 可能因协商条件不同而产生多个响应版本。

1. 数据类型与编码

  • text:即文本格式的可读数据,我们最熟悉的应该就是 text/html 了,表示超文本文档,此外还有纯文本 text/plain、样式表 text/css 等。
  • image:即图像文件,有 image/gifimage/jpegimage/png 等。
  • audio/video:音频和视频数据,例如 audio/mpegvideo/mp4 等。
  • application:数据格式不固定,可能是文本也可能是二进制,必须由上层应用程序来解释。常见的有 application/jsonapplication/javascriptapplication/pdf 等,另外,如果实在是不知道数据是什么类型,像刚才说的“黑盒”,就会是 application/octet-stream,即不透明的二进制数据

但仅有 MIME type 还不够,因为 HTTP 在传输时为了节约带宽,有时候还会压缩数据,为了不要让浏览器继续“猜”,还需要有一个“Encoding type”,告诉数据是用的什么编码格式,这样对方才能正确解压缩,还原出原始的数据。

比起 MIME type 来说,Encoding type 就少了很多,常用的只有下面三种

  • gzipGNU zip 压缩格式,也是互联网上最流行的压缩格式;
  • deflatezlibdeflate)压缩格式,流行程度仅次于 gzip
  • br:一种专门为 HTTP 优化的新压缩算法(Brotli

2. 数据类型使用的头字段

有了 MIME typeEncoding type,无论是浏览器还是服务器就都可以轻松识别出 body 的类型,也就能够正确处理数据了。

HTTP 协议为此定义了两个 Accept 请求头字段和两个 Content 实体头字段,用于客户端和服务器进行“内容协商”。也就是说,客户端用 Accept 头告诉服务器希望接收什么样的数据,而服务器用 Content 头告诉客户端实际发送了什么样的数据

img

Accept字段标记的是客户端可理解的 MIME type,可以用“,”做分隔符列出多个类型,让服务器有更多的选择余地,例如下面的这个头:

Accept: text/html,application/xml,image/webp,image/png

这就是告诉服务器:“我能够看懂 HTML、XML 的文本,还有 webppng 的图片,请给我这四类格式的数据”。

相应的,服务器会在响应报文里用头字段Content-Type告诉实体数据的真实类型:

Content-Type: text/html
Content-Type: image/png

这样浏览器看到报文里的类型是“text/html”就知道是 HTML 文件,会调用排版引擎渲染出页面,看到“image/png”就知道是一个 PNG 文件,就会在页面上显示出图像。

Accept-Encoding字段标记的是客户端支持的压缩格式,例如上面说的 gzip、deflate 等,同样也可以用“,”列出多个,服务器可以选择其中一种来压缩数据,实际使用的压缩格式放在响应头字段Content-Encoding

Accept-Encoding: gzip, deflate, br
Content-Encoding: gzip

不过这两个字段是可以省略的,如果请求报文里没有 Accept-Encoding 字段,就表示客户端不支持压缩数据;如果响应报文里没有 Content-Encoding 字段,就表示响应数据没有被压缩

3. 语言类型使用的头字段

同样的,HTTP 协议也使用 Accept 请求头字段和 Content 实体头字段,用于客户端和服务器就语言与编码进行“内容协商”。

Accept-Language字段标记了客户端可理解的自然语言,也允许用“,”做分隔符列出多个类型,例如:

Accept-Language: zh-CN, zh, en

这个请求头会告诉服务器:“最好给我 zh-CN 的汉语文字,如果没有就用其他的汉语方言,如果还没有就给英文”。

相应的,服务器应该在响应报文里用头字段Content-Language告诉客户端实体数据使用的实际语言类型

Content-Language: zh-CN
  • 字符集在 HTTP 里使用的请求头字段是Accept-Charset,但响应头里却没有对应的 Content-Charset,而是在Content-Type字段的数据类型后面用“charset=xxx”来表示,这点需要特别注意。
  • 例如,浏览器请求 GBKUTF-8 的字符集,然后服务器返回的是 UTF-8 编码,就是下面这样
Accept-Charset: gbk, utf-8
Content-Type: text/html; charset=utf-8

不过现在的浏览器都支持多种字符集,通常不会发送 Accept-Charset,而服务器也不会发送 Content-Language,因为使用的语言完全可以由字符集推断出来,所以在请求头里一般只会有 Accept-Language 字段,响应头里只会有 Content-Type字段

img

4. 内容协商的质量值

在 HTTP 协议里用 AcceptAccept-EncodingAccept-Language 等请求头字段进行内容协商的时候,还可以用一种特殊的“q”参数表示权重来设定优先级,这里的“q”是“quality factor”的意思。

权重的最大值是 1,最小值是 0.01,默认值是 1,如果值是 0 就表示拒绝。具体的形式是在数据类型或语言代码后面加一个“;”,然后是“q=value”。

这里要提醒的是“;”的用法,在大多数编程语言里“;”的断句语气要强于“,”,而在 HTTP 的内容协商里却恰好反了过来,“;”的意义是小于“,”的。

例如下面的 Accept 字段:

Accept: text/html,application/xml;q=0.9,*/*;q=0.8

它表示浏览器最希望使用的是 HTML 文件,权重是 1,其次是 XML 文件,权重是 0.9,最后是任意数据类型,权重是0.8。服务器收到请求头后,就会计算权重,再根据自己的实际情况优先输出 HTML 或者 XML

5. 内容协商的结果

内容协商的过程是不透明的,每个 Web 服务器使用的算法都不一样。但有的时候,服务器会在响应头里多加一个Vary字段,记录服务器在内容协商时参考的请求头字段,给出一点信息,例如:

Vary: Accept-Encoding,User-Agent,Accept

这个 Vary 字段表示服务器依据了 Accept-EncodingUser-AgentAccept 这三个头字段,然后决定了发回的响应报文。

Vary 字段可以认为是响应报文的一个特殊的“版本标记”。每当 Accept 等请求头变化时,Vary 也会随着响应报文一起变化。也就是说,同一个 URI 可能会有多个不同的“版本”,主要用在传输链路中间的代理服务器实现缓存服务,这个之后讲“HTTP 缓存”时还会再提到

6. 小结

img

  • 数据类型表示实体数据的内容是什么,使用的是 MIME type,相关的头字段是 AcceptContent-Type
  • 数据编码表示实体数据的压缩方式,相关的头字段是 Accept-EncodingContent-Encoding
  • 语言类型表示实体数据的自然语言,相关的头字段是 Accept-LanguageContent-Language
  • 字符集表示实体数据的编码方式,相关的头字段是 Accept-Charset和 Content-Type;
  • 客户端需要在请求头里使用 Accept 等头字段与服务器进行“内容协商”,要求服务器返回最合适的数据; Accept 等头字段可以用“,”顺序列出多个可能的选项,还可以用“;q=”参数来精确指定权重

💬 面试官追问

  • 下载接口实际返回 application/pdf,网关却统一改成 text/plain,浏览器预览区显示乱码;业务方认为文件字节没变就不影响,你同意吗?

    不同意,实体字节未变不代表客户端能正确处理,浏览器会依据响应的 Content-Type 选择解析或展示方式。服务端应如实返回 application/pdf;未知二进制才适合 application/octet-stream,错误标成文本会导致渲染、下载行为和安全判断出现偏差。

  • 图片组件声明只接受 image/webp,image/png,服务端仍返回 image/jpeg;前端负责人说请求头只是提示,页面能显示就不用管,你怎么处理?

    Accept 表达客户端可理解或期望接收的媒体类型,服务端应据此协商,并用 Content-Type 声明最终实体类型。若服务端无法提供期望格式,应形成明确的降级或错误约定;忽略协商可能暂时可显示,但会让带宽优化、格式兼容和缓存版本失去可靠依据。

  • 静态资源请求带了 Accept-Encoding: gzip, br,响应头却写 Content-Encoding: br,老旧客户端解压失败;你会要求服务端怎样选择?

    服务端只能从客户端声明支持的压缩格式中选择,并在 Content-Encoding 中准确标记实际采用的格式;未压缩时不应伪造该字段。若某类客户端不支持 br,请求中就不应声明它,服务端也要保留 gzip 或无压缩回退,否则实体无法还原。

  • 国际化首页偶发把英文内容发给中文用户,CDN 命中率很高,源站会根据 Accept-Language 返回不同实体;你从哪些响应头开始排查?

    先核对请求的 Accept-Language、响应的 Content-Language,再检查缓存响应是否包含与协商维度一致的 Vary: Accept-Language。同一 URI 可因语言协商产生多个版本,缓存若未区分该请求头就可能串内容;增加 Vary 会扩大缓存变体数量,需要接受命中率下降。

  • 接口团队想用 Accept: application/json,text/html;q=0.8 同时兼容应用和浏览器,另一个团队坚持拆成两个 URL;你如何评估?

    同一资源存在多种表示时,可以使用 Acceptq 值表达偏好,服务端再以 Content-Type 告知实际选择,并让缓存通过 Vary: Accept 区分版本。若两种响应的业务语义、鉴权或错误模型已经不同,拆分 URI 更清晰;内容协商不应掩盖接口契约差异。

# 7 谈一谈HTTP协议优缺点

⚡ 30 秒速记

  • 优点:简单灵活易扩展(头部可自定义)、应用广泛跨平台、无状态所以易水平扩展
  • 缺点一:无状态 —— 需要靠 cookie/Token 额外维护状态(既是优点也是缺点)
  • 缺点二:明文传输 —— 内容可被窃听篡改,所以需要 HTTPS
  • 缺点三:队头阻塞 —— 1.1 的一条连接里前一个请求不完成后面就得等
  • 缺点四:头部冗余 —— 每次请求都重复带大量头部,HTTP/2HPACK 就是为此而生

HTTP 的优势是简单、灵活、采用请求应答模式并基于 TCP/IP 可靠传输,代价则是无状态、明文传输和可能出现队头阻塞。 无状态让每次请求彼此独立、服务器不必天然保存会话上下文,但购物等场景又需要借助 cookiesession 维持状态。文本报文便于理解和扩展,也会让报文信息暴露在外。使用长连接共用一个 TCP 连接时,耗时较长的请求还可能阻塞后续请求。

超文本传输协议,HTTP 是一个在计算机世界里专门在两点之间传输文字、图片、音频、视频等超文本数据的约定和规范

  • HTTP 特点
    • 灵活可扩展。一个是语法上只规定了基本格式,空格分隔单词,换行分隔字段等。另外一个就是传输形式上不仅可以传输文本,还可以传输图片,视频等任意数据。
    • 请求-应答模式,通常而言,就是一方发送消息,另外一方要接受消息,或者是做出相应等。
    • 可靠传输,HTTP是基于TCP/IP,因此把这一特性继承了下来。
    • 无状态,这个分场景回答即可。
  • HTTP 缺点
    • 无状态,有时候,需要保存信息,比如像购物系统,需要保留下顾客信息等等,另外一方面,有时候,无状态也会减少网络开销,比如类似直播行业这样子等,这个还是分场景来说。
    • 明文传输,即协议里的报文(主要指的是头部)不使用二进制数据,而是文本形式。这让HTTP的报文信息暴露给了外界,给攻击者带来了便利。
    • 队头阻塞,当http开启长连接时,共用一个TCP连接,当某个请求时间过长时,其他的请求只能处于阻塞状态,这就是队头阻塞问题。

http 无状态无连接

  • http 协议对于事务处理没有记忆能力
  • 对同一个url请求没有上下文关系
  • 每次的请求都是独立的,它的执行情况和结果与前面的请求和之后的请求是无直接关系的,它不会受前面的请求应答情况直接影响,也不会直接影响后面的请求应答情况
  • 服务器中没有保存客户端的状态,客户端必须每次带上自己的状态去请求服务器
  • 人生若只如初见,请求过的资源下一次会继续进行请求

http协议无状态中的 状态 到底指的是什么?!

  • 【状态】的含义就是:客户端和服务器在某次会话中产生的数据
  • 那么对应的【无状态】就意味着:这些数据不会被保留
  • 通过增加cookiesession机制,现在的网络请求其实是有状态的
  • 在没有状态的http协议下,服务器也一定会保留你每次网络请求对数据的修改,但这跟保留每次访问的数据是不一样的,保留的只是会话产生的结果,而没有保留会话

💬 面试官追问

  • 购物车接口连续两次请求同一个 URL,第二次服务端却不知道第一次选了哪些商品;产品经理据此说 HTTP 不可靠,你会怎样纠正?

    这体现的是 HTTP 无状态,不等于传输不可靠;每个请求默认没有前一次会话的上下文,而基于 TCP/IP 的传输可靠性是另一层概念。购物车状态应由客户端随请求携带标识,或借助 cookiesession 关联;代价是系统开始承担会话保存和一致性管理。

  • 直播弹幕页面每秒持续拉取内容,架构师认为所有请求都必须在服务端保存完整会话上下文;无状态在这里是缺点吗?

    不一定,无状态能让相互独立的请求少依赖服务器会话,从而减少不必要的状态管理和网络附带信息。只有鉴权、进度或个性化确实依赖会话数据时才需要引入状态;若把所有访问上下文都持久化,会增加存储、清理和多节点共享成本。

  • 登录系统从单机扩到多个服务节点后,用户偶发被当成未登录;当前依赖本机 session,你如何定位并落地修正?

    先确认请求是否被负载均衡到不同节点,以及 cookie 中的会话标识是否稳定携带;本机 session 无法天然被其他节点读取。可采用共享会话存储或让路由保持粘性,前者增加共享存储依赖,后者影响流量调度与故障切换,都不能改变 HTTP 本身无状态的事实。

  • 安全评审发现内部管理页仍用明文 HTTP,负责人说报文是文本格式便于排障,而且内网没有风险;你会怎么回应?

    文本报文便于阅读也意味着传输内容更容易暴露,不能把可调试性当作安全保障。应使用 HTTPS 保护传输,并结合访问控制处理内网威胁;加密会引入证书和终止节点管理,但这是避免请求头与实体被直接窥视或篡改的必要成本。

  • HTTP/1.1 页面共用一条长连接,其中一个慢接口迟迟不返回,后续资源也被拖住;前端想无限增加并发连接,你会如何取舍?

    这是长连接上的队头阻塞表现:前面的请求耗时过长时,后续请求可能等待。应先定位并治理慢接口,再评估拆分关键资源、调整连接使用或升级传输方案;盲目增加连接只能缓解局部排队,还会增加客户端、服务器和网络的连接开销。

# 8 说一说HTTP 的请求方法

⚡ 30 秒速记

  • 两个定义先背下来:安全 = 不改服务端状态;幂等 = 执行 1 次和 N 次最终状态相同
  • GET 安全且幂等、可缓存、参数在 URLHEAD 是不要 bodyGET
  • POST 不幂等(连点两次出两单);PUT 整体覆盖所以幂等;DELETE 幂等(最终状态都是"不存在")
  • PATCH 局部更新,规范上不保证幂等;OPTIONS 就是跨域预检请求的真身
  • 工程价值:只有幂等的方法,网关和客户端才敢自动重试

HTTP/1.1 常见方法可按读取、写入和连接诊断来理解。 GET 获取资源,HEAD 只取响应头;POST 追加数据,PUT 存储或修改资源,DELETE 删除资源。OPTIONS 查询资源支持的方法,也用于复杂跨域请求的预检;TRACE 用于测试诊断,CONNECT 用于建立代理隧道。实际使用时还要看语义,例如 GET 通常无副作用且幂等,POST 通常会产生副作用且不幂等。

  • HTTP1.0定义了三种请求方法: GET, POST 和 HEAD方法
  • HTTP1.1新增了五种请求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT

http/1.1规定了以下请求方法(注意,都是大写):

  • GET: 请求获取Request-URI所标识的资源
  • POST: 在Request-URI所标识的资源后附加新的数据
  • HEAD: 请求获取由Request-URI所标识的资源的响应消息报头
  • PUT: 请求服务器存储一个资源,并用Request-URI作为其标识(修改数据)
  • DELETE: 请求服务器删除对应所标识的资源
  • TRACE: 请求服务器回送收到的请求信息,主要用于测试或诊断
  • CONNECT: 建立连接隧道,用于代理服务器
  • OPTIONS: 列出可对资源实行的请求方法,用来跨域请求

从应用场景角度来看,Get 多用于无副作用,幂等的场景,例如搜索关键字。Post 多用于副作用,不幂等的场景,例如注册。

options 方法有什么用

  • OPTIONS 请求与 HEAD 类似,一般也是用于客户端查看服务器的性能。
  • 这个方法会请求服务器返回该资源所支持的所有 HTTP 请求方法,该方法会用'*'来代替资源名称,向服务器发送 OPTIONS 请求,可以测试服务器功能是否正常。
  • JS 的 XMLHttpRequest对象进行 CORS 跨域资源共享时,对于复杂请求,就是使用 OPTIONS 方法发送嗅探请求,以判断是否有对指定资源的访问权限。

💬 面试官追问

  • 搜索页把“按关键词查询”设计成 POST /search,接口没有修改任何资源;后端说只要能返回数据,请求方法无所谓,你会怎么评审?

    该操作无副作用且期望重复执行结果语义一致,更符合 GET 的获取资源语义,也便于浏览器按常规方式缓存和表达查询地址。POST 并非不能返回搜索结果,但会弱化方法传递的意图;只有请求条件不适合放入 URI 等约束成立时,才值得接受这种取舍。

  • 用户资料页用 PUT /users/42 提交完整资料,网络超时后客户端准备自动重试;为什么这里不能只看“都是修改数据”?

    PUT 的目标是让指定 URI 对应资源达到请求描述的状态,重复提交相同内容应保持相同资源状态,因此具备幂等语义。重试前仍要判断响应是否丢失以及服务端是否夹带非幂等副作用,例如重复发通知;方法语义正确不代表实现天然安全。

  • 监控平台想探测一个大文件是否存在并读取响应头,但不希望下载文件正文;有人建议先 GET 再由前端中断,你会选什么?

    应优先使用 HEAD,它用于获取与目标资源相关的响应报头而不传输实体正文,更符合探测元数据的目的。服务端必须正确实现其语义,并保证关键响应头与对应 GET 一致;若服务端不支持或实现错误,才需要受控地退回其他探测方式。

  • 跨域上传在正式环境失败,浏览器控制台显示 OPTIONS 返回 405,但直接用命令行调用 POST 正常;你会沿着什么链路排查?

    浏览器对复杂跨域请求会先发送 OPTIONS 预检,命令行成功不能证明浏览器具备跨域访问权限。应检查路由、网关和服务端是否允许 OPTIONS,以及响应是否声明允许的来源、方法和请求头;仅放通实际 POST 仍会被浏览器拦截。

  • 公司代理需要访问外部 HTTPS 服务,网络团队提出开放 CONNECT,安全团队担心它变成任意隧道;你如何界定方案?

    CONNECT 用于让客户端通过代理建立连接隧道,确实适合代理转发加密流量,但不能无条件开放。代理应限制可达目标、端口、身份和审计范围;限制过严会破坏合法访问,限制过松则可能绕过现有网络策略,这属于代理治理而非前端调用细节。

# 9 谈一谈GET 和 POST 的区别

⚡ 30 秒速记

  • 语义差异(最本质):GET获取资源、安全且幂等;POST提交数据、不安全不幂等
  • 参数位置:GETURL 查询串(会被浏览器历史、日志、Referer 记录),POST 在请求体
  • 缓存:GET 可被缓存和收藏,POST 默认不缓存
  • 预检:跨域时 GET 多为简单请求,POSTContent-Type 可能触发 OPTIONS 预检
  • 要纠正的误解:「GET 有长度限制」是浏览器和服务器的实现限制,不是 HTTP 规范规定;「POST 更安全」也是错的 —— 不加 HTTPS 两者都是明文

GETPOST 的本质区别是语义:前者获取资源,后者提交资源。 因为用途不同,GET 通常是幂等的,也更容易被浏览器缓存;POST 默认不缓存,重复提交还可能改变资源状态。参数一般分别放在 URL 和请求体中,但这不代表 POST 天然安全,抓包时两者都能被看到。至于报文如何拆成 TCP 数据包,不能简单当作两种方法的固定区别。

本质上,只是语义上的区别,GET 用于获取资源,POST 用于提交资源。

具体差别👇

  • 从缓存角度看,GET 请求后浏览器会主动缓存,POST 默认情况下不能。
  • 从参数角度来看,GET请求一般放在URL中,因此不安全,POST请求放在请求体中,相对而言较为安全,但是在抓包的情况下都是一样的。
  • 从编码角度看,GET请求只能经行URL编码,只能接受ASCII码,而POST支持更多的编码类型且不对数据类型限值。
  • GET请求幂等,POST请求不幂等,幂等指发送 M 和 N 次请求(两者不相同且都大于1),服务器上资源的状态一致。
  • GET请求会一次性发送请求报文,POST请求通常分为两个TCP数据包,首先发 header 部分,如果服务器响应 100(continue), 然后发 body 部分。

💬 面试官追问

  • 登录页负责人说把密码从 GET 改成 POST 就已经安全,因为地址栏看不到参数;安全评审会上你会怎么反驳?

    POST 只是把数据放入请求体,并不会自动加密,抓包时仍可看到明文内容,传输安全必须依赖 HTTPS。它能减少参数直接出现在 URL、历史记录或常见访问日志中的机会,但服务端日志和调试工具仍可能记录请求体,敏感数据还需最小化留存。

  • 商品查询接口使用 GET,筛选条件变化不频繁,前端却每次都强制绕过缓存;从方法语义和浏览器行为看,你会怎么改?

    GET 用于获取资源且具备幂等语义,浏览器默认更容易对其结果进行缓存,应结合响应缓存策略复用稳定查询结果。是否真正缓存仍由响应头和中间节点决定;若结果与用户身份或实时库存相关,就要设置正确的缓存边界,不能只凭方法放开共享缓存。

  • 报表查询条件包含嵌套对象,序列化后不适合放进 URL,团队准备改成 POST;产品又要求链接可收藏,你如何取舍?

    复杂条件放入请求体时使用 POST 是可接受的工程折中,但会失去 GET 查询地址天然可分享、默认易缓存的优势。可把条件保存为服务端资源并返回短标识,再用 GET 访问该标识;代价是增加存储、权限、过期清理和链接生命周期管理。

  • 支付确认接口使用 POST,客户端超时后自动重试,线上出现重复处理;开发认为网络层会保证一次请求只执行一次,你会如何排查?

    POST 通常具有副作用且不保证幂等,超时只能说明客户端没收到结果,不能证明服务端没有执行。应先用业务请求标识核对服务端处理记录,再在支付侧实现幂等校验;依赖传输可靠性无法消除重试造成的重复业务动作。

  • 抓包发现一个小型 POST 请求并未先收到 100 Continue,而是请求头和请求体一起发送;同事据此判断客户端违反协议,你认可吗?

    不认可,POST 并不必然拆成两个 TCP 数据包,是否等待 100 Continue 取决于客户端是否使用相应机制、请求大小和具体实现。TCP 分段也不能直接等同于 HTTP 语义边界;排查应查看请求头是否包含 Expect: 100-continue,不能用单次抓包概括所有 POST

# 10 谈一谈队头阻塞问题

⚡ 30 秒速记

  • HTTP/1.1 的队头阻塞:一条 TCP 连接上请求必须按序响应,前一个慢后面全等着
  • 当年的绕法:浏览器开 6 个并发连接、域名分片、雪碧图、资源合并 —— 都是在绕这个限制
  • HTTP/2多路复用解决了 HTTP 层的队头阻塞:一条连接跑多个流,互不阻塞
  • HTTP/2 没解决 TCP的队头阻塞:丢一个包,后面的包全要等重传
  • HTTP/3 换成基于 UDPQUIC,每个流独立,一个流丢包不影响其它流 —— 至此彻底解决

队头阻塞就是同一任务队列串行处理请求时,队首请求过慢,导致后续请求只能等待。 在一个域名只使用少量长连接的情况下,每条连接都像一条独立队列,因此增加并发连接可以分散阻塞风险。域名分片也是类似思路:把资源放到多个二级域名,让客户端建立更多连接并行处理。不过这类方案本质上是增加队列数量,并没有让单条队列摆脱慢请求的影响。

什么是队头阻塞?

对于每一个HTTP请求而言,这些任务是会被放入一个任务队列中串行执行的,一旦队首任务请求太慢时,就会阻塞后面的请求处理,这就是HTTP队头阻塞问题。

有什么解决办法吗👇

并发连接

我们知道对于一个域名而言,是允许分配多个长连接的,那么可以理解成增加了任务队列,也就是说不会导致一个任务阻塞了该任务队列的其他任务,在RFC规范中规定客户端最多并发2个连接,不过实际情况就是要比这个还要多,举个例子,Chrome中是6个。

域名分片

  • 顾名思义,我们可以在一个域名下分出多个二级域名出来,而它们最终指向的还是同一个服务器,这样子的话就可以并发处理的任务队列更多,也更好的解决了队头阻塞的问题。
  • 举个例子,比如TianTian.com,可以分出很多二级域名,比如Day1.TianTian.comDay2.TianTian.com,Day3.TianTian.com,这样子就可以有效解决队头阻塞问题。

💬 面试官追问

  • 商品详情页同时请求价格、库存和推荐列表,推荐接口最先入队却响应很慢,后面的价格请求一定会被它阻塞吗?

    不一定,只有这些请求落在同一条串行处理队列中,队首的慢请求才会挡住后续请求。若浏览器已为该域名建立多条连接,请求可能分配到不同队列;但同一连接内的排队关系仍然存在。

  • 一个 HTTP/1.1 页面首屏要加载几十个静态资源,瀑布图里大量请求长期处于排队状态,前端和服务端分别能做什么?

    可以让浏览器对同一域名使用多条长连接,相当于增加并行处理队列,避免所有资源都被一个慢请求拖住。服务端还应缩短队首请求的处理时间;连接数并非无限,资源过多时仍会排队。

  • 团队准备把图片拆到 img1.example.comimg2.example.com,但这些二级域名最终都指向同一台服务器,这样还能缓解阻塞吗?

    仍可能缓解,因为浏览器按域名分配连接,多个二级域名能够扩展可并发的请求队列,即使它们最终指向同一服务器。代价是域名、连接与运维复杂度增加,后端处理能力不足时也只是把等待位置后移。

  • 线上活动页只有某个域名下的资源成批卡住,其他域名资源正常,抓包又没有发现所有接口都变慢,你会怎样定位?

    先按域名和连接查看请求瀑布图,确认卡住的资源是否排在同一慢请求之后,再定位队首请求的服务端耗时。若换到另一连接后请求立即恢复,更符合队头阻塞;若所有连接都慢,应继续排查服务器容量或网络。

  • 架构师主张无限增加并发连接,前端负责人主张域名分片;面对大量小资源,你会怎样取舍?

    两种方案本质上都在增加请求队列,但连接数受浏览器策略约束,不能无限扩张;域名分片则用多个二级域名换取更多并发。应先控制资源数量和慢请求,再谨慎分片,否则会引入额外连接与域名管理成本。

# 11 谈一谈HTTP数据传输

⚡ 30 秒速记

  • 定长传输:靠 Content-Length 声明字节数,接收方按长度读取
  • 不定长传输:Transfer-Encoding: chunked 分块传输,每块前面带长度,0 表示结束 —— 流式响应和 SSE 都靠它
  • 两者互斥:有 chunked 就不能有 Content-Length
  • 范围请求:Range + 206 Partial Content,断点续传和视频拖动进度条靠它
  • 压缩:Accept-Encoding 协商,Content-Encoding 声明;gzip 通用,brotli 压缩率更高

HTTP/1.1 传输数据时,定长内容通常用 Content-Length,长度无法预先确定时使用 Transfer-Encoding: chunked Content-Length 必须与实际传输长度一致,过短会截断,过长可能导致接收方继续等待;使用压缩时填写的是压缩后的长度。分块传输适合长连接中持续产生的动态内容,接收方根据各个分块判断数据边界。如果两者同时出现,应按 Transfer-Encoding 处理并忽略 Content-Length

大概遇到的情况就分为定长数据不定长数据的处理吧。

定长数据

对于定长的数据包而言,发送端在发送数据的过程中,需要设置Content-Length,来指明发送数据的长度。

当然了如果采用了Gzip压缩的话,Content-Length设置的就是压缩后的传输长度。

我们还需要知道的是👇

  • Content-Length如果存在并且有效的话,则必须和消息内容的传输长度完全一致,也就是说,如果过短就会截断,过长的话,就会导致超时。
  • 如果采用短链接的话,直接可以通过服务器关闭连接来确定消息的传输长度。
  • 那么在HTTP/1.0之前的版本中,Content-Length字段可有可无,因为一旦服务器关闭连接,我们就可以获取到传输数据的长度了。
  • 在HTTP/1.1版本中,如果是Keep-alive的话,chunked优先级高于Content-Length,若是非Keep-alive,跟前面情况一样,Content-Length可有可无。

那怎么来设置Content-Length

举个例子来看看👇

const server = require('http').createServer();
server.on('request', (req, res) => {
  if(req.url === '/index') {
  	// 设置数据类型
    res.setHeader('Content-Type', 'text/plain');
    res.setHeader('Content-Length', 10);
    res.write("你好,使用的是Content-Length设置传输数据形式");
  }
})

server.listen(3000, () => {
  console.log("成功启动--TinaTian");
})

不定长数据

现在采用最多的就是HTTP/1.1版本,来完成传输数据,在保存Keep-alive状态下,当数据是不定长的时候,我们需要设置新的头部字段👇

Transfer-Encoding: chunked

通过chunked机制,可以完成对不定长数据的处理,当然了,你需要知道的是

  • 如果头部信息中有Transfer-Encoding,优先采用Transfer-Encoding里面的方法来找到对应的长度。
  • 如果设置了Transfer-Encoding,那么Content-Length将被忽视。
  • 使用长连接的话,会持续的推送动态内容。

那我们来模拟一下吧👇

const server = require('http').createServer();
server.on('request', (req, res) => {
  if(req.url === '/index') {
  	// 设置数据类型
    res.setHeader('Content-Type', 'text/html; charset=utf8');
    res.setHeader('Content-Length', 10);
    res.setHeader('Transfer-Encoding', 'chunked');

    res.write("你好,使用的是Transfer-Encoding设置传输数据形式");
    setTimeout(() => {
      res.write("第一次传输数据给您<br/>");
    }, 1000);
    res.write("骚等一下");
    setTimeout(() => {
      res.write("第一次传输数据给您");
      res.end()
    }, 3000);
  }
})

server.listen(3000, () => {
  console.log("成功启动--TinaTian");
})

上面使用的是nodejs中http模块,有兴趣的小伙伴可以去试一试,以上就是HTTP对定长数据不定长数据传输过程中的处理手段。

💬 面试官追问

  • 下载页收到响应头 Content-Length: 10,服务端实际却继续写入更长的正文,前端为什么不能把多出的内容当成下一段数据?

    有效的 Content-Length 必须与消息的传输长度一致,接收方会按该长度划定当前消息边界,长度过短可能造成正文截断。它不是业务分段标记,错误复用还会破坏长连接上后续消息的解析。

  • 文件服务先用 Gzip 压缩再返回,产品要求进度条按原文件大小计算,直接读取 Content-Length 能满足吗?

    不能,启用 Gzip 后,Content-Length 表示压缩后的实际传输长度,而不是解压后的原始大小。前端可据此展示网络传输进度;若要显示原文件进度,需要服务端另行提供可靠的原始尺寸。

  • 报表接口必须保持 Keep-Alive,生成过程中又无法提前知道总长度,后端应该怎样让客户端识别响应边界?

    HTTP/1.1 长连接下,应使用 Transfer-Encoding: chunked,把动态生成的内容分块发送,并由分块机制标识结束。这样无需预先计算总长度,但客户端在响应开始时也无法仅靠标准长度字段得到最终总量。

  • 线上接口同时返回 Content-LengthTransfer-Encoding: chunked,监控发现部分客户端展示内容异常,你先按哪个字段判断?

    应优先按 Transfer-Encoding 指定的方式解析,存在该字段时 Content-Length 会被忽略。排查时应确认服务端为何同时写入冲突头部,并检查每个分块及结束标记;保留双重声明容易造成实现差异。

  • 旧接口通过服务端写完后关闭连接来标记正文结束,迁移到长连接后偶发超时,根因可能在哪里?

    关闭连接能够为短连接提供消息结束边界,但长连接需要继续复用,不能再依赖断开来判断正文长度。迁移后应提供正确的 Content-Length,或对不定长内容使用 chunked;两者都缺失时客户端可能持续等待。

⚡ 30 秒速记

  • cookie 存在浏览器session 存在服务端,两者靠 sessionId 关联(通常通过 cookie 传递)
  • 流程:登录成功 → 服务端建 session 并返回 sessionId → 浏览器存进 cookie → 后续请求自动携带 → 服务端查 session
  • cookie 的安全属性:HttpOnlyJS 读不到,防 XSS)、Secure(只走 HTTPS)、SameSite(防 CSRF
  • session 的痛点:多机部署要共享(Redis 集中存储),否则轮询到别的机器就掉登录态
  • 现代替代:JWTAuthorization 头,服务端无状态;代价是签发后无法主动作废(要配黑名单或短过期 + refresh token

cookie 保存在浏览器,session 通常保存在服务端,两者一般通过 sessionId 关联。 登录后,服务端创建会话并把 sessionId 交给浏览器,后续请求再通过 cookie 自动携带它。简单来说,cookieHTTP 头里的字段,而 session 是服务端维护会话状态的一种抽象和实现。多机部署时还要共享 session 数据,否则请求切换到另一台机器后可能找不到登录状态。

  • session: 是一个抽象概念,开发者为了实现中断和继续等操作,将 user agentserver 之间一对一的交互,抽象为“会话”,进而衍生出“会话状态”,也就是 session 的概念
  • cookie:它是一个世纪存在的东西,http 协议中定义在 header 中的字段,可以认为是 session 的一种后端无状态实现

现在我们常说的 session,是为了绕开 cookie 的各种限制,通常借助 cookie本身和后端存储实现的,一种更高级的会话状态实现

session 的常见实现要借助cookie来发送 sessionID

💬 面试官追问

  • 登录接口给浏览器写入一个 sessionID,安全评审却说“既然用了 cookie,服务端就没有 session”,你会怎么纠正?

    这个判断把载体和会话状态混为一谈了,cookie 是定义在 HTTP header 中的机制,而常见 session 会借助它传递 sessionID。真正的会话状态通常保存在后端,浏览器持有的只是用于关联状态的标识。

  • 客服后台要求员工关闭页面后再次进入仍能继续操作,后端准备使用 session,请求链路最少要怎样串起来?

    后端需要创建并保存会话状态,再通过 cookie 把对应的 sessionID 交给浏览器,后续请求携带它以恢复同一会话。若浏览器没有回传该标识,或后端找不到对应记录,中断后的状态就无法继续。

  • 移动端容器明确禁止持久化 cookie,服务端仍坚持现有的 sessionID 方案,会受到什么约束?

    常见 session 实现依赖客户端在后续请求中持续发送 sessionID,禁止保存或回传 cookie 会切断这种关联。可以改用其他受控方式传递标识,但那已经改变了载体设计,仍需保证每次请求能绑定到正确会话。

  • 线上出现用户登录成功后下一次请求立即变成未登录,接口响应里已经看到 Set-Cookie,你会沿哪条链路排查?

    先确认浏览器是否实际保存该 cookie,再检查后续请求是否携带同一个 sessionID,最后核对后端存储中是否存在对应会话。只看到 Set-Cookie 不能证明链路完整,任何一段丢失都会表现为会话中断。

  • 无状态接口负责人认为只用 cookie 保存全部状态更简单,平台负责人坚持 cookie + sessionID + 后端存储,取舍重点是什么?

    把状态交给 cookie 能减少后端会话存储,但会受到客户端载体自身限制;常见 session 则用较小的 sessionID 关联后端状态。后者更适合需要中断后继续的复杂会话,代价是维护会话存储及标识映射。

# 13 介绍一下HTTPS和HTTP区别

⚡ 30 秒速记

  • HTTPS = HTTP + TLS,在应用层和传输层之间加了一层加密
  • 解决三个问题:加密(防窃听)、完整性校验(防篡改)、身份认证(防冒充,靠证书)
  • 端口:HTTP80HTTPS443
  • 代价:需要证书(Let's Encrypt 已免费)、握手多一次往返、加解密有 CPU 开销 —— 但现代硬件下开销已很小
  • 收益远大于代价:浏览器把 HTTP 标记为不安全,且 HTTP/2Service Worker、地理位置等能力都要求 HTTPS

HTTPS 本质上是 HTTPSSL/TLS 的组合,相比明文传输的 HTTP,它能加密数据并认证服务器身份。 HTTP 默认使用 80 端口,HTTPS 默认使用 443 端口,而且后者需要配置和维护证书。加密可以降低数据被窃听或篡改的风险,也更利于搜索引擎收录。它的取舍是会增加证书维护、握手和计算开销,不过合理优化后访问速度的影响通常可以控制。

HTTPS 要比 HTTPS 多了 secure 安全性这个概念,实际上, HTTPS 并不是一个新的应用层协议,它其实就是 HTTP + TLS/SSL 协议组合而成,而安全性的保证正是 SSL/TLS 所做的工作。

SSL

安全套接层(Secure Sockets Layer)

TLS

(传输层安全,Transport Layer Security)

现在主流的版本是 TLS/1.2, 之前的 TLS1.0、TLS1.1 都被认为是不安全的,在不久的将来会被完全淘汰。

HTTPS 就是身披了一层 SSL 的 HTTP

那么区别有哪些呢👇

  • HTTP 是明文传输协议,HTTPS 协议是由 SSL+HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全。
  • HTTPS比HTTP更加安全,对搜索引擎更友好,利于SEO,谷歌、百度优先索引HTTPS网页。
  • HTTPS标准端口443,HTTP标准端口80。
  • HTTPS需要用到SSL证书,而HTTP不用。

我觉得记住以下两点HTTPS主要作用就行👇

  1. 对数据进行加密,并建立一个信息安全通道,来保证传输过程中的数据安全;
  2. 对网站服务器进行真实身份认证。

HTTPS的缺点

  • 证书费用以及更新维护。
  • HTTPS 降低一定用户访问速度(实际上优化好就不是缺点了)。
  • HTTPS 消耗 CPU 资源,需要增加大量机器。

💬 面试官追问

  • 支付页已经把端口从 80 改成 443,运维就宣称链路具备 HTTPS 安全性,这个判断成立吗?

    不成立,端口变化本身不会提供加密或身份认证,HTTPSHTTPTLS/SSL 组合形成的安全通信。服务端还必须正确配置证书和安全协议,否则即使监听 443,也不能据此证明连接可信。

  • 资讯站准备全站从 HTTP 迁移到 HTTPS,产品只关心搜索收录,工程侧还应明确哪些直接收益?

    迁移后传输内容由 TLS/SSL 建立的安全通道保护,并可通过证书认证网站服务器身份,不只是影响搜索引擎表现。实施时需要配置证书、更新维护并评估资源消耗,不能只把页面链接替换成 https://

  • 内网站点不传密码,负责人因此认为明文 HTTP 足够;但页面会下发业务数据,你会怎样评估?

    不传密码并不等于没有风险,HTTP 的业务数据仍以明文传输,也缺少 HTTPS 提供的服务器身份认证。若数据需要防止链路窃听或站点冒充,就应使用 HTTPS;证书维护和计算资源仍需纳入成本。

  • 线上切换 HTTPS 后浏览器持续提示证书相关错误,而接口代码和响应内容都正常,你先查什么?

    应先检查服务端是否提供了与站点匹配且可验证的证书,并确认连接确实通过 TLS/SSL 建立,而不是只开放了 443 端口。应用响应正常不能替代身份认证,证书链路失败时浏览器仍会把站点视为不可信。

  • 预算评审认为 HTTPS 只有证书费用,没有运行成本;基础设施团队要求扩容,你如何给出保守结论?

    除证书采购或更新维护外,HTTPS 的加密与握手还会消耗一定计算资源,并可能带来访问延迟。具体影响取决于实现和优化,不能凭题面给出固定数字;应通过实际负载评估容量,而不是直接认定成本为零。

# 14 HTTPS握手过程

⚡ 30 秒速记

  • 四步(TLS 1.2):客户端 ClientHello(支持的加密套件 + 随机数)→ 服务端 ServerHello + 证书 + 随机数 → 客户端验证证书并生成预主密钥用公钥加密发过去 → 双方用三个随机数算出会话密钥
  • 核心思路:非对称加密用来安全地交换密钥,对称加密用来传实际数据(非对称太慢)
  • 证书验证:查签发机构是否可信、域名是否匹配、有没有过期、有没有被吊销
  • TLS 1.3 大幅简化:握手只需 1-RTT,会话恢复可以 0-RTT
  • 优化手段:会话复用(Session ID/Session Ticket)、OCSP StaplingHSTS

HTTPS 握手的核心,是先验证服务器证书,再安全地协商出后续通信使用的对称密钥。 客户端发送支持的协议、加密方法和随机数,服务端确认参数并返回证书及自己的随机数。客户端验证证书后生成 Premaster secret,用证书中的公钥加密发送,服务端再用私钥解密。双方根据这些随机数生成会话密钥,后续数据便改用效率更高的对称加密传输。

  • 第一步,客户端给出协议版本号、一个客户端生成的随机数(Client random),以及客户端支持的加密方法
  • 第二步,服务端确认双方使用的加密方法,并给出数字证书、以及一个服务器生成的随机数
  • 第三步,客户端确认数字证书有效,然后生成一个新的随机数(Premaster secret),并使用数字证书中的公钥,加密这个随机数,发给服务端
  • 第四步,服务端使用自己的私钥,获取客户端发来的随机数(即Premaster secret)。
  • 第五步,客户端和服务端根据约定的加密方法,使用前面的三个随机数,生成"对话密钥"(session key),用来加密接下来的整个对话过程

总结

  • 客户端发起 HTTPS 请求,服务端返回证书,客户端对证书进行验证,验证通过后本地生成用于构造对称加密算法的随机数
  • 通过证书中的公钥对随机数进行加密传输到服务端(随机对称密钥),服务端接收后通过私钥解密得到随机对称密钥,之后的数据交互通过对称加密算法进行加解密。(既有对称加密,也有非对称加密)

💬 面试官追问

  • 安全评审提议客户端首次访问时直接生成对称密钥并明文发给服务器,认为后续正文已经加密就足够,这里错在哪里?

    明文发送会让链路中的观察者直接获得对称密钥,后续加密也就失去意义。典型握手会让客户端生成 Premaster secret,使用证书中的公钥加密后传输,服务端再用私钥解开。

  • 浏览器访问登录页时,服务端已经返回数字证书和 Server random,客户端接下来应完成哪些关键动作?

    客户端应先验证数字证书是否有效,再生成 Premaster secret,并用证书中的公钥加密后发送给服务端。若证书验证失败,就不应继续建立受信任会话,否则公钥来源无法得到保证。

  • 后端负责人想让整个订单响应都使用证书公钥加密,前端负责人坚持握手后改用对称加密,哪种更符合该流程?

    握手中的非对称加密主要用于安全传递 Premaster secret,后续双方根据三个随机数生成 session key,再用它加密整个对话。持续使用公钥并不符合这套流程,也混淆了密钥交换与数据传输的职责。

  • 线上只有部分实例在握手阶段失败,抓包显示客户端已发出加密后的 Premaster secret,但服务端无法继续,你会先排查什么?

    应先确认故障实例是否持有与所发证书公钥匹配的私钥,因为服务端必须用该私钥解开 Premaster secret。再核对双方确认的加密方法及随机数处理;私钥或协商信息不一致都会阻断会话密钥生成。

  • 网关团队认为只校验证书即可省略 Client randomServer random,应用团队认为三份随机材料都要保留,如何判断?

    按该握手流程,客户端与服务端会结合 Client randomServer randomPremaster secret 生成相同的 session key。证书验证只确认公钥及服务端身份,不能替代会话密钥材料;省略随机数会改变既定协商过程。

# 15 介绍一个HTTPS工作原理

⚡ 30 秒速记

  • 三个能力:加密(对称加密传数据)、密钥交换(非对称加密协商密钥)、身份认证(数字证书 + CA 信任链)
  • 为什么混用两种加密:非对称安全但慢,对称快但密钥没法安全送达 —— 所以用非对称交换密钥、用对称传数据
  • 数字证书解决的是「公钥是不是真的属于这个网站」,靠 CA 的信任链背书
  • 数字签名保证完整性:用私钥对摘要签名,接收方用公钥验证
  • 中间人攻击的防线就是证书校验 —— 所以永远不要在代码里忽略证书错误

HTTPS 通过证书证明服务器身份,用非对称加密完成密钥交换,再用对称加密传输实际数据。 只用对称加密会遇到密钥无法安全传递的问题,只用非对称加密又不适合持续处理大量通信,所以两者需要配合。浏览器会利用受信任认证机构的公钥验证证书和数字签名,避免服务器公钥被中间人替换。验证通过后双方取得相同的对称密钥,后续 HTTP 内容都用它加密和解密。

我们可以把HTTPS理解成HTTPS = HTTP + SSL/TLS

TLS/SSL 的功能实现主要依赖于三类基本算法:散列函数对称加密非对称加密,其利用非对称加密实现身份认证和密钥协商,对称加密算法采用协商的密钥对数据加密,基于散列函数验证信息的完整性。

1. 对称加密

加密和解密用同一个秘钥的加密方式叫做对称加密。Client客户端和Server端共用一套密钥,这样子的加密过程似乎很让人理解,但是随之会产生一些问题。

问题一: WWW万维网有许许多多的客户端,不可能都用秘钥A进行信息加密,这样子很不合理,所以解决办法就是使用一个客户端使用一个密钥进行加密。

问题二:既然不同的客户端使用不同的密钥,那么对称加密的密钥如何传输? 那么解决的办法只能是一端生成一个秘钥,然后通过HTTP传输给另一端,那么这样子又会产生新的问题。

问题三: 这个传输密钥的过程,又如何保证加密?如果被中间人拦截,密钥也会被获取, 那么你会说对密钥再进行加密,那又怎么保存对密钥加密的过程,是加密的过程?

到这里,我们似乎想明白了,使用对称加密的方式,行不通,所以我们需要采用非对称加密👇

2. 非对称加密

通过上面的分析,对称加密的方式行不通,那么我们来梳理一下非对称加密。采用的算法是RSA,所以在一些文章中也会看见传统RSA握手,基于现在TLS主流版本是1.2,所以接下来梳理的是TLS/1.2握手过程

非对称加密中,我们需要明确的点是👇

  • 有一对秘钥,公钥私钥
  • 公钥加密的内容,只有私钥可以解开,私钥加密的内容,所有的公钥都可以解开,这里说的公钥都可以解开,指的是一对秘钥
  • 公钥可以发送给所有的客户端,私钥只保存在服务器端。

3. 主要工作流程

梳理起来,可以把TLS 1.2 握手过程分为主要的五步👇

  • 步骤一:Client发起一个HTTPS请求,连接443端口。这个过程可以理解成是请求公钥的过程
  • 步骤二:Server端收到请求后,通过第三方机构私钥加密,会把数字证书(也可以认为是公钥证书)发送给Client。
  • 步骤三:
    • 浏览器安装后会自动带一些权威第三方机构公钥,使用匹配的公钥对数字签名进行解密。
    • 根据签名生成的规则对网站信息进行本地签名生成,然后两者比对。
    • 通过比对两者签名,匹配则说明认证通过,不匹配则获取证书失败。
  • 步骤四:在安全拿到服务器公钥后,客户端Client随机生成一个对称密钥,使用服务器公钥(证书的公钥)加密这个对称密钥,发送给Server(服务器)。
  • 步骤五:Server(服务器)通过自己的私钥,对信息解密,至此得到了对称密钥,此时两者都拥有了相同的对称密钥

接下来,就可以通过该对称密钥对传输的信息加密/解密啦,从上面图举个例子👇

  • Client用户使用该对称密钥加密'明文内容B',发送给Server(服务器)
  • Server使用该对称密钥进行解密消息,得到明文内容B。

接下来考虑一个问题,如果公钥被中间人拿到纂改怎么办呢?

客户端可能拿到的公钥是假的,解决办法是什么呢?

3. 第三方认证

客户端无法识别传回公钥是中间人的,还是服务器的,这是问题的根本,我们是不是可以通过某种规范可以让客户端和服务器都遵循某种约定呢?那就是通过第三方认证的方式

在HTTPS中,通过 证书 + 数字签名来解决这个问题。

这里唯一不同的是,假设对网站信息加密的算法是MD5,通过MD5加密后,然后通过第三方机构的私钥再次对其加密,生成数字签名

这样子的话,数字证书包含有两个特别重要的信息👉某网站公钥+数字签名

我们再次假设中间人截取到服务器的公钥后,去替换成自己的公钥,因为有数字签名的存在,这样子客户端验证发现数字签名不匹配,这样子就防止中间人替换公钥的问题。

那么客户端是如何去对比两者数字签名的呢?

  • 浏览器会去安装一些比较权威的第三方认证机构的公钥,比如VeriSign、Symantec以及GlobalSign等等。
  • 验证数字签名的时候,会直接从本地拿到相应的第三方的公钥,对私钥加密后的数字签名进行解密得到真正的签名。
  • 然后客户端利用签名生成规则进行签名生成,看两个签名是否匹配,如果匹配认证通过,不匹配则获取证书失败。

4. 数字签名作用

数字签名:将网站的信息,通过特定的算法加密,比如MD5,加密之后,再通过服务器的私钥进行加密,形成加密后的数字签名

第三方认证机构是一个公开的平台,中间人可以去获取。

如果没有数字签名的话,这样子可以就会有下面情况👇

从上面我们知道,如果只是对网站信息进行第三方机构私钥加密的话,还是会受到欺骗。

因为没有认证,所以中间人也向第三方认证机构进行申请,然后拦截后把所有的信息都替换成自己的,客户端仍然可以解密,并且无法判断这是服务器的还是中间人的,最后造成数据泄露。

5. 总结

  • HTTPS就是使用SSL/TLS协议进行加密传输
  • 大致流程:客户端拿到服务器的公钥(是正确的),然后客户端随机生成一个对称加密的秘钥,使用该公钥加密,传输给服务端,服务端再通过解密拿到该对称秘钥,后续的所有信息都通过该对称秘钥进行加密解密,完成整个HTTPS的流程。
  • 第三方认证,最重要的是数字签名,避免了获取的公钥是中间人的。

💬 面试官追问

  • 登录页已经用 HTTPS,有人却说只要拿到服务器证书里的公钥就能解密所有用户请求,你会怎么反驳?

    证书公钥不是后续业务数据的直接解密密钥,它主要用于验证服务器身份并参与握手阶段的密钥协商。握手完成后,双方使用协商出的对称密钥加密请求与响应;服务器私钥不能公开,否则身份认证和密钥交换的安全基础都会受损。

  • 一个首页要加载 HTML、接口和几十个静态资源,为什么 TLS 不用非对称算法加密全部内容?

    非对称加密适合身份认证和安全协商密钥,但不适合持续承担大量业务数据的加解密。连接建立后改用同一把对称密钥处理传输,能兼顾效率与机密性;握手阶段仍需证书和数字签名,不能只发送一个裸公钥。

  • 公司内网代理把站点公钥替换成自己的公钥,但浏览器仍然显示安全锁,这一定说明证书校验失效了吗?

    不一定,企业代理可能已把自己的根证书安装进设备信任库,再为目标域名动态签发可被本机接受的证书。浏览器仍会校验证书链、域名和签名,只是信任锚发生了变化;若设备并未授权该根证书,替换公钥通常会触发证书错误。

  • 某域名换证后桌面浏览器正常,部分老客户端却提示证书不可信,你会沿着握手流程查什么?

    先检查服务端是否发送了完整证书链,再核对证书域名、有效期以及客户端设备时间。桌面浏览器可能补全缺失的中间证书,而部分旧客户端不能,因此同一域名会表现不同;若处于企业网络,还要排查代理证书是否被客户端信任。

  • 团队提议为了简化部署直接使用自签名证书,面向公众的支付页能否接受?

    公众支付页不适合直接采用未被客户端信任的自签名证书,因为浏览器无法沿受信任证书链确认服务器身份。用户手动忽略警告会削弱防中间人攻击的关键保障;自签名方案更适合已预先分发并严格管理信任根的封闭环境。

  • 证书验证已经保证了身份,为什么传输阶段还需要完整性校验,只有加密不够吗?

    加密主要隐藏明文,并不天然等同于能够发现传输内容遭到修改。TLS 还需要基于散列等机制验证数据完整性,使接收方识别被篡改的记录;身份认证、机密性和完整性分别处理不同风险,缺少任一项都不能完整替代。

# 16 SSL 连接断开后如何恢复

⚡ 30 秒速记

  • 两种会话复用机制,目的都是跳过完整握手省掉往返
  • Session ID:服务端存会话状态,客户端下次带上 ID 直接复用 —— 缺点是服务端要存,集群下还要共享
  • Session Ticket:服务端把会话状态加密后交给客户端保存,自己不存 —— 更适合集群,但密钥轮换要处理好
  • TLS 1.3PSKPre-Shared Key)统一了会话恢复,可以做到 0-RTT
  • 0-RTT 的代价:有重放攻击风险,所以只能用于幂等请求

SSL 连接断开后,可以通过 Session IDSession Ticket 复用上次的会话信息,避免重新生成密钥。 Session ID 由服务端保存,客户端重连时带上编号,但请求被负载均衡到另一台服务器后可能无法恢复。Session Ticket 由客户端保存,服务端解密后即可取回密钥和加密方式等信息,因此更适合多服务器场景。

一共有两种方法来恢复断开的 SSL 连接,一种是使用 session ID,一种是 session ticket。

通过session ID

使用 session ID 的方式,每一次的会话都有一个编号,当对话中断后,下一次重新连接时,只要客户端给出这个编号,服务器如果有这个编号的记录,那么双方就可以继续使用以前的秘钥,而不用重新生成一把。目前所有的浏览器都支持这一种方法。但是这种方法有一个缺点是,session ID 只能够存在一台服务器上,如果我们的请求通过负载平衡被转移到了其他的服务器上,那么就无法恢复对话。

通过session ticket

另一种方式是 session ticket 的方式,session ticket 是服务器在上一次对话中发送给客户的,这个 ticket 是加密的,只有服务器能够解密,里面包含了本次会话的信息,比如对话秘钥和加密方法等。这样不管我们的请求是否转移到其他的服务器上,当服务器将 ticket 解密以后,就能够获取上次对话的信息,就不用重新生成对话秘钥了。

💬 面试官追问

  • 用户关闭页面十秒后重新访问,客户端带着旧 session ID,为什么服务端仍可能重新握手?

    session ID 只有在服务端仍保存对应会话记录时才能恢复,编号本身不包含完整会话状态。记录可能已过期、被清理,或请求被负载均衡到另一台没有该记录的服务器;这些情况下只能重新生成会话密钥。

  • 三台网关轮询承接 TLS,上线后会话恢复命中率明显下降,你会优先调整什么?

    若使用 session ID,应让节点共享会话状态,或通过负载均衡保证后续连接落到保存记录的节点。也可改用服务端可解密的 session ticket,降低对单机状态的依赖;无论选择哪种方式,都要处理过期和密钥管理。

  • 团队把网关从单机扩成二十个实例,session IDsession ticket 哪个更适合横向扩容?

    在各实例能够解密同类票据的前提下,session ticket 更适合横向扩容,因为会话信息随客户端票据返回,不依赖命中特定节点。session ID 则要求节点保存并找到编号对应的记录;共享状态或会话粘滞会增加系统复杂度。

  • 监控显示恢复连接全部退化为完整握手,但页面请求仍能成功,你如何区分是票据故障还是网络故障?

    请求仍成功说明基础连接和完整握手大概率可用,应重点检查客户端是否携带票据、票据是否过期,以及当前节点能否解密。若近期轮换过票据加密密钥或新增节点,还要核对节点配置是否一致;恢复失败通常会回退握手,不一定直接报业务错误。

  • 安全团队要求频繁轮换 session ticket 加密密钥,性能团队担心恢复率下降,怎么取舍?

    票据只有服务器能够解密,因此加密密钥的生命周期直接影响安全边界和可恢复窗口。轮换时可让节点在受控期限内兼容必要的旧密钥,再逐步淘汰;保留过久会扩大密钥泄露影响,立即切断则会让旧票据全部失效。

  • 前端想靠 preconnect 解决移动网络频繁断连,它和会话恢复处理的是同一件事吗?

    两者不是同一机制:preconnect 是提前建立到目标源的连接,会话恢复则是在新连接中复用先前协商的信息。前者只能把连接成本前移,后者才可能避免重新生成会话密钥;若票据无效或服务端没有 session ID 记录,仍会完整握手。

# 17 谈一谈你对HTTP/2理解

⚡ 30 秒速记

  • 四个改进:二进制分帧(不再是文本)、多路复用(一条连接跑多个流)、头部压缩 HPACK、服务端推送(已被主流浏览器废弃)
  • 多路复用解决了 HTTP/1.1 的队头阻塞,也让雪碧图、域名分片、资源合并全部失效甚至变成负优化
  • HPACK 用静态表 + 动态表 + 哈夫曼编码,重复的头部只传索引
  • 还支持流优先级和流量控制
  • 没解决的问题:TCP 层队头阻塞依然存在 —— 这是 HTTP/3 出现的原因

HTTP/2 的核心是二进制分帧和多路复用,让多个请求、响应可以共享一条 TCP 连接并发传输。 不同流的帧可以乱序到达,再通过 Stream ID 重新组装,从而缓解 HTTP/1.1 请求排队造成的队头阻塞。它还用 HPACK 压缩头部,并支持服务器推送。边界是底层仍然使用 TCP,一旦丢包等待重传,同一连接中的所有流都会受到影响。

首先补充一下,http 和 https 的区别,相比于 http,https 是基于 ssl 加密的 http 协议

简要概括:http2.0 是基于 1999 年发布的 http1.0 之后的首次更新

  • 提升访问速度(可以对于,请求资源所需时间更少,访问速度更快,相比 http1.0)
  • 允许多路复用:多路复用允许同时通过单一的 HTTP/2 连接发送多重请求-响应信息。改 善了:在 http1.1 中,浏览器客户端在同一时间,针对同一域名下的请求有一定数量限 制(连接数量),超过限制会被阻塞
  • 二进制分帧:HTTP2.0 会将所有的传输信息分割为更小的信息或者帧,并对他们进行二 进制编码
  • 首部压缩
  • 服务器端推送

头部压缩

HTTP 1.1版本会出现 User-Agent、Cookie、Accept、Server、Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,所以导致头部偏重。

HTTP 2.0 使用 HPACK 算法进行压缩。

多路复用

  • HTTP 1.x 中,如果想并发多个请求,必须使用多个 TCP 链接,且浏览器为了控制资源,还会对单个域名有 6-8个的TCP链接请求限制。

HTTP2中:

  • 同域名下所有通信都在单个连接上完成。
  • 单个连接可以承载任意数量的双向数据流。
  • 数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,因为根据帧首部的流标识可以重新组装,也就是Stream ID,流标识符,有了它,接收方就能从乱序的二进制帧中选择ID相同的帧,按照顺序组装成请求/响应报文。

服务器推送

浏览器发送一个请求,服务器主动向浏览器推送与这个请求相关的资源,这样浏览器就不用发起后续请求。

相比较http/1.1的优势👇

  • 推送资源可以由不同页面共享
  • 服务器可以按照优先级推送资源
  • 客户端可以缓存推送的资源
  • 客户端可以拒收推送过来的资源

二进制分帧

之前是明文传输,不方便计算机解析,对于回车换行符来说到底是内容还是分隔符,都需要内部状态机去识别,这样子效率低,HTTP/2采用二进制格式,全部传输01串,便于机器解码。

这样子一个报文格式就被拆分为一个个二进制帧,用Headers帧存放头部字段,Data帧存放请求体数据。这样子的话,就是一堆乱序的二进制帧,它们不存在先后关系,因此不需要排队等待,解决了HTTP队头阻塞问题。

在客户端与服务器之间,双方都可以互相发送二进制帧,这样子双向传输的序列,称为,所以HTTP/2中以流来表示一个TCP连接上进行多个数据帧的通信,这就是多路复用概念。

那乱序的二进制帧,是如何组装成对于的报文呢?

  • 所谓的乱序,值的是不同ID的Stream是乱序的,对于同一个Stream ID的帧是按顺序传输的。
  • 接收方收到二进制帧后,将相同的Stream ID组装成完整的请求报文和响应报文。
  • 二进制帧中有一些字段,控制着优先级流量控制等功能,这样子的话,就可以设置数据帧的优先级,让服务器处理重要资源,优化用户体验。

HTTP2的缺点

  • TCP 以及 TCP+TLS建立连接的延时,HTTP/2使用TCP协议来传输的,而如果使用HTTPS的话,还需要使用TLS协议进行安全传输,而使用TLS也需要一个握手过程,在传输数据之前,导致我们需要花掉 3~4 个 RTT。
  • TCP的队头阻塞并没有彻底解决。在HTTP/2中,多个请求是跑在一个TCP管道中的。但当HTTP/2出现丢包时,整个 TCP 都要开始等待重传,那么就会阻塞该TCP连接中的所有请求。

💬 面试官追问

  • 商品页的一个大图片响应很慢,同域名的接口却还能并发返回,这能说明 HTTP/2 已彻底消除队头阻塞吗?

    只能说明不同 Stream ID 的请求不必像 HTTP/1.1 那样按响应顺序等待,不能说明底层阻塞已消失。多个流仍共享一条 TCP 连接,一旦发生丢包,重传等待可能影响连接上的所有流;这是 HTTP/2 多路复用的边界。

  • 站点把接口、脚本和图片都放在同一域名,Network 面板却出现一条连接上的大量并发请求,你如何解释报文不会串?

    HTTP/2 会把头部和数据体拆成二进制帧,并用 Stream ID 标识所属数据流。不同流的帧可以交错传输,接收端再按标识重组为各自的请求或响应;同一数据流内部仍需保持可正确组装的顺序。

  • 迁移到 HTTP/2 后,团队仍把静态资源拆到四个域名以突破每域名连接数限制,这个策略还合理吗?

    该策略的收益会明显减弱,因为 HTTP/2 可在同域名的一条连接上承载多个双向数据流,不再依赖多条连接实现请求并发。继续分片会额外建立连接,并使多路复用和头部压缩分散到不同连接;若存在独立缓存或权限边界,仍需单独评估。

  • 线上只有弱网用户出现首页所有资源同时卡住,服务器 CPU 和接口耗时都正常,你会怀疑哪一层?

    应检查共享 TCP 连接是否出现丢包和重传,因为 HTTP/2 的多个流都运行在这条连接上。某个包丢失时,底层 TCP 的有序交付可能阻塞所有流;还要结合连接复用情况排除资源其实来自不同域名或不同协议。

  • 一个接口响应体只有几十字节,但每次都携带较大的 CookieUser-Agent,升级 HTTP/2 能否替代头部治理?

    HPACK 会利用连接内的表压缩重复头字段,后续请求可减少重复传输,因此小响应通常受益明显。但首次出现的字段仍需发送,频繁变化或过大的 Cookie 也不会凭空消失;协议压缩不能替代删减无用头部。

  • 服务端准备主动推送首页可能需要的所有脚本,前端认为越多越快,你会接受这个结论吗?

    不能直接接受,服务器推送可以省去浏览器发起后续请求,但客户端可能已有缓存或根本不需要这些资源。客户端能够拒收或缓存推送资源,服务端也应结合优先级选择;推送过多会占用连接带宽,反而挤压当前页面的关键响应。

# 18 HTTP3

⚡ 30 秒速记

  • 底层从 TCP 换成基于 UDPQUIC 协议
  • 解决的核心问题:TCP 层队头阻塞 —— QUIC 里每个流独立,一个流丢包不影响其它流
  • 0-RTT 建连:首次连接 1-RTT,恢复连接可以 0-RTTTLS 1.3 内置在 QUIC 里)
  • 连接迁移:靠 Connection ID 标识连接而不是四元组,所以切 WiFi/4G 不会断连 —— 移动端收益巨大
  • 拥塞控制搬到用户态,可以快速迭代算法而不用等操作系统升级

HTTP/3 本质上是让 HTTP 运行在基于 UDPQUIC 上,用独立数据流解决 TCP 层的队头阻塞。 QUICUDP 之上补上了重传、拥塞控制和流量控制,所以仍能提供可靠传输。它同时集成 TLS 1.3,连接可以通过 0-RTT1-RTT 快速建立。相比 HTTP/2,其价值不只是多路复用,还在于某个流受阻时不会拖住其他流。

Google 在推SPDY的时候就已经意识到了这些问题,于是就另起炉灶搞了一个基于 UDP 协议的“QUIC”协议,让HTTP跑在QUIC上而不是TCP上。主要特性如下:

  • 实现了类似TCP的流量控制、传输可靠性的功能。虽然UDP不提供可靠性的传输,但QUIC在UDP的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些TCP中存在的特性
  • 实现了快速握手功能。由于QUIC是基于UDP的,所以QUIC可以实现使用0-RTT或者1-RTT来建立连接,这意味着QUIC可以用最快的速度来发送和接收数据。
  • 集成了TLS加密功能。目前QUIC使用的是TLS1.3,相较于早期版本TLS1.3有更多的优点,其中最重要的一点是减少了握手所花费的RTT个数。
  • 多路复用,彻底解决TCP中队头阻塞的问题。

💬 面试官追问

  • 视频页切到 HTTP/3 后抓包仍能看到丢包,测试同学因此认定 UDP 会让视频数据缺失,你怎么判断?

    看到丢包不能直接推出业务数据缺失,QUICUDP 之上实现了确认、重传、拥塞控制和流量控制。它仍提供可靠传输,只是不依赖 TCP 完成这些能力;若播放异常,应继续检查重传、拥塞和应用层处理。

  • 移动首页有多个接口并发加载,为什么把 HTTP/2TCP 多路复用换成 QUIC 仍可能改善卡顿?

    HTTP/2 的流虽然彼此独立,但都共享同一条 TCP 连接,丢包会触发底层有序重传等待。QUIC 在传输层提供多路复用,某个流的数据丢失时不必阻塞其他流;改善幅度仍取决于实际网络质量和部署情况。

  • 团队只把 HTTPS 证书配置搬到新网关,却没有准备 TLS 1.3,还能按预期启用 HTTP/3 吗?

    不能只把它当作普通 UDP 服务部署,QUIC 集成了 TLS 1.3 加密与握手能力。网关必须同时支持对应的 QUIC 和加密协商,客户端也要能够选择该协议;任一环节不支持时,通常需要回退到其他可用协议。

  • 灰度 HTTP/3 后,公司网络用户全部回退,家庭网络正常,应用接口本身没有报错,你会先查哪里?

    应先检查企业防火墙、代理或出口策略是否允许承载 QUICUDP 流量,再确认客户端与网关是否完成协议协商。业务接口正常只说明回退路径可用,不代表 HTTP/3 已建立;同时要核对服务端端口和协议支持是否一致。

  • 产品要求首个支付请求使用 0-RTT 追求最快提交,仅依据“QUIC 支持快速握手”能否批准?

    不能仅凭快速握手特性批准,0-RTT 只说明连接可更早发送数据,不代表所有带副作用的请求都适合。涉及支付时还需评估早期数据可能被重放等安全边界,并由服务端限制可接受的请求类型;保守方案是完整握手后再提交。

  • 弱网下 HTTP/3 已减少连接层队头阻塞,是否就不再需要控制图片和脚本体积?

    仍然需要,QUIC 改善的是连接建立、可靠传输和多路复用中的阻塞方式,并不会减少资源本身的字节数。大资源依旧占用带宽并受拥塞控制影响,脚本还要解析执行;协议升级不能替代资源压缩、缓存和加载优先级治理。

# 19 HTTP/1.0 HTTP1.1 HTTP2.0版本之间的差异

⚡ 30 秒速记

  • 1.01.1:默认长连接(keep-alive,省掉重复建连)、Host 头(一台服务器可托管多个域名)、缓存机制完善(ETag/Cache-Control)、断点续传(Range
  • 1.12.0:二进制分帧、多路复用、头部压缩、服务端推送
  • 最关键的一条:1.1 有队头阻塞(一条连接串行响应),2.0 靠多路复用解决
  • 连带影响:2.0 之后雪碧图、域名分片、资源合并这些优化不再必要
  • 再往后 3.0QUIC,解决 TCP 层队头阻塞

HTTP/1.0HTTP/1.1HTTP/2 的差异,可以理解为不断减少重复建连、请求排队和协议冗余。 HTTP/1.0 通常一次请求使用一次连接,但已支持多种内容格式以及 GETPOSTHEADHTTP/1.1 默认使用持久连接,并增加管线化、缓存控制、Range 断点续传和 HostHTTP/2 进一步采用二进制分帧、HPACK 和多路复用,不过底层 TCP 的队头阻塞仍然存在。

  • HTTP 0.9:1991年,原型版本,功能简陋,只有一个命令GET,只支持纯文本内容,该版本已过时。
  • HTTP 1.0
    • 任何格式的内容都可以发送,这使得互联网不仅可以传输文字,还能传输图像、视频、二进制等文件。
    • 除了GET命令,还引入了POST命令和HEAD命令。
    • http请求和回应的格式改变,除了数据部分,每次通信都必须包括头信息(HTTP header),用来描述一些元数据。
    • 只使用 header 中的 If-Modified-Since 和 Expires 作为缓存失效的标准。
    • 不支持断点续传,也就是说,每次都会传送全部的页面和数据。
    • 通常每台计算机只能绑定一个 IP,所以请求消息中的 URL 并没有传递主机名(hostname)
  • HTTP 1.1 http1.1是目前最为主流的http协议版本,从1999年发布至今,仍是主流的http协议版本。
    • 引入了持久连接( persistent connection),即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。长连接的连接时长可以通过请求头中的 keep-alive 来设置
    • 引入了管道机制( pipelining),即在同一个TCP连接里,客户端可以同时发送多个 请求,进一步改进了HTTP协议的效率。
    • HTTP 1.1 中新增加了 E-tag,If-Unmodified-Since, If-Match, If-None-Match 等缓存控制标头来控制缓存失效。
    • 支持断点续传,通过使用请求头中的 Range 来实现。
    • 使用了虚拟网络,在一台物理服务器上可以存在多个虚拟主机(Multi-homed Web Servers),并且它们共享一个IP地址。
    • 新增方法:PUT、 PATCH、 OPTIONS、 DELETE。
  • http1.x版本问题
    • 在传输数据过程中,所有内容都是明文,客户端和服务器端都无法验证对方的身份,无法保证数据的安全性。
    • HTTP/1.1 版本默认允许复用TCP连接,但是在同一个TCP连接里,所有数据通信是按次序进行的,服务器通常在处理完一个回应后,才会继续去处理下一个,这样子就会造成队头阻塞。
    • http/1.x 版本支持Keep-alive,用此方案来弥补创建多次连接产生的延迟,但是同样会给服务器带来压力,并且的话,对于单文件被不断请求的服务,Keep-alive会极大影响性能,因为它在文件被请求之后还保持了不必要的连接很长时间。
  • HTTP 2.0
    • 二进制分帧 这是一次彻底的二进制协议,头信息和数据体都是二进制,并且统称为"帧":头信息帧和数据帧。
    • 头部压缩 HTTP 1.1版本会出现 User-Agent、Cookie、Accept、Server、Range 等字段可能会占用几百甚至几千字节,而 Body 却经常只有几十字节,所以导致头部偏重。HTTP 2.0 使用 HPACK 算法进行压缩。
    • 多路复用 复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,且不用按顺序一一对应,这样子解决了队头阻塞的问题。
    • 服务器推送 允许服务器未经请求,主动向客户端发送资源,即服务器推送。
    • 请求优先级 可以设置数据帧的优先级,让服务端先处理重要资源,优化用户体验。

💬 面试官追问

  • 一个下载接口在 HTTP/1.0 下中断后只能从头开始,换成 HTTP/1.1 为什么可能支持继续下载?

    HTTP/1.1 引入了基于 Range 请求头的范围请求,客户端可以请求尚未完成的字节区间,从而实现断点续传。前提是服务端和资源都支持范围响应;仅升级协议标识而未实现相应处理,下载仍可能从头开始。

  • 首页连续请求十个接口,团队说开启 keep-alive 就等于获得 HTTP/2 多路复用,你会如何反驳?

    keep-alive 只是让多个请求复用同一条 TCP 连接,不能让 HTTP/1.1 的响应像 HTTP/2 帧那样在多个流间交错传输。前一个响应处理缓慢时仍可能造成队头阻塞;HTTP/2 还包含二进制分帧、头部压缩和流标识。

  • 迁移到 HTTP/1.1 后,同一 IP 上部署多个站点不再要求每站独占地址,协议层依赖了什么变化?

    HTTP/1.1 请求会携带主机信息,使服务器能够在共享 IP 上区分目标虚拟主机。网关必须依据主机名正确路由,并避免默认证书或站点配置错配;缺失或错误的主机信息仍可能被导向错误服务。

  • 升级 HTTP/2 后接口并发能力提高,但弱网下偶尔所有请求一起停顿,你会如何定位版本收益与故障边界?

    先确认请求是否确实复用同一条 HTTP/2 连接,再检查该连接上的丢包和 TCP 重传。二进制分帧解决了应用层按响应次序等待的问题,但多个流仍共享 TCP;底层丢包造成的队头阻塞不会因协议升级彻底消失。

  • 团队准备为 HTTP/2 重新拆分一个超大脚本包,但担心小请求数量增加,你会依据什么取舍?

    HTTP/2 的多路复用和 HPACK 降低了同连接内多个请求及重复头部的成本,因此不必沿用只为减少连接并发而合并资源的策略。拆分仍要考虑资源总字节数、缓存复用和执行开销;协议能力不会自动消除过度碎片化的管理成本。

  • 安全评审认为从 HTTP/1.1 升到 HTTP/2 就能解决明文窃听,这个结论成立吗?

    不成立,版本升级关注分帧、压缩、多路复用、优先级和服务器推送,并不等同于获得传输加密。保护身份、机密性和完整性仍需使用 HTTPS 所依赖的 TLS;若链路仍是明文,协议性能特性不能阻止监听和篡改。

# 20 DNS如何工作的

⚡ 30 秒速记

  • 查找顺序:浏览器缓存 → 系统缓存 → hosts 文件 → 本地 DNS 服务器 → 根域名服务器 → 顶级域(.com)→ 权威域名服务器
  • 两种查询方式:客户端到本地 DNS递归查询(帮你查到底),本地 DNS 往上是迭代查询(一层层问)
  • 记录类型:AIPv4)、AAAAIPv6)、CNAME(别名,CDN 常用)、MX(邮件)、TXT
  • 前端优化:dns-prefetch 预解析、preconnect 顺带建连、减少域名数量
  • DNS 默认走 UDP:53 明文,有劫持风险;DoH/DoT 把它加密

DNS 是把域名解析成 IP 地址的分布式查询系统,属于应用层协议,通常使用 UDP53 端口。 浏览器会先检查自身缓存、操作系统缓存和 hosts 文件,仍未命中时再请求本地 DNS 服务器。客户端到本地服务器通常是递归查询,本地服务器则依次向根域名、顶级域名和权威域名服务器迭代查询。解析结果会按 TTL 缓存;同一域名返回多个服务器地址时,也可以借此分摊请求。

DNS 的作用就是通过域名查询到具体的 IP。DNS 协议提供的是一种主机名到 IP 地址的转换服务,就是我们常说的域名系统。是应用层协议,通常该协议运行在UDP协议之上,使用的是53端口号。

因为 IP 存在数字和英文的组合(IPv6),很不利于人类记忆,所以就出现了域名。你可以把域名看成是某个 IP 的别名,DNS 就是去查询这个别名的真正名称是什么。

当你在浏览器中想访问 www.google.com 时,会通过进行以下操作:

  • 本地客户端向服务器发起请求查询 IP 地址
  • 查看浏览器有没有该域名的 IP 缓存
  • 查看操作系统有没有该域名的 IP 缓存
  • 查看 Host 文件有没有该域名的解析配置
  • 如果这时候还没得话,会通过直接去 DNS 根服务器查询,这一步查询会找出负责 com 这个一级域名的服务器
  • 然后去该服务器查询 google.com 这个二级域名
  • 接下来查询 www.google.com 这个三级域名的地址
  • 返回给 DNS 客户端并缓存起来

我们通过一张图来看看它的查询过程吧👇

这张图很生动的展示了DNS在本地DNS服务器是如何查询的,一般向本地DNS服务器发送请求是递归查询的

本地 DNS 服务器向其他域名服务器请求的过程是迭代查询的过程👇

递归查询和迭代查询

  • 递归查询指的是查询请求发出后,域名服务器代为向下一级域名服务器发出请求,最后向用户返回查询的最终结果。使用递归 查询,用户只需要发出一次查询请求。
  • 迭代查询指的是查询请求后,域名服务器返回单次查询的结果。下一级的查询由用户自己请求。使用迭代查询,用户需要发出 多次的查询请求。

所以一般而言,本地服务器查询是递归查询,而本地 DNS 服务器向其他域名服务器请求的过程是迭代查询的过程

DNS缓存

缓存也很好理解,在一个请求中,当某个DNS服务器收到一个DNS回答后,它能够回答中的信息缓存在本地存储器中。返回的资源记录中的 TTL 代表了该条记录的缓存的时间。

DNS实现负载平衡

它是如何实现负载均衡的呢?首先我们得清楚DNS 是可以用于在冗余的服务器上实现负载平衡。

原因: 这是因为一般的大型网站使用多台服务器提供服务,因此一个域名可能会对应 多个服务器地址。

举个例子来说👇

  • 当用户发起网站域名的 DNS 请求的时候,DNS 服务器返回这个域名所对应的服务器 IP 地址的集合
  • 在每个回答中,会循环这些 IP 地址的顺序,用户一般会选择排在前面的地址发送请求。
  • 以此将用户的请求均衡的分配到各个不同的服务器上,这样来实现负载均衡。

DNS 为什么使用 UDP 协议作为传输层协议?

DNS 使用 UDP 协议作为传输层协议的主要原因是为了避免使用 TCP 协议时造成的连接时延

  • 为了得到一个域名的 IP 地址,往往会向多个域名服务器查询,如果使用 TCP 协议,那么每次请求都会存在连接时延,这样使 DNS 服务变得很慢。
  • 大多数的地址查询请求,都是浏览器请求页面时发出的,这样会造成网页的等待时间过长。

总结

  • DNS域名系统,是应用层协议,运行UDP协议之上,使用端口43。
  • 查询过程,本地查询是递归查询,依次通过浏览器缓存 —>> 本地hosts文件 —>> 本地DNS解析器 —>>本地DNS服务器 —>> 其他域名服务器请求。 接下来的过程就是迭代过程。
  • 递归查询一般而言,发送一次请求就够,迭代过程需要用户发送多次请求。

💬 面试官追问

  • 用户能用 IP 打开登录页,却不能通过域名访问,后端同事据此认定权威 DNS 配错了,你同意吗?

    不能直接认定,因为域名查询可能命中浏览器、操作系统或本地 hosts 中的旧结果,还未访问权威服务器。应逐层检查缓存和 hosts,再核对本地 DNS 返回的地址;直接访问 IP 只能说明网络与服务可能可达。

  • 新域名上线时,运维只给了权威解析记录,浏览器侧仍提示域名找不到,你会沿着哪条链路验证?

    先检查浏览器与操作系统缓存以及本地 hosts,再查询本地 DNS 得到的记录。若仍无结果,就继续确认解析链是否能从根域定位顶级域服务器,再找到该域名的权威服务器;任一层配置或缓存异常都可能中断查询。

  • 一个活动域名同时指向多台服务器,产品要求仅靠 DNS 分摊流量,并在某台机器故障后立即摘除,你会接受吗?

    DNS 可以返回多个服务器地址,并通过调整回答中的地址顺序实现较粗粒度的负载分配。但解析结果会按资源记录的 TTL 缓存,故障地址被删除后仍可能继续返回或被客户端使用,因此不能承诺立即摘除。

  • 发布后部分地区访问旧站、公司网络却正常,应用日志也没有异常,你怎么判断是否为解析缓存问题?

    分别在异常网络和正常网络查询域名,比较返回的 IP 与预期记录,同时检查本地 hosts 和各级缓存。若异常侧持续得到旧地址,应结合记录的 TTL 判断缓存何时失效;只清浏览器缓存未必能清除操作系统或本地 DNS 的结果。

  • 架构师要在反向代理和 DNS 多地址之间选择负载均衡,前端域名切换又要求故障影响尽量小,你会提醒什么?

    DNS 多地址无需让请求先经过统一代理,但调度较粗,并会受到各级缓存和 TTL 的约束。反向代理能在入口处选择真实服务器,控制更集中;代价是所有请求经过该入口,其容量与可用性也必须被保障。

# 21 短轮询、长轮询和 WebSocket 间的区别

⚡ 30 秒速记

  • 短轮询:定时发请求问「有没有新数据」—— 实现最简单,但延迟高、无效请求多
  • 长轮询:服务端 hold 住请求直到有数据才返回,客户端收到后立刻再发 —— 延迟低,兼容性最好
  • WebSocket:一次握手后建立全双工长连接,服务端可主动推送 —— 真正的实时
  • 开销对比:短轮询和长轮询每次都带完整 HTTP 头部,WebSocket 建连后帧头只有 2~10 字节
  • 选型:只需服务端单向推用 SSE(比 WebSocket 简单得多且自带重连),需要双向实时才上 WebSocket

短轮询是客户端定时询问,长轮询是请求挂起等待结果,WebSocket 则建立可双向通信的长连接。 短轮询实现最简单,但即使数据没变化也会持续建立 HTTP 连接,客户端和服务端都有额外开销。长轮询只在数据更新或超时后返回,减少了无效请求,不过挂起连接仍会占用资源。需要服务端主动推送且双方频繁通信时,可以使用 WebSocket,代价是服务端配置更复杂。

1. 短轮询

短轮询的基本思路:

  • 浏览器每隔一段时间向浏览器发送 http 请求,服务器端在收到请求后,不论是否有数据更新,都直接进行 响应。
  • 这种方式实现的即时通信,本质上还是浏览器发送请求,服务器接受请求的一个过程,通过让客户端不断的进行请求,使得客户端能够模拟实时地收到服务器端的数据的变化。

优缺点👇

  • 优点是比较简单,易于理解。
  • 缺点是这种方式由于需要不断的建立 http 连接,严重浪费了服务器端和客户端的资源。当用户增加时,服务器端的压力就会变大,这是很不合理的。

2. 长轮询

长轮询的基本思路:

  • 首先由客户端向服务器发起请求,当服务器收到客户端发来的请求后,服务器端不会直接进行响应,而是先将 这个请求挂起,然后判断服务器端数据是否有更新。
  • 如果有更新,则进行响应,如果一直没有数据,则到达一定的时间限制才返回。客户端 JavaScript 响应处理函数会在处理完服务器返回的信息后,再次发出请求,重新建立连接。

优缺点👇

  • 长轮询和短轮询比起来,它的优点是明显减少了很多不必要的 http 请求次数,相比之下节约了资源。
  • 长轮询的缺点在于,连接挂起也会导致资源的浪费

3. WebSocket

  • WebSocket 是 Html5 定义的一个新协议,与传统的 http 协议不同,该协议允许由服务器主动的向客户端推送信息。
  • 使用 WebSocket 协议的缺点是在服务器端的配置比较复杂。WebSocket 是一个全双工的协议,也就是通信双方是平等的,可以相互发送消息。

💬 面试官追问

  • 订单列表每分钟刷新一次,候选人说短轮询不算实时通信、必须改成 WebSocket,你会怎么判断?

    短轮询仍能模拟实时更新,只是实时程度受轮询间隔限制,并非不能用于状态刷新。若业务允许分钟级延迟,它实现简单且可能足够;强行使用 WebSocket 会增加服务端配置复杂度,而收益未必匹配。

  • 客服工作台要等待工单状态变化,绝大多数请求期间都没有更新,你会怎样把短轮询改成长轮询?

    客户端发起请求后,服务端先挂起,检测到状态更新时再响应,没有更新则到超时后返回。客户端处理完响应后立即建立下一次请求,这能减少短轮询产生的大量无效请求;挂起连接本身仍会占用服务端资源。

  • 在线协作页面要求浏览器和服务器都能随时发送操作,负责人仍想用长轮询统一所有通信,你会指出什么限制?

    长轮询主要依赖客户端持续发起并重新建立 HTTP 请求,服务端只能借助已挂起的请求返回数据。双向交互频繁时,这种模型管理成本较高;WebSocket 提供全双工通信,更符合双方都需要主动发送消息的约束。

  • 长轮询接口上线后连接数持续升高、很多请求长期处于等待状态,但业务更新很少,你会先排查哪里?

    先检查服务端是否为挂起请求设置了明确的超时,以及客户端在响应后是否只创建一个后继请求。长轮询减少了无效请求次数,却不会消除连接占用;超时过长或重复发起请求都会放大资源压力。

  • 十万级用户的状态页在短轮询、长轮询和 WebSocket 之间选型,业务只说要“尽量实时”,你会要求先明确什么?

    需要先明确更新频率、可接受延迟、通信是否双向,以及服务端能承受的请求和连接规模。短轮询简单但反复建立请求,长轮询减少无效请求却会挂起连接,WebSocket 支持全双工但服务端配置更复杂;没有这些约束就无法可靠选型。

# 22 说一说正向代理和反向代理

⚡ 30 秒速记

  • 正向代理代理的是客户端:服务器不知道真实客户端是谁 —— 典型是翻墙、公司出口代理
  • 反向代理代理的是服务端:客户端不知道真实服务器是谁 —— 典型是 NginxCDN、负载均衡
  • 一句话区分:正向代理藏客户端,反向代理藏服务端
  • 反向代理的用途:负载均衡、SSL 卸载、静态资源缓存、跨域转发、安全防护(WAF
  • 前端最常打交道的是反向代理:devServer.proxyNginx 配置都属于这一类

正向代理隐藏真实客户端,反向代理隐藏真实服务端,区别就在于代理替哪一侧转发请求。 使用正向代理时,目标服务只看到代理服务器发出的请求,不知道后面的真实客户端是谁。使用反向代理时,客户端只访问统一入口,不需要知道请求最终落到哪台真实服务器。反向代理常用于集群负载均衡;也可以通过 DNS 返回多个 IP 分流,但缓存可能导致故障地址暂时仍被返回。

正向代理

我们常说的代理也就是指正向代理,正向代理的过程,它隐藏了真实的请求客户端,服务端不知道真实的客户端是谁,客户端请求的服务都被代理服务器代替来请求。

反向代理

这种代理模式下,它隐藏了真实的服务端,当我们向一个网站发起请求的时候,背后可能有成千上万台服务器为我们服务,具体是哪一台,我们不清楚,我们只需要知道反向代理服务器是谁就行,而且反向代理服务器会帮我们把请求转发到真实的服务器那里去,一般而言反向代理服务器一般用来实现负载平衡。

负载平衡的两种实现方式?

  • 一种是使用反向代理的方式,用户的请求都发送到反向代理服务上,然后由反向代理服务器来转发请求到真实的服务器上,以此来实现集群的负载平衡。
  • 另一种是 DNS 的方式,DNS 可以用于在冗余的服务器上实现负载平衡。因为现在一般的大型网站使用多台服务器提供服务,因此一个域名可能会对应多个服务器地址。当用户向网站域名请求的时候,DNS 服务器返回这个域名所对应的服务器 IP 地址的集合,但在每个回答中,会循环这些 IP 地址的顺序,用户一般会选择排在前面的地址发送请求。以此将用户的请求均衡的分配到各个不同的服务器上,这样来实现负载均衡。这种方式有一个缺点就是,由于 DNS 服务器中存在缓存,所以有可能一个服务器出现故障后,域名解析仍然返回的是那个 IP 地址,就会造成访问的问题。

💬 面试官追问

  • 浏览器通过公司代理访问外部文档站,服务端日志只看到代理地址;同事却称这是反向代理,你怎么纠正?

    这里是正向代理,因为代理代表客户端向外部服务发起请求,外部服务看不到真实客户端。反向代理隐藏的是后方真实服务器,用户只知道统一入口;判断关键在代理代表请求方还是服务方。

  • 本地开发把 /api 交给 devServer.proxy 转发到后端,浏览器不再报跨域,你会怎样解释这条链路?

    对浏览器而言,请求仍发往同源的开发服务器,再由该服务器转发到真实接口,因此属于反向代理场景。浏览器不知道后端实例是谁,服务器间转发也不受浏览器同源策略约束;线上若不保留同域入口,跨域约束仍会出现。

  • 一个域名后有多台应用服务器,平台团队要求用户不能感知扩缩容,你会把负载均衡放在哪里?

    可让用户统一访问反向代理,再由代理把请求分配到真实服务器,扩缩容只需调整代理后的节点集合。这样隐藏了服务端拓扑并集中调度;反向代理自身也成为关键入口,需要具备足够容量和可用性。

  • 机房已删除故障服务器的 DNS 地址,部分用户却仍持续命中它,应用负责人认为删除记录应立即生效,你怎么排查?

    先查询受影响用户实际拿到的地址,并核对各级 DNS 缓存及记录的 TTL。缓存未过期时,客户端仍可能使用已删除的故障地址;若要求更快摘除,应评估由反向代理集中选择健康后端,而不能只依赖解析变更。

  • 流量入口评审中,一方主张 DNS 轮换多个地址,另一方主张所有请求经过反向代理,你如何说明取舍?

    DNS 可返回多个服务器地址并轮换顺序,部署分散,但缓存会降低故障切换的及时性。反向代理能集中转发和调整后端,更容易隐藏节点变化;相应地,它承担全部入口流量,必须额外保障自身稳定性。

# 23 介绍一下Connection:keep-alive

⚡ 30 秒速记

  • 作用:让一条 TCP 连接可以复用处理多个 HTTP 请求,避免每次都重新三次握手
  • HTTP/1.0 默认关闭,要显式发 Connection: keep-aliveHTTP/1.1 默认开启,要关闭才发 Connection: close
  • 收益:省掉建连的往返时间和 TCP 慢启动的代价,对小文件多的页面提升明显
  • 服务端会配 keepalive_timeout 和最大请求数,防止空闲连接占用资源
  • HTTP/2 之后这个头已无意义 —— 它本身就是单连接多路复用

Connection: keep-alive 表示多个 HTTP 请求可以复用同一条 TCP 连接,而不是每次请求结束后立即断开。 这样能减少反复建立和关闭连接带来的响应时间、CPU 资源及拥堵开销,适合同一客户端连续请求多个资源的场景。在 HTTP/1.0 中需要显式发送 Connection: keep-alive 才会启用。在 HTTP/1.1 中默认使用持久连接,如需关闭则发送 Connection: close,最终能否保持还取决于服务器配置。

什么是keep-alive

我们知道HTTP协议采用“请求-应答”模式,当使用普通模式,即非KeepAlive模式时,每个请求/应答客户和服务器都要新建一个连接,完成 之后立即断开连接(HTTP协议为无连接的协议);

当使用Keep-Alive模式(又称持久连接、连接重用)时,Keep-Alive功能使客户端到服 务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive功能避免了建立或者重新建立连接。

为什么要使用keep-alive

keep-alive技术的创建目的,能在多次HTTP之前重用同一个TCP连接,从而减少创建/关闭多个 TCP 连接的开销(包括响应时间、CPU 资源、减少拥堵等),参考如下示意图

客户端如何开启

在HTTP/1.0协议中,默认是关闭的,需要在http头加入"Connection: Keep-Alive”,才能启用Keep-Alive;

Connection: keep-alive

http 1.1中默认启用Keep-Alive,如果加入"Connection: close “,才关闭。

Connection: close

目前大部分浏览器都是用http1.1协议,也就是说默认都会发起Keep-Alive的连接请求了,所以是否能完成一个完整的Keep- Alive连接就看服务器设置情况。

💬 面试官追问

  • 商品页连续请求十个资源,候选人说 keep-alive 会把十个响应合成一次返回,你会怎么纠正?

    keep-alive 重用的是同一条 TCP 连接,并不会把多个 HTTP 请求或响应合并。每次交互仍遵循请求—应答模式,只是避免反复建立和关闭连接,从而减少握手、响应时间与资源开销。

  • 一个仍使用 HTTP/1.0 的内部接口每次请求后都断开,客户端想复用连接,应检查什么配置?

    HTTP/1.0 默认不启用持久连接,应检查请求是否携带 Connection: keep-alive,以及服务器是否支持并接受连接复用。只修改客户端头部并不能保证成功,完整的持久连接还取决于服务端设置。

  • 网关从 HTTP/1.0 升到 HTTP/1.1 后,团队仍在所有请求里显式添加 Connection: keep-alive,这有必要吗?

    HTTP/1.1 中持久连接默认启用,通常无需再显式添加 Connection: keep-alive。更应检查是否有人发送了 Connection: close,因为它会要求请求完成后关闭连接;最终能否复用仍受服务器配置影响。

  • 接口耗时没有变化,但瀑布图里每个请求都重新建立连接,页面整体变慢,你会从哪里定位?

    先确认请求或响应中是否出现 Connection: close,再检查服务器是否允许并维持持久连接。若连接无法复用,每个请求都会承担新建与关闭 TCP 连接的开销;仅在前端声明 keep-alive 不能覆盖服务端主动断开的配置。

  • 运维认为连接复用能降低开销,因此准备让所有空闲连接永久保留,你会接受这个结论吗?

    连接复用确实能减少重复建立和关闭 TCP 连接带来的时间、计算与拥塞成本,但不等于连接应永久存在。具体维持策略必须由客户端和服务器共同支持,并结合服务端资源约束设置;否则持久连接本身也可能成为负担。

# 24 http/https 协议总结

⚡ 30 秒速记

  • HTTP:应用层、无状态、明文、默认端口 80
  • HTTPS = HTTP + TLS:加密、完整性校验、身份认证,默认端口 443
  • 版本主线:1.1(长连接 + Host)→ 2(多路复用 + HPACK)→ 3QUIC 解决 TCP 队头阻塞)
  • 缓存两层:强缓存不发请求、协商缓存发请求拿 304
  • 面试高频三组对比:GET/POST301/302cookie/session/Token

HTTP 负责应用层通信,HTTPS 则通过证书和 SSL 加密提升传输安全性,默认使用 443 端口。 HTTP/1.1 用长连接复用连接,并通过 Host、身份认证和缓存等能力弥补 HTTP/1.0 的不足。HTTP/2 进一步引入多路复用、二进制分帧、首部压缩和服务端推送。缓存上,未过期时走 Cache-ControlExpires 强缓存,过期后再用 EtagLast-Modified 协商,且前者优先级更高。

1.0 协议缺陷:

  • 无法复用链接,完成即断开,重新慢启动和 TCP 3次握手
  • head of line blocking: 线头阻塞,导致请求之间互相影响

1.1 改进:

  • 长连接(默认 keep-alive),复用
  • host 字段指定对应的虚拟站点
  • 新增功能:
    • 断点续传
    • 身份认证
    • 状态管理
    • cache 缓存
      • Cache-Control
      • Expires
      • Last-Modified
      • Etag

2.0:

  • 多路复用
  • 二进制分帧层: 应用层和传输层之间
  • 首部压缩
  • 服务端推送

https: 较为安全的网络传输协议

  • 证书(公钥)
  • SSL 加密
  • 端口 443

TCP:

  • 三次握手
  • 四次挥手
  • 滑动窗口: 流量控制
  • 拥塞处理
    • 慢开始
    • 拥塞避免
    • 快速重传
    • 快速恢复

缓存策略: 可分为 强缓存 和 协商缓存

  • Cache-Control/Expires: 浏览器判断缓存是否过期,未过期时,直接使用强缓存,Cache-Controlmax-age 优先级高于 Expires
  • 当缓存已经过期时,使用协商缓存
    • 唯一标识方案: Etag(response 携带) & If-None-Match(request携带,上一次返回的 Etag): 服务器判断资源是否被修改
    • 最后一次修改时间: Last-Modified(response) & If-Modified-Since(request,上一次返回的Last-Modified)
      • 如果一致,则直接返回 304 通知浏览器使用缓存
      • 如不一致,则服务端返回新的资源
  • Last-Modified 缺点:
    • 周期性修改,但内容未变时,会导致缓存失效
    • 最小粒度只到 ss 以内的改动无法检测到
  • Etag 的优先级高于Last-Modified

💬 面试官追问

  • 首页在 HTTP/1.1 下开了长连接,候选人据此断言多个资源已经不会互相阻塞,你会怎么追问?

    长连接解决的是重复建连问题,不等于消除同一连接上的请求阻塞。HTTP/2 通过二进制分帧和多路复用改善多个请求相互影响,并配合首部压缩;仅看到 keep-alive 不能推断已获得这些能力。

  • 静态站升级协议时,网关团队问 HTTP/1.1 相比 HTTP/1.0 能带来哪些直接收益,你会抓哪些配置验收?

    应确认默认长连接是否生效,并检查 Host 是否正确路由到虚拟站点,同时验收断点续传、认证、状态管理和缓存相关行为。升级协议并不保证服务端配置自动正确,连接被关闭或缓存头缺失仍会抵消收益。

  • 资源服务器已配置 Expires,应用又返回了 Cache-Control: max-age,两者给出的过期时间冲突时浏览器应依据哪个?

    浏览器应优先采用 Cache-Controlmax-age 判断强缓存是否过期,Expires 的优先级更低。未过期可直接使用本地缓存;过期后还需进入协商缓存流程,不能把配置了过期时间等同于永远不请求服务器。

  • 发版后一张图片内容变了,但请求携带 If-Modified-Since 后仍收到 304,修改发生在同一秒内,你会怎样解释并修正?

    Last-Modified 的时间粒度只能到秒,同一秒内的修改可能无法被准确识别,因此服务器可能判断资源未变化。可同时采用 ETagIf-None-Match 校验内容标识,而且 ETag 的判断优先级更高;代价是服务端需要生成和比较标识。

  • 构建任务每次都会改写文件时间,但产物内容没有变化,线上却频繁重新下载,你会选择哪种协商缓存依据?

    仅依赖 Last-Modified 会把时间变化视为资源变化,即使实际内容相同也可能让缓存失效。更适合使用 ETagIf-None-Match 判断资源标识,未修改时返回 304;服务端必须保证标识生成规则稳定且能反映内容变化。

  • 安全评审要求站点从 HTTP 切到 HTTPS,产品却认为只要端口改成 443 就完成了,你会指出缺少什么?

    HTTPS 不只是更换端口,还需要证书提供公钥信息,并通过 SSL 建立加密传输。证书与加密配置缺失时,无法获得预期的安全通信;同时它解决的是传输安全,不能替代应用自身的认证和状态管理。

# 25 TCP为什么要三次握手

⚡ 30 秒速记

  • 目的:确认双方的收发能力都正常,并同步各自的初始序列号(ISN
  • 两次为什么不够:只能确认客户端发、服务端收,无法确认服务端发的包客户端能不能收到
  • 更重要的原因:防止已失效的旧连接请求突然到达服务端,导致服务端白白建立连接等待
  • 四次挥手为什么比握手多一次:关闭时服务端的 ACKFIN 不能合并 —— 收到关闭请求时它可能还有数据没发完
  • TIME_WAIT2MSL 的原因:保证最后一个 ACK 能到达,并让旧连接的残留报文自然消亡

TCP 需要三次握手,是为了确认双方都具备正常的发送和接收能力,并避免失效请求误建连接。 前两次通信只能让客户端确认服务端可收可发,服务端还不知道客户端能否收到自己的确认,所以需要第三次确认。这样即使旧的连接请求延迟到达,服务端收不到最终确认,也不会一直为无效连接等待数据。第三次握手时客户端已进入 ESTABLISHED 状态,因此可以携带数据;断开时双方关闭发送方向的时机可能不同,所以通常需要四次挥手。

客户端和服务端都需要直到各自可收发,因此需要三次握手

  • 第一次握手成功让服务端知道了客户端具有发送能力
  • 第二次握手成功让客户端知道了服务端具有接收和发送能力,但此时服务端并不知道客户端是否接收到了自己发送的消息
  • 为了防止出现失效的连接请求报文段被服务端接收的情况,从而产生错误。所以第三次握手就起到了这个作用

你可以能会问,2 次握手就足够了?。但其实不是,因为服务端还没有确定客户端是否准备好了。比如步骤 3 之后,服务端马上给客户端发送数据,这个时候客户端可能还没有准备好接收数据。因此还需要增加一个过程

TCP有6种标示:SYN(建立联机) ACK(确认) PSH(传送) FIN(结束) RST(重置) URG(紧急)

举例:已失效的连接请求报文段

  • client发送了第一个连接的请求报文,但是由于网络不好,这个请求没有立即到达服务端,而是在某个网络节点中滞留了,直到某个时间才到达server
  • 本来这已经是一个失效的报文,但是server端接收到这个请求报文后,还是向client发出确认的报文,表示同意连接。
  • 假如不采用三次握手,那么只要server发出确认,新的建立就连接了,但其实这个请求是失效的请求,client是不会理睬server的确认信息,也不会向服务端发送确认的请求
  • 但是server认为新的连接已经建立起来了,并一直等待client发来数据,这样,server的很多资源就没白白浪费掉了
  • 采用三次握手就是为了防止这种情况的发生,server会因为收不到确认的报文,就知道client并没有建立连接。这就是三次握手的作用

三次握手过程中可以携带数据吗

  • 第一次、第二次握手不可以携带数据,因为一握二握时还没有建立连接,会让服务器容易受到攻击
  • 而第三次握手,此时客户端已经处于 ESTABLISHED (已建立连接状态) ,对于客户端来说,已经建立起连接了,并且也已经知道服务器的接收、发送能力是正常的了,所以能携带数据也是没问题的。

为什么建立连接只通信了三次,而断开连接却用了四次?

  • 客户端要求断开连接,发送一个断开的请求,这个叫作(FIN)。
  • 服务端收到请求,然后给客户端一个 ACK,作为 FIN 的响应。
  • 这里你需要思考一个问题,可不可以像握手那样马上传 FIN 回去?
  • 其实这个时候服务端不能马上传 FIN,因为断开连接要处理的问题比较多,比如说服务端可能还有发送出去的消息没有得到 ACK;也有可能服务端自己有资源要释放。因此断开连接不能像握手那样操作——将两条消息合并。所以,服务端经过一个等待,确定可以关闭连接了,再发一条 FIN 给客户端
  • 客户端收到服务端的 FIN,同时客户端也可能有自己的事情需要处理完,比如客户端有发送给服务端没有收到 ACK 的请求,客户端自己处理完成后,再给服务端发送一个 ACK。

为了确保数据能够完成传输。因为当服务端收到客户端的 FIN 报文后,发送的 ACK 报文只是用来应答的,并不表示服务端也希望立即关闭连接。

当只有服务端把所有的报文都发送完了,才会发送 FIN 报文,告诉客户端可以断开连接了,因此在断开连接时需要四次挥手。

  • 关闭连接时,当收到对方的FIN报文通知时,它仅仅表示对方没有数据发送给你了;但未必你所有的数据都全部发送给对方了
  • 所以你未必会马上关闭SOCKET,也即你可能还需要发送一些数据给对方之后,再发送FIN报文给对方来表示你同意现在可以关闭连接了,所以它这里的ACK报文和FIN报文多数情况下都是分开发送的。

💬 面试官追问

  • 客户端发出 SYN 后,只要服务端返回 SYN+ACK 就算双方都具备收发能力,这个判断哪里不成立?

    此时客户端已确认服务端能接收并发送,但服务端只能确认客户端能发送,尚不知道客户端能否收到自己的报文。客户端再发一次 ACK,服务端才能确认双向收发链路均正常,因此两次握手不足以完成双方确认。

  • 一条旧的连接请求在网络节点滞留,原连接早已结束后才到达服务端;如果协议只握手两次,服务端会出现什么工程后果?

    服务端回复确认后就可能误认为新连接已经建立,并为它保留连接状态、等待客户端发送数据。客户端知道该请求已经失效,不会响应服务端;第三次握手缺失时,服务端无法及时识别这种半开连接,资源会被无效占用。

  • 同事想把第三次握手改成“服务端返回确认后立即下发业务数据”,以减少首页等待,这个方案为什么有风险?

    服务端收到首个请求时还没有确认客户端能接收,也不知道客户端是否仍认可这次连接,立即发送业务数据缺少可靠前提。第三次 ACK 到达后,服务端才确认客户端准备完成;握手规则不能由业务层为节省一次确认而随意省略。

  • 线上连接大量停留在服务端等待确认的阶段,却一直没有进入已建立状态,你会沿着哪条握手链路排查?

    先确认客户端的首个连接请求是否到达,再检查服务端确认报文能否返回客户端,以及客户端最终 ACK 是否被网络设备丢弃。若服务端持续收不到第三次确认,它不会把连接视为完整建立;还应排查防火墙、链路丢包和失效请求,而不是先归因于业务数据处理。

  • 为什么第三次握手可以携带数据,而前两次通常不应携带业务数据?

    发出第三次报文时,客户端已经确认服务端具备收发能力,并进入连接已建立状态,因此该报文具备承载数据的条件。前两次发生在连接尚未完成确认时,服务端若提前接收和处理数据,会扩大无效连接或恶意请求造成的资源风险。

  • 评审中有人认为建立连接三次、关闭连接也应该固定三次,你会如何解释关闭通常需要四次?

    收到对方的 FIN 只表示对方不再发送数据,不代表本端的数据已经发送完毕,因此本端通常先单独回复 ACK。待剩余报文和资源处理完成后再发送自己的 FIN,对方最后确认;只有关闭确认与本端 FIN 恰好可合并时,报文数量才可能变化。

# 26 为什么要有 WebSocket

⚡ 30 秒速记

  • HTTP 是请求-响应模型,服务端无法主动推送 —— 这是根本动因
  • 轮询的代价:延迟高、大量无效请求、每次都带完整 HTTP 头部
  • WebSocket 一次握手(Upgrade: websocket101 Switching Protocols)后建立全双工连接,之后帧头只有 2~10 字节
  • 工程要点:心跳保活(防中间代理断开)、断线重连(指数退避)、鉴权(建连后首条消息或 URL 参数)
  • 不受同源策略限制,所以服务端必须自己校验 Origin

需要 WebSocket,本质上是因为 HTTP 的请求—应答模式不适合服务端主动推送和实时双向通信。 轮询虽然能模拟实时效果,但会反复产生无效请求,浪费带宽和计算资源。WebSocket 建立后支持全双工通信,并使用二进制帧传输,适合即时消息、动态页面和网络游戏。它先通过带有 Connection: UpgradeUpgrade: websocketHTTP GET 请求握手,服务端返回 101 Switching Protocols 后切换协议,但连接、缓存和状态需要应用自己管理。

已经有了被广泛应用的 HTTP 协议,为什么要再出一个 WebSocket 呢?它有哪些好处呢?

其实 WebSocket 与 HTTP/2 一样,都是为了解决 HTTP 某方面的缺陷而诞生的。HTTP/2 针对的是“队头阻塞”,而 WebSocket 针对的是“请求 - 应答”通信模式

那么,“请求 - 应答”有什么不好的地方呢?

  • “请求 - 应答”是一种“半双工”的通信模式,虽然可以双向收发数据,但同一时刻只能一个方向上有动作,传输效率低。更关键的一点,它是一种“被动”通信模式,服务器只能“被动”响应客户端的请求,无法主动向客户端发送数据。
  • 虽然后来的 HTTP/2、HTTP/3 新增了 Stream、Server Push 等特性,但“请求 - 应答”依然是主要的工作方式。这就导致 HTTP 难以应用在动态页面、即时消息、网络游戏等要求“实时通信”的领域。
  • 在 WebSocket 出现之前,在浏览器环境里用 JavaScript 开发实时 Web 应用很麻烦。因为浏览器是一个“受限的沙盒”,不能用 TCP,只有 HTTP 协议可用,所以就出现了很多“变通”的技术,“轮询”(polling)就是比较常用的的一种。
  • 简单地说,轮询就是不停地向服务器发送 HTTP 请求,问有没有数据,有数据的话服务器就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现“实时通信”的效果。
  • 但轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU 资源,非常不经济。
  • 所以,为了克服 HTTP“请求 - 应答”模式的缺点,WebSocket 就“应运而生”了

WebSocket 的特点

  • WebSocket 是一个真正“全双工”的通信协议,与 TCP 一样,客户端和服务器都可以随时向对方发送数据
  • WebSocket 采用了二进制帧结构,语法、语义与 HTTP 完全不兼容,但因为它的主要运行环境是浏览器,为了便于推广和应用,就不得不“搭便车”,在使用习惯上尽量向 HTTP 靠拢,这就是它名字里“Web”的含义。
  • 服务发现方面,WebSocket 没有使用 TCP 的“IP 地址 + 端口号”,而是延用了 HTTP 的 URI 格式,但开头的协议名不是“http”,引入的是两个新的名字:“ws”和“wss”,分别表示明文和加密的 WebSocket 协议。
  • WebSocket 的默认端口也选择了 80 和 443,因为现在互联网上的防火墙屏蔽了绝大多数的端口,只对 HTTP 的 80、443 端口“放行”,所以 WebSocket 就可以“伪装”成 HTTP 协议,比较容易地“穿透”防火墙,与服务器建立连接
ws://www.chrono.com
ws://www.chrono.com:8080/srv
wss://www.chrono.com:445/im?user_id=xxx

WebSocket 的握手

和 TCP、TLS 一样,WebSocket 也要有一个握手过程,然后才能正式收发数据。

这里它还是搭上了 HTTP 的“便车”,利用了 HTTP 本身的“协议升级”特性,“伪装”成 HTTP,这样就能绕过浏览器沙盒、网络防火墙等等限制,这也是 WebSocket 与 HTTP 的另一个重要关联点。

WebSocket 的握手是一个标准的 HTTP GET 请求,但要带上两个协议升级的专用头字段:

  • “Connection: Upgrade”,表示要求协议“升级”;
  • “Upgrade: websocket”,表示要“升级”成 WebSocket 协议。

另外,为了防止普通的 HTTP 消息被“意外”识别成 WebSocket,握手消息还增加了两个额外的认证用头字段(所谓的“挑战”,Challenge):

  • Sec-WebSocket-Key:一个 Base64 编码的 16 字节随机数,作为简单的认证密钥;
  • Sec-WebSocket-Version:协议的版本号,当前必须是 13。

服务器收到 HTTP 请求报文,看到上面的四个字段,就知道这不是一个普通的 GET 请求,而是 WebSocket 的升级请求,于是就不走普通的 HTTP 处理流程,而是构造一个特殊的“101 Switching Protocols”响应报文,通知客户端,接下来就不用 HTTP 了,全改用 WebSocket 协议通信

小结

浏览器是一个“沙盒”环境,有很多的限制,不允许建立 TCP 连接收发数据,而有了 WebSocket,我们就可以在浏览器里与服务器直接建立“TCP 连接”,获得更多的自由。

不过自由也是有代价的,WebSocket 虽然是在应用层,但使用方式却与“TCP Socket”差不多,过于“原始”,用户必须自己管理连接、缓存、状态,开发上比 HTTP 复杂的多,所以是否要在项目中引入 WebSocket 必须慎重考虑。

  • HTTP 的“请求 - 应答”模式不适合开发“实时通信”应用,效率低,难以实现动态页面,所以出现了 WebSocket;
  • WebSocket 是一个“全双工”的通信协议,相当于对 TCP 做了一层“薄薄的包装”,让它运行在浏览器环境里;
  • WebSocket 使用兼容 HTTP 的 URI 来发现服务,但定义了新的协议名“ws”和“wss”,端口号也沿用了 80 和 443
  • WebSocket 使用二进制帧,结构比较简单,特殊的地方是有个“掩码”操作,客户端发数据必须掩码,服务器则不用;
  • WebSocket 利用 HTTP 协议实现连接握手,发送 GET 请求要求“协议升级”,握手过程中有个非常简单的认证机制,目的是防止误连接。

💬 面试官追问

  • 聊天页面每秒轮询一次消息接口,产品说“HTTP 也能双向传数据,没必要上 WebSocket”,你会指出哪个关键差异?

    HTTP 的主要工作方式仍是客户端请求、服务端应答,服务端不能在没有请求时主动推送新消息。高频轮询只能近似实时,还会产生大量没有新数据的请求,消耗带宽和服务端处理资源;持续双向通信才是 WebSocket 更匹配的场景。

  • 行情页需要服务端持续推送报价,同时客户端随时发送订阅变更;为什么全双工比“请求后等待响应”更合适?

    WebSocket 建立连接后,客户端和服务端都能随时发送数据,不必等待另一方向完成一次请求—应答。报价推送与订阅变更可以在同一连接上独立发生;代价是应用必须自行管理连接、缓存和状态,开发复杂度高于普通 HTTP

  • 公司出口防火墙只放行常见 Web 端口,架构师仍建议自定义一个全新端口承载实时协议;WebSocket 的选择解决了什么约束?

    WebSocket 沿用兼容 HTTPURI 形式,并通常使用 80443 端口,更容易通过现有防火墙和浏览器环境。它先借助 HTTP 完成协议升级,再切换到自己的二进制帧通信;这并不意味着升级后的数据仍是普通 HTTP 报文。

  • 线上 WebSocket 请求一直返回普通页面的 200,客户端没有进入连接成功状态,你会先核对哪些握手字段和响应?

    先检查客户端是否发送 HTTP GET,并包含 Connection: UpgradeUpgrade: websocketSec-WebSocket-Key 和版本字段。服务端应识别升级请求并返回 101 Switching Protocols;若返回 200,通常说明请求被当成普通 HTTP,还需检查代理是否转发了升级头。

  • 通知中心一天只有少量事件,团队却准备为它维护长连接、重连、缓存和状态机;你会如何做选型取舍?

    事件稀疏且实时性要求不高时,普通请求或适度轮询可能更简单,不必仅因服务端需要通知就引入 WebSocketWebSocket 更适合即时消息、游戏等持续实时通信,但连接生命周期和状态需要应用自行管理;收益不足时,复杂度会成为主要成本。

  • 安全评审看到 Sec-WebSocket-Key,便认为它能替代用户登录鉴权,这个结论为什么不可靠?

    Sec-WebSocket-Key 是握手中的随机挑战值,用于避免普通 HTTP 消息被意外识别为 WebSocket,并不是业务用户身份凭证。用户权限仍需由应用层单独校验;把协议握手校验当成登录认证,会让未授权连接进入后续通信流程。

# 27 UDP和TCP有什么区别

⚡ 30 秒速记

  • TCP 面向连接、可靠(确认重传 + 排序)、有流量控制和拥塞控制,开销大
  • UDP 无连接、不可靠、不保证顺序,但头部小(8 字节 vs 20 字节)、延迟低
  • TCP 是字节流,UDP 是数据报(保留消息边界)
  • 场景:TCP 用于 HTTP、文件传输、邮件;UDP 用于 DNS、视频直播、游戏、VoIP
  • 前端最该知道的一条:HTTP/3 基于 UDP —— 因为 TCP 的可靠传输机制本身造成了队头阻塞

TCP 面向连接并提供可靠传输,UDP 无连接且不保证数据可靠到达。 TCP 会给数据段编号,并通过确认、窗口和流量控制来保障传输,因此协议负载相对更高。UDP 不做这些连接与确认工作,机制更简单,但数据是否收到需要上层自行处理。简单来说,需要可靠性的场景更适合 TCP;能够接受丢失、希望减少协议负担时,可以考虑 UDP

  • TCP协议在传送数据段的时候要给段标号;UDP协议不
  • TCP协议可靠;UDP协议不可靠
  • TCP协议是面向连接;UDP协议采用无连接
  • TCP协议负载较高,采用虚电路;UDP采用无连接
  • TCP协议的发送方要确认接收方是否收到数据段(3次握手协议)
  • TCP协议采用窗口技术和流控制

💬 面试官追问

  • 文件上传页面要求数据完整到达,同事却说“UDP 更轻,丢了让用户重传整个文件就行”,这个选型忽略了什么?

    文件上传需要有序、可靠地交付数据,TCP 会给数据段编号,并通过确认、窗口和流量控制维护传输。UDP 无连接且不提供这些可靠性保证,应用若自行补齐确认和重传会增加复杂度;只追求较低协议负担不能覆盖完整性要求。

  • 浏览器调用上传接口时,抓包显示连接建立前有往返确认;这些报文如何体现 TCPUDP 的工程差异?

    TCP 面向连接,发送数据前要通过握手确认通信双方的能力,后续数据段还需要确认。UDP 采用无连接方式,不先建立这种虚连接;因此它的协议负担较低,但也不会自动保证接收方收到每个数据段。

  • 实时数据面板允许丢失个别过期采样,却要求新数据尽快送达;协议约束变化后,为什么仍不能只凭“UDP 更快”拍板?

    允许少量丢失会降低对传输可靠性的要求,使无连接的 UDP 成为候选,但 source 只能支持其负担较低、缺少可靠确认。仍需由业务明确乱序、丢失和拥塞时的处理方式;若最终要求完整交付,就必须在应用层补偿或改用 TCP

  • 线上日志显示发送端已经调用成功,但接收端缺少若干报文;使用 UDP 时为什么不能直接判定是服务端漏处理?

    UDP 本身不要求接收方对数据段逐一确认,也不保证报文可靠到达,因此发送调用成功不等于对端已经收到。排查时要同时检查链路丢包、接收缓冲和应用处理;若业务不能容忍缺失,应增加应用层确认机制或选择可靠传输。

  • 批量接口出现接收方处理不过来的现象,评审时如何用流量控制能力比较 TCPUDP

    TCP 采用窗口技术和流量控制,可根据接收能力约束发送过程,并配合确认维护可靠传输。UDP 不提供同等级的内建机制,发送节奏需要应用自行管理;高吞吐并不等于可以忽略接收方容量,否则丢失和过载风险会转移到业务层。

  • 同事把“TCP 三次握手”和“每个数据段的确认”混成一件事,你会怎样纠正这两个相邻概念?

    三次握手用于建立面向连接的通信状态,确认双方具备相应收发能力;数据传输阶段的确认则用于判断数据段是否到达。二者都服务于可靠性,但发生阶段和目的不同;UDP 既不建立这种连接,也不内建逐段可靠确认。

# 十一、9种前端常见的设计模式

# 1. 外观模式

⚡ 30 秒速记

  • 定义:给一组复杂的子系统提供一个统一的简化接口,屏蔽内部细节
  • 前端典型例子:封装一个 request()fetch 的错误处理、超时、鉴权、序列化全包进去
  • 兼容性封装也是外观模式:把 addEventListener/attachEvent 的差异藏在一个 on()
  • 好处:降低使用成本、内部实现可以随时替换而不影响调用方
  • 代价:多一层抽象;如果外观接口设计得不好,反而会限制灵活性

外观模式就是给一组复杂的子系统接口提供一个统一、简单的高层入口,让调用方更容易使用。 比如封装浏览器事件绑定时,内部处理 addEventListenerattachEvent 等兼容逻辑,外部只调用一个方法。它适合用来隔离系统分层、简化持续演化的子系统,或为遗留代码提供清晰入口。代价是外观接口变化时往往需要直接修改,不太符合开闭原则。

外观模式是最常见的设计模式之一,它为子系统中的一组接口提供一个统一的高层接口,使子系统更容易使用。简而言之外观设计模式就是把多个子系统中复杂逻辑进行抽象,从而提供一个更统一、更简洁、更易用的API。很多我们常用的框架和库基本都遵循了外观设计模式,比如JQuery就把复杂的原生DOM操作进行了抽象和封装,并消除了浏览器之间的兼容问题,从而提供了一个更高级更易用的版本。其实在平时工作中我们也会经常用到外观模式进行开发,只是我们不自知而已

兼容浏览器事件绑定

let addMyEvent = function (el, ev, fn) {
    if (el.addEventListener) {
        el.addEventListener(ev, fn, false)
    } else if (el.attachEvent) {
        el.attachEvent('on' + ev, fn)
    } else {
        el['on' + ev] = fn
    }
};

封装接口

let myEvent = {
    // ...
    stop: e => {
        e.stopPropagation();
        e.preventDefault();
    }
};

场景

  • 设计初期,应该要有意识地将不同的两个层分离,比如经典的三层结构,在数据访问层和业务逻辑层、业务逻辑层和表示层之间建立外观Facade
  • 在开发阶段,子系统往往因为不断的重构演化而变得越来越复杂,增加外观Facade可以提供一个简单的接口,减少他们之间的依赖。
  • 在维护一个遗留的大型系统时,可能这个系统已经很难维护了,这时候使用外观Facade也是非常合适的,为系系统开发一个外观Facade类,为设计粗糙和高度复杂的遗留代码提供比较清晰的接口,让新系统和Facade对象交互,Facade与遗留代码交互所有的复杂工作。

优点

  • 减少系统相互依赖。
  • 提高灵活性。
  • 提高了安全性

缺点

不符合开闭原则,如果要改东西很麻烦,继承重写都不合适。

💬 面试官追问

  • 页面把 addEventListenerattachEventonclick 的兼容判断复制到二十个组件里,这只是工具函数缺失,还是适合引入外观模式?

    这适合用外观模式收敛:对外只暴露统一的事件绑定接口,内部选择浏览器支持的实现。调用方不再依赖多个底层接口和兼容分支;但外观应保持职责集中,否则不断加入无关能力会变成新的复杂子系统。

  • 业务层同时直接调用缓存、请求和错误提示模块,重构其中一个模块时几十个页面都要修改;外观层应放在哪里?

    可在业务层与这些子系统之间建立统一的高层接口,让页面只依赖稳定的业务操作,外观再协调底层模块。这样能减少层间依赖并提高替换灵活性;若页面仍绕过外观直接访问子系统,隔离效果就会被破坏。

  • 遗留后台有多个命名混乱的接口,新页面只需要“查询并展示用户”,你会让新代码直接理解全部旧模块吗?

    应为遗留系统增加一个边界清晰的 Facade,向新页面提供简洁接口,并在内部适配旧代码的复杂调用。这样新系统只与外观交互,维护范围更可控;外观只能隔离复杂度,不能自动消除遗留实现本身的缺陷。

  • 统一 request 外观升级后,某个页面的特殊配置无法传入,开发者开始绕过它直接调用底层库;这暴露了什么设计故障?

    这说明外观提供的高层接口没有覆盖必要能力,已经从简化使用变成限制使用。应核对真实调用场景,补充明确扩展点或受控的配置透传;若把所有底层参数原样暴露,又会削弱外观减少依赖的价值。

  • 架构评审要求所有子系统能力都只能通过一个巨大 Facade 暴露,以获得最高“安全性”,你会接受吗?

    不会仅为统一入口就堆成单一巨大外观,应按稳定职责和层次划分接口,限制不必要的底层暴露。外观确实能保护子系统并减少依赖,但修改集中接口可能影响大量调用方,而且其扩展并不天然符合开闭原则。

  • 有人把外观模式和代理模式都理解为“包一层”,在接口聚合页面中如何区分它们?

    外观面向一组复杂子系统,目标是提供更统一、更易用的高层接口,调用形式通常会被重新组织。代理则通常代表某个目标对象并控制对它的访问;若代码主要在聚合多个模块,而非代替单一对象,就更接近外观模式。

# 2. 代理模式

⚡ 30 秒速记

  • 定义:为对象提供一个替身来控制对它的访问
  • 前端例子:图片懒加载(先给占位图,真图加载完再换)、Vue 3 的响应式(Proxy 拦截读写)、接口缓存代理
  • ES6Proxy 是语言级支持,能拦截 13 种操作
  • 和装饰器模式的区别:代理控制访问(能不能访问、什么时候访问),装饰器增强功能(在原有基础上加东西)
  • 常见变体:虚拟代理(延迟创建开销大的对象)、缓存代理(记忆化)、保护代理(权限校验)

代理模式是给目标对象增加一个代用品或占位对象,由它控制调用方如何访问目标对象。 代理位于双方之间,可以延迟转交请求、保护目标对象,也能在不修改目标对象的情况下扩展处理逻辑,从而降低耦合。前端常见例子是事件代理:在父元素监听点击,再通过 event.target 识别实际触发的子元素。它不是直接访问,因此会增加一层处理,并可能带来额外开销。

是为一个对象提供一个代用品或占位符,以便控制对它的访问

假设当A 在心情好的时候收到花,小明表白成功的几率有60%,而当A 在心情差的时候收到花,小明表白的成功率无限趋近于0。小明跟A 刚刚认识两天,还无法辨别A 什么时候心情好。如果不合时宜地把花送给A,花被直接扔掉的可能性很大,这束花可是小明吃了7 天泡面换来的。但是A 的朋友B 却很了解A,所以小明只管把花交给B,B 会监听A 的心情变化,然后选择A 心情好的时候把花转交给A,代码如下:

let Flower = function() {}
let xiaoming = {
  sendFlower: function(target) {
    let flower = new Flower()
    target.receiveFlower(flower)
  }
}
let B = {
  receiveFlower: function(flower) {
    A.listenGoodMood(function() {
      A.receiveFlower(flower)
    })
  }
}
let A = {
  receiveFlower: function(flower) {
    console.log('收到花'+ flower)
  },
  listenGoodMood: function(fn) {
    setTimeout(function() {
      fn()
    }, 1000)
  }
}
xiaoming.sendFlower(B)

场景

HTML元 素事件代理

<ul id="ul">
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>
<script>
  let ul = document.querySelector('#ul');
  ul.addEventListener('click', event => {
    console.log(event.target);
  });
</script>

优点

  • 代理模式能将代理对象与被调用对象分离,降低了系统的耦合度。代理模式在客户端和目标对象之间起到一个中介作用,这样可以起到保护目标对象的作用
  • 代理对象可以扩展目标对象的功能;通过修改代理对象就可以了,符合开闭原则;

缺点

处理请求速度可能有差别,非直接访问存在开销

💬 面试官追问

  • 图片组件只是把原对象的方法改了个名字,没有控制访问时机或保护目标对象,这还能算代理模式吗?

    仅改名通常不能体现代理模式,因为代理应作为目标对象的代用品或占位符,介入并控制对目标的访问。若中间层既不延迟、不保护也不扩展访问行为,它更像普通封装;额外一层还会带来调用开销。

  • 商品列表有一万个节点,团队准备给每个条目绑定点击监听;用父容器事件代理时,代理对象和目标对象分别是什么?

    父容器统一接收冒泡后的点击事件,相当于访问入口,再根据 event.target 判断实际被点击的子元素。这样无需在每个条目上分别注册处理器,降低监听关系的耦合;同时必须处理冒泡路径和非目标节点,避免误触发。

  • 送花逻辑改为由中间对象等目标状态合适时再转交,这层代理解决了什么约束变化?

    中间对象掌握目标状态,因此能控制真正访问目标的时机,而发送方仍只调用相同的接收接口。发送方与目标状态判断被分离,后续可修改代理逻辑而不改目标;若中间状态判断失准,请求仍可能延迟或无法送达。

  • 线上点击列表项偶尔触发父级空白区域的业务动作,代码使用了事件代理,你会先检查什么?

    先检查监听器是否只依据父容器收到 click 就执行,而没有核对 event.target 或其祖先是否为目标条目。事件代理把多个子元素的访问集中到一个入口,匹配逻辑写错会扩大误触发范围;还要确认冒泡是否被其他处理器阻止。

  • 权限模块计划为每个服务对象增加校验代码,另一方案是在访问入口设置保护代理;你会如何取舍?

    权限属于访问控制时,保护代理能把校验与目标对象分离,让客户端先经过中介再访问真实服务。修改代理即可调整控制逻辑,也更符合对目标扩展开放、修改收敛的思路;代价是多一层转发,并可能带来处理时延和排查复杂度。

  • 评审中有人说“代理只能控制访问,绝不能扩展功能”,这与代理模式的边界一致吗?

    不完全一致,代理的核心是代表目标并控制访问,但代理对象也可以在中介过程中扩展目标对象的功能。关键是客户端仍通过代理间接访问目标,二者保持分离;若新增能力彻底改变职责或接口,则应重新判断是否更接近其他结构型模式。

# 3. 工厂模式

⚡ 30 秒速记

  • 定义:把对象的创建过程封装起来,调用方不需要知道具体类名和构造细节
  • 简单工厂:一个函数根据参数返回不同类型的对象
  • 工厂方法:把创建延迟到子类,每个子类决定造什么
  • 抽象工厂:创建一相关对象(比如一整套主题组件)
  • 前端例子:document.createElementaxios.create()、按配置动态渲染不同类型的表单控件

工厂模式把对象创建过程封装到统一接口中,让调用方只关心要什么,而不用了解具体怎样实例化。 当系统需要根据名称或运行环境创建行为相同的不同对象时,工厂可以隔离构造逻辑,减少调用方与具体产品的耦合。新增产品通常通过扩展产品类和工厂完成,因而更容易扩展。若对象创建本来很简单,硬套工厂会增加抽象层、理解成本和单元测试难度,此时直接使用构造器更合适。

工厂模式定义一个用于创建对象的接口,这个接口由子类决定实例化哪一个类。该模式使一个类的实例化延迟到了子类。而子类可以重写接口方法以便创建的时候指定自己的对象类型。

class Product {
    constructor(name) {
        this.name = name
    }
    init() {
        console.log('init')
    }
    fun() {
        console.log('fun')
    }
}

class Factory {
    create(name) {
        return new Product(name)
    }
}

// use
let factory = new Factory()
let p = factory.create('p1')
p.init()
p.fun()

场景

  • 如果你不想让某个子系统与较大的那个对象之间形成强耦合,而是想运行时从许多子系统中进行挑选的话,那么工厂模式是一个理想的选择
  • 将new操作简单封装,遇到new的时候就应该考虑是否用工厂模式;
  • 需要依赖具体环境创建不同实例,这些实例都有相同的行为,这时候我们可以使用工厂模式,简化实现的过程,同时也可以减少每种对象所需的代码量,有利于消除对象间的耦合,提供更大的灵活性

优点

  • 创建对象的过程可能很复杂,但我们只需要关心创建结果。
  • 构造函数和创建者分离, 符合“开闭原则”
  • 一个调用者想创建一个对象,只要知道其名称就可以了。
  • 扩展性高,如果想增加一个产品,只要扩展一个工厂类就可以。

缺点

  • 添加新产品时,需要编写新的具体产品类,一定程度上增加了系统的复杂度
  • 考虑到系统的可扩展性,需要引入抽象层,在客户端代码中均使用抽象层进行定义,增加了系统的抽象性和理解难度

什么时候不用

当被应用到错误的问题类型上时,这一模式会给应用程序引入大量不必要的复杂性.除非为创建对象提供一个接口是我们编写的库或者框架的一个设计上目标,否则我会建议使用明确的构造器,以避免不必要的开销。

由于对象的创建过程被高效的抽象在一个接口后面的事实,这也会给依赖于这个过程可能会有多复杂的单元测试带来问题。

💬 面试官追问

  • 商品页只有 Product 一种实例,构造函数也只接收 name,同事仍要求先抽象 AbstractFactory、再建具体工厂;你会接受吗?

    暂时不会接受,直接使用明确的构造器更合适,因为对象类型单一、创建过程也没有环境分支。工厂只有在隔离复杂创建过程或支持多种产品扩展时才产生价值;过早引入抽象层会增加类数量和理解成本。

  • 低代码表单根据配置中的 type 创建 InputSelectDatePicker,渲染层已经堆了十几个 if/else;你会怎样落地工厂?

    把控件创建集中到工厂接口中,由 type 决定返回具有统一行为的具体控件,页面只接收创建结果。新增产品时扩展具体产品和工厂能力,不再让渲染层了解构造细节;代价是产品类型持续增加时,相关类和注册关系也会变多。

  • 同一套上传组件在浏览器创建 WebUploader,在桌面容器创建 NativeUploader,两者都提供 upload();环境判断应该放在哪里?

    环境判断应收进工厂,由工厂在运行时选择并创建实现相同行为的实例,业务层只依赖统一接口。这样能隔离具体环境与调用代码,但必须确保两种产品真正满足相同契约;若行为差异很大,强行统一会把分支转移到实例内部。

  • 线上新增 video 类型后,详情页拿到的产品是 undefined,随后调用 init() 报错;你会先查哪几处?

    先核对配置名称、工厂分支或注册项,以及具体产品是否实现了约定的 init(),确认故障发生在选择还是创建阶段。工厂应对未知名称显式报错或返回受控的兜底产品;静默返回 undefined 会把根因推迟到调用处。

  • 插件平台允许第三方增加控件,团队在“中心化 switch 工厂”和“插件各自注册工厂”之间争论;你怎么选?

    若产品集合由核心团队封闭维护,中心化工厂更直观;若第三方需要独立扩展,注册式工厂更符合开放封闭原则。后者减少核心代码改动,却需要处理名称冲突、接口校验和注册时机,不能只靠一张无约束的映射表。

# 4. 单例模式

⚡ 30 秒速记

  • 定义:保证一个类只有一个实例,并提供全局访问点
  • JS 里最简单的实现:ES module 天然单例 —— export default new Store(),模块只会被求值一次
  • 经典实现:闭包缓存实例(let instance; return instance || (instance = new X())
  • 前端例子:全局状态管理、弹窗管理器、WebSocket 连接池、日志器
  • 代价:全局状态难测试、隐藏依赖关系;能用依赖注入就别用单例

单例模式保证一个类最多只有一个实例,并提供统一的访问入口。JavaScript 中,可以用 IIFE 形成局部作用域,再由闭包中的 getInstance() 缓存并返回同一个对象,同时隐藏具体构造过程。它适合登录框,以及 VuexRedux 中需要贯穿系统的 store。不过单点访问会引入全局状态和模块耦合,使依赖它的代码难以单独测试,所以不需要唯一实例时应尽量避免。

顾名思义,单例模式中Class的实例个数最多为1。当需要一个对象去贯穿整个系统执行某些任务时,单例模式就派上了用场。而除此之外的场景尽量避免单例模式的使用,因为单例模式会引入全局状态,而一个健康的系统应该避免引入过多的全局状态。

实现单例模式需要解决以下几个问题:

  • 如何确定Class只有一个实例?
  • 如何简便的访问Class的唯一实例?
  • Class如何控制实例化的过程?
  • 如何将Class的实例个数限制为1?

我们一般通过实现以下两点来解决上述问题:

  • 隐藏Class的构造函数,避免多次实例化
  • 通过暴露一个 getInstance() 方法来创建/获取唯一实例

Javascript中单例模式可以通过以下方式实现:

// 单例构造器
const FooServiceSingleton = (function () {
  // 隐藏的Class的构造函数
  function FooService() {}

  // 未初始化的单例对象
  let fooService;

  return {
    // 创建/获取单例对象的函数
    getInstance: function () {
      if (!fooService) {
        fooService = new FooService();
      }
      return fooService;
    }
  }
})();

实现的关键点有:

  • 使用 IIFE创建局部作用域并即时执行;
  • getInstance() 为一个 闭包 ,使用闭包保存局部作用域中的单例对象并返回。

我们可以验证下单例对象是否创建成功:

const fooService1 = FooServiceSingleton.getInstance();
const fooService2 = FooServiceSingleton.getInstance();

console.log(fooService1 === fooService2); // true

场景例子

  • 定义命名空间和实现分支型方法
  • 登录框
  • vuex 和 redux中的store

优点

  • 划分命名空间,减少全局变量
  • 增强模块性,把自己的代码组织在一个全局变量名下,放在单一位置,便于维护
  • 且只会实例化一次。简化了代码的调试和维护

缺点

  • 由于单例模式提供的是一种单点访问,所以它有可能导致模块间的强耦合
  • 从而不利于单元测试。无法单独测试一个调用了来自单例的方法的类,而只能把它与那个单例作为一 个单元一起测试。

💬 面试官追问

  • 订单列表页每打开一个筛选面板都创建独立 FilterModel,评审却要求改成单例,只因为“实例越少越好”;这个理由成立吗?

    不成立,筛选状态属于各面板时,多个实例正是合理隔离,单例反而会让面板互相覆盖状态。单例适合确实需要由一个对象贯穿系统的任务;仅为减少实例而共享可变状态,会引入隐藏耦合。

  • 后台系统的登录框只能同时存在一个,多个入口都可能触发它;你会怎样控制创建和访问?

    隐藏具体构造过程,通过 getInstance() 延迟创建并返回唯一登录框,所有入口使用同一访问点。闭包或模块局部作用域可保存该实例,避免外部重复实例化;同时要定义关闭后的状态重置,否则唯一实例可能残留上次交互数据。

  • 团队把 Reduxstore 做成模块级唯一实例,后来测试要求每个用例使用不同初始状态;还应坚持单例吗?

    生产入口可以持有唯一 store,测试侧则应依赖创建函数或可注入接口,为每个用例生成隔离实例。若业务模块直接导入全局实例,就无法单独替换依赖,只能把模块与单例一起测试;改造会增加传参或容器配置成本。

  • 线上出现两个弹窗同时展示,日志显示 getInstance() 被调用两次;你会如何判断是单例失效还是状态逻辑失效?

    先比较两次返回值是否严格相等,再检查构造函数是否仍被导出、单例变量是否被放进了每次调用都会重建的作用域。若实例相同,则继续查展示状态和重复挂载;单例只保证实例数量最多为一,不自动保证一次只执行一个业务动作。

  • 公共组件库要提供通知服务,维护者在“导出唯一实例”和“导出构造器”之间选型;你倾向哪一种?

    若库的设计目标就是提供全局唯一通知通道,可以暴露受控的 getInstance();若使用方可能需要多个容器或隔离测试,导出明确构造器更灵活。单例简化访问和维护位置,却会形成单点依赖,因此不应替调用方擅自固定生命周期。

# 5. 策略模式

⚡ 30 秒速记

  • 定义:把一组可互换的算法各自封装起来,运行时按需选择
  • 最直接的价值:用映射表消灭大段 if-else/switch
  • 前端例子:表单校验规则表、支付方式选择、不同促销规则的计算、动画缓动函数
  • 写法:const strategies = { A: fn1, B: fn2 },然后 strategies[type](params)
  • 好处:新增策略不用改原有代码(开闭原则),每个策略可独立测试

策略模式就是把同一类行为的不同算法分别封装起来,让调用方可以按场景选择并互相替换。 这样能用组合和委托减少多重条件分支,每个策略也可以独立理解、切换和扩展,更符合开放封闭原则。表单校验很适合使用它,例如把非空、长度和手机号校验做成不同策略。代价是策略对象会增多,而且调用方必须清楚各策略的差异,才能选对实现。

策略模式简单描述就是:对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。把它们一个个封装起来,并且使它们可以互相替换

<html>
<head>
    <title>策略模式-校验表单</title>
    <meta content="text/html; charset=utf-8" http-equiv="Content-Type">
</head>
<body>
    <form id = "registerForm" method="post" action="http://xxxx.com/api/register">
        用户名:<input type="text" name="userName">
        密码:<input type="text" name="password">
        手机号码:<input type="text" name="phoneNumber">
        <button type="submit">提交</button>
    </form>
    <script type="text/javascript">
        // 策略对象
        const strategies = {
          isNoEmpty: function (value, errorMsg) {
            if (value === '') {
              return errorMsg;
            }
          },
          isNoSpace: function (value, errorMsg) {
            if (value.trim() === '') {
              return errorMsg;
            }
          },
          minLength: function (value, length, errorMsg) {
            if (value.trim().length < length) {
              return errorMsg;
            }
          },
          maxLength: function (value, length, errorMsg) {
            if (value.length > length) {
              return errorMsg;
            }
          },
          isMobile: function (value, errorMsg) {
            if (!/^(13[0-9]|14[5|7]|15[0|1|2|3|5|6|7|8|9]|17[7]|18[0|1|2|3|5|6|7|8|9])\d{8}$/.test(value)) {
              return errorMsg;
            }
          }
        }

        // 验证类
        class Validator {
          constructor() {
            this.cache = []
          }
          add(dom, rules) {
            for(let i = 0, rule; rule = rules[i++];) {
              let strategyAry = rule.strategy.split(':')
              let errorMsg = rule.errorMsg
              this.cache.push(() => {
                let strategy = strategyAry.shift()
                strategyAry.unshift(dom.value)
                strategyAry.push(errorMsg)
                return strategies[strategy].apply(dom, strategyAry)
              })
            }
          }
          start() {
            for(let i = 0, validatorFunc; validatorFunc = this.cache[i++];) {
              let errorMsg = validatorFunc()
              if (errorMsg) {
                return errorMsg
              }
            }
          }
        }

        // 调用代码
        let registerForm = document.getElementById('registerForm')

        let validataFunc = function() {
          let validator = new Validator()
          validator.add(registerForm.userName, [{
            strategy: 'isNoEmpty',
            errorMsg: '用户名不可为空'
          }, {
            strategy: 'isNoSpace',
            errorMsg: '不允许以空白字符命名'
          }, {
            strategy: 'minLength:2',
            errorMsg: '用户名长度不能小于2位'
          }])
          validator.add(registerForm.password, [ {
            strategy: 'minLength:6',
            errorMsg: '密码长度不能小于6位'
          }])
          validator.add(registerForm.phoneNumber, [{
            strategy: 'isMobile',
            errorMsg: '请输入正确的手机号码格式'
          }])
          return validator.start()
        }

        registerForm.onsubmit = function() {
          let errorMsg = validataFunc()
          if (errorMsg) {
            alert(errorMsg)
            return false
          }
        }
    </script>
</body>
</html>

场景例子

  • 如果在一个系统里面有许多类,它们之间的区别仅在于它们的'行为',那么使用策略模式可以动态地让一个对象在许多行为中选择一种行为。
  • 一个系统需要动态地在几种算法中选择一种。
  • 表单验证

优点

  • 利用组合、委托、多态等技术和思想,可以有效的避免多重条件选择语句
  • 提供了对开放-封闭原则的完美支持,将算法封装在独立的strategy中,使得它们易于切换,理解,易于扩展
  • 利用组合和委托来让Context拥有执行算法的能力,这也是继承的一种更轻便的代替方案

缺点

  • 会在程序中增加许多策略类或者策略对象
  • 要使用策略模式,必须了解所有的strategy,必须了解各个strategy之间的不同点,这样才能选择一个合适的strategy

💬 面试官追问

  • 结算页只有“满百减十”一条固定规则,同事仍拆出 Context、策略接口和多个占位策略;你认为这是合理扩展吗?

    当前只有单一且稳定的算法时,直接函数通常更清晰,完整策略体系未必有收益。策略模式适用于同一行为需要动态替换多种实现的场景;为尚不存在的变化预建对象,会增加抽象和选择成本。

  • 注册页的用户名、密码、手机号校验混在提交事件的十几段条件分支里,产品还会持续加规则;你会怎样重构?

    isNoEmptyminLengthisMobile 等校验分别封装为策略,再由验证器按字段配置选择并执行。提交主流程只负责收集规则和返回错误,从而避免继续增长条件分支;新增策略仍需明确参数格式和错误消息约定。

  • 运营要求同一优惠计算器根据会员等级在运行时切换算法,而且后续允许活动模块注入新算法;策略由谁选择?

    由上下文根据会员等级或活动配置选择策略,具体算法只承担计算行为并保持可替换。注入新策略能减少对主流程的修改,但调用方必须了解各策略的适用条件;选错策略不会被模式本身自动纠正。

  • 线上表单把规则写成 minLength:8,校验器偶尔提示错误规则不存在;你会沿着什么链路排查?

    先检查规则字符串的解析是否修改了共享数组,再核对解析出的策略名、参数顺序和策略表注册项。验证器应对未知策略显式失败,并避免在重复执行时用 shift() 破坏原配置;否则第二次校验可能得到不同结果。

  • 表单校验既能写成策略表,也能为每条规则创建校验类;团队担心几十个策略对象太碎,你怎么取舍?

    规则主要是无状态函数时,策略表更轻量,也保留动态选择和独立扩展的价值;存在独立状态或复杂依赖时,再使用策略类更合适。策略模式会增加策略数量,命名、发现和配置管理不清晰时,维护成本可能超过条件分支。

  • 控件平台里,同一个映射表既返回组件实例又存放校验函数;评审说工厂模式和策略模式没有区别,你怎么解释?

    返回 InputSelect 实例的部分关注创建哪种产品,属于工厂意图;选择并执行 isMobile 等算法的部分关注替换行为,属于策略意图。两者都可能用映射实现,但扩展契约不同,混在一张表里会模糊生命周期和错误处理。

# 6. 迭代器模式

⚡ 30 秒速记

  • 定义:提供一种统一的方式顺序访问集合元素,而不暴露集合的内部结构
  • JS 里已内置:实现 [Symbol.iterator] 就能用 for...of、展开运算符、解构、Array.from
  • 迭代器协议:对象有 next() 返回 { value, done }
  • Generator 函数返回的对象同时满足两个协议,是自定义迭代最省事的实现方式
  • 价值:调用方不需要知道底层是数组、链表还是树,遍历代码完全一致

迭代器模式提供统一的顺序遍历方式,同时不暴露集合内部的数据结构。 它把“怎么取下一个元素”交给迭代器,因此调用方无须了解容器的具体实现,也不用修改容器原有接口。传统实现通常提供 hasNext()next();在 ES6 中,对象实现 [Symbol.iterator] 并返回带 next() 的迭代器后,就能使用 for...of。例如数字区间可以自行维护当前位置,逐个返回 { value, done }

如果你看到这,ES6中的迭代器 Iterator 相信你还是有点印象的,上面第60条已经做过简单的介绍。迭代器模式简单的说就是提供一种方法顺序一个聚合对象中各个元素,而又不暴露该对象的内部表示。

迭代器模式解决了以下问题:

  • 提供一致的遍历各种数据结构的方式,而不用了解数据的内部结构
  • 提供遍历容器(集合)的能力而无需改变容器的接口

一个迭代器通常需要实现以下接口:

  • hasNext():判断迭代是否结束,返回Boolean
  • next():查找并返回下一个元素

为Javascript的数组实现一个迭代器可以这么写:

const item = [1, 'red', false, 3.14];

function Iterator(items) {
  this.items = items;
  this.index = 0;
}

Iterator.prototype = {
  hasNext: function () {
    return this.index < this.items.length;
  },
  next: function () {
    return this.items[this.index++];
  }
}

验证一下迭代器是否工作:

const iterator = new Iterator(item);

while(iterator.hasNext()){
  console.log(iterator.next());
}
//输出:1, red, false, 3.14

ES6提供了更简单的迭代循环语法 for...of,使用该语法的前提是操作对象需要实现 可迭代协议(The iterable protocol),简单说就是该对象有个Key为 Symbol.iterator 的方法,该方法返回一个iterator对象。

比如我们实现一个 Range 类用于在某个数字区间进行迭代:

function Range(start, end) {
  return {
    [Symbol.iterator]: function () {
      return {
        next() {
          if (start < end) {
            return { value: start++, done: false };
          }
          return { done: true, value: end };
        }
      }
    }
  }
}

验证一下:

for (num of Range(1, 5)) {
  console.log(num);
}
// 输出:1, 2, 3, 4

💬 面试官追问

  • 报表页只读取固定三项数组,同事专门实现带 hasNext()next() 的迭代器类;你会保留吗?

    若数组结构不会替换且没有特殊遍历规则,直接使用现有循环更清晰,自定义迭代器收益有限。迭代器的价值在于统一不同聚合对象的遍历接口并隐藏内部表示;简单集合强行包装只会增加状态和边界处理。

  • 组件库的分页数据内部按多个批次保存,但业务方只想用统一循环顺序读取;你会怎样暴露接口?

    让容器提供迭代器,把批次索引和元素索引封装在内部,调用方只通过 next() 获取下一个值。若希望接入 for...of,则实现 Symbol.iterator 并返回符合协议的迭代器;容器结构变化时,消费代码无需同步改写。

  • 组织架构从扁平数组改成树结构,但搜索页仍要求按既定顺序消费员工节点;哪些代码应该变化?

    应调整容器内部的迭代实现,让它按约定顺序产出树节点,搜索页继续依赖统一遍历接口。这样无需向调用方暴露树的栈、队列或层级细节;不过遍历顺序必须成为明确契约,否则结构迁移可能悄悄改变结果。

  • 自定义 Range(1, 5) 在线上循环时意外输出了 5,而需求是只输出 14;你会检查什么?

    检查 next() 在结束分支返回的对象是否正确标记 done: true,以及递增和边界判断发生的顺序。迭代协议以 done 决定值是否被消费,结束对象即使携带 value: 5 也不应进入 for...of;手写消费逻辑则需同样遵守该标记。

  • 数据容器团队想只提供 hasNext()next(),页面团队坚持必须支持 for...of;这两种接口如何取舍?

    内部专用且调用点受控时,显式的 hasNext()next() 已能完成顺序遍历;面向通用 JavaScript 消费者时,实现可迭代协议更容易接入 for...of。后者需要正确提供 Symbol.iterator 和结束状态,但能与语言统一遍历语法协作。

# 7. 观察者模式

⚡ 30 秒速记

  • 定义:目标对象状态变化时,自动通知所有依赖它的观察者
  • 和发布订阅的区别:观察者模式里 Subject 直接持有 Observer 列表(两者互相知道);发布订阅多了一个事件中心(双方互不知情)
  • 前端例子:Vue 的响应式(Dep 持有 Watcher)、MutationObserverIntersectionObserver
  • 实现要点:subscribe/unsubscribe/notify 三个方法
  • 坑:忘了取消订阅就是内存泄漏,组件卸载时必须清理

观察者模式是由被观察对象维护一组观察者,并在自身状态变化时统一通知它们。 被观察对象通常提供 subscribe()unsubscribe()fire(),分别负责订阅、取消订阅和广播通知,从而让双方依赖抽象而不是具体实现。DOM 事件和 Vue 响应式都属于常见使用场景。它能提升扩展性和复用性,但过度使用会弱化对象间的显式联系,让调用链更难跟踪和维护。

观察者模式又称发布-订阅模式(Publish/Subscribe Pattern),是我们经常接触到的设计模式,日常生活中的应用也比比皆是,比如你订阅了某个博主的频道,当有内容更新时会收到推送;又比如JavaScript中的事件订阅响应机制。观察者模式的思想用一句话描述就是:被观察对象(subject)维护一组观察者(observer),当被观察对象状态改变时,通过调用观察者的某个方法将这些变化通知到观察者。

观察者模式中Subject对象一般需要实现以下API:

  • subscribe(): 接收一个观察者observer对象,使其订阅自己
  • unsubscribe(): 接收一个观察者observer对象,使其取消订阅自己
  • fire(): 触发事件,通知到所有观察者

用JavaScript手动实现观察者模式:

// 被观察者
function Subject() {
  this.observers = [];
}

Subject.prototype = {
  // 订阅
  subscribe: function (observer) {
    this.observers.push(observer);
  },
  // 取消订阅
  unsubscribe: function (observerToRemove) {
    this.observers = this.observers.filter(observer => {
      return observer !== observerToRemove;
    })
  },
  // 事件触发
  fire: function () {
    this.observers.forEach(observer => {
      observer.call();
    });
  }
}

验证一下订阅是否成功:

const subject = new Subject();

function observer1() {
  console.log('Observer 1 Firing!');
}


function observer2() {
  console.log('Observer 2 Firing!');
}

subject.subscribe(observer1);
subject.subscribe(observer2);
subject.fire();

//输出:
Observer 1 Firing!
Observer 2 Firing!

验证一下取消订阅是否成功:

subject.unsubscribe(observer2);
subject.fire();

//输出:
Observer 1 Firing!

场景

  • DOM事件
document.body.addEventListener('click', function() {
    console.log('hello world!');
});
document.body.click()
  • vue 响应式

优点

  • 支持简单的广播通信,自动通知所有已经订阅过的对象
  • 目标对象与观察者之间的抽象耦合关系能单独扩展以及重用
  • 增加了灵活性
  • 观察者模式所做的工作就是在解耦,让耦合的双方都依赖于抽象,而不是依赖于具体。从而使得各自的变化都不会影响到另一边的变化。

缺点

过度使用会导致对象与对象之间的联系弱化,会导致程序难以跟踪维护和理解

💬 面试官追问

  • 详情页只有父组件和一个子组件,数据通过属性就能传递,同事却增加全局事件中心来同步标题;你赞成吗?

    不赞成,明确的父子依赖用直接传递更容易追踪,全局订阅会弱化对象之间的可见联系。观察者模式适合一个状态源需要广播给多个可独立扩展的观察者;在局部单向关系中使用会增加维护难度。

  • 后台导航栏、个人中心和权限面板都要响应登录状态变化,你会怎样设计订阅接口?

    让登录状态主体维护观察者集合,并提供 subscribe()unsubscribe() 和触发通知的 fire()。各模块只注册自己的响应函数,不直接依赖其他模块,从而能够独立扩展;组件销毁时必须取消订阅,避免无效回调继续存在。

  • 消息中心原来只有两个订阅者,现在插件也能动态订阅和卸载;主体通知期间有人取消订阅,你会关注什么?

    需要明确通知时修改观察者集合的语义,例如本轮是否仍调用已进入通知序列的观察者,并让实现保持一致。可在触发前使用观察者快照,避免遍历中的增删造成漏通知或重复通知;代价是本轮变化通常到下一次触发才完全生效。

  • 单页应用运行数小时后越来越卡,性能记录显示已卸载页面的回调仍在登录事件触发时执行;你怎么定位?

    先记录每次 subscribe()unsubscribe() 的观察者身份,核对组件卸载路径是否传回同一个函数引用。再检查主体的观察者数组是否持续增长,并为重复订阅建立防护;只清理页面 DOM 不会自动移除手写订阅。

  • 支付结果需要通知订单页、消息角标和埋点模块,团队在直接逐个调用与观察者模式之间争论;你怎么选?

    订阅方可能独立增删且支付模块不应了解它们时,观察者模式更利于解耦和广播;接收者固定、调用顺序与失败处理严格时,显式调用更容易控制。观察者会弱化调用链,线上排查需补充事件名称、订阅来源和触发记录。

  • 前端同事说 DOM 事件和 Vue 响应式都体现了观察者思想,但准备把所有跨模块通信都改成事件;你会怎么制止过度设计?

    应按依赖关系选择机制,而不是因为底层具有订阅通知能力就统一事件化。DOM 事件和响应式适合各自的状态传播场景,但大量匿名事件会让对象联系难以追踪;核心业务流程仍应保留清晰的数据源、契约和调用边界。

# 8. 中介者模式

⚡ 30 秒速记

  • 定义:用一个中介对象封装一组对象之间的交互,让它们不再直接相互引用
  • 解决的问题:N 个对象两两通信会形成 的网状依赖,加一个中介就变成星型
  • 前端例子:Redux/Vuexstore(组件间不直接通信,都通过 store)、聊天室服务器、表单联动控制器
  • 和发布订阅的关系:发布订阅可以看作中介者模式的一种实现
  • 代价:中介本身会越来越臃肿,容易变成「上帝对象」

中介者模式让多个同级对象不再直接互相调用,而是把交互统一交给中介者协调。 本质上是用中介者与各对象的一对多关系,替代对象之间复杂的网状多对多关系,因此各对象可以独立调整,耦合也更松。购物车中颜色、内存和数量表单的联动,或聊天室成员间发消息,都可以由中介者转发处理。取舍是交互复杂度会集中到中介者里,它很容易变得庞大并且难以维护。

  • 在中介者模式中,中介者(Mediator)包装了一系列对象相互作用的方式,使得这些对象不必直接相互作用,而是由中介者协调它们之间的交互,从而使它们可以松散偶合。当某些对象之间的作用发生改变时,不会立即影响其他的一些对象之间的作用,保证这些作用可以彼此独立的变化。
  • 中介者模式和观察者模式有一定的相似性,都是一对多的关系,也都是集中式通信,不同的是中介者模式是处理同级对象之间的交互,而观察者模式是处理Observer和Subject之间的交互。中介者模式有些像婚恋中介,相亲对象刚开始并不能直接交流,而是要通过中介去筛选匹配再决定谁和谁见面。

场景

例如购物车需求,存在商品选择表单、颜色选择表单、购买数量表单等等,都会触发change事件,那么可以通过中介者来转发处理这些事件,实现各个事件间的解耦,仅仅维护中介者对象即可。

var goods = {   //手机库存
    'red|32G': 3,
    'red|64G': 1,
    'blue|32G': 7,
    'blue|32G': 6,
};
//中介者
var mediator = (function() {
    var colorSelect = document.getElementById('colorSelect');
    var memorySelect = document.getElementById('memorySelect');
    var numSelect = document.getElementById('numSelect');
    return {
        changed: function(obj) {
            switch(obj){
                case colorSelect:
                    //TODO
                    break;
                case memorySelect:
                    //TODO
                    break;
                case numSelect:
                    //TODO
                    break;
            }
        }
    }
})();
colorSelect.onchange = function() {
    mediator.changed(this);
};
memorySelect.onchange = function() {
    mediator.changed(this);
};
numSelect.onchange = function() {
    mediator.changed(this);
};
  • 聊天室里

聊天室成员类:

function Member(name) {
  this.name = name;
  this.chatroom = null;
}

Member.prototype = {
  // 发送消息
  send: function (message, toMember) {
    this.chatroom.send(message, this, toMember);
  },
  // 接收消息
  receive: function (message, fromMember) {
    console.log(`${fromMember.name} to ${this.name}: ${message}`);
  }
}

聊天室类:

function Chatroom() {
  this.members = {};
}

Chatroom.prototype = {
  // 增加成员
  addMember: function (member) {
    this.members[member.name] = member;
    member.chatroom = this;
  },
  // 发送消息
  send: function (message, fromMember, toMember) {
    toMember.receive(message, fromMember);
  }
}

测试一下:

const chatroom = new Chatroom();
const bruce = new Member('bruce');
const frank = new Member('frank');

chatroom.addMember(bruce);
chatroom.addMember(frank);

bruce.send('Hey frank', frank);

//输出:bruce to frank: hello frank

优点

  • 使各对象之间耦合松散,而且可以独立地改变它们之间的交互
  • 中介者和对象一对多的关系取代了对象之间的网状多对多的关系
  • 如果对象之间的复杂耦合度导致维护很困难,而且耦合度随项目变化增速很快,就需要中介者重构代码

缺点

系统中会新增一个中介者对象,因为对象之间交互的复杂性,转移成了中介者对象的复杂性,使得中介者对象经常是巨大的。中介 者对象自身往往就是一个难以维护的对象。

💬 面试官追问

  • 购物车页面里,颜色下拉框直接修改容量选项,容量选项又直接控制数量输入框;只有 3 个控件时能跑,为什么还值得改成中介者?

    能跑不代表关系清晰,控件之间的直接调用会形成网状依赖,任一交互变化都可能牵动多个对象。让控件只上报 change,由中介者决定库存、选项和数量状态,可把多对多关系收敛为一对多;控件始终固定且交互简单时,引入它反而增加层次。

  • 商品详情页有颜色、容量、数量三个表单和一张组合库存表,你会怎样设计中介者的输入与输出?

    每个表单只把自身变化和当前值交给中介者,中介者读取完整选择并查询类似 red|32G 的库存键,再统一更新可选项、数量上限和购买按钮。库存判断应集中在中介者内,避免各控件复制规则;若中介者同时负责请求、渲染和埋点,职责会迅速膨胀。

  • 产品新增套餐、优惠券和配送方式后,原来的一个 changed(obj) 已经出现十几个 case,你会继续扩展还是拆分?

    十几个 case 表明交互复杂性已集中转移到中介者,应按库存、促销或履约等稳定业务边界拆分协调逻辑。各子中介者仍只处理同级对象的协作,必要时再由上层编排;拆得过细会产生中介者之间的新耦合,重新形成难追踪的调用链。

  • 线上出现“切换蓝色后购买按钮仍可点,但该容量库存为零”,你会沿哪些事件链排查?

    先确认颜色控件是否触发 change,再检查事件是否把正确对象和值交给 mediator.changed,最后核对组合库存键与按钮更新分支。示例式 switch(obj) 很依赖对象身份和分支完整性,新增控件却漏注册或漏处理都会留下旧状态;修复后还要覆盖组合切换的回归用例。

  • 表单团队主张用观察者模式广播所有字段变化,业务团队坚持使用中介者统一决策,你如何划分两者?

    字段变化仅需通知多个独立订阅者时,观察者的 SubjectObserver 关系更自然;字段彼此同级且交互结果需要协调时,中介者更合适。两者都属于集中式一对多通信,但中介者承载对象间协作规则;把所有通知都塞进去会让它沦为难维护的上帝对象。

# 9. 访问者模式

⚡ 30 秒速记

  • 定义:把作用于数据结构的操作从数据结构本身分离出来,新增操作不用改原结构
  • 适用场景:数据结构稳定但操作经常变 —— 最典型的就是编译器处理 AST
  • 前端最真实的例子:Babel 插件的 visitor{ Identifier(path){}, CallExpression(path){} })、ESLint 规则、PostCSS 插件
  • 好处:新增一种处理逻辑只要加一个 visitor,不用改 AST 节点的定义
  • 代价:数据结构一旦要加新节点类型,所有访问者都得跟着改

访问者模式把操作逻辑从对象结构中拆出来,让对象不改结构也能增加新操作。 接收对象通过 accept(visitor) 把自己交给访问者,访问者再用 visit() 读取数据或执行逻辑。本质上它适合对象类型较稳定、操作经常变化的场景,也能避免无关逻辑污染元素类。代价是元素要暴露实现细节,而且新增或调整具体元素时,访问者往往也要跟着修改。

访问者模式 是一种将算法与对象结构分离的设计模式,通俗点讲就是:访问者模式让我们能够在不改变一个对象结构的前提下能够给该对象增加新的逻辑,新增的逻辑保存在一个独立的访问者对象中。访问者模式常用于拓展一些第三方的库和工具。

// 访问者
class Visitor {
    constructor() {}
    visitConcreteElement(ConcreteElement) {
        ConcreteElement.operation()
    }
}
// 元素类
class ConcreteElement{
    constructor() {
    }
    operation() {
       console.log("ConcreteElement.operation invoked");
    }
    accept(visitor) {
        visitor.visitConcreteElement(this)
    }
}
// client
let visitor = new Visitor()
let element = new ConcreteElement()
elementA.accept(visitor)

访问者模式的实现有以下几个要素:

  • Visitor Object:访问者对象,拥有一个visit()方法
  • Receiving Object:接收对象,拥有一个 accept() 方法
  • visit(receivingObj):用于Visitor接收一个Receiving Object
  • accept(visitor):用于Receving Object接收一个Visitor,并通过调用Visitor的 visit() 为其提供获取Receiving Object数据的能力

简单的代码实现如下:

Receiving Object:

function Employee(name, salary) {
  this.name = name;
  this.salary = salary;
}

Employee.prototype = {
  getSalary: function () {
    return this.salary;
  },
  setSalary: function (salary) {
    this.salary = salary;
  },
  accept: function (visitor) {
    visitor.visit(this);
  }
}
Visitor Object:

function Visitor() { }

Visitor.prototype = {
  visit: function (employee) {
    employee.setSalary(employee.getSalary() * 2);
  }
}

验证一下:

const employee = new Employee('bruce', 1000);
const visitor = new Visitor();
employee.accept(visitor);

console.log(employee.getSalary());//输出:2000

场景

对象结构中对象对应的类很少改变,但经常需要在此对象结构上定义新的操作

需要对一个对象结构中的对象进行很多不同的并且不相关的操作,而需要避免让这些操作"污染"这些对象的类,也不希望在增加新操作时修改这些类。

优点

  • 符合单一职责原则
  • 优秀的扩展性
  • 灵活性

缺点

  • 具体元素对访问者公布细节,违反了迪米特原则
  • 违反了依赖倒置原则,依赖了具体类,没有依赖抽象。
  • 具体元素变更比较困难

💬 面试官追问

  • 工资管理页只有一个 Employee 类,却为了“薪资翻倍”新增 accept(visitor)Visitor,这一定比直接写方法好吗?

    不一定;只有一个元素类型和一次操作时,直接方法更短,也不会暴露额外协作接口。访问者的价值在于对象结构较稳定而新操作持续增加,可把薪资调整等逻辑移出 Employee;若操作不会扩展,这套分派结构只是额外成本。

  • 你接手一个不能随意修改的第三方节点库,需要增加校验、导出和统计三种处理,访问者怎样落地?

    为每种处理建立独立访问者,由接收对象通过 accept(visitor) 把自身传给对应的 visit 方法,使算法不混入节点类。这样新增第四种操作主要增加一个访问者,而不是改写全部节点行为;前提是库能提供接收入口或可控适配层,否则无法机械套用。

  • 编辑器节点结构半年不变,但导出格式从一种增加到五种;另一个团队却每周新增节点类型,哪边更适合访问者?

    导出操作持续增加而节点类型稳定的编辑器更适合,每种格式可作为独立访问者扩展。频繁新增节点类型的一方代价更高,因为已有访问者通常都要识别新元素;访问者优化的是“新增操作”,并不擅长“频繁改变对象结构”。

  • 线上新增 VideoNode 后,HTML 导出正常,统计结果却漏掉视频数量,你会先查哪里?

    先检查 VideoNode.accept 是否调用了正确的访问方法,再逐个核对统计访问者是否实现了对应处理。具体元素变化困难正是该模式的边界,新类型容易让部分访问者遗漏分支;应通过类型覆盖检查或遍历用例验证所有访问者,而不能只测一个导出结果。

  • 架构评审中,一方要把所有操作放回节点类以保持封装,另一方要用访问者避免污染节点,你怎么取舍?

    若操作与节点核心职责紧密且数量有限,留在节点类能减少对象细节外泄;若存在许多互不相关且持续增加的操作,访问者更符合单一职责。代价是具体元素需向访问者提供数据并依赖具体分派,封装性、迪米特原则和依赖倒置都会承受压力。

# 十二、综合问题

# 前端常见面试流程

⚡ 30 秒速记

  • 典型五轮:简历筛选 → 笔试/机试 → 技术一面(基础)→ 技术二面(项目 + 深度)→ 交叉面/主管面 → HR
  • 一面重基础:JS 核心、CSS 布局、浏览器原理、手写题
  • 二面重项目:技术选型的理由、遇到的难点、你的具体贡献、可量化的结果
  • 主管面看的是「能不能带」:沟通、协作、抗压、成长性
  • 每轮结束都要提问 —— 问团队构成、业务阶段、技术挑战,既显诚意也在筛选公司

前端面试通常会经过多轮筛选,不同轮次关注的能力会有所区别。 技术面一般会结合基础知识、项目经历和解决问题的过程判断候选人的实际水平,后续沟通则可能更关注岗位匹配、发展规划和稳定性。了解每轮面试的侧重点,可以让准备更贴近岗位要求。具体流程会因公司和团队而异,不能把固定轮次当成统一标准。

# 面试一定要问这几个问题

⚡ 30 秒速记

  • 团队构成:前端几个人、梯队如何、我进去是什么角色
  • 业务阶段:这条业务线处于探索期还是稳定期,直接决定加班强度和技术自由度
  • 技术现状:技术栈、有没有历史包袱、有没有基建(组件库/监控/CI
  • 我要解决的问题:这个岗位最紧急的三件事是什么
  • 成长空间:团队有没有技术分享、Code Review、晋升通道
  • 反面信号:回避问题、说不清业务、强调「能吃苦」

面试结束前要主动了解部门业务、团队配置和项目技术情况,这也是判断岗位是否合适的重要机会。 我一般会问产品处于什么赛道、使用规模如何,因为这些信息能帮助判断它是不是核心业务。团队人数和角色分工可以反映协作是否规范,技术栈以及对接团队则能看出项目现状。对方回答得比较笼统时,可以继续追问日常工作内容,但不要仅凭某一个答案下结论。

最后一个问题:面试官问,你想了解什么(面试一定要问这几个问题)

  • 部门所做的产品和业务(赛道),产品的用量和规模(看产品是否核心)
  • 部门有多少人,有什么角色(看部门是否规范)
  • 项目的技术栈,以及对接的其他技术团队(看技术栈是否老旧)

# 经历

⚡ 30 秒速记

  • STAR 结构讲:Situation 背景 → Task 任务 → Action 我做了什么 → Result 可量化的结果
  • 重点在 ActionResult:面试官想听的是你的决策和贡献,不是团队的成果
  • 一定要有数字:「首屏从 4.2s 降到 1.6s」比「优化了性能」强十倍
  • 提前准备三段经历:最有技术含量的、最有业务价值的、最失败但学到最多的
  • 别背稿:讲的时候留出被追问的空间,追问才是加分环节

介绍个人经历时,要把接触计算机和前端的过程、关键事件以及未来规划讲清楚。 比起只罗列时间点,更值得说明的是哪件事最让自己骄傲、哪件事最难,以及当时为什么会产生自豪感或挫败感。这样面试官能更直接地理解你的选择和成长轨迹。涉及未来发展时应结合真实想法回答,不必为了显得完整而编造经历或结果。

  • 整个经历自我介绍,越详细越好,什么时候接触计算机,什么时候接触前端。
  • 整个经历中,你认为最值得骄傲的事情,最难的事情是什么。
  • 什么事情让你自豪,什么事情让你有挫败感。
  • 未来的发展,自己的规划。

# 项目相关

⚡ 30 秒速记

  • 必答四问:项目是干什么的、你负责哪块、技术选型为什么这么定、遇到最大的困难是什么
  • 讲项目先给背景和规模(多少用户、多少页面、几个人做),面试官才有判断基准
  • 技术选型要说权衡:为什么选 A 不选 B,代价是什么 —— 只说优点会显得没深度
  • 准备好被追问细节:数据怎么流转、异常怎么处理、上线怎么保障
  • 别夸大参与度,追问两层就会露馅

介绍项目时,我会讲清项目背景、负责范围、技术选型和实际难点。 背景和职责能界定参与深度,选型则要说明为什么这样做,以及代码规范、测试和引入 TS 等方案解决了什么问题。谈难点时不能只报结果,还要交代如何发现、怎样分析和最终效果。若让我从零搭建项目,我还会考虑架构、协作流程和后续维护,但不会为了显得复杂而过度设计。

  • 项目难点。(如何发现问题,解决思路,最后结果)
  • 项目考虑过优化吗,你是如何优化的,思路是什么。
  • 项目的组织架构,你对它的现有架构的理解,哪些优点值得借鉴,哪些缺点需要改进。
  • 如果让你从0到1建一个项目,你考虑的点是什么,有哪些流程需要注意的。
  • 如何进行技术选型,需要考虑哪些点。
  • 项目中代码规范,你们项目有方案吗,你了解的代码规范有哪些方案。
  • 说一说项目中你们是如何测试的,有哪些单元测试方案,能不能说一说。
  • 项目中引入TS的原因,为什么这么做。

# 项目难点问题分析

⚡ 30 秒速记

  • 「难点」不等于「工作量大」:加班三天写完 20 个页面不是难点,有技术判断和取舍的才是
  • 好的难点长这样:性能瓶颈定位、复杂状态管理、跨端兼容、大数据量渲染、历史代码重构
  • 讲法:问题现象 → 排查过程 → 根因 → 方案对比 → 最终选择和理由 → 结果
  • 排查过程是最能体现能力的部分,别一句「后来发现是 XXX 问题」跳过
  • 提前准备一个能讲五分钟、且经得起追问的难点

项目难点要讲清问题造成的影响、解决过程,以及自己从中得到的经验。 难点不一定罕见,关键是能说明当时如何分析现象、找到原因并验证解决方案。比如编辑器无法回显旧版 HTML 数据,可以将其反解析为新版需要的 JSON 格式,同时兼顾老用户。复盘时还要补充以后如何避免,例如设计数据输入输出时提前考虑版本兼容。

遇到问题要注意积累

  • 每个人都会遇到问题,总有几个问题让你头疼
  • 日常要注意积累,解决了问题要自己写文章复盘

如果之前没有积累

  • 回顾一下半年之内遇到的难题
  • 思考当时解决方案,以及解决之后的效果
  • 写一篇文章记录一下,答案就有了

答案模板

  • 描述问题:背景 + 现象 + 造成的影响
  • 问题如何被解决:分析 + 解决
  • 自己的成长:学到了什么 + 以后如何避免

一个示例

  • 问题:编辑器只能回显JSON格式的数据,而不支持老版本的HTML格式
  • 解决:将老版本的HTML反解析成JSON格式即可解决
  • 成长:要考虑完整的输入输出 + 考虑旧版本用户 + 参考其他产品

# 项目流程相关面试题

⚡ 30 秒速记

  • 完整流程:需求评审 → 技术方案设计 → 排期 → 开发 → 自测 → 联调 → 提测 → Bug 修复 → 灰度 → 上线 → 复盘
  • 前端在需求评审阶段就要介入,提前识别技术风险和不合理的交互
  • 协作要点:接口定义提前对齐(Mock 先行)、UI 走查、多端兼容清单
  • 质量保障:Code ReviewCIlint 和测试、灰度发布、错误监控和回滚预案
  • 答这题要体现你不只是写代码,而是对交付质量负责

一个完整项目通常要经过需求分析、方案设计、开发联调、测试和上线,核心是各角色持续协作并控制风险。 需求阶段要确认背景、合理性和依赖,方案阶段保持简单并完成评审,因为未经确认就排期或过度设计都容易返工。开发中通过规范、单元测试、Mock APICode Review 保证质量,联调时再让后端、设计和产品分别确认实现。遇到临时加需求应走变更评审并重估排期;上线后及时通知 QA 回归,异常时先回滚止损再排查。

和前端开发相关的项目角色有哪些

  • PM产品经理
  • UE视觉设计师
  • FE前端开发
  • RD后端开发
  • CRD移动端开发
  • QA测试人员

一个完整的项目要分哪些阶段

  • 阶段1 需求分析 评审项目需求时需要注意哪些事项
    • 了解背景
    • 质疑需求是否合理
    • 需求是否闭环
    • 开发难度是否如何
    • 是否需要其他支持
    • 不要急于给排期
  • 阶段2 技术方案设计 如何做好技术方案设计
    • 求简,不过渡设计
    • 产出文档
    • 找准设计重点
    • 组内评审
    • RDCRD沟通
  • 阶段3 开发 如何保证代码质量
    • 如何反馈排期,预留缓冲时间
    • 符合开发规范
    • 写出开发文档
    • 及时单元测试
    • Mock API
    • Code Review
  • 阶段3 联调
    • RDCRD技术联调
    • UE确定视觉效果
    • PM确定产品功能
  • 阶段4 加需求 项目过程中PM加需求怎么办
    • 不能拒绝,走需求变更流程即可
    • 如果公司有固定,按规定走
    • 否则发起项目组和leader的评审,重新评估排期
  • 阶段5 测试 测试不要对QA说:我电脑没问题
    • 提测发邮件,抄送项目组
    • 测试问题要详细记录
    • 有问题及时沟通
  • 阶段6 项目上线
    • 上线之后及时通知AQ回归测试
    • 上线之后及时同步给PM和项目组
    • 如有问题及时回滚。先止损,在排查问题
  • 项目沟通的重要性
    • 多人协作,沟通是最重要的事
    • 每日一沟通(如站会)
    • 及时识别风险,及时汇报(如设计图没出来)

# 把握投递简历的黄金时间段

⚡ 30 秒速记

  • 春招 3~4 月(金三银四)、秋招 9~10 月(金九银十),这两段岗位最多、HC 最充足
  • 年前(12~1 月)和年中(7~8 月)是淡季,但竞争者也少,急招岗位反而好谈
  • 一周里 周二到周四上午投递,HR 处理简历最集中;周五下午和周末容易被淹没
  • 内推优先于官网投递:能跳过初筛、进度更快、还能提前了解团队情况
  • 简历投递要有节奏:先投 2~3 家练手,调整后再投目标公司

投递简历更合适的时间是上午 10~11 点或下午 3~4 点。 数据显示,招聘人员通常在上午 11~12 点、下午 4~5 点更活跃,提前一小时投递,更可能在其集中筛选前进入视线。候选人的投递高峰又恰好集中在上午 11 点和下午 4 点,直接赶在高峰提交容易被大量简历淹没。这个时间只能提高被及时看到的可能性,最终是否进入面试仍取决于简历与岗位的匹配程度。

大家从事不同种类的工作,每天也在不断地制定自己的工作时间表。每个月总结的时候会发现有些事情总是在一个固定的时间去做,也可能在这个时间段发起同一件事情的几率非常的大,而且不止自己这样做,做同样工作的小伙伴亦如此。这就是工作种类作息时间的安排,招聘人员也一样,他们也有固定看简历和电话沟通的时间段。如果抓住这个“黄金投递点”,就等于抓住了招聘人员的视线,进而获得更多关注的可能性会更大。

HR 工作作息时间表

每个公司特别是互联网公司都有大量的招聘需求,而面对这么多的需求,公司 HR 是如何应对的?每天的工作作息时间是否有规律可循呢?

下面通过曲线图的形式来展示拉勾网 HR 的工作作息表:

由上图可知,每天 HR 最活跃的时间段为上午 11 ~ 12 点、下午 4 点 ~ 5 点。也就是说在这两个时间段里,我们的招聘小伙伴在疯狂的筛选简历,即在招聘平台上筛选来自不同候选人的简历。

如果面试者投递简历的时间为上午的 10 ~ 11 点 或者下午的 3 ~ 4 点,那么简历有可能会被优先处理。相信你也有过体验:一天当中,上午的工作心情以及认真度普遍是最高的,也就是说投递的简历是最容易被招聘人员筛选出来的。

候选人投递时间表

上面分析了 HR 最活跃的时间段,那求职者是不是也有个投递简历的高峰期?如何错开高峰期呢?调取了拉勾网投递简历的数据。

下面通过曲线图的形式来展示候选人投递简历的时间表:

由上图可知,候选人投递简历的高峰期是在上午 11 点和下午 4 点这两个时间段,也就是说和 HR 筛选简历的时间段完全重合。相信你也有过类似的体验:当专心做某一件事情的时候,肯定不会注意到投递来的新简历,这就是为什么简历石沉大海的原因。

通过上面两个数据的分析,相信你也应该知道了 HR 筛选简历的时间段,由此可知,投递简历的“黄金时间段”在上午的 10 ~ 11 点 或者下午的 3 ~ 4 点。因此,从现在起,调整投递简历的时间吧,在更好的时间段将自己的简历呈现到 HR 的面前。

# 把握面试时的关键点

⚡ 30 秒速记

  • 开场自我介绍控制在 1~2 分钟:现状 + 一个最相关的项目亮点 + 为什么投这个岗位
  • 不会的题别硬编:说清「我了解到哪一步」再讲思路,比胡说更容易过
  • 被追问时不要急着改口,先确认对方的意思 —— 有时追问只是在测你的确信度
  • 讲技术要有层次:先给结论,再给理由,最后给例子(结论先行)
  • 全程注意:语速别太快、留出交流空间、面完记得复盘写下被问倒的题

面试的关键是表达有重点、内容真实,并让对方快速看清你和岗位的匹配度。 自我介绍控制在 3~5 分钟,重点讲近期经历、个人业绩和与岗位相关的优势。遇到追问先确认对方想了解什么,不会的部分如实说明。着装保持干净得体,离职原因和职业规划则强调成长逻辑,避免抱怨前公司。

面试前的准备工作

先说说面试前的准备吧。常规的准备相信你一定知道,比如制作一份吸引 HR 的简历、穿一身体面的衣服、整理一下自己的发型等。简历相关的准备前面已经详细讲过,这里就不多介绍了。

下面说说穿着相关的准备,很多小伙伴认为面试时的穿着并不是很重要,面试官肯定更看重个人魅力和知识的储备。当然这么说是没错的,但如果你和面试官首次见面,在还没有开始正式聊天之前,他是无法感知你的个人魅力或者知识储备的。

假如第一次见面就看到邋遢的外表或者奇怪的着装,面试官会怎么给你贴标签呢?首先他一定会认为你并不尊重这次面试,给他造成一种没有礼貌的印象;然后就是被你身上的味道熏倒无法和你多交流;最后根本来不及了解你的个人魅力和知识储备就草草地结束了这次面试。相信这个结果一定不是你想碰到的吧?所以,干净得体的着装是面试非常重要的一个环节。

面试官也会通过你的着装去判断你的性格,以及判断与公司的文化、团队的气氛是否匹配。这时可能你会问:我也没有进入到这家公司和团队,该如何判断面试当天穿什么衣服才符合这个公司的文化或者符合这个团队的气氛呢?当然,我们没有办法做到“把面试官的感受照顾到很细”的层面。

但是不同的穿着一定会表现出你的性格,有些表现出来的性格可能不会被大众所接受的,希望可以回避一下。下面简单说说几种可以表现性格的穿着:

喜欢穿简单朴素衣服的人,往往给人的印象是性格比较沉着稳重、为人比较真诚和随和,无论是在工作或学习上,还是在生活中,会给人一种勤奋好学、诚实肯干的感觉;

喜欢穿样式繁杂、颜色多样、花里胡哨的衣服的人,多是虚荣心比较强,爱表现自己而且又是乐于炫耀的人,会给人一种性格有些飞扬跋扈的感觉;

喜欢穿浅色衣服的人,性格比较活泼好动,十分健谈,会给人一种喜欢交朋友的感觉;

喜欢穿深色衣服的人,性格比较稳重,显得城府很深,会给人一种比较沉默,做人做事深谋远虑的感觉。

如果你希望在面试中表现的不是那么具有攻击力或者给人比较亲和、稳重性格的话,建议穿简单、朴素、纯色的衣服,会显得整个人比较清爽,且比较容易亲近,相信面试官也愿意和你多聊几句。当然不仅穿着干净,而且一定要注意个人卫生,最好不要让自己身上的体味过重或者使用太重味道的香水。化妆时,不建议浓妆艳抹,自然的淡妆让自己看起来很精神就可以。

如何全面的介绍自己

接下来就是面试的过程了,首先面试官会说:“请简单介绍一下自己。”

面试官有两个目的:(1)希望通过你的简单描述可以和简历上的经历做校对;(2)通过简单地介绍来看看你的逻辑和总结能力如何。所以自我介绍也是非常重要的一个环节,好的自我介绍一定要做到以下几点。

  1. 面试时的自我介绍

一定要把握住时间。面试时的自我介绍一般控制3~5分钟最合适,尽量不要超过10分钟。时间过短说明你根本没有清晰的介绍自己,这时面试官很难了解你到底做了什么;时间过长可能很多内容不是面试官需要的信息,这时大部分的面试官会主动打断你,从而留下了不太好的印象。

那如何把握好时间呢?建议在介绍时包含以下几个部分就好:(1)情况介绍,包括教育经历;(2)工作经验的介绍;(3)介绍最有价值的经历。这样的一个自我介绍应该可以很好的控制在5分钟左右了,既可以让面试官清晰的了解你的情况,也能表现出你的优势。

  1. 面试过程中需突出的几个点

在面试过程中一定要突出以下几个点:做过什么、有哪些工作业绩、优势是什么,这样可以很好的突出自己。

做过什么:介绍自己,把自己曾经做过的事情说清楚,每段工作对应时间节点的公司名称、担任职务、工作内容等,尤其是对最近两份工作做过的事情要重点说说,较早之前的工作经验,或者学习的经验可以一带而过,要把握“重点突出”的原则。

有哪些工作业绩:把自己在不同阶段做成的有代表性的项目经验介绍清楚,但是一定要注意:(1) 应与应聘岗位需要的能力相关的业绩多介绍,不相关的一笔带过或不介绍,因为面试官关注的是对用人单位有用的业绩;(2)要注意介绍你个人的业绩而不是团队业绩,要把自己最精彩的一两段业绩加以重点呈现。当然也要做好充足的准备,可以迎接面试官的提问。

突出自己的优势:注意介绍自己的优势一定要与应聘的岗位密切相关,主要是围绕自己专业特长来介绍。除专业特长以外的特长,特别突出可以介绍,但要点到为止。

举个例子:你好,我是某某,2018年3月加入XXX公司,担任产品经理一职,主要负责公司核心产品的规划和设计工作;在这段期间,我独立完成过XX项目的产品跟进和上线的工作,将产品的数据提升了30%,业绩突出,获得了公司的认可。在项目中,我通过学习和与外部专家的沟通,获许了XXX新策略的信息,并积极尝试,达成了我的目标。

  1. 每段工作的离职原因

在面试的过程中一定要突出自己职业规划的逻辑性,也就是说需要让面试官感受到你的每次工作变动都是为了个人成长以及有规划的进行变动。所以在表述的时候最好可以清晰地说出你在每段工作中的收获和成长点,当然如果在陈述这些内容时可以体现出你的个人思考,就更是画龙点睛了。

如何回答面试中的问题

相信你经常会碰到面试官问以下的问题,这些问题也是面试官给你的一些考验,如果更好地回答这些问题可能会成为你入职心仪公司的敲门砖。

  1. 你为什么选择我们公司?

这个问题相信不少小伙伴遇到过,可能你的原因是随便投递、公司离自己住的地方近、工资给的高、公司不加班、公司有各种补助等。如果这些答案出现在你的面试回答中,那 HR 会重新考虑是否要录用你了。

所以在回答这个问题时需要有一些准备:

  • 可以先描述一下自己的能力与岗位要求的契合度,表现出在公司提供的岗位上有机会可以一展所长;
  • 说出几个被企业所吸引的优点,这些优点能为以后的工作带来什么好处;
  • 自己的职业发展与公司前景作出总结。

相信这些回答可以很容易抓住面试官的心,不过前期也是需要你对这家企业,以及所招聘的岗位做了一定的功课。

  1. 你为什么从上家公司离职?

也许你在前公司受到了委屈、也许前公司人事关系复杂所以离职,但无论前公司有多么的糟糕,都千万不能在面试时说出来。因为你在上家公司离职的原因,会使面试官联想到你会不会因为在新公司受到委屈而轻易离职?再者,面试官其实并不关心你为什么要离职,所以面试时只需要给在场所有的人一个都可以接受的答案就可以了。

例如,可以这样回答:为了更好的发展,所以选择离职。切记在回答这个问题的时候,不能贬低前公司、不要损害前领导的形象。

  1. 你的优点和缺点是什么?

相信很多小伙伴对这个问题都很头疼,自己的优点说的太多会让面试官感觉过于自大,可在面试的过程中又有谁愿意说自己的缺点呢?下面列举几个简单的方向,希望可以帮助你解决这个尴尬的困境。

  • 优点:可以结合过往的工作经历工作业绩等讲述一下自己的优势。例如,我曾经参加过某某项目,相信我的这个工作经验可以很好的帮助到公司解决什么方面的问题等。当然也可以通过一些例子说明自己的人品或性格方面的优势,哪家企业可以拒绝一位性格和能力都很好的候选人呢?
  • 缺点:金无足赤、人无完人,要勇敢的面对自己的缺点,可以向面试官说明,你针对自己的缺点做了哪些改变,以此来说明你正在积极地改变自己去成为更优秀的人。
    • 比如你是做前端的,你可以说你对运维那块的部署相关不熟悉,经验还不足等等。你是做后端的,你可以说你对那些炫酷的页面交互不太熟悉。
    • 优秀案例:突出你好学的心态
      • 以前因为工作的关系不常用xxx技术栈,在业余时间略有接触,但是理解还不够深。
      • 但是自从xxx后,我就买了有关的书籍和一些视频教学深度学习。
      • 每天都会下班后用一个小时的时间在掘金,CSDN等论坛活跃,阅读网友的文章。同时我也会把我自己的疑惑跟大家交流,大家一起进步,让我在这方面越来越熟
  1. 未来 3 年或 5 年,你的职业规划是什么?

当面试官问到这个问题时,是希望看到你的自我学习力和未来牵引你的职业动力是什么。对职业规划不清晰的人,很难获得成功,也不会在一个岗位上待很久,所以也不是公司最合适的人选。

当被问到你的职业规划是什么的时候,此时可以设定一个短期就能实现的规划和一个未来希望实现的目标

例如,我希望可以在未来的 1 ~ 2 年内,梳理和参与到几个完整的项目中,从中学习和看到整个项目进度是什么样的,从而提升自己的工作能力和项目经验。在未来的 3 ~ 5 年内我希望可以独立承担项目,做一个可以让大家都能使用并且体验良好的产品出来。

这样的回答,在短期规划上会让面试官认为你是一个脚踏实地,希望可以通过学习而成长的人,而且也在积极的改变自己;在长期规划上也能让面试官感受到你对这份工作的热情,具有很强的成就动机。

  1. 在选工作中更看重的是什么?

很多小伙伴反馈,这个问题很难回答,其实也能想到面试官肯定更看重你的是个人成长和发展空间。当然也许你的内心想的是涨薪或者培训,虽然薪资是一定的,但是如果让面试官认为你是一个物质的人,并没有长久的培养空间,那面试的结果就可想而知了。

  1. 你还有什么问题吗?

这是面试结束前的最后一个问题,也可以认为是个形式问题或走个流程,此时可根据前面面试过程中的表现程度来适当的提问,比如公司福利、上下班时间、团队氛围、个人岗位发展等,但尽量不要问从网上就能查到公司信息的问题。

# 工作交接流程 & 福利衔接

⚡ 30 秒速记

  • 离职流程:提离职(一般提前 30 天)→ 交接 → 离职证明 → 社保公积金转移
  • 交接清单:代码仓库权限、文档、账号密钥、未完成需求的进度和风险点、对接人
  • 社保公积金不要断缴:断缴会影响落户、买房、医保报销,尽量做到无缝衔接
  • 离职证明和解除劳动合同证明是入职新公司的必需材料,别忘了要
  • 竞业协议要看清:范围、期限、补偿标准;没有补偿的竞业条款通常不具约束力

离职交接要把工作顺利移交,同时确认离职证明、社保、公积金和年假的处理方式。 我一般会先和直属负责人确定交接人,再整理项目文档、未完事项和对接关系,并留出时间协助接手人熟悉工作。相关文档转交时抄送领导,减少后续责任不清的问题。福利办理存在公司和地区差异,入离职日期应提前向双方人事确认。

工作交接流程

如何不伤和气的提出辞呈

终于拿到了自己心仪公司的 Offer 了,可能有很多小伙伴又开始发愁了:如何与领导顺利提出辞呈,又不伤和气呢?这个时候一定要做好最坏的打算,你要明白,心软拖着不说会更伤害自己与前公司的关系,不如直截了当、当机立断。

一般提出离职的方式分为两种:

  • 通过邮件的形式提出辞呈;
  • 直接找直属 leader 沟通。

具体采用哪种方式,可根据自己的个性来判断,比如不太擅长沟通、偏内向的可以通过邮件的方式;如果已经想好了怎么和上级沟通,也可以直接找 leader 阐明心意。那在写邮件或直接沟通时需要注意哪些呢?

  • 首先,可以先表达出对公司和领导在工作中的指导和帮助的感激,以及这段时间在公司的工作和成长的开心,同时说明一下做出辞职的决定对自己来说是多么难的一次选择。相信这样的表达可以让领导对你有个不错的印象。
  • 其次,不论你的离职原因是不满意薪资、不适应团队的管理风格还是发展空间到达了上限等,都不要在这里抱怨出来,因为每个公司的 leader 都清楚公司里的问题,与其这样,不如直接告诉 leader,辞职的原因是希望可以有更好的发展,或者是让自己有更好的学习成长的空间。相信你的决心加上这样的理由,leader 一定会领会里面的意思。

如果这时 leader 突然问:找到下家了么?该怎么回答?建议这样委婉地回答:手里有好几个 Offer,还没确定好去哪家

最不建议的离职理由:经常会有小伙伴为了避免双方尴尬,会选择“家人生病需要较长的时间照顾”、“家人要求我回老家工作”等类似这样的理由,如果是真实的当然不会有问题,如果是虚构的,以后万一被发现,则会给前公司留下一个不诚信的印象,以后再相见时会更尴尬。

当然也有小伙伴提出离职是为了通过拿到的 Offer 要求涨薪,这样的“小聪明”玩不好可能就把自己“玩”进去了,不但在拿到 Offer 的公司名声坏了,也不会被现在的公司重用的。

最后,可以和前司表示一下,自己一定会负责任地把手里的工作交接清楚,站好最后一班岗,这样也可以给前司 leader 留下一个让人踏实的印象。毕竟你的面试背调还在人家手里,总不希望闹得不可开交,拿不到一个好的背调反馈吧。

合理安排交接工作

一般来说,如果你是一位已经转正的全职员工,那么交接的时间为一个月,所以公司也会要求你在这一个月里正常工作,那么,如何清晰地在这一个月里合理安排交接工作呢?

  • 先和直属 leader 协商找到一个靠谱的工作交接人;
  • 把自己以往的项目文档整理好,分类发给交接人;
  • 如果你手里还有未结束的项目,可以带着交接人熟悉一下,一起对这个项目做收尾工作;
  • 通知同事或者项目对接人自己已经离职,接下来的项目由被交接人负责;
  • 空出两周的时间,协助交接人熟悉你手里的工作内容,在旁做好支持工作。
  • 如果新的公司期望你能尽快入职的话,多数情况下会担心你拒绝入职,此时建议你诚恳地向新公司解释,并和新公司同步交接工作的进度。

交接文档有以下注意事项,比如:

  • 清晰的文档归类,发现问题可以马上与你沟通;
  • 尽可能将相关的文档都涉及到,让你的交接文档更容易查找;
  • 记得文档转出时抄送给领导,这个很重要,一定要记得;

我相信这样的交接流程不会让自己手忙脚乱,也可以给前司留下不错的印象。

离职最后一天走的时候,记得和同事们一一打招呼,感谢大家以往的照顾和帮助,以后要常保持联系。更重要的一点是,一定要拿到“离职证明”文件或“解除 / 终止劳动合同报告书”

福利衔接

交接工作都做完了,很多小伙伴会问:我的社保、公积金怎么办?下面来讲讲 3 种常用的福利交接事项。

社保公积金

  • 各个公司的社保、公积金都是以每个月的 15 日作为分界点,如果你是在 15 号前入职的新公司,那么就会帮你交当月的社保和公积金,如果你是在 15 号后从前公司离职,社保、公积金会由前公司承担。当然也会有特殊情况,要看人才局的具体安排。
  • 如果你正好是 15 号前离职,中间休息了一段时间,15 号后入职新公司的,可能需要你自己找第三方保险代缴公司自行缴纳社保公积金了。

年假

通常,公司会按照你出勤的月份帮你做年假的换算,然后与你协商安排延后几天离职,或结算成工资,或者按照公司的规定有其他操作。

# 十三、人事面

# 第一个要点: 你是否胜任这份工作?

⚡ 30 秒速记

  • HR 判断的是匹配度:你的技能和经历能不能直接解决岗位的问题
  • 答法:把简历里最贴合 JD 的经历挑出来,用 STAR 讲一遍,落到可量化的结果
  • 提前逐条对照 JD:每一条要求都准备一个自己的对应案例
  • 有短板别回避:承认 + 说明你已经在补 + 给出已见成效的证据
  • 加分项:主动说出「我理解这个岗位最需要解决的是 X,我之前做过类似的 Y

我会用岗位要求对应自己的相关经验、技能和成果,说明为什么能够胜任。 比起笼统地说有热情,更有效的是选两三个最契合的点,例如类似岗位经验、处理复杂任务的能力或完成过的代表性工作。暂时缺少的能力不必回避,可以说明愿意参加培训并持续学习。遇到没有直接经验的职责,也要明确边界,不能把团队成果说成个人成果。

1. 对于这份工作,你最感兴趣的是什么?

  • 能充分发挥我的工作热情,知识和技术。
  • 是我过去_____年从事_____(职业)的延续。
  • 工作任务有挑战性,有战略性和有价值感。
  • 短期项目和长期项目有很好的平衡。
  • 热爱并有能力完成这份工作。
  • 我将全身心的投入到工作中,能够很好的完成工作项目如:_____(可以根据招聘信息上对该岗位工作内容的描述填写)

2. 你认为这份工作能给你带来什么?

  • 有机会接触更多的客户。
  • 能发挥我的特长:_____。
  • 赋予我责任包括:_____(岗位责任)。
  • 有吸引我的企业文化,如贵司的_____(可以适当夸赞下公司的企业文化、特色等)。
  • 能更好的锻炼我的_____(能力)。
  • 提供与人交流沟通并互相帮助的工作环境。

3. 对于上一份工作,你最喜欢和最讨厌的是什么?

  • 喜欢在团队会议上开展头脑风暴。
  • 喜欢富有挑战性,比如:_____。
  • 讨厌按部就班的工作,更追求能体现自我价值的工作,比如:_____。
  • 喜欢富有创造性,比如:_____。
  • 讨厌重复的工作,但是愿意在机械性的工作中寻求新的方法,提高工作效率。
  • 处理大量的邮件是比较有挑战性的,但我喜欢及时的回复,看着待处理邮件越来越少带给我很大满足感。

4. 你管理过多少人的团队?

  • 我现在正管理一个_____人的团队。
  • 负责项目的大小决定团队人员的数量。
  • 有过管理团队和小组的经验。
  • 目前没有管理团队的经验,但我对做管理有充分的准备。
  • 我经常志愿参加团体活动,一般都有_____到_____人参加,我在其中担任负责人。
  • 我负责过_____人的项目,虽然并不是日常管理,但是作为项目经理,我主持的项目都会提前完成,并控制预算在_____元以内。

5. 你承担过哪些经济责任?

  • 在上份工作中,我负责过预算为_____的项目。
  • 经过前三份工作,我负责的项目预算越来越多,经济责任也越来越大,从第一份工作的_____到最近_____的项目。
  • 我是从负责技术转为负责财务。随着公司的发展,我不仅负责部门的技术工作,还负责采购和执行方面的财务工作。
  • 在我之前的工作中,我并未涉及过财务方面的事务,但我相信我全面了解自己的工作,能够处理好。
  • 我虽然没有直接负责财务问题,但是我负责管理过一个_____的项目。
  • 我负责过一个大型的项目,对公司尤为重要,能够带来_____的利润。虽然我没有直接负责财务,但我的领导对项目的成功起着举足轻重的作用。
  • 我设法使销售额在18个月的时间里翻了三倍,从_____到_____。

6. 过去一年中,你做过的最艰难的决定是什么?

  • 可以说一个你遇到并成功解决的问题。
  • 是否进行裁员。
  • 是继续呆在老公司还是寻找新的工作机会。
  • 是否暂缓部门扩大计划。
  • 是否减少自己和手下员工的薪酬,以免裁员。
  • 是否接受晋升继续在原公司工作还是回学校深造。我选择_____,因为_____。

7. 带给你最大满足感的成就有哪些?

  • 描述一个让你感到骄傲又做的非常专业的事情,并解释下为什么这件事带给你满足感。
  • 设法在降低40%成本的基础上,将产能提高了一倍。
  • 解决了同事无法解决的问题。
  • 提前在预算内完成任务。
  • 在面对巨大困难_____时,也能圆满达成目标_____。
  • 可以描述一个与你现在正在面试的岗位要求类似,你又成功完成的项目。
  • 在大学期间,获得_____,使我得到了_____的实习机会。

8. 你能否胜任这份工作?

  • 列举两到三个你能胜任的原因。
  • 愿意参加额外的培训以满足岗位要求。
  • 列举两到三个相关技能。
  • 列举自己的特长。
  • 将在_____领域的持续深造,积累经验和学识。

9. 你是否愿意接受心理测试?

  • 可以,能否告知这个测试在面试中起多少决定作用?
  • 可以,对能够定义我工作能力的任何方式我都接受。
  • 可以,也希望贵司告知测试结果。
  • 可以,请问你们怎样测试?
  • 我很愿意你们对我的专业能力做出评估,并回答任何相关问题。
  • 注意如果你表现出对心理测试的反感,很可能给面试官不好的映像

10. 从上份工作中你学到了什么?

  • 学会了如何同时处理多个任务和管理复杂的计划。
  • 学会了如何根据优先级处理多项工作。
  • 学会了认真做好每一件事(即使只是一些日常小事),并从中体会到工作的乐趣。
  • 学会了在开放和真诚的工作环境中与同事互相帮助,互相成就。
  • 学会了如何在大/小公司工作,如:_____。
  • 学会了如何高效地处理重要事务。比如:_____。
  • 学会了不论工作环境怎样复杂,都有值得学习和使人成长的地方,比如_____。

11. 在上份工作中,你是否有发现前任所遗留的问题?

  • 是的,我发现了一个问题并开发了一项新的技术,比如_____。
  • 是的,我在简历上有写明我在上份工作中所解决的问题,包括:_____。
  • 没有,我的上司很有能力,把工作做的很好。为我后续开展工作打下了良好的基础。
  • 没有,但是因为我处理问题的能力受到领导的赏识,我被赋予了比前任更多的权限和责任,比如:_____。
  • 老实说,我不能说有什么特别的问题是我的前任造成或遗留的。对我来说,最重要的是共同解决问题,而不是考虑这是谁的责任。

12. 哪种岗位更适合你:基层或管理?

  • 在我的上份工作中,我同时扮演两种角色,无论是作为员工还是经理,我处理起来都得心应手。
  • 两种岗位我都可以,但是我更倾向于_____,因为_____。
  • 我认为我更适合做一名普通员工,因为我更擅长执行领导指派的任务。
  • 我认为管理岗更适合我,因为我是一名天然的领导者,无论何时,都愿意担任领导的任务。

13. 这个领域,你认为将来的主要趋势是什么?

  • 在参加面试前,我曾做过调查,发现了多个趋势:_____。
  • 我发现_____有巨大的潜力,这也是我选择这个行业的原因。
  • 据我所知,_____正发生着细微的变化。我将关注这一变化在市场中的运用和对我们行业的影响。
  • 我认为我们这个领域的技术正在变得越来越先进,在未来,拥有先进的技术是必不可少的。

14. 你适合这份工作的原因是什么?

  • 我认为我可以在两个方面增加公司的价值:比如_____(举两个实例)。
  • 列举几个自己最突出的优点。
  • 结合今天与您的会面所了解到的信息,我可以说,我具备贵公司期望员工所具有的潜力、热情和毅力。
  • 我有类似的岗位经验,比如:_____。

15. 你是否认为对这份工作来说,你的资历过高?

  • 也许,但是我希望能在贵司长期发展。我相信贵司能及时发现我对公司的其他帮助,并与公司共同成长。
  • 我在_____的丰富的经历,可以使我比那些慢慢成长起来的人更快的开展工作。
  • 我过往的经历和能力都很适合这份工作,我有信心在岗位上有优秀的表现。
  • 你是否对我简历上所写的能力有疑问,我很愿意为你解答。
  • 如果是,我相信这部分资历也能为您和您的公司所用。
  • 也许是这样,在我的职业生涯中有幸获得过很多好的工作机会,使我更注重工作给我的满足感,并更乐于_____(表述岗位职责)。

16. 对于你申请的这份工作,你如何理解?

  • 列举一个你觉得该岗位的关键任务。
  • 这是一份具有挑战性的工作。
  • 这是一份需要注重客户满意度的工作。
  • 这是我理想中的工作,为了这份工作,我做过各种调研,努力提高自我能力,比如:_____。
  • 这份工作注重_____,需要很高的职业素养,如:_____,我很愿意提高自身水平达到贵司的要求。

17. 你将如何职业性的提升自己?

  • 通过三种途径提升自己:阅读专业杂志、参加会议、研修继续教育课程。
  • 紧跟行业潮流,学习技术,不断突破。
  • 尝试新事物,学习新技能,开发新兴趣,比如:_____。
  • 接受行业导师的指导,提高自身能力。
  • 在成人大学参加课程,扩大视野,增加知识储备。
  • 在岗位上学习,了解同事的工作、技能、兴趣,学会多角度看待问题。

18. 你最大的成就是什么?

  • 详细叙述几项自己的成就,比如:上线产品、重组部门、质量管控等。
  • 我是个非常善于交际的人,经常致力于改善部门同事之间的关系。
  • 在工作中,我能避免犯错,降低成本,达成目标。
  • 在我23岁时便升任经理,尽管有很多资历和年纪都比我老的员工,但我领导还是因为我的潜力冒着风险提拔了我,最终她没有后悔自己的决定。
  • 我完成了一个项目,给公司带来了巨大的利益。(可以详细说明)
  • 在我的职业生涯中,我经常被任命完成各种艰巨的项目,被赋予了更多的责任。
  • 我以优异的成绩大学毕业,后被留任为两个教授的助教。

19. 你理想的工作状态是怎样的?

  • 举两到三个在工作中能让你感到快乐的事情,比如同事、工作环境等。
  • 开放而轻松的工作环境。
  • 团队之间互相协作,又能互相体谅的工作环境。
  • 相对自由的环境(我是个自律的人,做事主动,不太喜欢日常工作被过多的监管)。
  • 能独立工作的环境。
  • 有适当压力的工作,因为压力便是动力。

20. 你希望签署固定期限还是无固定期限合同?

  • 贵司提供哪一种合同?
  • 固定期限合同更适合我,因为:_____。
  • 在签署合同前,我想先明确岗位职责。
  • 比起我对该工作的热情,合同是次要的。
  • 两种形式我都可以接受。

21. 如果你被指派过多的任务并无法在期限内完成,你将如何处理?

  • 类似的事我碰到过两次,第一次我_____,第二次我_____。
  • 如果我发现自己无法按时完成任务,我会第一时间告知我的领导,并明确难点,看是否能通过合作尽量减少延误带来的损失。
  • 尽快通知相关人员,告知我的进度与交期。
  • 寻求帮助,看是否能按时完成。
  • 考虑是否能先将重要的部分提前完成。

22. 你更喜欢单独作业还是团队合作?

  • 任何环境我都可以适应,团队合作有利于创造,独立工作则能让我更专注。
  • 各有优点,团队合作有利于创造,独立工作则有利于自我反馈。
  • 在我看来,最理想的是将工作分为两部分,一部份专注于自我完成,一部分于小组协作完成。
  • 过去我更专注于独立工作,以后我会注意团队沟通。
  • 过去我更专注于团队协作,以后我会注意独立工作。

23. 如何最有效的学习?

  • 在工作中学习。
  • 多看,多听,多读,多应用。
  • 制定学习计划,稳固提升。
  • 创造性,逻辑性,记忆性相结合的学习。
  • 多阅读,多尝试。结合指导,运用于实际。

24. 你认为铁饭碗还存在吗?

  • 如今市场变化如此之快,很难说哪些工作还是铁饭碗。
  • 努力的话还是有可能存在的。保持工作的热情,不断学习,有责任心是维持工作竞争力的关键。
  • 有稳定的工作,比如提供终身聘用制的岗位。而那些日新月异的新兴技术行业相对而言则风险更大。
  • 我不认为岗位的稳定性是首要的,我更关心的是我能否在岗位上习得技能,能否从中有所获利。
  • 随着全球市场化的推进,曾经的铁饭碗逐渐消失。相反,因为科技的进步,不断产生新的工作岗位。不稳定有时也意味着机遇。
  • 随着经济下行和失业率的攀升,铁饭碗越来越少。要想获得工作就必须向雇主展现你的价值。

25. 包括你在内,现有三名候选人,你认为我决定录用的标准是什么?

  • 是否热爱这份工作。
  • 是否拥有担任这个岗位的能力。
  • 是否符合公司的价值观。
  • 是否能够融入公司的企业文化。
  • 是否真诚,忠于公司利益。

26. 在你全面投入工作前,你觉得你需要多久的适应期?

  • 很快,我相信我对工作和责任已经有了很好的理解;等我接触了我的同事,熟悉了环境,稳定下来,我就会在这个职位上做出成绩。
  • 在我回答这个问题前,有两个问题请教:这份工作的首要任务是什么?有什么项目是需要我马上负责处理的?
  • 我适应环境很快,大概需要_____周。
  • 我可以马上开始着手日常事务的处理。对于特定的项目,我需要详细了解项目内容,熟悉公司流程,了解客户背景后才能处理。

27. 作为一名企业员工,将如何彰显自己的社会责任?这是否会困扰你?

  • 企业需要关心的不仅仅是利润。作为一个国际企业,应该在解决全球重大问题上体现自身的价值。
  • 我想在一家绿色环保的公司工作。我认为企业应当注重环境保护,尽其所能阻止全球变暖。
  • 企业应当有企业良心,保护环境,承担社会责任。
  • 每一个企业,无论大小,都应该做一些事情来解决我们今天所面临的社会问题。这可能是简单的用纸杯替换塑料杯,也可能是复杂的,如提供货车方便拼车。
  • 我认为理想的企业应该尊重他人,不仅仅局限于那些对他产品感兴趣的群体,而是尊重企业所在地,所在国,乃至全球所有人。
  • 有社会责任感的企业定能增加员工的忠诚度和满意度,吸引相同价值观的人才一起为企业为社会做贡献。
  • 这对我不重要,我认为企业的首要任务便是为自己的员工提供好的福利。

# 第二个要点:你是怎样的人?

⚡ 30 秒速记

  • HR 看的是协作性稳定性:好不好共事、会不会很快离职
  • 答性格题要落到工作场景:不说「我很细心」,说「我习惯在提测前自己过一遍边界用例,上季度我负责的模块线上零 P1
  • 团队冲突类问题:讲清你怎么对事不对人、怎么用数据而不是情绪说服对方
  • 别说自己是「工作狂」或「没有生活」,HR 会担心你倦怠离职
  • 保持一致性:性格描述要和你讲的项目经历对得上,前后矛盾最扣分

我会用真实的工作习惯和具体表现来说明自己的性格,而不是只堆“负责、抗压、善于沟通”这类形容词。 比如遇到方案被否定时,我会先弄清原因,再判断是调整方案还是适当坚持。面对错误和压力,则及时承担责任、同步风险并安排优先级。谈缺点也会保持真诚,同时说明已经采取了什么改进办法。

28. 开场白。 当你和面试官打完招呼后,有可能会出现短暂的沉默,面试官以此测试你的反应和主动性。你可以用以下方式开场:

  • 请问我能坐这吗?
  • 感谢您安排这次的面试。我很期待我们的谈话。
  • 您希望从什么问题开始?
  • 您希望我先自我介绍下还是您先介绍下工作岗位情况?
  • 在我们开始前,是否需要我再详细介绍下我的简历?
  • 请问看了我的简历,哪点最打动您?

29. 请做自我介绍。

  • 这是个很宽泛的要求,你可以适当的提问面试官,然后再介绍面试官关心的信息。例如:_____。
  • 您最想了解哪方面的信息:工作经历还是工作风格。
  • 您是想了解我过去的经历还是最近的成就?
  • 您希望我粗略介绍下还是详细叙述我的经历?
  • 自我介绍下与现岗位有关的工作经历。
  • 自我介绍下性格、理想、优点、成就等。

30. 你的特点是什么?

  • 列举与应聘岗位契合的性格优点和技能优势。
  • 对雇主而言,我的综合优势明显:有良好的技术背景、参加过管理培训和拥有跨国公司经历。
  • 我过去的雇主大多认可我的优点:注重细节、守时、善于整合信息。
  • 我有胜任这份工作的技术,热情和学识。
  • 我是个很好的倾听者,善于处理人际关系。在工作中,我发现我的同行们很多都技术精湛,精力充沛,乐于奉献,但是都不善沟通,不会协作,不懂互惠互谅。

31. 当你的想法被驳斥你将如何处理?

  • 迅速重新组织语言,反省被拒原因。一旦找出问题所在,马上解决,寻求新的方案。
  • 不轻言放弃,同时寻求其他方案。
  • 举例说明自己曾经方案被拒,调整后被重新启用,并且获得了成功。
  • 我希望贵司能有一个开明的环境,鼓励员工多提方案,互相交流。
  • 说实话,我不介意我的提议偶尔不被采纳。我明白并不是每一个提议都是合理的。我更愿意求同存异,与时俱进,博采众长。
  • 反思己见,细心揣摩。
  • 如果我认为我的提案非常优秀,对公司非常有利,我会适当的坚持。

32. 有哪些事将导致你对一个项目失去兴趣?

  • 像大多数人一样,对按部就班,机械重复的工作缺少兴趣。赋予我更多的责任,能使我在工作中保持热情和专注的工作更吸引我。
  • 很多时候,相处的同事如果刻薄而严肃,比如固执己见,墨守成规,甚至打压异己都会让我对这份工作失去兴趣。
  • 过于清闲,自己的能力无法得到充分的发挥。
  • 缺少正向反馈,上层处事不公,不够赏罚分明。
  • 看不到前途,没有挑战。

33. 下班时间你喜欢做什么?

  • 多提体育和文化相关的活动,少提会引起面试官反感的项目。
  • 阅读。
  • 音乐。
  • 健身。
  • 球类运动。
  • 志愿者。
  • 看电影。
  • 看展览(可以是跟行业有关)。

34. 当你意识到自己犯错时将如何处理?

  • 分析当下的处境再采取下一步行动。
  • 对涉事人员道歉,保证不再犯。
  • 反省原因,确保不再犯。
  • 承担责任,继续工作,确保不再犯。

35. 抗压能力。

  • 有时,面试官会保持适当的沉默,以此测试你的抗压能力。不要被吓倒,更不要因为害怕沉默而被迫开口。开口必言之有物。
  • 你可以在心理默数,一般数到8时,面试官都会开口打破沉默。
  • 询问面试官感兴趣的问题。
  • 询问岗位内容和岗位职责。
  • 询问公司情况,部门组成。
  • 也可以主动询问面试官是否对录用自己还有什么其他问题?

36. 当你感到生气时你会如何表现?

  • 当我被排除在项目之外,但我又认为自己有能力参加这个项目的时候,我会感到生气。但我不会任由情绪控制自己,会主动询问决策人缘由,冷静处理。
  • 我是一个性情平和、积极向上的人,这有助于我在遇事不顺时保持冷静。我认为沟通可以防止引起愤怒和沮丧,也是处理事情的关键。
  • 我会冷静下来,整理思绪,不冲动行事。愤怒往往使人口不择言。
  • 我会直言不讳,不会用沉默对抗使我感到生气的人或事。
  • 只要不触碰我的底线(比如缺乏责任心、不作为等),一般我不会轻易生气。
  • 我会先将自己置身事外,将真正激怒我的缘由记录下来,我往往会发现,那些让我感到愤怒的事情并没有我想的那样对我造成伤害。

37. 处于压力下,你将如何工作?

  • 压力就是动力,压力使我做事更有效率。我自认能在任何环境下都专注于完成任务的人。
  • 总的来说,我是个有计划的人,很少让自己处于压力之下,但对于意料之外的事也能很好的处理。如果压力无法避免,我也会尽量克服压力。
  • 我抗压能力很强。在上份工作中,我就在非常紧急的期限内顺利完成项目。
  • 如果压力来自于同事,我会努力解决它。我明白人与人之间的误会和矛盾都会打压士气,我想我应该是个不错的调停者。
  • 遇到压力的时候,我会努力让自己吃好,睡好,锻炼好身体,以此来应对工作中的挑战。

38. 对于你的职业生涯,你有哪些遗憾?

  • 我希望我能在将来找到一份理想的工作(描述下自己的职业目标)。
  • 老实说,我目前为止没有遗憾。我很清楚自己的目标,也努力得到了我想得到的成就。
  • 作为一名职场新人,暂时还不好说有什么遗憾。
  • 我很遗憾在我过去的职业生涯中没有发挥我最大的潜力。
  • 我后悔没有更早的投入到自己的事业中,认为做什么都是一样的。随着我的成熟,我开始明白做自己真正喜欢的事情才是职业生涯中最重要的。

39. 你不相信我们会履行达成的协议吗?

  • 这个问题往往在你与面试官达成某项口头协议但是你提出需要书面协议之后。
  • 我只是希望能把协议规范化,保证我们的沟通没有问题。
  • 我当然相信贵司,但是我只是建议把我们的沟通内容记录下来,以免产生误解。
  • 只是防止以后贵司其他部门,如人事需要了解我们的协议时,我可以有所凭证。
  • 我当然相信贵司,记录下来只是方便我更仔细的研读。
  • 这无关信任。书面协议更加专业,更加有效,以免将来产生不必要的问题。

40. 你有哪些优缺点?

  • 充满热情,精力旺盛,工作努力。
  • 工作专注,效率高。
  • 能保持长时间工作的状态。
  • 其他。

41. 在未来的一年中,你最想得到哪方面的提升?

  • 提高自身能力的同时,提高自己组员的水平,共同成长。
  • 更好的了解_____的市场需求。
  • 工作方面,想参加一个_____培训提高自己的技能。私人生活上,想练习下_____(这里可以提一些对社交有帮助的活动或是兴趣爱好)。
  • 对公司来说,想提高市场份额,维护好关键客户。

42. 能否举例说明你在工作中的创新?

  • 去年,我策划组织并举办了一场贸易展,取得了巨大的成功。这得益于我在摊位的设计和实施上的创新。
  • 我非常善于分析并多角度的观察事物。能欣赏不同的思维方式,接受不同的观点。
  • 我善于倾听,能够为同事提供思路,帮助他们更好的完成工作。
  • 在我看来,创新便是能从不同的或者时全新的角度去思考并发现各种可能性的一种能力。我在上一份工作中,曾经:_____。
  • 我会思考,并将所思化为行动。纸上谈兵不过是空中楼阁,创意必须能在实际中运用。

43. 你如何形容自己的个性?

  • 思考下自己的真实个性,而不是你认为面试官想要你展现的个性。
  • 乐于迎接挑战,喜欢处理问题,不会被困难所吓倒。
  • 执行力高。
  • 善于学习。
  • 善于分析,对数字敏感。
  • 处事高效,可靠。
  • 善于交际,开朗。

44. 当被告知你的方案不奏效时你将如何处理?

  • 在修改我的方案前,我会听取建议,看是否合理。
  • 我非常乐于接受他们的真诚的建议。
  • 最初,很难接受自己的方案被否绝,但事实上,这也是个激励自己重新思考,提高自己的机会。
  • 只要能完成任务,我很乐意接受新的思路新的方案。
  • 我会弄明缘由,只要新的方案能更快更好的完成任务,我会欣然接受。

45. 你如何定义成功?

  • 我认为成功是应该被量化的。比如,我决定让手下员工接受培训,尽管竞争激烈,但在半年间,减少了9%的流转率。
  • 我认为成功就是超既定的目标不断前进。
  • 成功就像是一段旅程,会随着时间而改变。对现在的我而言,找一份能发挥我的潜力,让我变得与众不同的工作便是成功。
  • 成功是是拥有不断学习的能力。并让学识丰富我的人生。
  • 成功就是不畏艰难,遇到问题不放弃。
  • 成功便是不忘初心。始终保持真诚。很多人为了成功放弃了自己的原则,但是往往时间会让他们付出代价。

46. 你的领导风格时怎样的?

  • 平易近人。
  • 以身作则。
  • 有福同享,有难同当。
  • 照章办事,有据可依。
  • 开明,自由。
  • 富有激情,感染力。

47. 你最喜欢的网站是哪个?为什么?

  • 可以选择一个与你工作有关的,充满学术性和知识性的网页。

48. 在你的职业生涯中对你鼓舞最大的人是谁?为什么?

  • 第一份工作的领导。一个好的领导能丰富员工的人生。我从他身上学会了尊重和欣赏他人。
  • 我的父亲。他让我明白工作不分贵贱。每一份工作都有他的价值。
  • 我的导师。他一直支持鼓励我尝试新的事物,不畏惧失败。我希望我能将这种精神传递下去。
  • 我六年级时的一位任课老师。他能发现每个学生身上的闪光点,告知学生每一个个体都是独一无二的。这份独特的礼物我倍感珍惜,在我的职业生涯中,不断的鼓舞着我。
  • 其他,可举例说明。

49. 你的工作风格是怎样的?

  • 我更倾向于团队工作。我认可他人的贡献,努力培养团队精神,给团队中的每一位成员树立正确的价值观。
  • 我是个实干派。我喜欢直面核心,并解决问题,喜欢接受新的挑战。
  • 我做事有条理,有计划。喜欢确保细节万无一失。
  • 我做事有计划性,有头有尾。成功完成项目会给我带来很大的成就感。
  • 能独立完成指派的任务,无需太多的指导。
  • 目标导向型。

50. 你未来的目标是什么?

  • 扩大视野,增加知识储备,学习_____。
  • 举例与自身职业发展有关的目标。
  • 岗位(指明一个你感兴趣的部门领导岗,并说明原由)。
  • 学习外语(对岗位有利的)。

51. 你如何看待我的面试风格?如果让你来主持面试,你会有哪些不同?

  • 我认为您的提问恰到好处,完全能判断谁是合适的候选人。我希望你能发现我非常合适。
  • 你的面试安排非常合理,所提的问题直接而全面。过程有条不紊,我相信完全能为这岗位寻找到合适的候选人。
  • 您非常友善,提问也非常翔实。最初,我有一些紧张,但是您平复了我的情绪并提供了很多信息,使我确定我很适合这个工作。
  • 感谢您给我足够的机会展示自己。

52. 当你面临失业的时候,你将如何处理负面影响?

  • 一开始会非常困难,但是我会克服失业的迷茫,坚持寻找新的工作。
  • 一开始会感到绝望,但是后来慢慢发现这也是开始一份新工作的机会。
  • 持续学习,保持自身的竞争力,以此保持自己的自信心。
  • 先多花时间陪伴家人朋友。然后重新审视自身,开始寻找新的工作。

53. 你最大的失败是什么?你从中吸取了哪些教训?

  • 尽量减少对失败细节的描述,特别是不要情绪化。侧重于描述你从失败的经历中学到的东西。
  • 学会两手准备。当计划失败时,能马上启动第二套备选方案。
  • 在我目前的职业生涯中还没有可以回答这个问题的经历,我可以说说我在学校时期遇到的类似的事情。没能按时完成论文以至没能取得好的成绩。让我加深了对时间的敏感,让我明白必须按时完成任务。
  • 失败并不可耻,只要你能从中有所得。如果你没有失败过,只能说明你不曾尝试。
  • 我曾在一个快速发展的行业工作,一下扩招了很多员工,当经济下行时,我不得不解雇其中一部分人。使我明白眼光必须放长远,不要轻易做出判断。
  • 我在职业生涯的早期,就职前没有做好充分的调查,入职后不久便离职。自此,我学会在做决定前先做仔细的调查。

54. 你的缺点和局限是什么?

  • 提一些与你的工作无关的缺点。并且着重讨论你如何客服他们。最重要的是真诚。不要自以为是的编造。
  • 不懂拒绝。后来我发现把我的安排和截止日等标注在日历上非常有帮助。当我被求助时,我可以给出合理的理由去拒绝。
  • 非常讨厌浪费时间,这也使我对他人表现的非常不耐烦。为了克服这一点,我强迫自己理清思路,提前告知他人自己的项目流程,有效避免无意义的解释,浪费时间。
  • 当我超负荷工作时,我往往会忽略一些常规性的任务。我意识到这个问题后,我会每天花一刻钟的时间更新我的安排,整理文件,把第二天重要的事情记录下来。
  • 头天工作太晚影响第二天的工作。强迫自己定时睡觉,养精蓄锐开始第二天的工作。
  • 不会在众人面前表达自己的观点。最近,我跟我的领导也讨论了这个问题,并且跟他分享了我的观点,也得到了他的鼓励,使我更有自信大声的说出我的想法。

55. 请描述下你的理想职业和理想领导。

  • 理想的工作:
  • 不断学习,不断成长,不断变强;
  • 学有所用,能够体现自身价值
  • 理想的公司:
  • 尊重下属的价值,贡献,给予成长的机会。
  • 中型公司,互相熟识。

56. 你的长期目标是什么?

  • 在一个能长期发展的岗位上工作。帮助公司拓展业务。
  • 在一个能让我发挥能力和潜力的公司工作。
  • 回学校继续进修。增强自己技能,与时俱进。以便能胜任管理岗。

57. 如果你被告知今天的表现不是很理想,你将如何处理?

  • 不要表现的过分抗拒和对立,也不要感到内疚和歉意。保持积极,自信的态度,尊重对方,听取建议和指导。
  • 请告知我今天哪里表现的不理想?
  • 请问我哪里还需要改进?
  • 感谢您的反馈,如果你给我一些建议,我会努力改进,对这份工作我还是很感兴趣的。
  • 我很渴望得到这份工作,我也认为自己是适合的人选。大概是太紧张了,没有发挥好。

58. 什么样的情况,让你无法做出决定?

  • 有些不受欢迎但又必须要做的决定。
  • 解雇员工。
  • 当某一职位空出时,在两个同样热情和富有竞争力的候选人中做选择。
  • 很难拒绝别人的请求。
  • 在决定是启用老人还是新人完成项目时会两难。老人富有经验,但是不给新人机会则永远无法发现他们的潜力。
  • 当我的意见和大家相左的时候,很难决定是尊崇大家的意见还是坚持自己的看法。

59. 请描述下你过的最糟糕的一天,又是如何度过的?

  • 遇到裁员,部门被裁撤一半的员工。当时就算是被留下的也很难保持好的心态。我只能加倍努力的工作以避免过多沉浸于裁员的阴影中。
  • 我的直系上司辞职的时候。工作中,我跟她配合非常默契,他的离开让我非常不舍。后来,新领导的风格完全不同,但是我也试着与他建立良好的工作关系。
  • 曾经在一个项目中估算错误,不得不重组人员,纠正错误。幸好,最终结果没有受影响,但是给其他人造成了很多额外的工作量。
  • 我最糟糕的经历是曾经有人在公司传播关于我的谣言。这些流言不但不真实还恶意中伤我。我与散步谣言的人对峙,让他停止对我的中伤。
  • 当我意识到我无法按时完成所有的项目时,我感到很沮丧。如今,我学会了给自己的计划预留一些时间,以免出现意外情况而无法按时完成。

60. 你是否言行一致?

  • 是的,我的价值观指导我的行为,比如:
  • 热爱工作。
  • 尽职尽责。
  • 富有创造性。
  • 勤奋。
  • 单纯诚信。

61. 在你的下一份工作中,你最满意的一点将是什么?

  • 能实现我以下三个目标:_____(跟工作有关的)。
  • 能负责大型项目:_____。
  • 好的团队,自由的环境。
  • 能展现自身才华,帮助公司解决问题:_____。

# 第三个要点:你是否适合这个企业?

⚡ 30 秒速记

  • 考察的是文化契合度和求职动机是否真诚
  • 面试前做功课:公司业务、主要产品、近期动态、技术博客,答的时候引用具体细节
  • 「为什么选我们」要给具体理由:业务方向、技术挑战、团队氛围,别说「公司平台大」这种放之四海皆可的话
  • 反向体现契合:说出你认同的做事方式,并举例证明你一直是这么做的
  • 别贬低前东家,也别过度吹捧对方,真诚具体最有说服力

判断是否适合一家企业,关键是岗位、发展空间和企业文化能否与自己的能力及长期目标匹配。 回答时要结合公司的产品、岗位职责、技术内容或近期动态,说明具体吸引力,而不是泛泛地说平台大、前景好。也可以补充自己能怎样参与团队协作、为岗位创造价值,让对方看到双向匹配。对于不了解的信息要坦诚,并通过提问进一步确认岗位挑战、团队要求和发展机会。

62. 你将与我们一起相处多久?

  • 我希望能长期任职,至少_____年。
  • 只要我对公司有贡献,关系融洽,我愿意长期服务。
  • 我更喜欢稳定的工作。
  • 只要有进步的空间,有前途,并且公司也满意我的表现,我就能一直工作下去。

63. 如何形容你的上一任领导?

  • 不要批评你的上任领导。即使关系不融洽,也可以说说他带给你的积极影响或者你从他身上学到的东西。
  • 在这行经验丰富,知识渊博的人才。
  • 有自信,有竞争力。
  • 平易近人。
  • 我从他身上学到了很多,比如:_____(如何与人相处,如何开展谈判,如何据理力争等)。

64. 你将如何对团队合作做出贡献?

  • 我喜欢通过团队协作来完成项目。这有利于加强个体间的联系,培养合作意识。我很愿意在下一份工作中为加强团队凝聚感做贡献。
  • 我很喜欢以一明成员的身份进行团队合作。我欣赏每一个人的观点、能力以及对团队的贡献。互相交换观点,并鼓励每一个人发挥自己的特长,以此加强团队精神。
  • 我乐于倾听别人的意见并分享我的观点。我相信用我的才智提出我的方案便是给整个团队做出贡献。
  • 我相信每个人的意见和建议都应该被听取和尊重,人人都是平等的。让所有人都感受到他的价值被认可,全面加强团队合作精神。

65. 为什么放弃上一份工作?

  • 我所在的部门经历了重组,而我还是希望能从事原来的工作。
  • 我的专业和原岗位不够匹配,我的领导也无法提供更合适的岗位给我。
  • 我负责的项目结束了。我希望能有更广阔的平台和发展空间。
  • 我所在的部门因预算等原因被裁撤。
  • 我被调离到与我的专业不匹配的岗位上,跟上司谈判后觉得应该找一份更适合我的工作。
  • 我和我的上司在_____意见无法达成一致。经过考虑,换一份工作更好。

66. 你如何看待你的下属对你的看法?

  • 有条理,注重细节。
  • 自由,公平,开放。
  • 平易近人,好相处。
  • 敬业,认真。
  • 严于律己,宽以待人。
  • 良师益友。

67. 请形容下在你工作中遇到的最难相处的人。

  • 某一任领导:咄咄逼人,缺乏耐心。但是我从他那也学到了很多。比如学会了如何有理有据地坚持自己的观点。
  • 前任领导:很难相处,言行不一。
  • 工作中的同事:充满负能量。
  • 工作中的同事:逻辑混乱,目标模糊,难以沟通。
  • 很难说我的工作生涯中遇到过多难相处的人。也许是我工作时间还太短。但是我相信面对难相处的人,我会努力去理解他的动机,再去处理。

68. 你愿意在我的位置上坐一天吗?

  • 当然,在我能力与之相匹配那天。
  • 当然,但是是等您等到升职后。
  • 现在不愿意,以后吧。
  • 当然,等您发现更舒服的位置后。老实说,我渴望将来能达到您的位置。

69. 如何形容你和同事的关系?

  • 非常好,他们非常支持我的工作,也非常信任我。
  • 大家互相尊重,非常和谐。
  • 彼此了解,对对方的贡献也非常欣赏。
  • 非常好,合作非常愉快。能彼此学习,进步。
  • 我的工作相对独立,没有太多的常规接触。但我认为,我与人相处非常有礼,处事也很专业。

70. 哪类人是你最难相处的?

  • 我处事灵活,与人为善,能跟大多数人很好的相处。但是,对固执己见的人敬谢不敏。
  • 我更喜欢与真诚开朗的人共事。
  • 通常,我在与人相处上很少遇到问题,除了不太喜欢与总为自己的错误找借口和懒散的人相处。
  • 我和那些死板、专制的人相处得不像我和那些直接、互相鼓励合作的人相处得那么好。
  • 我更喜欢和那些对他人的出色工作表示认可和赞扬的人一起工作,而不是那些做什么都只想邀功的人。

71. 描述下你与上司发生争执的情况。

  • 我们会就公司一些战略层面的观点展开讨论,但是一旦我们考虑了关键因素和可能的结果,我们通常就会达成一致。
  • 我们从未发生过争执,但是如果有意见相左,也会试着去理解对方的想法。
  • 我记得有一次我们对一个问题的解决方法发生了分歧。但是最终我们没有使用对方的方法,而且用共同讨论得出的第三种方案成功的解决了问题。
  • 在过去,我们很少发生分歧,一旦发生,往往是因为看待事物不够全面,当我们了解了各方面后,就会达成共识。
  • 在是否聘请顾问解决项目中,我和领导产生了争执,最终我说服了他,并被证实是非常正确的决定。
  • 当我无法说服我的领导时,我会接受他的决定,尊重领导的权威。

72. 请形容下你最满意的一个领导。

  • 在我过去的三份工作中,我都从与我领导的共事中学到了很多,比如:_____。
  • 我的上一任领导乐于跟我们分享各种主意。并且如果得到他的认可,也很乐意实施运用,给予我们认可。
  • 我毕业后的第一位领导。他不断鼓励和支持我追逐自己的目标。当我进步时从不吝啬赞扬。当我沮丧时总能给我建议帮助我。
  • 我最喜欢的一任领导总是待我非常真诚,虽然有时候过于直白,但是我知道他给我的反馈都是真心实意的。
  • 我最喜欢的一位领导总能看到我的成功,而不是只看到我的错误。所以我工作非常努力。
  • 我最喜欢的一位领导总是鞭策我不断前进,不断学习,不断成功,不会放任我自暴自弃。
  • 我最喜欢的一位领导会不断的让我接受挑战,促使我越来越强大。

73. 如果你领导提出的计划或者制定的政策与你的想法南辕北辙,你将如果处理?

  • 我会让领导了解我的想法,而不是与领导对抗。我相信交流是非常重要的。
  • 我认可领导的方案或者计划,但是也会提出建议反复测试,以求更加完善。
  • 在提出我的计划前我会先开展全面的调查,确保自己的方案有建设性,并有策略的提出自己的建议。
  • 曾经当我和领导意见相左的时候我会感到非常的羞耻,但后来我意识到这是一种不成熟的心态,这意味着我跟领导没有建立坚实的关系。自从想明白以后,我会努力和每一任领导建立良好的关系,能自信的表达不同的观点。
  • 我会私下表达我的不同观点,不会当面反驳领导。

74. 如何评价你的上家单位?

  • 尽量回答积极的一面,即使上家单位有让你不满意的地方,也着重说下你从中学到的经验。
  • 我的上家雇主非常专业。日常运营有序,奖罚得当。在那里,我学会了高效的、及时的、以目标为导向的工作。
  • 我在上家单位的时间很短,但是同事都非常的友善,离职的时候也非常的友好。

75. 如何处理办公室政治?

  • 我尽量专注于自身的工作,不让自己卷入这些斗争。也会详细的记录自己的工作,以免出现权责不清的纠纷。
  • 我不喜欢说闲话,如果我不小心听到也会无视。流言蜚语是有害无益的,传播流言蜚语是惹上麻烦的主要原因,也是不尊重同事的表现。
  • 当事情出错并且影响到我,我会合理的提出意见。不会浑水摸鱼,制造矛盾。
  • 我尊重同事,以诚待人,无论对方是谁,处于哪个阶层。如果出现问题,我会指出并提出解决方案。由于我是个值得信赖的人,所以同事也很愿意严肃的对待我的提案。
  • 我认为了解自己的盲点很重要。我会小心地注意任何小集团或派系的形成,并了解他们的动机。这样我就不会有意无意地疏远办公室里的其他人。

76. 请讨论一个你在职业生涯中做过的有争议的决定。

  • 我向领导提出离职后被挽留,并且得到很多许诺,但是我还是决定重新找工作,因为:_____。
  • 同事劝我放弃在某一项目中的利益,因为:。 但是我坚持我的做法,因为:
  • 我坚持推进一项计划,但领导有所顾虑,我从各方面说服了他,项目得以顺利进行。
  • 做我领导,我往往是最终的决策者。为了让下属更好的执行,我决定让他们参与其中,这样能让他们得到相关的信息,能更好的理解我的选择,能明白我为什么做这样的决定,能有更宏观的视角。

77. 你为何认为在工作中沟通是十分重要的?

  • 交流是成功的一个关键因素。一个人是否能够更好的完成任务往往取决于他的沟通能力。
  • 频繁的沟通可以有效的分享信息。能将消息传递给所有人,给人更多的参与感,也更容易互相理解。
  • 真诚的沟通——说我所想,想我所说,是一个企业的血脉。当人们不愿意表达并掩饰自己的真实想法时,误会就会随之产生。
  • 同事相处中,清楚明细的表达是首要的,特别是在传递关键信息和下达指令时。

78. 你团队的合作风格是什么?

  • 我是一个有团队精神的人,经常主动领导一个团队,积极性高、专注力强,对自己的领导能力有信心。我会提出很多问题,以确保每个人都能跟上,而且我会非常注意,从不让自己显得专横或排斥新想法。
  • 我非常专注于手头的项目,如果我们偏离了既定轨道,我会引导团队成员回到正轨,确保工作尽可能完全和有效地完成。
  • 我重视团队合作,喜欢合作,愿意聆听大家的意见和建议,在工作中达成共识。
  • 我是个有想法的人,喜欢挑战。我鼓励其他人接受新的不同项目,并不断寻找新的方法去克服问题。
  • 我做事很有条理,喜欢让团队保持稳定的步调前进。我重视逻辑和系统性的思维。
  • 我发现,当我定期与我的团队联系时——通过电子邮件、会议或去公司拜访——我的效率最高。

79. 你上次的绩效考核成绩如何?

  • 非常出色。这是领导跟我之间积极又建设性的交流。我明确达到了目标:_____。
  • 总体不错。是一次对我来说非常有帮助的反馈。我意识到在_____我需要在未来的工作中加强训练。
  • 不是很满意。我应该多跟领导沟通。对他的期许我还有很多沟通不到位的地方。
  • 非常好。(提下自己优异的表现)

80. 你为什么要找工作?

  • 我在上一家公司已经工作了X年,我相信我的工作技能和能力都在此期间得到了提高,但是没有进一步的发展空间。我想找一份与我能力更匹配的工作。
  • 我希望我的职业生涯有所改变,能服务于更好的公司,比如:_____。
  • 行业发展趋势使得更多的公司向海外转移,目睹很多同事被辞退,我意识到我需要寻找新的工作。
  • 和无数其他人一样,我也因为最近的经济萧条而被解雇了。尽管我们有许多有能力和有成就的员工,但还是由于我们无法控制的财务原因,整个部门团队被解散。

81. 你为什么长时间没有就职?

  • 完善自己的知识体系,比如:_____。
  • 对自己进行全面的评估,更好的认识自己,以最大的热情投入到新的工作中。
  • 对已收到的offer进行研究,仔细考虑。只接受有意义、有质量的工作岗位。
  • 对自己的职业生涯进行反思,我现在很很确定贵司便是我寻求的发展平台。
  • 找份工作不难,但是找到一份合适的工作却需要时间和坚持。
  • 当下的求职市场不景气,需要更多的努力和毅力去寻找一份合适的工作。

82. 你为什么辞去上份工作?

  • 我的专业知识得不到发挥。我相信贵司的岗位能提供更多的机会,以及更好的施展空间。
  • 在我从事上一份工作之前,我一直处在一个能激励我成长的工作环境中,在那里我可以全身心地投入工作。我最终意识到,如果我继续留在那个岗位,我的主动性将会磨灭,也将得不到成长。
  • 我所在的部门因为战略和财务状况被裁撤。
  • 得不到与我能力匹配的晋升机会。
  • 我所在的公司在走下坡路,但我知道我必须不断进步,不断磨砺自己的技能。
  • 前一份工作不能提供我进步的机会。
  • 企业文化和我个人的工作风格不匹配。

83. 你为什么要来我们公司应聘?

  • 岗位和公司都符合我的目标。比如:_____。
  • 贵公司以卓越著称,我相信我能在未来为贵公司的成功做出积极的贡献。
  • 贵司提供的岗位符合我之前的工作经验,我相信我能为公司做出贡献。
  • 我关注贵司很久,贵司最吸引我的地方是:_____。

84. 你目前的求职状态如何?

  • 诚实告知你目前的状态。除非你真的收到offer,否则不要杜撰。即使你没有拿到offer, 你仍要在面试官面前表现的积极乐观。
  • 我有自信。我已经完成了第一阶段:收集信息和分析信息。现在只需要展开第二阶段:联系公司和参加面试。
  • 感觉不错,我已经参加了四次面试,包括跟您的面试。正在和其中两家公司洽谈,但我仍未做出承诺,追求更具有吸引力的岗位,对我来说是明智的。
  • 收到两家公司的邀请,如果算上贵司,我将考虑加入哪个团队。
  • 我每天都在忙着找工作,已经得到了一些好的offer。我希望能在六周内找到一份工作。

85. 是否应聘其他公司?

  • 没有,贵司提供的岗位正合我意。在得到结果前我将专注于此次的机会。
  • 没有,我刚开始找工作,这里是我认为最适合的工作。
  • 是的,面过两家。都是很有意思的公司,但是贵司仍是我的首选。

86. 我们为什么要聘用你而不是其他人?

  • 这个岗位非常有吸引力,我相信会有很多候选人跟我一样对这个岗位有兴趣。列举两个特长:_____。
  • 我对这份工作非常有兴趣,并且也符合岗位的要求。
  • 我的性格、能力和经验都很符合贵司的要求。

87. 如果同意聘用你,你将如何答复?

  • 我需要时间考虑。
  • 我很高兴,能否告知岗位职责、工作环境等信息。
  • 非常感谢您的赏识,我会接受。

88. 有收到其他的入职通知吗?

  • 上个月,我参加了三次面试,都是我感兴趣的公司,我对结果很乐观。
  • 没有,仍在等之前面试公司的答复。
  • 猎头有联系我并提供可能的岗位。
  • 我只接触了很少的公司,因为我要确保他们符合我的要求,不想草率做决定。
  • 是的,但是贵司仍是我的首选。
  • 没有,我刚开始找工作,我想先从最感兴趣的开始。

89. 包括我们公司在内,你将决定接受哪家的offer?

  • 我会接受贵司提供的岗位。我相信这是适合我的工作。
  • 我会比较公司的优缺点,考虑我的技能和哪家公司更匹配。我的理想是找一家能开展我职业生涯的公司而不仅仅是找一份工作。
  • 总的来说我倾向于选择贵司,能否给我一天时间考虑下?
  • 很高兴能取得您的认同,我还想就几点细节跟你确认下,看你是否能考虑?

90. 到此为止,你还有什么问题吗?

  • 至少提出一个问题;这样能让面试官感觉你对面试是上心的,并且显示你是好问有求知欲的。但不要问的过于深入,也不要问你已经知道的事情。
  • 可以提问一些关于工作和公司的事情。
  • 提问面试官是否对自己今天的表现满意,是否还会安排后续面试?
  • 贵司希望找一个怎样的候选人?
  • 可以请面试官做个自我介绍,包括一些公司的介绍。
  • 请问前任是离职还是升迁?
  • 请问您个人觉得这个岗位最重要最富挑战的点是什么?

# 第四个要点:聘用你,公司需付出多少?

⚡ 30 秒速记

  • 这一问的本质是薪资谈判,先做功课:查清目标城市、目标级别的市场区间
  • 尽量让对方先出价:「想先了解一下这个岗位的预算范围」
  • 必须先说时给区间而不是具体数字,下限设为你能接受的最低值 +10%
  • 谈的是总包:基本工资、绩效、年终月数、股票期权、公积金比例、补贴
  • 跳槽涨幅 20%~30% 是常见区间;有竞品 offer 可适度提及但态度要平和
  • 所有口头承诺必须落到书面 offer,尤其年终和股票部分

谈薪时我会先了解岗位职责和公司的预算范围,再给出有依据的薪资区间,并围绕整体回报沟通。 只谈月薪容易忽略培训、保险、差旅、调薪和晋升机会,这些都会影响最终选择。如果必须先报价,可以结合行业情况、经验和岗位要求说明区间,避免随口给出一个死数字。涉及薪资、福利或岗位职责的重要承诺,最好明确写入 offer,减少入职后的理解偏差。

91. 你的上份薪资是多少?

  • 直接回答薪水。
  • 委婉拒绝,因为和前公司签过保密协议,不对外讨论自己的薪资。如果贵司认为我是合适的人选,我相信工资我们可以协商。
  • 如果贵司决定聘用我,我很乐意讨论下我的薪资报酬问题。

92. 你是否愿意降薪?

  • 这取决于您希望我降低多少。
  • 可以(但是请思考一分钟后再答复,这样可以迫使面试官说出自己的目标薪水)。
  • 可以,只要公司能够定期调薪。
  • 可以,如果我还可以得到其他非工资福利的话。
  • 可以,如果公司环境良好,有晋升机会的话。
  • 可以,但是岗位职责还需要重新讨论,在岗位职责和项目上我希望有更多的选择权。
  • 考虑到我个人的财务情况,我不接受降薪。

93. 你如何评价你上一份工作的薪资?

  • 很简单,我遵循一个准则:多劳多得。
  • 总的来说,我的付出超过我所得X倍。

94. 在你现阶段的职业生涯中,为什么没有得到高薪?

  • 我以前的工作与我们现在讨论的工作完全不同;受行业限制,薪资水平是比较低的。这是一个非常新的领域,所以大多数员工都认为在经济上所有牺牲是值得的,但我现在意识到,我需要寻求更多的经济保障。
  • 薪水不是我最看重的。我最看重的在我喜欢的公司做一份喜欢的工作。
  • 我之前的工资并不能完全反应我的所得,公司还提供很多额外的福利。
  • 我认为我值得更多,这也是我跳槽的原因。
  • 我很幸运在我接受一个岗位的时候,最首要的原因不是薪资。
  • 我还会分出额外的时间在工作以外的兴趣上,所以导致工资不是很高,这是我个人的选择。

95. 你认为你值多少?

  • 我更看重我的职业前途而不是薪资。也许在我们进一步讨论了我的资历和经验之后,再来看这个问题会比较合适。
  • 非常感谢你们能坦言这个问题。在此之前,我们能否再详述下岗位职责,让我也能对这个工作有更全面的了解。
  • 我对薪资的要求为:。因为:
  • 我很自信我能达到岗位的要求,所以我的薪资也应该是在最高档。
  • 我在这个领域已经工作了X年,我也对这个行业的薪资有所了解。在此基础上,我想我可以基于这个岗位,要求¥_____。

96. 你心目中的薪资水平是多少?

  • 我希望得到一份与我的潜力和未来贡献值相匹配的薪水。
  • 在3-5年间能达到:_____。
  • 在_____到_____之间,等我对岗位有了更具体的了解,我们可以把工资的范围再缩小。
  • 根据我的调查,在_____和_____之间。另外我认为我的能力和经验可以拿最高档
  • 如果能告诉我贵司对该岗位的薪资预算,对我们讨论这个问题会很有帮助。

97. 在为期6个月培训期中,你是否能接受降低工资?

  • 培训期减薪是每个新员工的标准要求,还是有什么特别的原因。
  • 对提升自我学习新技能我非常感兴趣,能详细说说关于培训的事吗。
  • 我对这份工作非常感兴趣,能等我了解该工作所有的岗位责任后再来谈这个问题吧。
  • 完成这次内部培训后,我还有什么额外的责任吗?
  • 可以考虑,但是我想知道这个培训为期多久?考虑到我学东西很快,培训时间是取决个人的学习进度吗?
  • 不接受,我认为我的能力可以拿全薪。

98. 你寻求哪些福利?

  • 医疗保险。
  • 岗位培训,技能培训等。
  • 据我了解,贵司的福利非常全面。我对贵司在这方面很满意。
  • 考虑到这份工作会有很多出差,想了解差旅费的报销问题。

99. 薪资对你有多重要?

  • 薪资当然是我选择工作很重要的一个因素。但是我更想在我的工作中有所作为,与志同道合的人一起做喜欢的工作。对我来说,这比钱更重要。
  • 薪资是很重要的,并且在我的职业生涯中,我的薪水一直与我的贡献相当。我希望以后也是如此。
  • 我认为高工资是对工作价值的肯定。
  • 工资很重要,但是实现自我价值更重要。我想要的工作是能展现我的能力和技术,而不仅仅是能提供一份高工资。

100. 你对加班怎么看?

  • 如果是任务需要,我可以安排加班。
  • 合理的加班我没问题。有加班费吗?
  • 我愿意加班直到把工作做完。
  • 公司支持在家办加班吗?
  • 也许我们可以想法避免加班。
  • 这要看加班的时常和频率。理论上我都可以接受,但是实际上,我希望公司能平衡好工作时间和私人时间。
  • 只要有合理的理由都可以。

101. 你预期在未来5年内,薪资水平达到多少?

  • 增长15-20%。
  • 我希望能在未来5年内,磨练自己的能力,在职位和薪水上都有增长。
  • 大概_____-_____。
  • 由于我的职业生涯才刚刚开始,所以我有理由相信在五年内我会赚得更多,但我无法预测一个具体的数字。
  • 这行变化很大,我认为我可以增长50%甚至更多。
webapp
公众号
开发者导航
切换夜间模式
点击侧边栏上一篇
点击侧边栏下一篇
折叠侧边栏
收起全部