2023前端面试

146 阅读23分钟

原理

事件循环

1、浏览器进程

进程是指在计算机中运行的一个程序实例,每个进程都有自己的内存空间和系统资源。

线程是进程中的一个执行单元,是进程的实际执行者。一个进程可以包含多个线程,它们共享进程的内存空间和系统资源。

现代浏览器通常是多进程的。浏览器将不同的任务分配给不同的进程来执行,每个进程都是相互独立的,拥有自己的内存空间。

常见的浏览器进程:

  • 主进程(Main Process):也称为浏览器进程或渲染进程管理器,负责协调和控制其他进程的工作。它负责创建和销毁其他进程,并提供浏览器的用户界面。
  • 渲染进程(Renderer Process):每个标签页通常都有一个独立的渲染进程,负责解析和渲染网页内容。它将HTML、CSS和JavaScript转换为可视化的网页,并将其显示在屏幕上。
  • 网络进程(Network Process):负责处理网络请求和响应。它负责下载网页内容、图像、脚本等资源,并将其提供给渲染进程。
  • GPU进程(GPU Process):负责处理浏览器中的图形操作,如绘制网页内容、执行CSS动画等。将这些任务交给GPU进程可以提高渲染的性能。
  • 插件进程(Plugin Process):如果浏览器使用了插件(如Flash Player),每个插件通常都运行在独立的进程中,以增加安全性和稳定性。

每个进程都在操作系统级别独立运行,通过进程间通信(IPC)机制进行通信和协作。

2、渲染进程

渲染进程会包含如下线程:

  • 主线程(Main Thread):主线程负责处理用户输入、执行JavaScript代码以及管理其他线程。事件循环在主线程上运行,它负责监听和分发各种事件,如用户输入事件、计时器事件、网络请求完成事件等。
  • 渲染线程(Render Thread):渲染线程负责解析和渲染网页内容。它将HTML、CSS和JavaScript转换为可视化的网页,并将其显示在屏幕上。渲染线程会将渲染结果发送给主线程,以便主线程更新显示。
  • 合成线程(Compositor Thread):合成线程负责将渲染线程生成的图像进行合成和绘制。它将各个图层合成为最终的屏幕图像,并将其显示在屏幕上。合成线程与主线程协作,确保渲染结果按正确的顺序显示在屏幕上。

事件循环

  1. 在最开始的时候,渲染主线程进入一个无线循环
  2. 每次循环会检查任务队列,如果有任务,则从任务队列中取出第一个任务执行,执行完后进入下一次循环。
  3. 其他线程可以向任务队列末尾添加任务。

任务队列优先级

  • 任务有不同类型,同一个类型任务会添加到同一个队列中,意味着任务队列有多个。
  • 在一次事件循环中,浏览器根据情况从不同队列中取出任务执行。
  • 浏览器必须准备好一个微任务队列,该队列的执行优先级最高。

将任务添加到微任务队列可以使用Promise、MutationObserver等。

例:

// 将 fn 加入微任务队列
// 1、Promise
Promise.resolve().then(fn)

// 2、MutationObserver
// 当document对象的子节点发生变化时,触发回调函数,将任务添加到微任务队列中。
const observer = new MutationObserver(fn);
observer.observe(document, { childList: true });

// 3、queueMicrotask()
queueMicrotask(fn);

浏览器渲染原理

在通过网络获取到 HTML 之后,一个渲染任务被加入到任务队列中。浏览器通过事件循环从队列中取出该任务,执行渲染过程:

  1. 解析 HTML:解析 HTML 文本,生成DOM树CSSOM树

    (1)CSSOM树大致结构如下:根节点 StyleSheetList,该节点的每个子节点即代表一个样式表对象CSSStyleSheet,如内部样式表style标签,外部样式表,行内样式表,浏览器默认样式表等。

    (2)因为执行js可能会更改DOM树,在解析到 script 标签时,会停止HTML的解析,转而下载该js文件并执行全局代码,完成后再继续解析HTML,因此 js 的执行会阻塞HTML的解析过程。

  2. 样式计算:根据前一步生成的DOM树和CSSOM树计算每一个DOM元素的样式,并分别得到它们的计算后(Computed)的样式(可在浏览器控制台的Computed选项中查看),即每个元素都会包含所有样式属性,且属性值的单位为绝对单位(rem -> px,red -> rgb(255,0,0)等)。初始的时候,所有元素的所有样式属性都没有值,需要经过计算得到值。计算过程如下:

    (1)确定声明值:对比作者样式表和浏览器默认样式表,找到没有冲突的样式,直接作为计算后的样式。

    (2)层叠:通过层叠规则确定有冲突的样式的值。

    第一步:比较重要性,带有 !important 的作者样式 > 带有 !important 的默认样式 > 作者样式 > 默认样式。如果作者样式表中存在冲突,继续第二步。

    第二步:比较特殊性,对每个样式属性分别计数:

    是否内联样式id选择器数量类、伪类、属性选择器数量元素、伪元素选择器数量
    是1,否0例如一个:1如一个类选择器、一个伪类选择器:2如一个元素选择器、一个伪元素选择器:2

    例如:

    .bold {
      font-size: 40px; // 特殊性值为 0010
    }
    h1 {
      font-size: 26px; // 特殊性值为 0001
    }
    #div div h1.bold:first-child {
      font-size: 34px; // 特殊性值为 0122
    }
    

    从前往后比,如:0132 > 0032。如果仍存在冲突,继续第三步。

    第三步:比较顺序。靠后的覆盖靠前的,如:

    div {
      font-size: 23px;
      font-size: 30px; // 最终样式
    }
    

    (3)继承:经过前两步计算后仍然没有值的属性,若可继承,则使用继承值。可继承的CSS属性:字体相关属性如font-size、font-weight,文本相关属性如color等等。

    (4)使用默认值:经过前三步仍然没有值的属性,使用默认值。CSS中所有属性都有一个默认值,如

    background-color: transparent;
    text-align: left;
    ...
    
  3. 布局:遍历DOM树,计算每个节点的几何信息(宽、高和相对包含块位置等),得到布局树。一般DOM树和布局树并非一一对应,比如某个DOM元素display:none没有几何信息,则布局树中将没有该元素;比如使用了伪元素选择器,虽然在DOM树中不存在,但它们拥有几何信息,所以会生成到布局树中;还有匿名行盒、匿名块盒等都只存在于布局树中。

  4. 分层:主线程会使用一套策略对布局树进行分层。分层的好处在于,某一个层改变后,仅会对该层进行后续处理,从而提升效率。will-change属性可以很大程度影响分层结果。

  5. 绘制:为每一层生成绘制指令(类似canvas画图)集,用于描述该层如何绘制。

    (以下并非在渲染主线程中执行)

  6. 分块:主线程将每个图层信息交给合成线程,它会对每个图层进行分块,划分为更多小区域,这里它会从线程池取多个线程来完成分块工作。

  7. 光栅化:合成线程将块信息交给GPU进程,GPU会开启多个线程完成光栅化,将每个块变成位图,优先处理靠近视口的块。

  8. :合成线程拿到每个层、每个块的位图后,生成一个个指引(quad)信息,并提交给GPU进程,由GPU硬件完成屏幕成像。

回流(reflow)会使渲染主线程从“样式计算”步骤开始往后执行,而重绘(repaint)一般会从“绘制”步骤往后执行,所以reflow一定引起repaint。

使用transform变换效率高的原因:不由渲染主线程执行,只涉及后几个步骤。例:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .ball {
      width: 200px;
      height: 200px;
      border-radius: 50%;
      background-color: red;
      margin-bottom: 30px;
    }
    .ball1 {
      animation: move1 1s alternate infinite;
    }
    .ball2 {
      position: fixed;
      left: 0;
      animation: move2 1s alternate infinite;
    }
    @keyframes move1 {
      to {
        transform: translate(100px);
      }
    }
    @keyframes move2 {
      to {
        left: 100px;
      }
    }
  </style>
</head>
<body>
  <button onclick="makeDelay()">死循环3s</button>
  <div class="ball1 ball"></div>
  <div class="ball2 ball"></div>
  <script>
    function delay(duration) {
      const start = Date.now();
      while ((Date.now() - start) < duration) {}
    }
    function makeDelay() {
      delay(3000)
    }
  </script>
</body>
</html>

网络

http和https

  • http是明文传输,https是加密传输。
  • http默认端口80,https默认端口443。

http1.0、http1.1、http2.0协议的区别

http1.0

每次请求和响应完毕后都会销毁 TCP 连接,同时规定前一个响应完成后才能发送下一个请求。这样做有两个问题:

1、无法复用连接

每次请求都要创建新的 TCP 连接,完成三次握手和四次挥手,网络利用率低

2、队头阻塞

如果前一个请求被某种原因阻塞了,会导致后续请求无法发送。

http1.1

http1.1 是 http1.0 的改进版,它做出了以下改进:

  • 长连接

http1.1 允许在请求时增加请求头connection:keep-alive,这样便允许后续的客户端请求在一段时间内复用之前的 TCP 连接

  • 管道化

基于长连接的基础,管道化可以不等第一个请求响应继续发送后面的请求,但响应的顺序还是按照请求的顺序返回。

  • 缓存处理

新增响应头 cache-control,用于实现客户端缓存。

  • 断点传输

在上传/下载资源时,如果资源过大,将其分割为多个部分,分别上传/下载,如果遇到网络故障,可以从已经上传/下载好的地方继续请求,不用从头开始,提高效率

http2.0

http2.0 进一步优化了传输效率,它主要有以下改进:

  • 二进制分帧

将传输的消息分为更小的二进制帧,每帧有自己的标识序号,即便被随意打乱也能在另一端正确组装

  • 多路复用

基于二进制分帧,在同一域名下所有访问都是从同一个 tcp 连接中走,并且不再有队头阻塞问题,也无须遵守响应顺序

  • 头部压缩

http2.0 通过字典的形式,将头部中的常见信息替换为更少的字符,极大的减少了头部的数据量,从而实现更小的传输量

  • 服务器推送

http2.0 允许服务器直接推送消息给客户端,无须客户端明确的请求

多路复用

为什么 HTTP1.1 不能实现多路复用

HTTP/1.1 不是二进制传输,而是通过文本进行传输。由于没有流的概念,在使用并行传输(多路复用)传递数据时,接收端在接收到响应后,并不能区分多个响应分别对应的请求,所以无法将多个响应的结果重新进行组装,也就实现不了多路复用。

http2 的多路复用

在 HTTP/2 中,有两个非常重要的概念,分别是帧(frame)和流(stream)。 帧代表着最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流。 多路复用,就是在一个 TCP 连接中可以存在多条流。换句话说,也就是可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的队头阻塞问题,极大的提高传输性能。

Http 状态码 301 和 302 的应用场景

301 表示永久重定向,302 表示临时重定向。

如果浏览器收到的是 301,则会缓存重定向的地址,之后不会再重新请求服务器,直接使用缓存的地址请求,这样可以减少请求次数。但如果浏览器收到的是 302,则不会缓存重定向地址,浏览器将来会继续以原有地址请求。

因此,301 适合地址永久转移的场景,比如域名变更;而 302 适合临时转移的场景,比如首页临时跳转到活动页

文件上传如何做断点续传

客户端将文件的二进制内容进行分片,每片数据按顺序进行序号标识,上传每片数据时同时附带其序号。服务器接收到每片数据时,将其保存成一个临时分片文件,并记录每个文件的 hash 和序号。

若上传中止,将来再次上传时,可以向服务器索要已上传的分片序号,客户端仅需上传剩余分片即可。

当全部分片上传完成后,服务器按照分片的顺序组装成完整的文件,并删除分片文件。

SSL 和 TLS

它们都是用于保证传输安全的协议,介于传输层和应用层之间,TLS 是 SSL 的升级版。

它们的基本流程一致:

  1. 客户端向服务器端索要公钥,并使用数字证书验证公钥。
  2. 客户端使用公钥加密会话密钥,服务端用私钥解密会话密钥,于是得到一个双方都认可的会话密钥
  3. 传输的数据使用会话密钥加密,然后再传输,接收消息方使用会话密钥解密得到原始数据

GET 和 POST 的区别

  • 浏览器在发送 GET 请求时,不会附带请求体
  • GET 请求的传递信息量有限,适合传递少量数据;POST 请求的传递信息量是没有限制的,适合传输大量数据。
  • GET 请求只能传递 ASCII 数据,遇到非 ASCII 数据需要进行编码;POST 请求没有限制
  • 大部分 GET 请求传递的数据都附带在 path 参数中,能够通过分享地址完整的重现页面,但同时也暴露了数据,若有敏感数据传递,不应该使用 GET 请求,至少不应该放到 path 中
  • 刷新页面时,若当前的页面是通过 POST 请求得到的,则浏览器会提示用户是否重新提交。若是 GET 请求得到的页面则没有提示。
  • GET 请求的地址可以被保存为浏览器书签,POST 不可以

webSocket 与传统的 http 有什么优势

当需要前后端实时通信时,过去我们往往使用两种方式完成:

第一种是短轮询,即客户端每隔一段时间就向服务器发送消息,询问有没有新的数据

第二种是长轮询,发起一次请求询问服务器,服务器可以将该请求挂起,等到有新消息时再进行响应。响应后,客户端立即又发起一次请求,重复整个流程。

无论是哪一种方式,都暴露了 http 协议的弱点,即响应必须在请求之后发生,服务器是被动的,无法主动推送消息。而让客户端不断的发起请求又白白的占用了资源。

websocket 的出现就是为了解决这个问题,它利用 http 协议完成握手之后,就可以与服务器建立持久的连接,服务器可以在任何需要的时候,主动推送消息给客户端,这样占用的资源最少,同时实时性也最高。

基础

css

瀑布流布局

使用CSS多列布局实现,元素会优先从上到下排列:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container {
      column-count: 3;
      column-gap: 20px;
    }
    .item {
      break-inside: avoid;
      margin-bottom: 20px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
    <div class="item">4</div>
    <div class="item">5</div>
    <div class="item">6</div>
    <div class="item">7</div>
    <div class="item">8</div>
    <div class="item">9</div>
  </div>
  <script>
    const items = document.getElementsByClassName('item')
    function getH() {
      const r = Math.ceil(Math.random() * 200)
      return r + 100
    }
    for(const e of items) {
      e.style.height = getH() + 'px'
    }
  </script>
</body>
</html>

使用grid布局实现,元素会优先从左到右排列:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      column-gap: 10px;
      grid-auto-rows: 1px;
    }
    .item {
      grid-row-start: auto;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
    <div class="item">4</div>
    <div class="item">5</div>
    <div class="item">6</div>
    <div class="item">7</div>
    <div class="item">8</div>
    <div class="item">9</div>
  </div>
  <script>
    const items = document.getElementsByClassName('item')
    const mb = 10
    // 获取100~300的随机高度
    function getH() {
      const r = Math.ceil(Math.random() * 200)
      return r + 100
    }
    for(const e of items) {
      e.style.gridRowEnd = 'span ' + (getH() + mb) // "span 1"相当于"grid-auto-rows * 1",且一个span会包含外边距在内
      e.style.marginBottom = mb + 'px'
    }
  </script>
</body>
</html>

还可使用flex布局实现,需要对数据数组进行按列分组,示例略。

虚拟列表

使用CSS属性 "content-visibility: auto;" 实现隐藏可见区域外的元素内容(高度为0),请注意其浏览器兼容性,且会有滚动条抖动问题:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .container::-webkit-scrollbar {
      display: none;
    }
    .item {
      border: 1px solid black;
      margin-bottom: 30px;
      content-visibility: auto;
      line-height: 1.5em;
      /* contain-intrinsic-height: 500px; */
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="item">长内容</div>
    <div class="item"></div>
    <div class="item"></div>
    <div class="item"></div>
    <div class="item"></div>
    <div class="item"></div>
    <div class="item"></div>
  </div>
</body>
</html>

js

原型链

每个构造函数都有其对应的原型对象,由构造函数创建的实例会有一个内部属性指向该原型对象,实例会继承该原型对象的属性。原型对象本身也有一个内部属性指向一个原型对象,依此类推,终点为null。访问对象的某个属性时会沿原型链往上查找,直到找到或返回 undefined。

原型链示意图:

image-20230816154029149.png

例:

function A() {}
const a = new A();
// 以下都输出 true
console.log(A.__proto__ === Function.prototype) // A.__proto__ 可替换为 Object.getPrototypeOf(A)
console.log(Object.__proto__ === Function.prototype)
console.log(Function.__proto__ === Function.prototype)
console.log(A.prototype.__proto__ === Object.prototype)
console.log(Function.prototype.__proto__ === Object.prototype)
console.log(Object.prototype.__proto__ === null)

展开运算符是深拷贝还是浅拷贝?

当使用展开运算符复制数组或对象时,它会创建一个新的数组或对象。但是对于嵌套的对象或数组,它复制的仅仅是引用值而非创建新的堆内存。故展开运算符是浅拷贝。如:

// 对象
const obj = {
  name: 'a',
  innerObj: {
    name: 'b',
  }
}
const obj1 = { ...obj }
console.log(obj === obj1) // false
console.log(obj.innerObj === obj1.innerObj) // true
// 数组
const arr = [1, 2, [3]]
const arr1 = [...arr]
console.log(arr === arr1) // false
console.log(arr[2] === arr1[2]) // true

深拷贝的几种方法

1、递归

function deepCopy(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  let copy = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      copy[key] = deepCopy(obj[key]);
    }
  }
  return copy;
}

使用递归时可能会因为循环引用而导致无限递归,故可使用Set对象存储已访问过的属性并添加重复访问的判断:

function deepCopy(obj, cache = new Set()) {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  if (cache.has(obj)) {
    return obj; // 已经处理过该对象,直接返回
  }
  cache.add(obj);
  let copy = Array.isArray(obj) ? [] : {};
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      copy[key] = deepCopy(obj[key], cache);
    }
  }
  return copy;
}

2、JSON序列化与反序列化

function deepCopy(obj) {
  return JSON.parse(JSON.stringify(obj));
}

此方法无法复制函数和特殊对象(如正则表达式、Date 对象)的属性和方法;此方法会忽略对象的原型链和构造函数。

3、第三方库

例如 Lodash 的 cloneDeep() 方法:

// 使用 Lodash 的 cloneDeep 方法
const clone = _.cloneDeep(obj);

new运算符做了什么

使用new调用构造函数时,做了如下工作:

  1. 创建一个新的空对象({})。
  2. 将新创建的对象的__proto__属性设置为构造函数的 prototype 属性。
  3. 将构造函数的 this 关键字绑定到新创建的对象。
  4. 执行构造函数内部的代码。在构造函数内部,可以使用 this 来引用新创建的对象,并对其进行属性和方法的设置。
  5. 如果构造函数返回一个对象,则返回该对象;否则,返回新创建的对象。
const a = new A()
// new 做了如下工作
const obj = {}
obj.__proto__ = A.prototype
A.call(obj)
return obj

Map、Set、WeakMap、WeakSet

Map

Map 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者基本类型)都可以作为一个键或一个值,一个键只能出现一次,意味着对已存在的相同的键设置值时,会覆盖其值:

const map = new Map()
const objKey = { aaa: 'bbb' }
map.set(objKey, '222')
map.set(objKey, '333')
const keys = map.keys() // 返回一个迭代器对象
console.log(keys.next().value) // { aaa: 'bbb' }
console.log(keys.next().value) // undefined
console.log(map.get(objKey)) // '333'

键的比较使用零值相等(NaN与NaN相等,-0和+0相等,其他与 "===" 表现相同),例:

const map = new Map()
map.set(0, '0000')
map.set(-0, '-0000')
map.set(NaN, 'NaN1')
map.set(NaN, 'NaN2')
console.log(map.get(0)) // "-0000"
console.log(map.get(-0)) // "-0000"
console.log(map.get(NaN)) // "NaN2"

Map与Object不同之处:

1、Map默认情况不包含任何键,只包含显式插入的键。而一个 Object 有一个原型,原型链上的键名有可能和你自己在对象上设置的键名产生冲突。例:

console.log(Map.prototype.__proto__ === Object.prototype) // true
Object.prototype.a = 1
const map = new Map()
console.log(map.get('a')) // undefined
console.log(map.a) // 1
console.log(map['a']) // 1

2、一个 Map 的键可以是任意值,包括函数、对象或任意基本类型。一个 Object 的键必须是一个 String 或是 Symbol。

3、Map 是可迭代的,可以直接被迭代。而 Object 不可直接被迭代。

4、Map 不支持序列化和解析。而 Object 原生支持。例:

// Object
const obj = {
  a: 1,
  b: {
    bb: '222'
  }
}
const obj1 = JSON.parse(JSON.stringify(obj))
console.log(obj) // { a: 1, b: { bb: '222' }}
console.log(obj1) // { a: 1, b: { bb: '222' }}
console.log(obj.b === obj1.b) // false
// Map
const map = new Map([['a', 111], ['b', {
  c: {
    d: new Map([['dd', 'text']])
  }
}]])
const map1 = JSON.parse(JSON.stringify(map))
console.log(map) // Map(2)
console.log(map1) // {}
console.log(Object.prototype.toString.call(map1)) // [object Object]

不过,可以通过传入额外的参数使Map可以进行序列化及解析:

function replacer(key, value) {
  if(value instanceof Map) {
    return {
      dataType: 'Map',
      value: Array.from(value.entries()), // or with spread: value: [...value]
    }
  } else {
    return value
  }
}
function reviver(key, value) {
  if(typeof value === 'object' && value !== null) {
    if (value.dataType === 'Map') {
      return new Map(value.value)
    }
  }
  return value
}
const map = new Map([['a', 111], ['b', {
  c: {
    d: new Map([['dd', 'text']])
  }
}]])
const str = JSON.stringify(map, replacer)
const map2 = JSON.parse(str, reviver)
console.log(map2) // Map(2)
console.log(JSON.stringify(map) === JSON.stringify(map2)) // true
console.log(Object.prototype.toString.call(map2)) // [object Map]

WeakMap

WeakMap 对象是一组键/值对的集合。其键必须是对象,而值可以是任意的。其中的键是弱引用的,意味着在没有其他引用存在时垃圾回收能正确进行,避免内存泄漏。正由于这样的弱引用,WeakMap 的 key 是不可枚举的(没有方法能给出所有的 key)。

Set

Set对象用于存储任何类型唯一值,你可以按照插入的顺序迭代它的元素。例:

const arr = [1, 1, 1, 2, 2, 2]
const arr1 = Array.from(new Set(arr))
const arr2 = [...new Set(arr)]
console.log(arr1) // [1, 2]
console.log(arr2) // [1, 2]

WeakSet

它和 Set 的主要区别有:

  • WeakSet 只能是对象的集合,而 Set 可以是任何类型的任意值。
  • WeakSet弱引用:集合中对象的引用为弱引用。如果没有其他的对 WeakSet 中对象的引用,那么这些对象会被当成垃圾回收掉。

这也意味着 WeakSet 中没有存储当前对象的列表,故WeakSet不可枚举的。一般用于成员的存在性检查

WeakSet 可用于检测循环引用

// 对 传入的 subject 对象 内部存储的所有内容执行回调
function execRecursively(fn, subject, _refs = new WeakSet()) {
  // 避免无限递归
  if (_refs.has(subject)) {
    return
  }
  fn(subject)
  if (typeof subject === "object") {
    _refs.add(subject)
    for (const key in subject) {
      execRecursively(fn, subject[key], _refs)
    }
  }
}
const foo = {
  foo: "Foo",
  bar: {
    bar: "Bar",
  },
}
foo.bar.baz = foo // 循环引用!
execRecursively((obj) => console.log(obj), foo)

常见手写题

手写Promise

实现Promise的构造函数及then方法:

const PENDING = 'pending'
const FULFILLED = 'fulfilled'
const REJECTED = 'rejected'
class MyPromise {
  #state = PENDING // "#"定义为私有属性
  #result = undefined
  #handlers = []
  constructor(executor) {
    const resolve = (data) => {
      this.#changeState(FULFILLED, data)
    }
    const reject = (err) => {
      this.#changeState(REJECTED, err)
    }
    try {
      executor(resolve, reject)
    }
    catch(err) {
      reject(err)
    }
  }
  #changeState(state, result) {
    if(this.#state !== PENDING) return
    this.#state = state
    this.#result = result
    this.#run()
  }
  // 判断传入值是否为promise对象
  #isPromise(value) {
    if(value !== null && (typeof value === 'object' || typeof value === 'function')) {
      return (typeof value.then) === 'function'
    }
    return false
  }
  // 将传入值加入微任务队列执行
  #addToMicrotaskRun(task) {
    // Node环境
    if(typeof process === 'object' && typeof process.nextTick === 'function') {
      process.nextTick(task)
    }
    // 浏览器环境
    else {
      // 此处可增加对浏览器环境下不同API的检查,这里没有做判断,直接使用了queueMicrotask
      queueMicrotask(task)
      // 或使用 MutationObserver
      // const m = new MutationObserver(task)
      // const text = document.createTextNode('1')
      // m.observe(text, {
      //   characterData: true
      // })
      // text.data = '2'
    }
  }
  #runCb(callback, resolve, reject) {
    this.#addToMicrotaskRun(() => {
      // 1、传入then的不是函数,直接透传结果给下一个Promise
      if(typeof callback !== 'function') {
        if(this.#state === FULFILLED) {
          resolve(this.#result)
        }
        else {
          reject(this.#result)
        }
      }
      // 2、是函数,则传入当前promise结果执行函数,并将执行的返回值传递给下一个promise
      // 如果执行期间报错,直接reject错误
      else {
        try {
          const result = callback(this.#result)
          // 判断执行then中函数的返回result是否为promise
          if(this.#isPromise(result)) {
            result.then(resolve, reject)
          }
          else {
            resolve(result)
          }
        }
        catch(err) {
          reject(err)
        }
      }
    })
  }
  // 引入此方法处理Promise传入构造函数的参数内部异步改变状态的情况:
  // new MyPromise((res, rej) => {
  //  setTimeout(() => { res(123) }, 1000)
  // })
  #run() {
    if(this.#state === PENDING) return
    while (this.#handlers.length) {
      const { onFulfilled, onRejected, resolve, reject } = this.#handlers.shift()
      if(this.#state === FULFILLED) {
        this.#runCb(onFulfilled, resolve, reject)
      }
      else {
        this.#runCb(onRejected, resolve, reject)
      }
    }
  }
  then(onFulfilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      this.#handlers.push({
        onFulfilled,
        onRejected,
        resolve,
        reject
      })
      this.#run()
    })
  }
   catch(onRejected) {
    return this.then(undefined, onRejected)
  }
  finally(onFinally) {
    return this.then(res => {
      onFinally()
      return res
    }, reason => {
      onFinally()
      throw reason
    })
  }
  static resolve(value) {
    if(value instanceof MyPromise) return value // 如果是一个Promise,直接返回
    let _resolve, _reject
    const p = new MyPromise((resolve, reject) => {
      _resolve = resolve
      _reject = reject
    })
    if(p.#isThenable(value)) {
      value.then(_resolve, _reject)
    } else {
      _resolve(value)
    }
    return p
  }
  static reject(reason) {
    return new MyPromise((resolve, reject) => {
      reject(reason)
    })
  }
}

使用示例:

const p = new MyPromise((resolve, reject) => {
  resolve(1111)
})
p.then((res) => {
  console.log(res) // 1111
  return new MyPromise(resolve => {
    setTimeout(() => {
      resolve(2222)
    }, 1000)
  })
}).then(res => {
  console.log(res) // 2222
})
setTimeout(() => {
  console.log(4444)
}, 2000)
console.log(3333)
// 输出 3333 1111 2222 4444

Promise.resolve、Promise.reject示例:

const p = new MyPromise((resolve, reject) => {
  resolve(1111)
})
const p1 = new Promise((resolve, reject) => {
  resolve(2222)
})
console.log(MyPromise.resolve(p) === p) // true
console.log(MyPromise.resolve(p1) === p1) // false
MyPromise.resolve(1233).then(res => {
  console.log(res) // 1233
})
MyPromise.resolve(p1).then(res => {
  console.log(res) // 2222
})
MyPromise.reject('errr').catch(reason => {
  console.log(reason) // errr
})

防抖、节流

防抖,一段时间内重复触发会重新计时,只执行最后一次。常见防抖应用有:响应窗口尺寸变化的函数、输入框搜索请求函数、响应按钮点击的函数等等。

function debounce(fn, delay) {
  let timer = null
  return function() {
    if(timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn.apply(this, arguments)
      timer = null
    }, delay)
  }
}

节流,一段时间内重复触发只执行一次。常见节流应用:页面滚动事件、鼠标移动事件等等。

function throttle(fn, delay) {
  let timer = null
  return function() {
    if(timer) return
    timer = setTimeout(() => {
      fn.apply(this, arguments)
      timer = null
    }, delay)
  }
}

排序

冒泡排序

比较相邻的两个元素,如果顺序错误则交换它们的位置,重复执行这个过程直到整个数组排序完成。

function bubbleSort(arr) {
  const len = arr.length
  for(let i=0; i<len-1; i++) { // 总比较轮数len-1,每一轮将待排序列表中的最大数排到末尾,已排序元素个数i
    for(let j=0; j<len-1-i; j++) {
      if(arr[j] > arr[j+1]) {
        const temp = arr[j]
        arr[j] = arr[j+1]
        arr[j+1] = temp
      }
    }
  }
  return arr
}

快速排序

选择一个基准元素,将数组分为两部分,一部分小于基准元素,一部分大于基准元素,对这两部分递归地应用快速排序,直到整个数组排序完成。

function quickSort(arr) {
  if(arr.length<=1) {
    return arr
  }
  const pIndex = Math.floor((arr.length-1)/2)
  const p = arr.splice(pIndex, 1)[0] // splice返回包含删除元素的数组
  const left = []
  const right = []
  for(let i=0; i<arr.length; i++) {
    if(arr[i] < p) {
      left.push(arr[i])
    } else {
      right.push(arr[i])
    }
  }
  return [...quickSort(left), p, ...quickSort(right)]
}

发布订阅模式

发布订阅模式是一种行为设计模式。发布订阅模式:存在一个消息代理对象,其内部维护了一个消息类型与订阅者列表的映射,订阅者通过代理对象订阅特定类型的消息来接收相应的通知,代理对象通过发布指定类型消息广播给所有该类型订阅者。可以取消一个类型的所有订阅,或只取消一个类型的特定订阅。

const msProxy = {
  subs: new Map(),
  // 订阅
  subscribe(type, cb) {
    if(!this.subs.get(type)) {
      this.subs.set(type, [])
    }
    this.subs.get(type).push(cb)
  },
  // 发布
  publish(type, data) {
    const cbs = this.subs.get(type)
    if(cbs) {
      for(const cb of cbs) {
        cb(data)
      }
    } else {
      console.log('不存在此类型消息')
    }
  },
  // 取消订阅
  unsubscribe(type, cb) {
    if(this.subs.get(type)) {
      if(cb) {
        const newSubs = this.subs.get(type).filter(item => item !== cb)
        this.subs.set(type, newSubs)
      }
      else {
        this.subs.delete(type)
      }
    }
  }
}

使用示例:

// 定义订阅者
function sub1(data) {
  console.log('sub1: ', data)
}
function sub2(data) {
  console.log('sub2: ', data)
}
// 订阅
msProxy.subscribe('type1', sub1)
msProxy.subscribe('type1', sub2)
// 发布
msProxy.publish('type1', {a: 123, b: '666'})
// 取消订阅
msProxy.unsubscribe('type1')
// msProxy.unsubscribe('type1', sub2)
// 再次发布
msProxy.publish('type1', {a: 123, b: '666'})

观察者模式

观察者模式是一种行为设计模式,用于在对象之间建立一种一对多的依赖关系,当一个对象的状态发生变化时,它的所有依赖对象都会收到通知并自动更新。

// 主题对象(被观察者)
class Subject {
  constructor() {
    this.observers = new Set() // 观察者列表
  }
  // 注册观察者
  addObserver(observer) {
    this.observers.add(observer)
  }
  // 注销观察者
  removeObserver(observer) {
    if(this.observers.has(observer)) {
      this.observers.delete(observer)
    }
  }
  // 通知观察者
  notify(data) {
    this.observers.forEach(observer => {
      observer.update(data)
    })
  }
}
// 观察者对象
class Observer {
  constructor(name) {
    this.name = name
  }
  update(data) {
    console.log(`${this.name} update, data: ${data}`)
  }
}
const subject = new Subject()
const observer1 = new Observer('observer1')
const observer2 = new Observer('observer2')
// 注册观察者
subject.addObserver(observer1)
subject.addObserver(observer2)
subject.addObserver(observer2)
// 通知观察者
subject.notify('notify data')

单例模式

单例模式是一种创建型模式。单例模式中,多次实例化将得到相同的对象,可通过将第一次创建的实例化对象存储为类静态属性实现:

class Singleton {
  constructor() {
    if (!Singleton.instance) {
      // 初始化单例对象
      Singleton.instance = this
    }
    return Singleton.instance
  }
  // 单例对象的方法...
}
// 使用单例模式
const instance1 = new Singleton()
const instance2 = new Singleton()
const instance3 = new Singleton()
console.log(instance1 === instance2) // true
console.log(instance2 === instance3) // true
console.log(instance1 === Singleton.instance) // true