一、虚拟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暂停上传和恢复上传
暂停上传,其实就是把还在请求中的接口直接中断。这时候我们就可以用到axios的AbortController方法去取消接口请求,(原生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后直接访问之前的缓存结果数据。