前端面试题

153 阅读13分钟

一、虚拟dom

1.虚拟dom的本质

虚拟dom的本质其实是用js对象来描述真实的dom结构 例如:

{
 tag:'div',
 props:{ class:'container' }
 children:[
  { tag:'h1', children:'标题' }
 ]
}

当数据发生变化时,react会先对比新旧虚拟dom的差异,再只更新真实dom中变化的部分

2.为什么要使用虚拟dom

直接操作真实dom会造成浏览器的重排和重绘,频繁更新就会导致页面卡顿,而通过虚拟dom的对比,只把变化的部分同步到真实的dom(这就是key的重要性),大大减少了浏览器的操作次数

而变化的部分就是依靠diff算法,react首先会按层级对比,不会跨越层级移动节点,同一层级的节点通过key属性判断是否为同一节点。

二、fiber

1.fiber是什么

fiber本质是一种可中断的渲染机制,它会将同步的渲染任务拆分成多个小任务,允许浏览器在任务间隙处理其他事件

fiber通过js对象描述组件渲染形态,形成纤维树(替代传统的虚拟dom树),实现任务的暂停、恢复和优先调度

2.为什么react要使用fiber

react推出fiber架构是为了解决react中的某些性能问题,在react的以往框架中,react采用的是递归去创建虚拟dom,而递归的过程是不能中断的。当你的组件树层级很深时,递归就会占用大量的时间,用户就会感觉到卡顿。因为js是单线程的,当递归占用线程时,ui渲染就会一直等待。

3.fiber架构的核心机制

3.1任务切片

fiber可以将更新任务拆分成多个小任务,按照帧级别去执行任务,如果fiber发现了某个任务的执行时间超过了当前帧的剩余时间,它会将任务暂停让浏览器先进行ui渲染

3.2优先级调度

在react以往的渲染过程中是没有所谓优先级这一概念的,所有任务都是相同的优先级。而fiber会动态的给每一个任务安排一个优先级,在执行任务的过程中会动态调整执行任务的顺序,它会优先执行优先级较高的任务,后执行优先级低的。

3.3可中断渲染

fiber允许react渲染过程中对任务进行暂停,然后在适当的时候重启。而在传统架构中,更新过程一旦开始就必须完成,例如render函数的递归到底等所引起的页面卡顿问题

三、前端埋点与监控

1.埋点

埋点主要收集用户行为数据,在日常开发中,我们会通过前端代码插入代码或者脚本方式来实现埋点

埋点的主要作用就是用于捕获特定用户行为(如点击、浏览、提交表单)以及关键业务数据

埋点的实现方案大致可以分为三大类:

1.手动埋点:在代码中手动加入计入代码来捕获特定事件

const trackEvent(eventType,details) {
  console.log(eventType,details)
  // 调用接口
}

2.自动埋点:利用dom事件代理等捕获页面上的所有事件,从而减少手动配置

3.可视化埋点:通过工具界面标记需要采集的元素和事件,可以不用手写代码

2.监控

监控主要关注系统的性能和稳定性。在日常开发中,我们会通过采集页面的加载时间、资源请求、错误日志等数据来实现前端监控

// 例如监听页面的首屏加载
window.addEventListener('load',function() {
  const pageLoadTime = performance.now() // 性能监控api
  trackEvent('pageLoad',{
    duration:pageLoadTime
  })
})

监控也大概分为三大类

1.性能监控:如首屏加载时间,页面交互耗时、资源加载耗时等

2.错误监控:如js错误、网络失败请求、资源加载异常

3.用户体验监控:收集白屏、卡顿等影响用户体验的问题

三、浏览器从输入url到渲染页面都发发生了什么

1.DNS解析

浏览器会将输入的域名地址解析为ip地址。 主要通过浏览器DNS缓存查找 - 系统DNS缓存 - 路由器DNS缓存 - 网络运营商DNS缓存 - 递归查找DNS解析(都找不到就是出错了)

2.建立TCP连接(TCP的三次握手)

第一次握手,由浏览器先发起,告诉服务器我要发送请求了

第二次握手,由服务器发起,告诉浏览器我准备好了,你准备发送吧

第三次握手,由浏览器发起,告诉服务器我马上就发了,准备接收吧

3.发送请求(请求报文)

4.接受响应(响应报文)

5.渲染页面

遇见Html标记,浏览器会调用HTML解析器来解析成Token并构建成DOM树

遇见style/link标记,浏览器会调用css解析器,处理css标记并构建cssom树

遇见script标记,调用js解析器,处理script代码(绑定事件,修改dom/cssom树)

将dodm树和cssom树合并成一个渲染树

根据渲染树计算布局,计算每个节点的几何信息

6.断开连接(TCP四次挥手)

第一次挥手:由浏览器发起,告诉服务器,我东西发送完了(请求报文),你准备关闭吧。

第二次挥手:由服务器发起,告诉浏览器,我东西接收完了(请求报文),我准备关闭了,你也准备吧

第三次挥手:由服务器发起,告诉浏览器,我东西发送完了(响应报文),你准备关闭吧

第四次挥手:由浏览器发起,告诉服务器,我东西接受完了(响应报文),我准备关闭了,你也准备吧

四、前端大文件上传(项目亮点)

1.大文件上传的基本思路

获取文件,将文件对象分片,分片后要有唯一标识,用文件内容创建唯一的hash标识,将分片调用接口发送给后端服务器,由后端将这些分片组装起来

将文件分片

const createChunk(file,chunkSize) { // 文件和分片大小(字节)
  const result = []
  for(let i = 0;i<file.size;i+=chunkSize) { // 当i大于文件的size时停止循环
   // file.slice是原型链上挂载的方法,我们可以用这个方法来进行分片
     result.push(file.slice(i,i+chunkSize))
  }
  return result
}

生成文件的唯一标识(hash)

hash是一种将任意长度的输入数据通过特定算法转换成固定长度的过程,而输出的结果叫hash值

hash值具有固定长度(无论原始数据多大和多小,输出的结果都是固定长度),不可逆(hash无法反推回原始数据),唯一性(理想情况下不同输入会产生不同的hash值,实际可能会产生hash碰撞,但是概率和你买彩票中500w一样渺小)

const toHash = (chunks) => { // 获取之前完成的切片数组

  // 由于spark是异步的,所以要通过promise来解决
  return new Promise((resolve) => {
  // 引入spk-md5库
  const spark = new SparkMd5()
 
 // 使用递归完成增量算法
  const _read = (i) => { // 传入切片数量
   if(i >= chunks.length) {
    // 如果切片计算完成就退出
    resolve(spark.end())
    return
   }
   // 获取其中的一个片段
   const blob = chunks[i]
   
   // 创建一个fileReader对象,它可以读取存储在用户计算机上的文件的内容
   const reader = new FileReader()
   
   // readAsArrayBuffer()放法用于开始读取指定的blob内容,当操作完成时,会触发onload事件,此时result会返回包含文件数据的参数
    reader.onload = e => {
    const bytes = e.target.result
    
    // 获取参数后放入md5进行计算
    spark.append(bytes)
    // 计算完这一切片开始进行下一个的计算
    _read(i+1)
    }
     reader.readAsArrayBuffer(blob)
   }
    _read(0)
 })

 
}

分片上传

const uploadChunk(chunk,hash,fileName) => {
  // 入参为之前创建的分片,hash值和文件名
  
  // 因为是多文件请求,所以使用promise.all进行
  const taskArr = []
  
  chunk.forEach((item,index) => {
    // 因为是文件,所以要创建formData的格式传给后端
    const formData = new FormData()
    formData.append('chunk',chunk)
    formData.append('chunkName',`${hash}-${index}-${fileName}`)
    formData.append('fileName',fileName)
    // 给后端发送请求,注意设置请求头Content-Type为formData格式
    const task = axios.post('xxx',formData,{
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
    taskArr.push(task)
  })
  Promise.all(taskArr).then(() => {
    // 所有分片完成
  })
}

2.优化体验

前面把大文件上传的基本原理表现了出来,在这个过程中,会引申出一些其他的性能问题。例如当用户上传的文件非常大,你切片数量很多时,用户去等待切片出来的结果会很漫长。这时候就需要Web Worker去完成了

2.1Web Worker

众所周知,js执行是单线程的,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。而Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。

  // 基本用法
  
  // 创建一个worker
  const worker = new Worker('work.js')
  
  // 主线程调用postMessage向worker发送信息
  worker.postMessage({
    // 发送文件,切片大小,切片起点和终点等
    file,
    chunk
  });
  
  // 主线程通过onmessage接收子线程发送过来的消息
  worker.onmessage = (e) => {
    console.log(e.data)
  }
  
  // 当worker将值传给主线程后销毁worker
  worker.terminate()
2.2发送请求到后端

当我们发送请求时,如果文件很大,那么分出的切片数量就会很多,就会发起很多请求,引起高并发。所以我们要把请求控制在一定数量,而且要标记切片的状态,判断切片是否已完成或者失败或者还未上传,标记状态可以用作进度条和失败条件的判断

  // 设置状态标记切片 0不做任何处理,1是计算hash中,2是正在上传中,3是上传完成,4是上传失败,5是上传取消
  // 浏览器同域名同一时间请求的最大并发数限制为6
  // 例如如果有3个文件同时上传/处理中,则每个文件切片接口最多调 6 / 3 == 2个相同的接口 
  maxRequest = Math.ceil(6 / isTaskArrIng.length) 
  // 从数组的末尾开始提取 maxRequest 个元素。
  let whileRequest = taskArrItem.allChunkList.slice(maxRequest)

  
  // 设置正在请求中的个数
  taskArrItem.whileRequests.push(...whileRequest) 
  // 如果总请求数大于并发数
  if (taskArrItem.allChunkList.length > maxRequest) {
  // 则去掉即将要请求的列表 
  taskArrItem.allChunkList.splice(maxRequest)
  } else {
  // 否则总请求数置空,说明已经把没请求的全部放进请求列表了,不需要做过多请求
  taskArrItem.allChunkList = []
  }
  
  // 上传失败
  taskArrItem.state = 4
  // 当切片上传失败时,记录失败次数
  taskArrItem.errNumber++
  // 上传成功时,改变状态并记录成功数,并删除正在请求中数组内的这个请求
  taskArrItem.finishNumber++
  needObj.finish = true
  taskArrItem.whileRequests = taskArrItem.whileRequests.filter( (item) => {
     return item.chunkFile !== needObj.chunkFile 
  })
  // 如果单个文件最终成功数等于切片个数,说明全部上传完成
  if (taskArrItem.finishNumber === chunkNumber) {
  // 全部上传完切片后就开始合并切片
  await handleMerge(taskArrItem) 
  } else { 
  // 如果还没完全上传完,则继续上传 
  uploadSignleFile(taskArrItem)
  }

核心思想:

动态设置请求数量防止高并发

设置一个正在上传的请求列表,和所有的请求列表,这两个列表就用来防止一次请求过多。当发送请求时,从所有的请求列表数组末尾截取最大请求数量的切片,然后判断最大请求数和所有请求列表长度,如果最大请求数大于所有请求长度,说明已经全部请求完成了,则将所有请求列表数组置空,如果小于则将所有请求数组从后往前删除最大请求数量,然后就可以循环开始请求

失败的判定,用户可能因为各种网络原因上传失败,前端这边就定义一个标准,比如三次失败内继续上传,超过三次失败后就判定该文件上传失败。这个标准可以以后端是否处理成功并返回为标准,防止出现切片丢失等问题,并把该切片标识为上传完成,文件切片上传成功数+1,并动态设置文件上传进度条。 进度条可以通过 (上传成功的切片数 / 该文件的总切片数) * 100计算,可以设置一个finishNumber

2.3秒传、断点上传

秒传其实就是上传之前询问后端这个文件是否存在于服务器,存在则直接显示上传成功,不存在则继续上传。

断点上传是这个文件已经有一部分切片上传到后端了,但是因为一些原因剩下的还未上传。这时候前端又不想把之前的切片再上传一次,那我们在上传文件之前就要调接口询问后端:我到底上传了哪些切片给你?赶紧给我返回过来,我要过滤掉这些已经传过的,留下没传过的。

 // 秒传和断点上传就是在上传前再发送一次请求,检查是否在服务器中存在。
 const uploadList = res.data
 // 根据判断文件是否上传了,和上传的文件数量,将已上传的文件数量从总切片中过滤出来
 inTaskArrItem.allChunkList = inTaskArrItem.allChunkList.filter((item) => {
  return !uploadedList.includes(item.chunkHash) 
 })


2.4暂停上传和恢复上传

暂停上传,其实就是把还在请求中的接口直接中断。这时候我们就可以用到axiosAbortController方法去取消接口请求,(原生XMLHttpRequest 使用 abort 方法)

恢复上传,其实就只是把刚才暂停的正在上传中所有切片(whileRequests)放到待上传切片列表中(allChunkList ,再去调用单个文件上传方法(uploadSignleFile

  // 暂停上传(是暂停剩下未上传的)
  const pauseUpload = (taskArrItem, elsePause = true) => {
  // elsePause为true就是主动暂停,为false就是请求中断
  // 4是成功 6是失败 如果不是成功或者失败状态
  if (![4, 6].includes(taskArrItem.state)) {
  // 3是暂停,5是中断
  if (elsePause) {
  taskArrItem.state = 3
   } else {
     taskArrItem.state = 5
   }
  }
  taskArrItem.errNumber = 0
  
  // 取消还在请求中的所有接口
  if (taskArrItem.whileRequests.length > 0) { 
  for (const itemB of taskArrItem.whileRequests) {
    itemB.cancel ? itemB.cancel() : ''
    } 
  } 
}

// 恢复上传
const resumeUpload = (taskArrItem) => {
// 2为上传中
taskArrItem.state = 2 
// 把刚才暂停的正在上传中所有切片放到待上传切片列表中 
taskArrItem.allChunkList.push(...taskArrItem.whileRequests) 
taskArrItem.whileRequests = [] uploadSignleFile(taskArrItem) 
}

强缓存和协商缓存

1.强缓存

通过响应头设置cache-control:max-age,指定该请求在浏览器缓存多久,在有效时间内重复请求则无需再次访问服务器,直接从浏览器获取结果

2.协商缓存

在首次请求资源时,服务端会把结果签名缓存在服务端,并设置在响应头ETag字段中,返回给浏览器。业务侧这边则需要缓存ETag和结果数据,并且在下次请求时候,带在请求头if-none-match字段中。服务端再次接受到请求之后,判断ETag和if-none-match是否相等,如果相等返回304,业务侧接受到304后直接访问之前的缓存结果数据。