Skip to content

知识点摘要大纲

一个认知

不能为了背八股文去梳理知识,知识始终是为了建立认知和解决问题而存在的

梳理模板

  • 是什么:专业的概念

  • 为什么:解决了什么问题,想达到什么目的

  • 怎么做:原理

  • 应用场景:这个问题涉及到的一些实际场景和业务

  • 优缺点:好处和隐患,如何衡量

  • 还有更好的方案吗

WARNING

待完成:基于这个原则,以下的所有问题,都需要重新梳理

语言原理

JS

0.1+0.2==0.3 吗,为什么?

是什么:小数进制转换对阶运算导致的精度丢失

怎么做:通过字符串自己实现运算的进位逻辑,或者在小数位已知的前提下转换成整数运算。

更好的方案:可以借助社区成熟的第三方包实现

应用场景:价格的计算

JS 数据类型

基本类型:Number, Boolean, String, null, undefined, symbol, BigInt

引用类型:Object(对象子类型 Array, Function, Map, Set)

有什么区别:基本类型在栈内存上保存数据,引用类型在栈内存上保存指针,指向堆内存

栈内存和堆内存的区别在那些场景下有体现:

  1. 变量赋值,如果等号右边的变量是基本类型,相当于重新创建,如果是引用类型,则是指针的引用,对新变量的改动会影响到同一条数据

  2. 函数传参,基本类型和引用类型作为函数参数传递的区别,基本类型会重新创建,引用类型会传递指针

引用类型的赋值如何解决:浅拷贝和深拷贝,有哪些方法:Object.asign 浅拷贝,手动实现 cloneDeep 深拷贝(也可以借助 message 和 JSON.parse)

如何实现对象深拷贝?

是什么:深拷贝的本质是在内存上重新创建原对象的所有数据和指针,其中也包括了函数的声明

怎么做:

  1. 方案一:递归遍历对象,object 类型执行递归,基本类型直接赋值,其它引用类型调用它的构造器创建

  2. 方案二:使用 JSON.parse 和 JSON.strigify,仅针对内部的基本类型,无法处理函数类型和循环引用问题

  3. 方案三:使用 MessageChannel 放到 event 传递,js 会自动进行深拷贝

应用场景:纯函数构造新对象

new 一个函数发生了什么?

是什么:

new 关键字可以用来调用函数的[[Constructor]],产生它的实例

在函数的内部有[[Call]]和[[Constructor]]两个指针,普通的调用是 Call,用 new 调用的时候是 Constructor,两种情况有差异

通过 new 调用的时候会有以下 4 个步骤:

  1. 创建一个新对象

  2. 新对象的__proto__指向构造函数 prototype 所引用的对象

  3. 新对象绑定函数的 this

  4. 如果函数没有返回值,就直接返回这个新对象

用伪代码表示如下:

js
function myNew(func, args) {
  // 1.新建一个空对象
  let obj = new Object()
  // 2.将构造函数的prototype指向新对象的__proto__
  obj.__proto__ = fun.prototype
  // 3.改变this的指向(在新对象环境中调用构造函数使this指向新对象)
  fun.call(obj, ...args)
  // 4.返回对象
  return obj
}
function myNew(func, args) {
  // 1.新建一个空对象
  let obj = new Object()
  // 2.将构造函数的prototype指向新对象的__proto__
  obj.__proto__ = fun.prototype
  // 3.改变this的指向(在新对象环境中调用构造函数使this指向新对象)
  fun.call(obj, ...args)
  // 4.返回对象
  return obj
}

应用场景:面向对象编程(封装、继承、多态)

this 的指向问题,分别有哪几种情况?

是什么:

this 关键字是在全局上下文和函数上下文中创建的指针,它的指向有以下两种情况

全局:严格模式指向 undefined,非严格模式指向 window 或 global

函数:指向调用它的对象,如果找不到则指向全局

应用场景:

在函数中获取到调用它的对象,本质上是为了获得上下文的信息

箭头函数和普通函数的区别

是什么:

箭头函数是 ES6 新增的一种函数类型,最直接的区别是它产生块级作用域,同时不产生 this

  • 箭头函数的 this 在创建时绑定了外层定义它的函数的作用域,且无法被修改(词法作用域,定义时就确定了)

  • 箭头函数无法通过 new 关键字调用

为什么:

函数内部有两个方法[[Call]]和[[Construct]],通过 new 调用时,会先执行[[Construct]]方法创建实例对象绑定 this,再执行函数体

箭头函数没有[[Construct]]方法,用 new 调用会报错

应用场景:

在函数中定义函数的场景,能更直观地使用 this,减少开发者的心智负担。

symbol 的作用

是什么:

独一无二的命名,防止命名冲突,同时对象中的 symbol 键不会被常规的方法遍历到。Symbol.for 可以在全局访问 symbol。

应用场景:

  1. 给对象挂载一个临时的 key

  2. 全局定义的唯一值

  3. 对象的 symbol['iterator']用于注册迭代器使用

闭包是什么?如何产生?使用场景

是什么:

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

::: MDN 定义:闭包是指那些可以访问自由变量的函数。 :::

何为自由变量,指的是在函数中使用,但既不是函数也不是函数局部变量的变量。

因此更权威的定义是:

闭包 = 函数 + 函数能访问的自由变量
闭包 = 函数 + 函数能访问的自由变量

从理论角度:

所有的函数在定义的时候就将执行上下文数据保存起来了,它的外部作用域的变量就变成了自由变量。

从实践角度,以下的两种情况形成了闭包:

    1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    1. 在代码中引用了自由变量
js
var scope = 'global scope'
function checkscope() {
  var scope = 'local scope'
  function f() {
    return scope
  }
  return f
}

var foo = checkscope()
foo()
var scope = 'global scope'
function checkscope() {
  var scope = 'local scope'
  function f() {
    return scope
  }
  return f
}

var foo = checkscope()
foo()

在这个例子中,f 的执行上下文维护了一个作用域链:[A0, checkscopeContext.AO, globalContext.VO],正因为这个作用域链的存在,f 读取到了 checkscope 中变量的值,即使 checkscopeContext 被销毁了,js 依然会让它的变量活跃在内存中,成为自由变量。

闭包的使用场景:

  1. 封装工具函数,返回一个新函数(例如防抖和节流)

  2. 在模块化的开发中,所有的文件都是在函数中运行,模块导出了方法,被方法访问到的模块顶层的变量,都会形成闭包,可以用于模块的状态管理和通信

什么是作用域链?作用域链是怎么产生的?

是什么:

JS 的函数是静态作用域,作用域链本质上是函数的执行上下文,上下文是在定义时创建的,只跟代码结构有关,跟调用位置无关

作用域链的追溯机制:当访问一个变量时,编译器在执行代码时会在当前作用域查找是否有这个标识符,如果没有找到则到父作用域中查找,直到找到最顶层为止。

为什么:

从 JS 解释型语言的角度考虑

函数的作用域是什么时候产生的?

JS 的函数是静态作用域,所以函数的作用域在定义的时候就确定了。

看个例子:

js
var value = 1
function foo() {
  console.log(value)
}
function bar() {
  var value = 2
  foo()
}
bar() // 输出1,因为作用域在定义的时候确定了
var value = 1
function foo() {
  console.log(value)
}
function bar() {
  var value = 2
  foo()
}
bar() // 输出1,因为作用域在定义的时候确定了

词法作用域:函数的作用域基于函数创建的位置

再看个例子:

js
var scope = 'global scope'
function checkscope() {
  var scope = 'local scope'
  function f() {
    return scope
  }
  return f
}
checkscope()() // 输出 local scope
var scope = 'global scope'
function checkscope() {
  var scope = 'local scope'
  function f() {
    return scope
  }
  return f
}
checkscope()() // 输出 local scope

引用《JavaScript 权威指南》的说法,js 函数执行的时候用访问到了作用域链,而作用域链正是在函数定义的时候产生的,因此不管何时执行函数,都会访问它创建的时候绑定的作用域。

var 和 function 的优先级谁更高?

如下例子:

js
var foo = function () {
  console.log('1')
}
foo() // 1
var foo = function () {
  console.log('2')
}
foo() // 2
var foo = function () {
  console.log('1')
}
foo() // 1
var foo = function () {
  console.log('2')
}
foo() // 2
js
function foo() {
  console.log('1')
}
foo() // 2
function foo() {
  console.log('2')
}
foo() // 2
function foo() {
  console.log('1')
}
foo() // 2
function foo() {
  console.log('2')
}
foo() // 2

这是因为 JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候会先发生变量提升函数提升

当执行函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕后,就会将函数的执行上下文从栈中弹出。

但是变量和函数的提升方式是有区别的

变量提升是先定义为 undefined,执行的时候才会赋值(例子 1 中的 function 是匿名函数)

函数提升是将函数体直接注册到全局,提升的时候如果有重名会进行覆盖

在上述例子 2 中,伪代码表示如下

ECStack.push(<fun1>, functionContext)
// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext)
// fun2还调用了fun3!
ECStack.push(<fun3> functionContext)

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();
ECStack.push(<fun1>, functionContext)
// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext)
// fun2还调用了fun3!
ECStack.push(<fun3> functionContext)

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

let 和 var 的区别

是什么:

都用于声明变量,let 是 ES6 引入的块级变量声明方式

具体表现:

  1. 上下文环境不同,let 在词法环境,var 在变量环境,var 在全局或者函数作用域内,而 let 在块级作用域内(最明显的体现是 if 和 for 产生的花括号,let 不能在花括号外访问)

  2. 全局作用域下的 var 会自动变成 window 的属性

  3. var 声明的变量在作用域内会自动提升(提前调用 undefined),let 声明的变量有暂时性死区(提前调用报错)

怎么理解原型链?

原型是什么:在 JS 环境中一切皆对象,每个对象 A 上都有一个[[prototype]]属性(可以通过__proto__访问),指向另一个对象 B,那么 B 就是 A 的原型。

原型如何挂载:通过 new 方法调用 Function,就会把函数的 prototype 属性挂载到对象的proto指针上

判断原型

isPrototypeOf, Object.getPrototypeOf, instanceof

手写instanceof

js
function myInstanceOf(left, right) {
  let proto = Object.getPrototypeOf(left)
  while (true) {
    if (proto === null) {
      return false
    }
    if (proto === right.prototype) {
      return true
    }
    proto = Object.getPrototypeOf(proto)
  }
}
function myInstanceOf(left, right) {
  let proto = Object.getPrototypeOf(left)
  while (true) {
    if (proto === null) {
      return false
    }
    if (proto === right.prototype) {
      return true
    }
    proto = Object.getPrototypeOf(proto)
  }
}

原型链是什么:

对象可以访问原型上的属性和方法,当读取对象属性时,如果属性不存在,则会到原型上查找,而原型也可以访问自己原型的属性的方法,这样一种层层向上追溯的机制,构成了一条原型链。

这个过程是自动的,我们只需直接调用属性。

原型的一些常用方法:

枚举属性

方法枚举属性范围
Object.keys实例上,可枚举
for(let prop in obj)实例和原型上,可枚举
Object.getOwnPropertyNames(obj)实例
prop in obj实例和原型上

应用场景:

  1. 因为原型链的存在,Array 可以调用 Object 原型上的方法

  2. 可以借助原型链实现对象的继承

  3. 因为原型的动态性,可以借助原型链挂载全局方法

防抖和节流,手写代码

防抖:短时间内重复调用,只会执行最后一次

js
function debounce(func, time) {
  let timeout = null
  return function (...args) {
    const context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      func.apply(context, args)
      timeout = null
    }, time)
  }
}
function debounce(func, time) {
  let timeout = null
  return function (...args) {
    const context = this
    if (timeout) {
      clearTimeout(timeout)
    }
    timeout = setTimeout(() => {
      func.apply(context, args)
      timeout = null
    }, time)
  }
}

节流:短时间内重复调用,限制 n 秒最多执行一次

js
function throttle(func, time) {
  let timeout = null
  return function (...args) {
    const context = this
    if (timeout) return
    timeout = setTimeout(() => {
      func.apply(context, args)
      timeout = null
    }, time)
  }
}
function throttle(func, time) {
  let timeout = null
  return function (...args) {
    const context = this
    if (timeout) return
    timeout = setTimeout(() => {
      func.apply(context, args)
      timeout = null
    }, time)
  }
}

for...in...和 for...of...的区别

是什么:for of 本质上是迭代器的语法糖

区别:

对象类型:for in 用于遍历对象的可枚举属性;for of 用于迭代可迭代对象

遍历顺序:for in 循环可以任意顺序遍历属性,对数组和字符串则是遍历索引;而 for of 则是严格按照对象的迭代协议,按顺序进行遍历。

遍历机制:for in 会遍历对象和原型链上所有的可枚举属性,而 for of 只能遍历可迭代对象自身的元素。

迭代变量:for in 提供变量表示属性的索引,而 for of 提供的变量表示元素的值

JS 中的类有什么特点

是什么:在 ES6 中提供了 class 关键字创建一个类,在 ES5 中则是通过 new Function 实现

特点:

  • JS 的类同样具备面向对象编程的特点,具备封装、继承和多态的特性。

  • 比较特别的是,JS 类的继承是基于原型的。

  • 同时,JS 类中的方法可以通过子类的重写来实现多态。

JS 函数如何实现重载

JS 中的函数声明会发生提升,如果名称发生重复,后面的会覆盖前面的。

在 JavaScript 中,通常更倾向于根据函数的用途和上下文动态处理参数,而不是通过显式的重载来处理。

例如根据参数的数量和类型执行不同的逻辑,来达到类似重载的效果。

Promise 用了什么设计模式

  1. 观察者模式:then 方法的回调充当观察者

  2. 链式调用:更清晰地表达和处理多个步骤,消灭回调地狱

手写数组 reduce 方法

js
Array.prototype.myReduce = function (fun, init) {
  const list = [...this]
  let total = init !== undefined ? init : list[0]
  for (let i = 0; i < list.length; i++) {
    total = fun(total, list[i], i)
  }
  return total
}
Array.prototype.myReduce = function (fun, init) {
  const list = [...this]
  let total = init !== undefined ? init : list[0]
  for (let i = 0; i < list.length; i++) {
    total = fun(total, list[i], i)
  }
  return total
}

手写 Function.call

易错点:key 冲突问题、未清空 key、未考虑基本类型

js
Function.prototype.myCall = function (context, ...args) {
  // 判断调用myCall的是否为函数
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myCall - 被调用的对象必须是函数')
  }
  // 如果没有传入上下文对象,则默认为全局对象
  // ES11 引入了 globalThis,它是一个统一的全局对象
  // 无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  if (context === null || context === undefined) {
    context = globalThis
  } else {
    // 基本类型数字、字符串需要转成原始对象,避免下面调用context[key]报错
    context = Object(context)
  }
  // 防止key冲突
  const key = Symbol()
  // 将this(当前函数)放到context中
  context[key] = this
  // 在context中执行函数
  const res = context[key](...args)
  // 删除context的this
  delete context[key]
  // 返回结果
  return res
}
Function.prototype.myCall = function (context, ...args) {
  // 判断调用myCall的是否为函数
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myCall - 被调用的对象必须是函数')
  }
  // 如果没有传入上下文对象,则默认为全局对象
  // ES11 引入了 globalThis,它是一个统一的全局对象
  // 无论在浏览器还是 Node.js 中,都可以使用 globalThis 来访问全局对象。
  if (context === null || context === undefined) {
    context = globalThis
  } else {
    // 基本类型数字、字符串需要转成原始对象,避免下面调用context[key]报错
    context = Object(context)
  }
  // 防止key冲突
  const key = Symbol()
  // 将this(当前函数)放到context中
  context[key] = this
  // 在context中执行函数
  const res = context[key](...args)
  // 删除context的this
  delete context[key]
  // 返回结果
  return res
}

手写 extend

js
function extend(Child, Parent) {
  // 创建一个空的构造函数用于中转
  function Temp() {}

  // 中转函数的指针指向父类的原型
  Temp.prototype = Parent.prototype

  // 使用中转函数构造子类的原型
  Child.prototype = new Temp()

  // 子类的构造函数指向自身
  Child.prototype.constructor = Child
}

function Parent() {}
function Child() {}
extend(Child, Parent)

const child = new Child()

console.log(child instanceof Parent) // true
function extend(Child, Parent) {
  // 创建一个空的构造函数用于中转
  function Temp() {}

  // 中转函数的指针指向父类的原型
  Temp.prototype = Parent.prototype

  // 使用中转函数构造子类的原型
  Child.prototype = new Temp()

  // 子类的构造函数指向自身
  Child.prototype.constructor = Child
}

function Parent() {}
function Child() {}
extend(Child, Parent)

const child = new Child()

console.log(child instanceof Parent) // true

CSS

CSS 中 1px 边框怎么适配

1 像素边框的适配问题通常出现在高密度屏幕(如 Retina 屏幕)的移动设备上

在这些设备上 1 个 CSS 像素对应多个物理像素,导致 1 像素边框看起来比较粗

可以采用的方案有:

  1. 使用物理像素单位,例如 px、pt,而不是逻辑像素单位 rem、em

  2. 使用border-width: thin;(可能有兼容性问题)

  3. 使用伪元素和缩放:通过设置:aftertransform: scale(0.5);实现 0.5px 的效果

  4. 使用box-shadow实现:box-shadow: 0 0 0 1px #000;通过将模糊半径设为 1px,实际上是给周围加了个 1px 的边框。

介绍一下 transform 和 transition 属性

transform 属性用于对元素进行变换:包括平移 translate、rotate 旋转、scale 缩放、skew 倾斜

transition 用于在元素状态改变时创建平滑的过度效果

由于 GPU 处理图形比 CPU 更高效,这两个属性都是通过调用 GPU 来加速渲染,提高动画的性能。

当使用这两个属性时,浏览器会将它们的绘制指令委托给 GPU 执行,达到更流畅的动画效果。

前端通过调用 GPU 在图形方面的优化:硬件加速、在合成层调用指令避免重新布局、优化属性(transform 替代 left 和 top)

GPU 加速的触发条件:

  • 某些 CSS 属性,如 transform、opacity

  • 使用了 CSS 动画或者过渡

  • 使用了 canvas 或 webgl

此外,合理使用 will-change 可以让浏览器在渲染阶段提前准备好资源,从而加速执行

will-change 可能会触发浏览器创建一个合成层单独执行渲染

框架原理

介绍一下 Vue 的 diff 过程

当组件创建和更新时,会执行它的 update 方法,调用 render 方法生成新的虚拟 DOM 树,组件指向新树,然后调用 patch 方法执行更新。

patch 方法会对比新旧两棵树找到差异点,最终更新到真实 DOM,这个过程就叫 diff。

diff 具体过程:

在对比时,采用深度优先逐层比较的方式进行比对。

在判断两个节点是否相同时,vue 是通过对比两个节点的 tag 和 key 来判断的。

根节点的对比流程:具体来说,如果两个节点相同,就将旧节点的 dom 挂载到新节点上,然后把变更的属性更新到真实 DOM,接着进入子节点数组的对比流程。

如果根节点不相同,则进入创建的流程,并且移除掉旧的 DOM。

子节点数组的对比流程:对子节点数组创建两个指针,分别指向头和尾,然后不断向中间靠拢来进行对比。这样做是为了尽可能少的创建和销毁节点。如果子节点相同,则递归进入根节点的对比流程。如果不相同,则移动到合适的位置,在循环结束后执行增加和删除操作。

就按照上述流程一直递归遍历下去,直到完成整棵树的对比。


此外 Vue3 针对 diff 过程进行了 AOT 的优化:

  1. patchFlag

标记节点会发生更新的属性类型,diff 的时候直接找这个属性进行对比。

同时标记静态节点,diff 的时候直接跳过。

  1. 数结构打平

由于遍历数结构并不知道哪些节点是静态的,哪些节点是动态的,只能逐层遍历。

而 vue3 编译时直接将动态节点抽取成一个数组,diff 的时候直接遍历这个数组。

这大大减少了虚拟 DOM 需要遍历的节点数量,任何静态的部分都被高效略过。

介绍一下 react 的 hook 原理

react 的渲染流程分为 render 阶段和 commit 阶段

  • render 阶段执行 reconcile 把 vdom 转成 fiber

  • commit 阶段更新 dom,执行 effect 等副作用

commit 阶段分为 before mutation,mutation 和 layout 三个小阶段

  • hook 数据就保存在 fiber.memoizedState 的链表上,每个 hook 对应链表的一环

  • hook 的执行分为 mount 和 update 两个阶段,第一次会走 mount 创建 hook 链表,之后执行 update

其中 useRef, useCallback, useMemo 是为了读取链表的缓存数据,从而减少不必要的渲染。

而 useState 和 useEffect 则是和渲染流程相关

  • useEffect 在 render 阶段把 effect 放到 fiber 的 updateQueue 上,在 commit 阶段把 effect 取出来异步执行

  • useLayoutEffect 和 useEffect 类似,区别在于它是在 layout 阶段同步执行

  • useState 的 mountState 阶段返回 state 和 dispatch 函数,执行 dispatch 的时候会创建 hook.queue 记录更新,然后标记当前 fiber 和所有父节点的 fiber 的 lane 需要更新,然后调度下次渲染。

以上就是核心 hook 的原理

此外还提供了 useReducer、useImperativeHandler 等 hook

React 如何避免非预期的 diff 更新

ReactRouter 有哪些特点,以及优化手段

特点:

  • 组件化:通过组件的方式挂载路由,能够清晰地表达导航结构

  • 路由匹配:能够处理重复路由和嵌套路由

  • 导航切换:处理浏览器历史管理和 URL 的变更

  • 插件和中间件

  • 性能优化:避免不必要的渲染,以及处理大型项目的路由配置

React Router 在其设计和实现中采取了一些性能优化策略,以确保在大型应用中也能够高效地处理导航。以下是一些 React Router 进行性能优化的关键方面:

    1. 路由匹配的惰性加载:程序初始化时只加载当前活动路由和相关的代码,减少初始化的时间
    1. 使用 React.memo 和 PureComponent:ReactRouter 的内部组件使用了这两个 API 来减少不必要的重渲染
    1. 设计上避免不必要的重新渲染:主要表现在避免导航切换时避免触发整个组件树的重新渲染,只有与导航相关的组件会重新渲染。这需要合理地使用 React 的生命周期、hook 和 context 进行控制
    1. 合理使用 shouldComponentUpdate:React Router 中的一些核心组件可能会通过 shouldComponentUpdate 钩子来决定是否进行重新渲染。这有助于避免在某些情况下不必要的渲染操作。
    1. 提供 hooks 供开发者进行细粒度的监听:如 useParams、useLocation、useHistory
    1. 支持代码拆分和按需加载:React 的路由页面组件可以通过 React.lazy 结合动态 import 导入
    1. 路径匹配的性能优化:排序、path-to-regexp、轻量级策略匹配动态参数、缓存
  1. 减少不必要的 DOM 操作:给列表子节点设置 key 属性

  2. 精准控制更新条件:使用 componentShouldUpdate 或 React.memo 的第二个参数

  3. 避免频繁的全量更新:在某些关键节点使用 React.memo 和 PureComponent

  4. 手动优化复杂场景:比如使用 useMemo 来显示

  5. 长列表采用分页加载或虚拟滚动:减少每次更新的数据量,降低 diff 算法的负担

如何实现路由监听

react-router: 使用 useHistory 或者 useLocation

vue-router: 使用导航守卫,比如 beforeEach 或者 beforeRouteEnter 的钩子函数

通过监听路由变化,可以执行一些自定义操作,比如记录浏览历史、权限控制、埋点统计数据

Axios 的核心模块

优点:promise 化、统一配置、支持拦截器和错误处理

Axios 最核心的点还是 promise 化,通过使用 Promise,Axios 可以更方便地进行异步操作的处理,使得在使用 Axios 的时候,开发者能够使用 .then、.catch 等语法轻松处理请求和响应的结果。

主文件:axios.js,引入一系列模块,导出对外的 api

Axios 类:负责处理 http 请求和响应,封装了底层的 XHR 和 Node 的 http 模块,提供统一的 api,以及拦截器和转换器

defaults 模块:用于全局修改 axios 实例的配置

拦截器:可以定义请求和响应数据的拦截器,并且支持多个拦截器的执行

工具函数和辅助模块:utils 提供了一系列工具函数处理请求参数、url 和头部,helpers 模块提供辅助函数来处理配置和合并配置

适配器:adapters 提供了针对不同环境(浏览器、Node)的适配器,封装发送 HTTP 请求的底层逻辑

取消请求:提供 cancel 处理取消操作,或者 cancelToken 创建取消令牌

错误处理:创建错误对象的函数

前端工程化

在工程中如何实现模块的动态导入

首先代码中需要使用 import()来导入模块,它是一个类 promise 的异步方法

js
import('./module')
  .then(mod => {
    mod.someFunction()
  })
  .catch(err => {
    console.error('模块加载失败:', err)
  })
import('./module')
  .then(mod => {
    mod.someFunction()
  })
  .catch(err => {
    console.error('模块加载失败:', err)
  })

通过 babel 的转换实现,通常@babel/preset-env 中包含了这个特性,如果没有,可以通过配置@babel/plugin-syntax-dynamic-import来实现。

模块的动态导入编译成 CMD 或 IIFE 后,其原理是通过 JSONP 来加载分包资源

介绍一下 devOps,以及对 docker 和 k8s 的了解

DevOps(Development 和 Operations 的组合词)是一种软件开发运维相结合和实践方法,旨在通过加强开发团队与运维团队之间的协作和沟通,以及采用自动化工具和流程,来提高软件交付的效率、可靠性和速度。

DevOps 的核心流程包括:

  • 自动化

  • 持续集成与持续交付(CICD)

  • 监控与反馈

  • 基础设施即代码

Docker是一种容器化平台,它提供了创建、打包和分发容器的工具和技术。

  • Docker 通过使用容器镜像(Container Image)来封装应用程序和其依赖,使得应用程序能够在不同的环境中一致运行。

  • Docker 提供了一种简化和标准化应用程序交付的方法。交付的是镜像而不是代码。

Kubernetes,简称 k8s,是一个容器编排和管理系统。

  • 用于自动化容器化应用程序的部署、扩展、运维和维护

  • 可以管理大规模的容器集群

  • Kubernetes 提供了诸如负载均衡、服务发现、滚动升级等功能,使得在生产环境中更容易地管理分布式应用程序。

通常来说前端工程接入 devOps 的流程包括:

  • 版本控制和分支管理

  • 持续集成(CI)

    • 自动化测试:单元测试、端到端测试(e2e)、集成测试,常见工具有 jest、mocha、cypress

    • 自动化构建:通过 github action 或 gitlab-ci 触发仓库的构建流水线

  • 持续交付(CD)

    • 自动化构建和测试后,自动部署到预生产环境或者生产环境
  • 环境管理:使用容器化技术(Docker)创建一致的环境,确保环境的一致性。

  • 监控与日志:集成监控与日志系统,有助于发现和解决问题。

  • 文档和知识分享:沉淀文档,使团队成员可以理解流程。

Node.js

node 单线程容易崩溃,怎么维护服务的?

  1. 异常处理,减少错误导致的阻塞

可以使用 try...catch 块来捕获同步代码中的异常

  1. 使用进程管理工具

如 PM2 或 Forever,可以帮助监控 Node.js 进程的状态,并在进程崩溃时自动重启

  1. 健康检查

实现定期的健康检查,确保服务正常运行

  1. 集群化

使用集群化技术,将服务部署在多个 Node.js 进程中,通过负载均衡分配请求

此外还有日志记录、监控和警报、代码审查和测试、定期更新等措施

pm2 怎么同时启动多个服务,能不能做负载均衡

  1. 可以通过-i 参数设置实例数量,如果为 max 则根据核心数自动计算,也可以自己指定
shell
pm2 start index.js -i max
pm2 start index.js -i max
  1. 可以做负载均衡,pm2 提供了多种负载均衡的模式,例如 cluster 模式,会创建多个进程来处理请求,从而实现负载均衡。

可以配置一个ecosystem.config.js,通过exec_mode指定cluster模式,通过instances指定实例数量

js
module.exports = {
  apps: [
    {
      name: 'app1',
      script: 'app1.js',
      instances: 2,
      exec_mode: 'cluster'
    },
    {
      name: 'app2',
      script: 'app2.js',
      instances: 2,
      exec_mode: 'cluster'
    }
  ]
}
module.exports = {
  apps: [
    {
      name: 'app1',
      script: 'app1.js',
      instances: 2,
      exec_mode: 'cluster'
    },
    {
      name: 'app2',
      script: 'app2.js',
      instances: 2,
      exec_mode: 'cluster'
    }
  ]
}

然后通过这个命令启动服务

shell
pm2 start ecosystem.config.js
pm2 start ecosystem.config.js

pm2 开启多进程的实现原理

本质上是如何通过 node 开启多进程

node 可以通过cluster模块来开启多进程,实现并行处理请求或任务

cluster模块允许一个主进程创建多个工作进程, 并且共享端口

js
const cluster = require('cluster')
const os = require('os')

if (cluster.isMaster) {
  // 主进程代码
  console.log(`Master process is running with process ID ${process.pid}`)

  // 获取 CPU 核心数量
  const numCPUs = os.cpus().length

  // 创建工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork()
  }

  // 监听工作进程退出事件,如果有工作进程退出,则创建新的工作进程
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker process ${worker.process.pid} died`)
    // 重新创建工作进程
    cluster.fork()
  })
} else {
  // 工作进程代码
  console.log(`Worker process is running with process ID ${process.pid}`)

  // 在这里写你的应用程序逻辑,例如启动 Express 服务器

  // 示例:监听端口
  const express = require('express')
  const app = express()

  app.get('/', (req, res) => {
    res.send(`Hello from worker process ${process.pid}`)
  })

  app.listen(3000, () => {
    console.log('Server is running on port 3000')
  })
}
const cluster = require('cluster')
const os = require('os')

if (cluster.isMaster) {
  // 主进程代码
  console.log(`Master process is running with process ID ${process.pid}`)

  // 获取 CPU 核心数量
  const numCPUs = os.cpus().length

  // 创建工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork()
  }

  // 监听工作进程退出事件,如果有工作进程退出,则创建新的工作进程
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker process ${worker.process.pid} died`)
    // 重新创建工作进程
    cluster.fork()
  })
} else {
  // 工作进程代码
  console.log(`Worker process is running with process ID ${process.pid}`)

  // 在这里写你的应用程序逻辑,例如启动 Express 服务器

  // 示例:监听端口
  const express = require('express')
  const app = express()

  app.get('/', (req, res) => {
    res.send(`Hello from worker process ${process.pid}`)
  })

  app.listen(3000, () => {
    console.log('Server is running on port 3000')
  })
}

主进程负责创建多个工作进程,每个工作进程都可以独立处理请求。

但需要注意的是:工作进程不共享内存,他们的状态的相互独立的

我当时没有答出来,如果答出来了,可能会被追问:怎么进行进程之间的通信

如何进行进程之间的通信

  1. 进程间消息传递(IPC)

使用 child_process 模块创建子进程,通过标准输入输出通信

js
const { fork } = require('child_process')
const child = fork('child.js')

child.on('message', message => {
  console.log(`Message from child: ${message}`)
})

child.send('Hello from parent')

// 子进程 (child.js)
process.on('message', message => {
  console.log(`Message from parent: ${message}`)
  process.send('Hello from child')
})
const { fork } = require('child_process')
const child = fork('child.js')

child.on('message', message => {
  console.log(`Message from child: ${message}`)
})

child.send('Hello from parent')

// 子进程 (child.js)
process.on('message', message => {
  console.log(`Message from parent: ${message}`)
  process.send('Hello from child')
})
  1. 共享内存

通过worker_threads模块创建线程,这些线程可以共享内存

js
// 主线程
const { Worker, isMainThread, workerData } = require('worker_threads')
// ...省略了关键代码...
// 最终是通过share buffer和workerData共享内存的
const sharedBuffer = new SharedArrayBuffer(4)
const worker = new Worker(__filename, { workerData: sharedBuffer })
// 主线程
const { Worker, isMainThread, workerData } = require('worker_threads')
// ...省略了关键代码...
// 最终是通过share buffer和workerData共享内存的
const sharedBuffer = new SharedArrayBuffer(4)
const worker = new Worker(__filename, { workerData: sharedBuffer })
  1. 通过网络通信

例如 http 或者 websocket,在不同的进程间进行通信

网络

http1.1 和 http2.0 的区别

https 和 http 的区别

https 就是在 http 的基础上加入了 ssl/tsl 验证

同时在传输过程中数据是加密的,这个过程中如果请求被运营商劫持了,没有公钥解密就不知道数据的真实内容,也无法篡改

它的流程如下:

1. TCP三次握手
2. 客户端向服务端请求数字证书
3. 客户端获取到数字证书,拿到里面的公钥生成摘要,然后和CA颁发的数字证书中的公钥进行比对确认**公钥无误**
4. 客户端向服务端请求会话使用的key,服务端生成临时key使用私钥加密后发给客户端,客户端使用第3步的公钥解密得到key
5. 客户端和服务端用前面的key开始对称加密通信
1. TCP三次握手
2. 客户端向服务端请求数字证书
3. 客户端获取到数字证书,拿到里面的公钥生成摘要,然后和CA颁发的数字证书中的公钥进行比对确认**公钥无误**
4. 客户端向服务端请求会话使用的key,服务端生成临时key使用私钥加密后发给客户端,客户端使用第3步的公钥解密得到key
5. 客户端和服务端用前面的key开始对称加密通信

关于中间人攻击,就是指请求被代理劫持了,代理重新生成一组公钥和私钥,分别和客户端和服务端交换,客户端拿到的是中间人伪造的公钥,真正的公钥保存在中间人那里。而 CA 的存在相当于公证处,就是服务器把公钥提交给权威机构 CA,这样客户端可以从 CA 那里拿到可信的公钥进行比对,确保公钥没有被篡改过。

https 解决了三大问题:

窃听风险、篡改风险、冒充风险

对 JWT 的理解

JWT 的组成方式是 HEADER.PAYLOAD.signature,它由服务端生成传给客户端,客户端将它保存在 localStorage 中,每次请求通过Access-TokenAuthrization带上,用于身份验证。

HEADER: 头部信息的 base64,通常包含 rsa 类型

PAYLOAD: 数据内容的 base64,比如用户 ID,过期时间

signature: HEADERPAYLOAD通过 rsa 算法加密得到的签名

其中 rsa 的密钥只存在服务端,当服务器接收到传过来的签名后,会根据HEADERPAYLOAD重新计算出signature和客户端传过来的值进行比较,确认信息没有被篡改过。

JWT 可以弥补 http 无状态的问题,用于替代 cookie 的方案,用于用户身份的验证,同时也可以用于跨域场景。

token 建议保存到 localStorage 上,而不是 cookie 上,是因为 cookie 是明文传输的,容易被劫持,并且 cookie 通常有过期时间。

浏览器

事件流

三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。

DOM 0 级事件:onclick

DOM 2 级事件:addEventListener

DOM 3 级事件:增加了事件类型,如 UI 事件、焦点事件、鼠标事件

回流和重绘

回流发生在布局阶段,重绘发生在绘制阶段,回流一定会导致重绘

触发回流的属性:宽高、位置、边距、边框等

触发回流的操作:

  1. 通过 DOM 访问元素的布局信息(如:offsetTop)或者使用了 getComputedStyle

  2. 修改样式表,通过 js 修改了元素样式

  3. 刷新页面(初始化渲染)

回流的影响范围:被修改的元素和所有的子元素,以及它们的兄弟元素,甚至影响整棵渲染树

触发重绘的操作:

修改了颜色、边框、文本属性、透明度、transform 等不影响布局的属性

算法

时间复杂度的计算方式

  1. 确定基本操作:找出占用大部分运行时间的代码,通常是循环、递归、条件

  2. 确定影响执行次数的函数:基本操作在最坏情况下的执行次数,通常是输入规模 n 的函数

  3. 用大 O 表示函数:得到的函数用 O 表示,找到一个与之同阶的函数,忽略常数系数

  4. 去掉不必要的项:通常只关注增长最快的项,去掉次要项

常见的复杂度:

  1. 常数复杂度 O(1)

  2. 线性复杂度 O(n): 运行时间和输入规模成正比,通常是一个 for

  3. 平方复杂度 O(n^2): 运行时间与输入规模的平方成正比,通常是 2 个 for

  4. 递归的复杂度 O(n)或 O(n^2): 通常需要解递归方程得到问题的增长趋势

项目相关

其它

最近关注哪些新技术

  1. monorepo + pnpm workspace: 未来包管理的最佳实践

  2. 桌面端应用实践:Electron 到 Tauri,PWD 的回归

2022 年 6 月诞生的新项目,底层使用了 Rust,目前同时支持网页端和桌面端,未来计划支持移动端 app

  • electron 的问题:由于塞入 Chromium 和 nodejs,一个什么也不做的 electron 项目压缩后也大概要 50m。

  • 其次,electron 还有个问题:内存消耗过大,因为 Chromium 本身就很吃内存,再加上提供操作系统访问能力的 nodejs,有很大的内存消耗,对小工具类的项目不友好。

  • tauri 看了一下,不再塞入 Chromium 和 nodejs,前端使用操作系统的 webview,后端和操作系统集成这块使用 rust 实现,理论上应该比 nodejs 要精简高效。

  • Vue 的核心成员通过 touri 创建了 guijs 的项目用于管理依赖

目前最近我也是在用 touri 开发自己的音乐客户端项目

  1. 随着浏览器对 es module 的支持,未来轻量级的应用可能不需要进行本地工程化,开发过程可以搬到线上,直接引用 unpkg 包直接进行开发,那么公司也可以自建线上开发的工作台,将打包工具和公司已有资源全部整合起来,实现从开发到构建部署的完整链路工作流,前端程序员只需要关注业务逻辑本身