知识点摘要大纲
一个认知
不能为了背八股文去梳理知识,知识始终是为了建立认知和解决问题而存在的
梳理模板
是什么:专业的概念
为什么:解决了什么问题,想达到什么目的
怎么做:原理
应用场景:这个问题涉及到的一些实际场景和业务
优缺点:好处和隐患,如何衡量
还有更好的方案吗
WARNING
待完成:基于这个原则,以下的所有问题,都需要重新梳理
语言原理
JS
0.1+0.2==0.3 吗,为什么?
是什么:小数进制转换和对阶运算导致的精度丢失
怎么做:通过字符串自己实现运算的进位逻辑,或者在小数位已知的前提下转换成整数运算。
更好的方案:可以借助社区成熟的第三方包实现
应用场景:价格的计算
JS 数据类型
基本类型:Number, Boolean, String, null, undefined, symbol, BigInt
引用类型:Object(对象子类型 Array, Function, Map, Set)
有什么区别:基本类型在栈内存上保存数据,引用类型在栈内存上保存指针,指向堆内存
栈内存和堆内存的区别在那些场景下有体现:
变量赋值,如果等号右边的变量是基本类型,相当于重新创建,如果是引用类型,则是指针的引用,对新变量的改动会影响到同一条数据
函数传参,基本类型和引用类型作为函数参数传递的区别,基本类型会重新创建,引用类型会传递指针
引用类型的赋值如何解决:浅拷贝和深拷贝,有哪些方法:Object.asign 浅拷贝,手动实现 cloneDeep 深拷贝(也可以借助 message 和 JSON.parse)
如何实现对象深拷贝?
是什么:深拷贝的本质是在内存上重新创建原对象的所有数据和指针,其中也包括了函数的声明
怎么做:
方案一:递归遍历对象,object 类型执行递归,基本类型直接赋值,其它引用类型调用它的构造器创建
方案二:使用 JSON.parse 和 JSON.strigify,仅针对内部的基本类型,无法处理函数类型和循环引用问题
方案三:使用 MessageChannel 放到 event 传递,js 会自动进行深拷贝
应用场景:纯函数构造新对象
new 一个函数发生了什么?
是什么:
new 关键字可以用来调用函数的[[Constructor]],产生它的实例
在函数的内部有[[Call]]和[[Constructor]]两个指针,普通的调用是 Call,用 new 调用的时候是 Constructor,两种情况有差异
通过 new 调用的时候会有以下 4 个步骤:
创建一个新对象
新对象的
__proto__
指向构造函数prototype
所引用的对象新对象绑定函数的
this
如果函数没有返回值,就直接返回这个新对象
用伪代码表示如下:
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。
应用场景:
给对象挂载一个临时的 key
全局定义的唯一值
对象的 symbol['iterator']用于注册迭代器使用
闭包是什么?如何产生?使用场景
是什么:
闭包指的是有权访问另一个函数作用域中的变量的函数。
::: MDN 定义:闭包是指那些可以访问自由变量的函数。 :::
何为自由变量,指的是在函数中使用,但既不是函数也不是函数局部变量的变量。
因此更权威的定义是:
闭包 = 函数 + 函数能访问的自由变量
闭包 = 函数 + 函数能访问的自由变量
从理论角度:
所有的函数在定义的时候就将执行上下文数据保存起来了,它的外部作用域的变量就变成了自由变量。
从实践角度,以下的两种情况形成了闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
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 依然会让它的变量活跃在内存中,成为自由变量。
闭包的使用场景:
封装工具函数,返回一个新函数(例如防抖和节流)
在模块化的开发中,所有的文件都是在函数中运行,模块导出了方法,被方法访问到的模块顶层的变量,都会形成闭包,可以用于模块的状态管理和通信
什么是作用域链?作用域链是怎么产生的?
是什么:
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,因为作用域在定义的时候确定了
词法作用域:函数的作用域基于函数创建的位置
再看个例子:
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 的优先级谁更高?
如下例子:
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
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 引入的块级变量声明方式
具体表现:
上下文环境不同,let 在词法环境,var 在变量环境,var 在全局或者函数作用域内,而 let 在块级作用域内(最明显的体现是 if 和 for 产生的花括号,let 不能在花括号外访问)
全局作用域下的 var 会自动变成 window 的属性
var 声明的变量在作用域内会自动提升(提前调用 undefined),let 声明的变量有暂时性死区(提前调用报错)
怎么理解原型链?
原型是什么:在 JS 环境中一切皆对象,每个对象 A 上都有一个[[prototype]]
属性(可以通过__proto__
访问),指向另一个对象 B,那么 B 就是 A 的原型。
原型如何挂载:通过 new 方法调用 Function,就会把函数的 prototype 属性挂载到对象的proto指针上
判断原型
isPrototypeOf
, Object.getPrototypeOf
, instanceof
手写instanceof
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 | 实例和原型上 |
应用场景:
因为原型链的存在,Array 可以调用 Object 原型上的方法
可以借助原型链实现对象的继承
因为原型的动态性,可以借助原型链挂载全局方法
防抖和节流,手写代码
防抖:短时间内重复调用,只会执行最后一次
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 秒最多执行一次
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 用了什么设计模式
观察者模式:then 方法的回调充当观察者
链式调用:更清晰地表达和处理多个步骤,消灭回调地狱
手写数组 reduce 方法
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、未考虑基本类型
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
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 像素边框看起来比较粗
可以采用的方案有:
使用物理像素单位,例如 px、pt,而不是逻辑像素单位 rem、em
使用
border-width: thin;
(可能有兼容性问题)使用伪元素和缩放:通过设置
:after
和transform: scale(0.5);
实现 0.5px 的效果使用
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 的优化:
- patchFlag
标记节点会发生更新的属性类型,diff 的时候直接找这个属性进行对比。
同时标记静态节点,diff 的时候直接跳过。
- 数结构打平
由于遍历数结构并不知道哪些节点是静态的,哪些节点是动态的,只能逐层遍历。
而 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 进行性能优化的关键方面:
- 路由匹配的惰性加载:程序初始化时只加载当前活动路由和相关的代码,减少初始化的时间
- 使用 React.memo 和 PureComponent:ReactRouter 的内部组件使用了这两个 API 来减少不必要的重渲染
- 设计上避免不必要的重新渲染:主要表现在避免导航切换时避免触发整个组件树的重新渲染,只有与导航相关的组件会重新渲染。这需要合理地使用 React 的生命周期、hook 和 context 进行控制
- 合理使用 shouldComponentUpdate:React Router 中的一些核心组件可能会通过 shouldComponentUpdate 钩子来决定是否进行重新渲染。这有助于避免在某些情况下不必要的渲染操作。
- 提供 hooks 供开发者进行细粒度的监听:如 useParams、useLocation、useHistory
- 支持代码拆分和按需加载:React 的路由页面组件可以通过 React.lazy 结合动态 import 导入
- 路径匹配的性能优化:排序、path-to-regexp、轻量级策略匹配动态参数、缓存
减少不必要的 DOM 操作:给列表子节点设置 key 属性
精准控制更新条件:使用 componentShouldUpdate 或 React.memo 的第二个参数
避免频繁的全量更新:在某些关键节点使用 React.memo 和 PureComponent
手动优化复杂场景:比如使用 useMemo 来显示
长列表采用分页加载或虚拟滚动:减少每次更新的数据量,降低 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 的异步方法
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 单线程容易崩溃,怎么维护服务的?
- 异常处理,减少错误导致的阻塞
可以使用 try...catch 块来捕获同步代码中的异常
- 使用进程管理工具
如 PM2 或 Forever,可以帮助监控 Node.js 进程的状态,并在进程崩溃时自动重启
- 健康检查
实现定期的健康检查,确保服务正常运行
- 集群化
使用集群化技术,将服务部署在多个 Node.js 进程中,通过负载均衡分配请求
此外还有日志记录、监控和警报、代码审查和测试、定期更新等措施
pm2 怎么同时启动多个服务,能不能做负载均衡
- 可以通过-i 参数设置实例数量,如果为 max 则根据核心数自动计算,也可以自己指定
pm2 start index.js -i max
pm2 start index.js -i max
- 可以做负载均衡,pm2 提供了多种负载均衡的模式,例如 cluster 模式,会创建多个进程来处理请求,从而实现负载均衡。
可以配置一个ecosystem.config.js
,通过exec_mode
指定cluster
模式,通过instances
指定实例数量
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'
}
]
}
然后通过这个命令启动服务
pm2 start ecosystem.config.js
pm2 start ecosystem.config.js
pm2 开启多进程的实现原理
本质上是如何通过 node 开启多进程
node 可以通过cluster
模块来开启多进程,实现并行处理请求或任务
cluster
模块允许一个主进程创建多个工作进程, 并且共享端口
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')
})
}
主进程负责创建多个工作进程,每个工作进程都可以独立处理请求。
但需要注意的是:工作进程不共享内存,他们的状态的相互独立的
我当时没有答出来,如果答出来了,可能会被追问:怎么进行进程之间的通信
如何进行进程之间的通信
- 进程间消息传递(IPC)
使用 child_process
模块创建子进程,通过标准输入输出通信
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')
})
- 共享内存
通过worker_threads
模块创建线程,这些线程可以共享内存
// 主线程
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 })
- 通过网络通信
例如 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-Token
或Authrization
带上,用于身份验证。
HEADER
: 头部信息的 base64
,通常包含 rsa
类型
PAYLOAD
: 数据内容的 base64
,比如用户 ID,过期时间
signature
: HEADER
和PAYLOAD
通过 rsa
算法加密得到的签名
其中 rsa
的密钥只存在服务端,当服务器接收到传过来的签名后,会根据HEADER
和PAYLOAD
重新计算出signature
和客户端传过来的值进行比较,确认信息没有被篡改过。
JWT 可以弥补 http
无状态的问题,用于替代 cookie 的方案,用于用户身份的验证,同时也可以用于跨域场景。
token
建议保存到 localStorage
上,而不是 cookie
上,是因为 cookie
是明文传输的,容易被劫持,并且 cookie
通常有过期时间。
浏览器
事件流
三个阶段:事件捕获阶段、处于目标阶段、事件冒泡阶段。
DOM 0 级事件:onclick
DOM 2 级事件:addEventListener
DOM 3 级事件:增加了事件类型,如 UI 事件、焦点事件、鼠标事件
回流和重绘
回流发生在布局阶段,重绘发生在绘制阶段,回流一定会导致重绘
触发回流的属性:宽高、位置、边距、边框等
触发回流的操作:
通过 DOM 访问元素的布局信息(如:offsetTop)或者使用了 getComputedStyle
修改样式表,通过 js 修改了元素样式
刷新页面(初始化渲染)
回流的影响范围:被修改的元素和所有的子元素,以及它们的兄弟元素,甚至影响整棵渲染树
触发重绘的操作:
修改了颜色、边框、文本属性、透明度、transform 等不影响布局的属性
算法
时间复杂度的计算方式
确定基本操作:找出占用大部分运行时间的代码,通常是循环、递归、条件
确定影响执行次数的函数:基本操作在最坏情况下的执行次数,通常是输入规模 n 的函数
用大 O 表示函数:得到的函数用 O 表示,找到一个与之同阶的函数,忽略常数系数
去掉不必要的项:通常只关注增长最快的项,去掉次要项
常见的复杂度:
常数复杂度 O(1)
线性复杂度 O(n): 运行时间和输入规模成正比,通常是一个 for
平方复杂度 O(n^2): 运行时间与输入规模的平方成正比,通常是 2 个 for
递归的复杂度 O(n)或 O(n^2): 通常需要解递归方程得到问题的增长趋势
项目相关
其它
最近关注哪些新技术
monorepo + pnpm workspace: 未来包管理的最佳实践
桌面端应用实践: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 开发自己的音乐客户端项目
- 随着浏览器对 es module 的支持,未来轻量级的应用可能不需要进行本地工程化,开发过程可以搬到线上,直接引用 unpkg 包直接进行开发,那么公司也可以自建线上开发的工作台,将打包工具和公司已有资源全部整合起来,实现从开发到构建部署的完整链路工作流,前端程序员只需要关注业务逻辑本身