Vue 项目实现用户登录
在前后端完全分离的情况下,Vue 项目中实现 token 验证大致思路如下:
- 第一次登录的时候,前端调后端的登陆接口,发送用户名和密码。
- 后端收到请求,验证用户名和密码,验证成功,就给前端返回一个 token。
- 前端拿到 token,将 token 存储到 localStorage 和 Vuex 中,并跳转路由页面。
- 此后前端每次跳转路由,就判断 localStorage 中有无 token ,没有就跳转到登录页面,有则跳转到对应路由页面。
- 导航守卫:使用
router.beforeEach注册一个全局前置守卫,判断用户是否登陆 - 导航守卫仅仅简单判断是否有 token 值存在(不管该 token 是否有效),如果不存在/失效就进行重定向
- 导航守卫:使用
- 每次调后端接口,都要在请求头中加 token。
- 请求拦截器,每次请求都会在请求头中携带 token
- 请求拦截器是向后端发送请求并校验,如果 token 合法就访问成功,否则访问失败并进行重定向
- 后端判断请求头中有无 token,有的话就拿到 token 并验证 token,验证成功就返回数据,验证失败(例如:token 过期)就返回 401,请求头中没有 token 也返回 401。
- 如果前端拿到状态码为 401,就清除 token 信息并跳转到登录页面。
- 调取登录接口成功,会在回调函数中将 token 存储到 localStorage 和 Vuex 中。
前端应该在什么时候跳转到登录页?
- 未认证的用户访问受限资源时:当用户尝试访问需要登录才能查看的页面或资源,如个人中心、订单记录、付费内容等。前端发送请求,后端验证用户未登录(通常通过检查请求头中是否有有效令牌等方式),返回一个需要认证的状态码。前端根据这个响应,就会跳转到登录页面,提示用户登录后再访问。
- Token 过期时:如果应用使用令牌来验证用户身份,当令牌过期,用户进行操作(如刷新页面或者发送请求),后端验证令牌失效后,前端收到相应的错误消息,会自动跳转到登录页,让用户重新获取有效令牌进行登录。
- 用户主动登出后:当用户点击登出按钮,清除本地存储的登录相关信息(如令牌、用户信息等)后,就会跳转到登录页,方便下次登录或者其他用户登录。
菜单权限
菜单和路由都由后端返回,将后端返回的路由通过router.addRoutes()动态挂载,需要处理数据,将 component 字段换成对应的组件,注意嵌套路由的数据遍历。
按钮权限
v-permission:自定义权限指令,对需要权限判断的 DOM 进行显示隐藏(按钮权限)。
思路:根据权限数组判断用户的权限是否在这个数组内,如果是则显示,否则移除 DOM。
function checkArray(key) {
// 项目内该权限数组一般取后端接口返回的权限数组
let arr = ['1', '2', '3', '4']
let index = arr.indexOf(key)
if (index > -1) {
return true // 有权限
} else {
return false // 无权限
}
}
const permission = {
inserted: function (el, binding) {
let permission = binding.value // 获取到 v-permission的值
if (permission) {
let hasPermission = checkArray(permission)
if (!hasPermission) {
// 没有权限 移除Dom元素
el.parentNode && el.parentNode.removeChild(el)
}
}
}
}
export default permission
前端项目中会在请求拦截里做哪些限制?
- Token 验证:在现代前端应用中,尤其是涉及用户登录的系统,通常会使用令牌(如 JWT - JSON Web Token)来验证用户身份。在请求拦截器中,可以检查请求头中是否携带有效的令牌。如果没有令牌或者令牌无效,可能会阻止请求发送,或者将用户重定向到登录页面。
- 权限检查:根据用户的角色或权限来限制对某些 API 端点的访问。可以从服务器获取用户权限信息(如角色列表),并存储在前端(如 sessionStorage 或全局状态管理库中)。在请求拦截器中,检查用户是否有访问特定资源的权限。如果没有权限,可能会返回一个权限不足的提示或者阻止请求发送。
- 参数验证和格式化:在请求发送之前,检查请求参数是否符合要求。例如,确保必填参数已经填写,对参数进行类型转换(如将字符串类型的数字转换为实际的数字类型),或者对参数进行加密等操作。这有助于提高请求的准确性和安全性。例如,有一个发送用户注册信息的请求,需要确保用户名和密码都已填写并且密码长度符合要求。
- 数据格式统一:将请求数据格式化为服务器能够接受的形式。例如,将 JavaScript 对象转换为符合服务器要求的 JSON 字符串,或者添加特定的请求头来指定数据格式。这可以确保服务器能够正确解析接收到的数据。
- 请求频率限制:为了避免用户频繁发送请求导致服务器压力过大或者出现意外情况(如恶意攻击),可以在请求拦截器中限制请求的频率。例如,通过记录上一次请求的时间,确保在一定时间间隔内(如 1 秒内)不能发送超过一定数量的相同类型请求。
- 缓存控制:根据需求决定是否启用缓存来提高性能。对于一些不经常变化的数据,可以在请求拦截器中检查缓存是否存在。如果缓存存在且数据仍然有效,就直接使用缓存数据,而不发送实际的请求。例如,使用一个简单的对象来存储缓存数据,并且有一个方法来检查数据是否有效。
在请求拦截中如何做请求频率限制?
- 基于时间戳的简单限制
- 原理:记录每次请求的时间戳,在发送新请求时,检查当前时间与上一次请求时间的间隔是否小于设定的限制时间。如果小于限制时间,则阻止请求或延迟请求。
- 示例代码(使用 Axios):假设每个请求之间至少间隔 1 秒
在这个示例中,首先获取当前时间 currentTime。如果当前时间与上一次请求时间 lastRequestTime 的间隔小于 1000 毫秒(1 秒),则创建一个新的 Promise。在这个 Promise 中,使用 setTimeout 来延迟请求,延迟的时间是剩余的间隔时间(1000 - (当前时间 - 上一次请求时间))。当延迟时间结束后,更新 lastRequestTime 为当前时间,并通过 resolve 继续发送请求。如果间隔时间大于等于 1 秒,则直接更新 lastRequestTime 为当前时间并发送请求。let lastRequestTime = 0; import axios from 'axios'; axios.interceptors.request.use((config) => { const currentTime = Date.now(); if (currentTime - lastRequestTime < 1000) { return new Promise((resolve) => { setTimeout(() => { lastRequestTime = Date.now(); resolve(config); }, 1000 - (currentTime - lastRequestTime)); }); } else { lastRequestTime = currentTime; return config; } }, (error) => { return Promise.reject(error); });
- 基于令牌桶算法的限制
- 原理:令牌桶算法是一种常用的流量控制算法。它有一个固定容量的桶,按照固定的速率往桶里添加令牌。每个请求需要获取一个令牌才能被处理。如果桶里没有令牌,请求就会被限制。
- 示例代码(使用 Axios):
class TokenBucket { constructor(capacity, rate) { this.capacity = capacity; this.tokens = capacity; this.rate = rate; this.lastRefillTime = Date.now(); } refill() { const now = Date.now(); const tokensToAdd = (now - this.lastRefillTime) * this.rate / 1000; this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd); this.lastRefillTime = now; } consume() { if (this.tokens > 0) { this.tokens--; return true; } else { return false; } } } const tokenBucket = new TokenBucket(10, 1); // 容量为10个令牌,每秒添加1个令牌 import axios from 'axios'; axios.interceptors.request.use((config) => { tokenBucket.refill(); if (tokenBucket.consume()) { return config; } else { return new Promise((resolve) => { const interval = setInterval(() => { tokenBucket.refill(); if (tokenBucket.consume()) { clearInterval(interval); resolve(config); } }, 100); }); } }, (error) => { return Promise.reject(error); });- 在这个示例中,首先定义了一个 TokenBucket 类。constructor 方法初始化了令牌桶的容量 capacity、当前令牌数量 tokens、令牌生成速率 rate 和上次添加令牌的时间 lastRefillTime。refill 方法根据当前时间和上次添加令牌的时间计算需要添加的令牌数量,并更新 tokens 和 lastRefillTime。consume 方法用于检查是否有可用的令牌,如果有则减少一个令牌并返回 true,否则返回 false。
- 在请求拦截器中,首先调用 refill 方法添加令牌,然后尝试使用 consume 方法获取一个令牌。如果获取成功,则发送请求(返回 config)。如果没有令牌,则创建一个新的 Promise,并使用 setInterval 定期检查是否有令牌可用。当有令牌可用时,清除定时器并发送请求(通过 resolve)。
- 基于请求队列和时间窗口的限制
- 原理:维护一个请求队列,记录每个请求的时间。在一个时间窗口内,只允许一定数量的请求通过。如果队列中的请求数量超过限制,新请求将被添加到队列末尾等待。
- 示例代码(使用 Axios):假设在 5 秒的时间窗口内最多允许 3 个请求
在这个示例中,首先定义了一个请求队列 requestQueue、时间窗口 timeWindow(5000 毫秒,即 5 秒)和时间窗口内的最大请求数量 maxRequestsInWindow(3 个)。当一个请求到来时,将其时间戳添加到请求队列 requestQueue 中。然后,检查队列中是否有时间窗口外的请求,如果有则将其从队列头部移除。接着,检查队列中的请求数量是否超过限制。如果超过限制,则创建一个新的 Promise,并使用 setInterval 定期检查队列。当队列中的请求数量小于等于限制时,清除定时器并发送请求(通过 resolve)。如果队列中的请求数量没有超过限制,则直接发送请求(返回 config)。const requestQueue = []; const timeWindow = 5000; const maxRequestsInWindow = 3; import axios from 'axios'; axios.interceptors.request.use((config) => { const currentTime = Date.now(); requestQueue.push(currentTime); // 移除时间窗口外的请求 while (requestQueue.length > 0 && currentTime - requestQueue[0] > timeWindow) { requestQueue.shift(); } if (requestQueue.length > maxRequestsInWindow) { return new Promise((resolve) => { const interval = setInterval(() => { const currentTime = Date.now(); // 移除时间窗口外的请求 while (requestQueue.length > 0 && currentTime - requestQueue[0] > timeWindow) { requestQueue.shift(); } if (requestQueue.length <= maxRequestsInWindow) { clearInterval(interval); resolve(config); } }, 100); }); } else { return config; } }, (error) => { return Promise.reject(error); });
前端应该如何存储 token?
- localStorage 存储
- 原理
- localStorage 是一种在浏览器中提供的持久化存储机制,数据存储在用户的本地磁盘上,只要用户不手动清除或者达到浏览器的存储限制,数据就会一直保存。它以键值对的形式存储数据,存储容量一般在几兆字节左右,具体大小因浏览器而异。
- 当使用 localStorage 存储 token 时,通常将 token 作为一个字符串值,以一个自定义的键(如access_token)进行存储。在需要使用 token 的地方(如请求拦截器),可以通过这个键来获取存储的 token。
- 优点
- 持久性:数据在浏览器关闭后仍然存在,适合需要长期保存用户登录状态的场景,例如用户下次打开浏览器访问应用时能够自动登录。
- 简单易用:API 简单,只有 setItem、getItem、removeItem 等几个方法,容易理解和使用。
- 缺点
- 安全性较低:存储的数据是明文形式,如果浏览器存在安全漏洞,可能会导致 token 泄露。并且,localStorage 中的数据可以被 JavaScript 代码访问,存在跨站脚本攻击(XSS)的风险。
- 缺乏自动过期机制:localStorage 本身不会自动使存储的数据过期,需要开发者自己实现过期逻辑。
- 原理
- sessionStorage 存储
- 原理
- sessionStorage 与 localStorage 类似,也是以键值对的形式存储数据。但是, sessionStorage 的数据只在当前会话(浏览器标签页或窗口打开期间)有效,当会话结束(如关闭标签页或窗口)时,数据会自动清除。
- 优点
- 安全性相对较高(针对特定场景):由于数据在会话结束后自动清除,一定程度上降低了 token 长期存储带来的安全风险。如果用户关闭浏览器标签页,token 就会丢失,这对于一些对安全性要求较高,且不需要长期保持登录状态的应用比较合适。
- 缺点
- 非持久性:不适合需要长期保存用户登录状态的应用,例如用户希望下次打开浏览器还能自动登录的情况就无法满足。
- 与 localStorage 相同的安全风险(XSS):存储的数据同样可以被 JavaScript 代码访问,容易受到跨站脚本攻击。
- 原理
- Cookie 存储
- 原理
- Cookie 是由服务器发送给浏览器,并存储在浏览器中的一小块数据。浏览器在每次向服务器发送请求时,会自动将符合条件的 Cookie 发送回服务器。Cookie 可以设置过期时间、路径、域等属性。当存储 token 时,可以将 token 作为 Cookie 的值,并设置适当的属性。
- 优点
- 自动发送给服务器:在跨域请求(如果配置正确)和同域请求中,浏览器会自动将 Cookie 发送给服务器,方便服务器端进行身份验证等操作,不需要在每个请求中手动添加token。
- 可配置性强:可以通过设置HttpOnly属性来防止 JavaScript 访问 Cookie,提高安全性;还可以设置过期时间、作用域(域和路径)等属性,灵活地控制 Cookie 的行为。
- 缺点
- 大小限制:通常 Cookie 的大小限制在 4KB 左右,存储的数据量有限。
- 性能影响:每次浏览器发送请求都会带上 Cookie,可能会增加网络传输的数据量,对性能有一定的影响。并且,浏览器对每个域的 Cookie 数量也有限制。
- 原理
对于大量请求的优化方案有哪些?
- 请求合并与批量处理:将多个类似的请求合并为一个请求,减少请求次数。这在需要获取多个相关数据的场景中非常有用,例如,在一个页面需要同时获取用户的基本信息、用户的订单列表和用户的收藏列表时,可以将这三个请求合并为一个请求,让后端一次性返回所有数据。
- 缓存策略:合理利用缓存可以避免重复请求相同的数据。可以在浏览器端设置缓存,当请求的数据已经存在于缓存中且数据仍然有效时,直接使用缓存数据,而不发送新的请求。缓存可以基于时间(如设置数据的过期时间)、基于数据版本(如后端更新数据后更新版本号)等方式来判断数据是否有效。
- 懒加载与预加载
- 懒加载:延迟加载一些非关键资源(如图片、脚本、模块等),直到需要使用它们时才进行加载。这可以避免在页面初始加载时加载大量不必要的资源,提高页面的初始加载速度。例如,在一个长列表页面,当用户滚动到接近某个图片时才加载该图片。
- 预加载:在浏览器空闲时间或者在用户可能需要某些资源之前预先加载它们。这可以减少用户等待资源加载的时间。例如,在一个单页应用中,当用户在某个页面停留时,可以预先加载下一个可能访问的页面的资源。使用 link 标签的
rel="preload"属性来预加载脚本。
大文件分片上传
思路
前端大文件上传网上的大部分文章已经给出了解决方案,核心是利用 Blob.prototype.slice 方法,和数组的 slice 方法相似,文件的 slice 方法可以返回原文件的某个切片。
预先定义好单个切片大小,将文件切分为一个个切片,然后借助 http 的可并发性,同时上传多个切片。这样从原本传一个大文件,变成了并发传多个小的文件切片,可以大大减少上传时间。由于是并发,传输到服务端的顺序可能会发生变化,因此我们还需要给每个切片记录顺序。
服务端负责接受前端传输的切片,并在接收到所有切片后合并所有切片。
分片上传
以 Vue 为框架,已创建上传控件和请求逻辑
-
上传切片 接着实现比较重要的上传功能,上传需要做两件事:
- 对文件进行切片
- 将切片传输给服务端
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> </div> </template> <script> // 切片大小 // the chunk size const SIZE = 10 * 1024 * 1024 export default { data: () => ({ container: { file: null }, data: [] }), methods: { request() {}, handleFileChange(e) { const [file] = e.target.files if (!file) return Object.assign(this.$data, this.$options.data()) his.container.file = file }, // 生成文件切片 createFileChunk(file, size = SIZE) { const fileChunkList = [] let cur = 0 while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur size) }) cur += size } return fileChunkList } // 上传切片 async uploadChunks() { const requestList = this.data.map(({ chunk,hash }) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', hash) formData.append('filename', this.container.file.name) return { formData } }).map(({ formData }) => this.request({ url: 'http://localhost:3000', data: formData }) ) // 并发请求 await Promise.all(requestList) }, async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.data = fileChunkList.map(({ file },index) => ({ chunk: file, // 文件名 数组下标 hash: this.container.file.name + '-' + index })) await this.uploadChunks() } } } </script>当点击上传按钮时,调用
createFileChunk将文件切片,切片数量通过文件大小控制,这里设置 10MB,也就是说一个 100 MB 的文件会被分成 10 个 10MB 的切片。createFileChunk内使用 while 循环和 slice 方法将切片放入 fileChunkList 数组中返回。 在生成文件切片时,需要给每个切片一个标识作为 hash,这里暂时使用文件名 + 下标,这样后端可以知道当前切片是第几个切片,用于之后的合并切片。 随后调用uploadChunks上传所有的文件切片,将文件切片,切片 hash,以及文件名放入 formData 中,再调用上一步的 request 函数返回一个 Promise,最后调用Promise.all并发上传所有的切片。 -
发送合并请求 合并切片的方式采用前端主动通知服务端进行合并,前端发送额外的合并请求,服务端接受到请求时合并切片。
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> </div> </template> <script> // 切片大小 // the chunk size const SIZE = 10 * 1024 * 1024 export default { data: () => ({ container: { file: null }, data: [] }), methods: { request() {}, handleFileChange(e) { const [file] = e.target.files if (!file) return Object.assign(this.$data, this.$options.data()) his.container.file = file }, // 生成文件切片 createFileChunk(file, size = SIZE) { const fileChunkList = [] let cur = 0 while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur size) }) cur += size } return fileChunkList } // 上传切片 async uploadChunks() { const requestList = this.data.map(({ chunk,hash }) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', hash) formData.append('filename', this.container.file.name) return { formData } }).map(({ formData }) => this.request({ url: 'http://localhost:3000', data: formData }) ) // 并发请求 await Promise.all(requestList) // +++++++++ 新增代码 +++++++++ // 合并切片 await this.mergeRequest() // +++++++++++ End +++++++++++ }, // +++++++++ 新增代码 +++++++++ async mergeRequest() { await this.request({ url: 'http://localhost:3000/merge', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename: this.container.file.name }) }) } // +++++++++++ End +++++++++++ async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.data = fileChunkList.map(({ file },index) => ({ chunk: file, // 文件名+数组下标,后续优化 hash: this.container.file.name + '-' + index })) await this.uploadChunks() } } } </script> -
显示上传进度条 上传进度分两种,一个是每个切片的上传进度,另一个是整个文件的上传进度,而整个文件的上传进度是基于每个切片上传进度计算而来。
- 单个切片进度条
XMLHttpRequest 原生支持上传进度的监听,只需要监听
upload.onprogress即可,我们在原来的 request 基础上传入 onProgress 参数,给 XMLHttpRequest 注册监听事件:由于每个切片都需要触发独立的监听事件,所以需要一个工厂函数,根据传入的切片返回不同的监听函数。在原先的前端上传逻辑中新增监听函数部分:// 请求方法 request() 实现 request({ url, method = 'post', headers = {}, onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.upload.onprogress = onProgress xhr.open(method, url) Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ) xhr.send(data) xhr.onload = e => { resolve({ data: e.target.response }) } }) }每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可。// 上传切片,同时过滤已上传的切片,新增 index async uploadChunks() { const requestList = this.data.map(({ chunk, hash, index }) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', hash) formData.append('filename', this.container.file.name) return { formData, index } }).map(({ formData, index }) => this.request({ url: 'http://localhost:3000', data: formData }) ) // 并发请求 await Promise.all(requestList) // 合并切片 await this.mergeRequest() }, // 新增 index 和 percentage async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.data = fileChunkList.map(({ file },index) => ({ chunk: file, // 文件名 数组下标 hash: this.container.file.name + '-' + index, index, percentage: 0 })) await this.uploadChunks() } // 新增 createProgressHandler 方法 createProgressHandler(item) { return e => { item.percentage = parseInt(String((e.loaded / e.total) * 100)) } } - 总进度条
将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 Vue 的计算属性。
computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0 const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur) return parseInt((loaded / this.container.file.size).toFixed(2)) } }
- 单个切片进度条
XMLHttpRequest 原生支持上传进度的监听,只需要监听
断点续传
断点续传的原理在于前端或者服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能:前端使用 localStorage 记录已上传的切片 hash;服务端保存已上传的切片 hash,前端每次上传前向服务端获取已上传的切片。 第一种是前端的解决方案,第二种是服务端,而前端方案有一个缺陷,如果换了个浏览器就失去了记忆的效果,所以这里选后者。
-
生成 hash 之前使用文件名+切片下标作为切片 hash,这样做文件名一旦修改就失去了效果,而事实上只要文件内容不变,hash 就不应该变化,所以正确的做法是根据文件内容生成 hash,所以需要修改一下 hash 的生成规则。
使用另一个库
spark-md5,它可以根据文件内容计算出文件的 hash 值。另外考虑到如果上传一个超大文件,读取文件内容计算 hash 是非常耗费时间的,并且会引起 UI 的阻塞,导致页面假死状态,所以我们使用
web-worker在 worker 线程计算 hash,这样用户仍可以在主界面正常的交互。由于实例化
web-worker时,参数是一个 js 文件路径且不能跨域,所以单独创建一个hash.js文件放在 public 目录下,另外在 worker 中也是不允许访问 DOM 的,但它提供了importScripts函数用于导入外部脚本,通过它导入spark-md5。// /public/hash.js // 导入脚本 self.importScripts("/spark-md5.min.js") // 生成文件 hash self.onmessage = e => { const { fileChunkList } = e.data const spark = new self.SparkMD5.ArrayBuffer() let percentage = 0 let count = 0 const loadNext = index => { const reader = new FileReader() reader.readAsArrayBuffer(fileChunkList[index].file) reader.onload = e => { count++ spark.append(e.target.result) if (count === fileChunkList.length) { self.postMessage({ percentage: 100, hash: spark.end() }) self.close() } else { percentage += 100 / fileChunkList.length self.postMessage({ percentage }) // calculate recursively loadNext(count) } } } loadNext(0) }在 worker 线程中,接受文件切片 fileChunkList,利用 fileReader 读取每个切片的 ArrayBuffer 并不断传入
spark-md5中,每计算完一个切片通过postMessage向主线程发送一个进度事件,全部完成后将最终的 hash 发送给主线程。注意:
spark-md5文档中要求传入所有切片并算出 hash 值,不能直接将整个文件放入计算,否则即使不同文件也会有相同的 hash。接着编写主线程与 worker 线程通讯的逻辑:
// 新增 calculateHash 方法,生成文件 hash(web-worker) calculateHash(fileChunkList) { return new Promise(resolve => { // 添加 worker 属性 this.container.worker = new Worker("/hash.js") this.container.worker.postMessage({ fileChunkList }) this.container.worker.onmessage = e => { const { percentage, hash } = e.data this.hashPercentage = percentage if (hash) { resolve(hash) } } }) } async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.container.hash = await this.calculateHash(fileChunkList) // 新增代码 this.data = fileChunkList.map(({ file },index) => ({ fileHash: this.container.hash, // 新增代码 chunk: file, // 文件名 数组下标 hash: this.container.file.name + '-' + index, index, percentage: 0 })) await this.uploadChunks() }主线程使用 postMessage 给 worker 线程传入所有切片 fileChunkList,并监听 worker 线程发出的 postMessage 事件拿到文件 hash。至此前端需要将之前用文件名作为 hash 的地方改写为 worker 返回的 hash。
-
文件秒传 在实现断点续传前先尝试实现一下文件秒传,因为在后续恢复上传时会用到。文件秒传就是在服务端已经存在了上传的资源,所以当用户再次上传时会直接提示上传成功。文件秒传需要依赖上一步生成的 hash,即在上传前,先计算出文件 hash,并把 hash 发送给服务端进行验证,由于 hash 的唯一性,所以一旦服务端能找到 hash 相同的文件,则直接返回上传成功的信息即可。
// 新增 verifyUpload 方法 async verifyUpload(filename, fileHash) { const { data } = await this.request({ url: 'http://localhost:3000/verify', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename, fileHash }) }) return JSON.parse(data) } async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.container.hash = await this.calculateHash(fileChunkList) // +++++++++ 新增代码 +++++++++ const { shouldUpload } = await this.verifyUpload(this.container.file.name, this.container.hash) if (!shouldUpload) { this.$message.success('skip upload:file upload success') return } // +++++++++++ End +++++++++++ this.data = fileChunkList.map(({ file },index) => ({ fileHash: this.container.hash, chunk: file, // 文件名 数组下标 hash: this.container.file.name + '-' + index, index, percentage: 0 })) await this.uploadChunks() }秒传其实就是给用户看的障眼法,实质上根本没有上传。
-
暂停上传 完成了生成 hash 和文件秒传,回到断点续传。断点续传即断点 + 续传,所以第一步先实现“断点”,也就是暂停上传。原理是使用 XMLHttpRequest 的 abort 方法,可以取消一个 xhr 请求的发送,为此需要将上传每个切片的 xhr 对象保存起来,需要再改造一下 request 方法。
request({ url, method = 'post', headers = {}, onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.upload.onprogress = onProgress xhr.open(method, url) Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ) xhr.send(data) xhr.onload = e => { // +++++++++ 新增代码 +++++++++ // 将请求成功的 xhr 从列表中删除 if (requestList) { const xhrIndex = requestList.findIndex(item => item === xhr) requestList.splice(xhrIndex, 1) } // +++++++++++ End +++++++++++ resolve({ data: e.target.response }) } // +++++++++ 新增代码 +++++++++ // 暴露当前 xhr 给外部 requestList?.push(xhr) // +++++++++++ End +++++++++++ }) }这样在上传切片时传入 requestList 数组作为参数,request 方法就会将所有的 xhr 保存在数组中了。每当一个切片上传成功时,将对应的 xhr 从 requestList 中删除,所以 requestList 中只保存正在上传切片的 xhr。
之后新建一个暂停按钮,当点击按钮时,调用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上传的切片。
handlePause() { this.requestList.forEach(xhr => xhr?.abort()) this.requestList = [] } -
恢复上传 断点续传采用服务端存储的方式实现。由于当文件切片上传后,服务端会建立一个文件夹存储所有上传的切片,所以每次前端上传前可以调用一个接口,服务端将已上传的切片的切片名返回,前端再跳过这些已经上传切片,这样就实现了续传的效果,而这个接口可以和之前秒传的验证接口合并,前端每次上传前发送一个验证的请求,返回两种结果:
- 服务端已存在该文件,不需要再次上传
- 服务端不存在该文件或者已上传部分文件切片,通知前端进行上传,并把已上传的文件切片返回给前端。
前端有两个地方需要调用验证的接口:
- 点击上传时,检查是否需要上传和已上传的切片
- 点击暂停后的恢复上传,返回已上传的切片 新增恢复按钮并改造原来上传切片的逻辑。
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> <el-button @click="handlePause" v-if="isPaused">暂停</el-button> <!-- 新增代码 --> <el-button @click="handleResume" v-else>恢复</el-button> <!-- END --> </div> </template> <script> const SIZE = 10 * 1024 * 1024 export default { data: () => ({ container: { file: null }, data: [] }), computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0 const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur) return parseInt((loaded / this.container.file.size).toFixed(2)) } }, methods: { request({ url, method = 'post', headers = {}, onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.upload.onprogress = onProgress xhr.open(method, url) Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ) xhr.send(data) xhr.onload = e => { if (requestList) { const xhrIndex = requestList.findIndex(item => item === xhr) requestList.splice(xhrIndex, 1) } resolve({ data: e.target.response }) } requestList?.push(xhr) }) }, handleFileChange(e) { const [file] = e.target.files if (!file) return Object.assign(this.$data, this.$options.data()) his.container.file = file }, handlePause() { this.requestList.forEach(xhr => xhr?.abort()) this.requestList = [] }, // +++++++++ 新增代码 +++++++++ async handleResume() { const { uploadedList } = await this.verifyUpload(this.container.file.name, this.container.hash) await this.uploadChunks(uploadedList) }, // ++++++++++ END ++++++++++ createFileChunk(file, size = SIZE) { const fileChunkList = [] let cur = 0 while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur size) }) cur += size } return fileChunkList }, async uploadChunks(uploadedList = []) { // 新增 uploadedList const requestList = this.data // +++++++++ 新增代码 +++++++++ .filter(({ hash }) => !uploadedList.includes(hash)) // ++++++++++ END ++++++++++ .map(({ chunk, hash, index }) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', hash) formData.append('filename', this.container.file.name) return { formData, index } }).map(({ formData, index }) => this.request({ url: 'http://localhost:3000', data: formData }) ) await Promise.all(requestList) // +++++++++ 新增代码 +++++++++ // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时合并切片 if (uploadedList.length + requestList.length === this.data.length) { await this.mergeRequest() } // ++++++++++ END ++++++++++ }, async mergeRequest() { await this.request({ url: 'http://localhost:3000/merge', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename: this.container.file.name }) }) }, async verifyUpload(filename, fileHash) { const { data } = await this.request({ url: 'http://localhost:3000/verify', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename, fileHash }) }) return JSON.parse(data) }, calculateHash(fileChunkList) { return new Promise(resolve => { this.container.worker = new Worker('/hash.js') this.container.worker.postMessage({ fileChunkList }) this.container.worker.onmessage = e => { const { percentage, hash } = e.data this.hashPercentage = percentage if (hash) { resolve(hash) } } }) }, async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.container.hash = await this.calculateHash(fileChunkList) const { shouldUpload, uploadedList } = await this.verifyUpload(this.container.file.name, this.container.hash) // 新增 uploadedList if (!shouldUpload) { this.$message.success('skip upload:file upload success') return } this.data = fileChunkList.map(({ file },index) => ({ fileHash: this.container.hash, chunk: file, hash: this.container.file.name + '-' + index, index, percentage: 0 })) await this.uploadChunks(uploadedList) // 新增 uploadedList }, createProgressHandler(item) { return e => { item.percentage = parseInt(String((e.loaded / e.total) * 100)) } } } } </script>这里给原来上传切片的函数新增 uploadedList 参数,即上图中服务端返回的切片名列表,通过 filter 过滤掉已上传的切片,并且由于新增了已上传的部分,所以之前合并接口的触发条件做了一些改动。到这里断点续传的功能基本完成了。
-
进度条改进 虽然实现了断点续传,但还需要修改一下进度条的显示规则,否则在暂停上传/接收到已上传切片时的进度条会出现偏差。
<template> <div> <input type="file" @change="handleFileChange" /> <el-button @click="handleUpload">上传</el-button> <el-button @click="handlePause" v-if="isPaused">暂停</el-button> <el-button @click="handleResume" v-else>恢复</el-button> </div> </template> <script> const SIZE = 10 * 1024 * 1024 export default { data: () => ({ container: { file: null }, data: [], // +++++++++ 总进度条 新增代码 +++++++++ fakeUploadPercentage: 0 // +++++++++++++++ END +++++++++++++++ }), computed: { uploadPercentage() { if (!this.container.file || !this.data.length) return 0 const loaded = this.data .map(item => item.size * item.percentage) .reduce((acc, cur) => acc + cur) return parseInt((loaded / this.container.file.size).toFixed(2)) } }, // +++++++++ 总进度条 新增代码 +++++++++ watch: { uploadPercentage(now) { if (now > this.fakeUploadPercentage) { this.fakeUploadPercentage = now } } }, // +++++++++++++++ END +++++++++++++++ methods: { request({ url, method = 'post', headers = {}, onProgress = e => e, requestList }) { return new Promise(resolve => { const xhr = new XMLHttpRequest() xhr.upload.onprogress = onProgress xhr.open(method, url) Object.keys(headers).forEach(key => xhr.setRequestHeader(key, headers[key]) ) xhr.send(data) xhr.onload = e => { if (requestList) { const xhrIndex = requestList.findIndex(item => item === xhr) requestList.splice(xhrIndex, 1) } resolve({ data: e.target.response }) } requestList?.push(xhr) }) }, handleFileChange(e) { const [file] = e.target.files if (!file) return Object.assign(this.$data, this.$options.data()) his.container.file = file }, handlePause() { this.requestList.forEach(xhr => xhr?.abort()) this.requestList = [] }, async handleResume() { const { uploadedList } = await this.verifyUpload(this.container.file.name, this.container.hash) await this.uploadChunks(uploadedList) }, createFileChunk(file, size = SIZE) { const fileChunkList = [] let cur = 0 while (cur < file.size) { fileChunkList.push({ file: file.slice(cur, cur size) }) cur += size } return fileChunkList }, async uploadChunks(uploadedList = []) { const requestList = this.data .filter(({ hash }) => !uploadedList.includes(hash)) .map(({ chunk, hash, index }) => { const formData = new FormData() formData.append('chunk', chunk) formData.append('hash', hash) formData.append('filename', this.container.file.name) return { formData, index } }).map(({ formData, index }) => this.request({ url: 'http://localhost:3000', data: formData }) ) await Promise.all(requestList) if (uploadedList.length + requestList.length === this.data.length) { await this.mergeRequest() } }, async mergeRequest() { await this.request({ url: 'http://localhost:3000/merge', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename: this.container.file.name }) }) }, async verifyUpload(filename, fileHash) { const { data } = await this.request({ url: 'http://localhost:3000/verify', headers: { 'content-type': 'application/json' }, data: JSON.stringify({ filename, fileHash }) }) return JSON.parse(data) }, calculateHash(fileChunkList) { return new Promise(resolve => { this.container.worker = new Worker('/hash.js') this.container.worker.postMessage({ fileChunkList }) this.container.worker.onmessage = e => { const { percentage, hash } = e.data this.hashPercentage = percentage if (hash) { resolve(hash) } } }) }, async handleUpload() { if (!this.container.file) return const fileChunkList = this.createFileChunk(this.container.file) this.container.hash = await this.calculateHash(fileChunkList) const { shouldUpload, uploadedList } = await this.verifyUpload(this.container.file.name, this.container.hash) if (!shouldUpload) { this.$message.success('skip upload:file upload success') return } this.data = fileChunkList.map(({ file },index) => ({ fileHash: this.container.hash, chunk: file, hash: this.container.file.name + '-' + index, index, percentage: uploadedList.includes(index) ? 100 : 0 // 单个切片进度条 新增 percentage 逻辑 })) await this.uploadChunks(uploadedList) }, createProgressHandler(item) { return e => { item.percentage = parseInt(String((e.loaded / e.total) * 100)) } } } } </script>-
单个切片进度条 由于在点击上传/恢复上传时,会调用验证接口返回已上传的切片,所以需要将已上传切片的进度变成 100%。uploadedList 会返回已上传的切片,在遍历所有切片时判断当前切片是否在已上传列表里即可。
-
总进度条 总进度条是一个计算属性,根据所有切片的上传进度计算而来,这就遇到了一个问题,点击暂停会取消并清空切片的 xhr 请求,此时如果已经上传了一部分,就会发现文件进度条有倒退的现象,当点击恢复时,由于重新创建了 xhr 导致切片进度清零,所以总进度条就会倒退。
解决方案是创建一个假的进度条,这个假进度条基于文件进度条,但只会停止和增加,然后给用户展示这个假的进度条,这里使用 Vue 的监听属性。当 uploadPercentage 即真的文件进度条增加时,fakeUploadPercentage 也增加,一旦文件进度条后退,假的进度条只需停止即可。
-
至此一个大文件上传 + 断点续传的解决方案就完成了。
总结
-
大文件上传
- 前端上传大文件时使用
Blob.prototype.slice将文件切片,并发上传多个切片,最后发送一个合并的请求通知服务端合并切片 - 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
- 原生 XMLHttpRequest 的
upload.onprogress对切片上传进度的监听 - 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度
- 前端上传大文件时使用
-
断点续传
- 使用
spark-md5根据文件内容算出文件 hash - 通过 hash 可以判断服务端是否已经上传该文件,从而直接提示用户上传成功(秒传)
- 通过 XMLHttpRequest 的 abort 方法暂停切片的上传
- 上传前服务端返回已经上传的切片名,前端跳过这些切片的上传
- 使用
web-worker 补充(断点续传需要使用)
web-worker是 Web 浏览器提供的一种在后台线程中运行 JavaScript 的功能。它独立于主线程运行,可以执行计算密集型或长时间运行的任务,而不会阻塞页面的渲染和交互。通过将大文件切片上传的逻辑放在web-worker中执行,可以充分利用浏览器的多线程能力,提高上传速度,并保持页面的流畅运行。
web-worker 基于 Vue 的基本用法:
- 首先,需要安装
worker-loader,这是一个 webpack 的 loader,用于处理 worker 文件。 - 配置 webpack:
module.exports = { publicPath: './', chainWebpack: config => { config.module .rule('worker') .test(/\.worker\.js$/) .use('worker-loader') .loader('worker-loader') .options({ // 可以查阅 worker-loader 文档,根据自己的需求进行配置 }) } } - 创建 worker
创建一个 worker 文件,并给它一个
.worker.js的扩展名。例如,可以创建一个my-worker.worker.js文件:self.onmessage = function(e) { console.log('Worker: Hello World') const result = doSomeWork(e.data) self.postMessage(result) } function doSomeWork(data) { // 模拟一些工作 return data * 2 } - 使用 worker
在 Vue 组件或其他 JavaScript 文件中,可以像下面这样创建一个 worker 实例:
当组件被挂载时,它将启动 worker,发送一个消息,并在收到 worker 的响应时打印结果。// MyComponent.vue 或其他.js文件 import MyWorker from './my-worker.worker.js' export default { mounted() { this.startWorker() }, methods: { startWorker() { const myWorker = new MyWorker() myWorker.onmessage = (e) => { console.log('Main script: Received result', e.data) } myWorker.postMessage(100) } } }
Web worker 完成之后怎么通知主线程?
- 使用 postMessage 方法
- 在 Web Worker 内部:当任务完成后,使用 postMessage 发送消息给主线程。例如,在一个简单的计算任务中,Web Worker 计算完一个复杂的数学式子后,可以这样发送消息:
// Web Worker内部代码 self.onmessage = function (event) { // 假设event.data是要计算的数据 let result = calculate(event.data) self.postMessage(result) } - 在主线程中接收消息:通过
worker.onmessage来监听 Web Worker 发送的消息。例如:const worker = new Worker('worker.js') worker.onmessage = function (event) { console.log('收到Web Worker的消息:', event.data) // 在这里可以进行后续操作,比如更新UI显示计算结果 }
- 在 Web Worker 内部:当任务完成后,使用 postMessage 发送消息给主线程。例如,在一个简单的计算任务中,Web Worker 计算完一个复杂的数学式子后,可以这样发送消息:
- 结合事件机制(自定义事件)
- 在主线程中定义自定义事件和监听器:
const workerCompletionEvent = new Event('workerCompleted') window.addEventListener('workerCompleted', function () { console.log('Web Worker任务已完成') // 进行相应的操作 }) - 在 Web Worker 内部发送完成信号:任务完成后,通过 postMessage 发送一个特殊的消息来触发主线程的自定义事件。在主线程接收到这个特殊消息后,手动触发自定义事件。例如:
// Web Worker内部代码 self.onmessage = function (event) { // 完成任务 let result = calculate(event.data) // 发送特殊消息表示完成 self.postMessage({ type: 'completed', data: result }) } // 主线程中修改onmessage监听器 worker.onmessage = function (event) { if (event.data.type === 'completed') { window.dispatchEvent(workerCompletionEvent) } }
- 在主线程中定义自定义事件和监听器:
前端的缓存策略怎么设置合适?
- 对于不常更新的静态资源
- CSS 和 JavaScript 文件
- 设置长缓存时间:可以通过在服务器响应头中设置
Cache-Control为max-age = 31536000(1 年,单位是秒)左右。这样用户再次访问包含相同资源的页面时,浏览器可以直接从缓存中获取,减少网络请求,提高加载速度。 - 添加版本号或哈希值:当这些文件内容更新时,改变文件名中的版本号或哈希值。例如,从
styles.css变为styles-v2.css或者styles-1234abcd.css(其中 1234abcd 是文件内容的哈希值)。这样浏览器会将其视为新的文件进行下载,避免用户一直使用旧的缓存文件。
- 设置长缓存时间:可以通过在服务器响应头中设置
- 图片资源
- 缓存时间根据情况设置:对于网站图标、背景图等很少改变的图片,可设置较长缓存时间,如
Cache-Control: max-age = 2592000(30 天)。对于产品图片等可能会定期更新的,缓存时间可以适当缩短。 - 利用浏览器缓存机制:如果图片是通过 HTML 的
<img>标签加载的,浏览器会自动根据服务器返回的缓存策略进行缓存。
- 缓存时间根据情况设置:对于网站图标、背景图等很少改变的图片,可设置较长缓存时间,如
- CSS 和 JavaScript 文件
- 对于经常更新的资源
- 动态生成的脚本或样式
- 不缓存或短缓存时间:如果是每次页面加载都需要重新生成的脚本(如包含实时数据的脚本),可以设置
Cache-Control: no-cache,让浏览器每次都从服务器获取最新的内容。如果是更新频率稍低(如一天内可能会更新几次)的资源,可以设置较短的缓存时间,如Cache-Control: max-age = 3600(1 小时)。
- 不缓存或短缓存时间:如果是每次页面加载都需要重新生成的脚本(如包含实时数据的脚本),可以设置
- HTML 页面本身
- 考虑用户体验和更新频率:如果是新闻类网站,内容更新频繁,可能希望用户每次访问都获取最新内容,可以设置
Cache-Control: no-cache。但对于一些功能型网站,页面结构和大部分内容不变,只有部分模块更新(如用户的未读消息数量),可以缓存整个页面,通过 JavaScript 来更新动态部分,同时设置Cache-Control的max-age为一个合适的值,如max-age = 600(10 分钟)。
- 考虑用户体验和更新频率:如果是新闻类网站,内容更新频繁,可能希望用户每次访问都获取最新内容,可以设置
- 动态生成的脚本或样式
- 利用本地存储(LocalStorage 和 SessionStorage)作为缓存补充
- 对于一些小型数据资源:如网站的主题配置、用户的简单偏好设置等。这些数据更新频率较低,且对实时性要求不高。可以将其存储在 LocalStorage 中,在页面加载时先从本地存储读取,减少对服务器的请求。例如,用户选择了网站的深色主题,这个信息存储在 LocalStorage 中,下次打开网站时可以直接使用,直到用户再次修改主题。
对于经常更新的文件,如果设已经设置了哈希值,还有没有必要设置一些别的缓存策略?
即使文件设置了哈希值,同时设置其他缓存策略也是有必要的。
- 提升性能方面,能合理利用缓存时间设置进一步优化性能:哈希值主要用于确保文件更新时浏览器能获取新文件,但缓存时间(
Cache-Control中的max-age)设置可以影响浏览器多久检查一次文件是否更新。如果不设置缓存时间,浏览器每次请求都要向服务器发送请求验证文件是否更新(通过If-Modified-Since或If-None-Match请求头),会产生额外的网络开销。而适当设置缓存时间,如对于更新相对不那么频繁(一天更新几次)的文件设置max-age = 3600(1 小时),可以让浏览器在这段时间内直接使用缓存文件,减少验证请求次数,提高加载效率。 - 资源控制方面,利用
no-cache或must-revalidate等指令增强控制:有时候可能希望在文件更新后,用户能尽快获取新内容,但是哈希值更新可能因为某些部署流程延迟而没那么及时生效。这时可以使用Cache-Control: no-cache指令,这样浏览器每次使用缓存文件前会向服务器验证文件有效性,或者使用Cache-Control: must-revalidate,它允许浏览器使用缓存,但一旦服务器上的文件更新,下次使用时必须从服务器获取最新的。这种方式能在哈希值机制外,从缓存策略角度加强对资源更新的控制。
单页面应用和多页面应用的使用场景
- 单页面应用(SPA)的使用场景
- 对用户体验要求高的交互性应用
- 原理:单页面应用在加载初始页面后,后续的页面切换和数据更新通过 JavaScript 动态加载和渲染内容,无需重新加载整个页面。这种方式能够提供非常流畅的用户体验,尤其适用于具有丰富交互功能的应用。
- 示例:像社交媒体应用(如 Facebook、Twitter),用户在浏览动态、评论、私信等不同功能模块时,页面的切换是无缝的。例如,在 Facebook 中,当用户从 “动态” 页面切换到 “好友请求” 页面时,只是部分内容更新,页面不会出现明显的刷新,让用户能够快速地在不同功能之间切换,不会因为页面的重新加载而中断操作,提升了用户的使用效率和体验。
- 移动应用的 Web 版本或响应式 Web 应用
- 原理:SPA 能够很好地适应移动设备的小屏幕和触摸操作。通过单页面的架构,可以针对移动设备的特点进行优化,如手势操作、屏幕适配等。同时,响应式设计在 SPA 中更容易实现,因为可以在一个页面模板的基础上,根据设备的屏幕大小和方向动态调整布局和内容。
- 示例:许多移动应用的 Web 版,如 Instagram 的 Web 应用,采用单页面应用架构,用户在移动设备上访问时,可以方便地浏览图片、查看评论、点赞等操作。并且,在不同的移动设备(如手机和平板电脑)上,页面能够自适应屏幕大小,提供一致的用户体验。
- 需要频繁更新局部数据的应用
- 原理:单页面应用可以通过 Ajax 或其他数据获取方式,方便地更新页面的局部内容。这对于那些需要实时显示数据变化的应用非常有用,如股票交易应用、实时聊天应用等。
- 示例:在股票交易应用中,股票价格是实时变化的。单页面应用可以通过 WebSockets 或定期的 Ajax 请求获取最新的股票价格数据,并在页面的局部区域(如股票价格显示栏)进行更新,而不会影响其他部分的显示,让用户能够及时了解股票行情的变化。
- 对用户体验要求高的交互性应用
- 多页面应用(MPA)的使用场景
- 内容驱动型网站
- 原理:多页面应用每个页面都有独立的 HTML 文件,这种结构非常适合内容以页面为单位进行组织的网站。搜索引擎更容易对每个独立页面进行索引,有利于内容的搜索优化。同时,对于以文章、产品介绍等内容为主的网站,用户通常是通过链接从一个页面跳转到另一个页面,这种页面跳转的方式符合用户的浏览习惯。
- 示例:新闻网站(如 BBC、CNN),每一篇新闻文章都有自己独立的页面。当用户通过搜索引擎搜索新闻或者在网站的分类目录中浏览时,会打开不同的新闻页面。这些页面的内容相对独立,而且网站需要搜索引擎能够准确地索引每一篇新闻,以便用户能够方便地找到所需的信息。
- 企业官网或展示型网站
- 原理:企业官网通常包括多个不同功能的页面,如首页、关于我们、产品展示、联系我们等。这些页面的内容和布局相对固定,不需要复杂的交互功能。多页面应用的结构可以清晰地划分每个页面的功能,方便网站的维护和更新。
- 示例:一个汽车制造企业的官网,首页用于展示品牌形象和最新车型,“产品展示” 页面详细介绍各种车型的配置、参数等信息,“联系我们” 页面提供销售咨询和售后服务的联系方式。每个页面都有独立的 URL,用户可以通过浏览器的前进、后退按钮方便地在页面之间切换,而且这种结构对于网站的开发和后期维护(如更新产品信息、修改页面布局等)都比较简单。
- 电子商务网站的某些部分(如结账流程)
- 原理:在电子商务网站中,购物流程的部分环节(如商品列表、商品详情等)可以采用单页面应用的方式来提升用户体验,如快速查看商品详情、加入购物车等操作。但是,结账流程通常采用多页面应用的方式。这是因为结账涉及到多个步骤(如填写收货信息、选择支付方式、确认订单等),每个步骤的信息完整性和准确性非常重要,采用独立的页面可以让用户更加专注于当前步骤的操作,避免信息混乱。
- 示例:在亚马逊的购物流程中,用户在浏览商品和添加商品到购物车时,页面的交互比较流畅,类似单页面应用的方式。但是当用户进入结账流程时,会出现一系列独立的页面,引导用户完成收货信息填写、支付方式选择等步骤,每个页面都有明确的功能和提示,帮助用户顺利完成购物。
- 内容驱动型网站
选择单页面应用还是多页面应用的考量因素有哪些?
- 用户体验方面
- 页面切换流畅度
- 单页面应用(SPA):在 SPA 中,页面切换通常是通过 JavaScript 动态加载和替换页面部分内容来实现的,这种方式可以让用户在切换页面时感觉非常流畅,几乎没有明显的刷新感。例如,在一个 SPA 的电商应用中,用户从商品列表页面切换到商品详情页面时,只是商品详情部分的内容更新,而页面的其他部分(如导航栏、购物车图标等)保持不变,整个过程就像在一个页面内进行局部更新一样,不会打断用户的操作流程,提供了连贯的体验。
- 多页面应用(MPA):MPA 每次页面切换都需要重新加载整个页面,这可能会导致短暂的空白或加载动画,用户会明显感觉到页面的刷新。不过,如果页面内容相对独立,且用户对这种切换方式已经习惯(如新闻网站,用户浏览不同新闻文章时),这种刷新带来的影响可能可以接受。
- 响应速度和交互性
- 单页面应用:由于 SPA 可以预先加载一些资源,并且在页面内进行局部更新,对于需要频繁交互的操作,如实时数据更新(如股票价格实时显示)、动态筛选数据(如在房产应用中根据价格和面积筛选房源)等场景,能够更快地响应用户操作。例如,在一个在线协作工具(SPA)中,用户对文档的编辑、评论等操作可以实时更新在页面上,不需要重新加载页面就能看到其他用户的操作和反馈。
- 多页面应用:MPA 在交互性方面相对较弱,因为每次交互可能涉及到整个页面的重新加载。但对于一些简单的交互,如表单提交(如在企业官网的联系我们页面提交咨询信息)后跳转到确认页面,这种方式也是足够的。
- 页面切换流畅度
- 开发和维护方面
- 代码结构和复杂度
- 单页面应用:SPA 的代码结构通常比较复杂,因为所有的页面逻辑和功能都集中在一个 JavaScript 应用中。前端路由的管理、状态管理(如使用 Redux 或 Vuex)等都需要精心设计。例如,在一个大型的 SPA 项目中,随着功能的增加,路由配置可能会变得非常复杂,需要处理各种不同的页面路径和参数,而且不同组件之间的通信和数据共享也需要良好的架构来支持。
- 多页面应用:MPA 的每个页面相对独立,代码结构比较清晰。每个页面都有自己的 HTML、CSS 和 JavaScript 文件,开发人员可以分别对每个页面进行开发和维护。例如,在一个新闻网站(MPA)中,新闻编辑页面和新闻展示页面的代码可以分别进行开发,互不干扰,维护起来相对简单。
- 技术栈和团队技能
- 单页面应用:SPA 通常需要使用一些前端框架(如 React、Vue、Angular)来构建复杂的单页应用架构,并且需要掌握如 Webpack 等构建工具来打包和优化代码。如果团队成员对这些技术比较熟悉,那么开发 SPA 会更顺利。例如,一个以 JavaScript 开发为主的团队,已经有丰富的 React 开发经验,那么选择 SPA 来构建应用可以更好地发挥他们的技能优势。
- 多页面应用:MPA 对技术栈的要求相对灵活,开发人员可以使用基本的 HTML、CSS 和 JavaScript 来构建页面。对于一些小型项目或者团队技术能力不太统一的情况,MPA 可能更容易上手。例如,一个由设计人员和初级开发人员组成的团队,在开发企业官网时,采用 MPA 可以利用设计人员熟悉 HTML 和 CSS 的优势,分别制作各个页面,然后由初级开发人员添加简单的 JavaScript 交互功能。
- 代码结构和复杂度
- 性能和资源管理方面
- 首次加载性能
- 单页面应用:SPA 首次加载时需要加载大量的 JavaScript、CSS 等资源,因为它要包含整个应用的逻辑和样式,这可能导致首次加载时间较长。不过,可以通过代码分割、懒加载等技术来缓解这个问题。例如,一个 SPA 电商应用首次加载时,可能需要加载商品列表、购物车、用户中心等多个模块的代码,但是可以将一些不常用的模块(如用户的订单历史详情模块)进行懒加载,在用户需要访问时再加载,以减少首次加载的负担。
- 多页面应用:MPA 每个页面只加载自己所需的资源,首次加载相对较快。例如,在一个简单的企业官网(MPA)中,首页可能只需要加载基本的品牌展示图片、导航栏样式和少量的 JavaScript 交互代码,而产品展示页面则只加载与产品相关的图片和介绍文本的资源,每个页面的资源加载比较有针对性。
- 资源缓存和更新
- 单页面应用:SPA 由于资源集中,在更新资源时可能需要重新加载整个应用或者部分重要模块。但同时,也可以利用缓存策略来提高性能,例如,将一些基本的框架代码和样式进行缓存,在下次访问时可以更快地加载。不过,这也可能导致版本更新时出现缓存问题,需要合理设置缓存策略来确保用户获取到最新的应用版本。
- 多页面应用:MPA 可以根据页面的更新情况分别对每个页面的资源进行更新和缓存。例如,在新闻网站中,当一篇新闻文章的内容更新时,只需要更新该文章对应的页面资源,而其他页面的资源不受影响,缓存管理相对简单。
- 首次加载性能
- 搜索引擎优化(SEO)方面
- 单页面应用:SPA 对 SEO 不太友好,因为搜索引擎爬虫在初始抓取时可能无法获取到完整的页面内容,尤其是那些通过 JavaScript 动态加载的内容。不过,可以通过一些技术手段来改善,如服务器端渲染(SSR)或者预渲染,将页面内容提前生成 HTML,以便搜索引擎更好地索引。例如,对于一个 SPA 的博客应用,采用 SSR 技术可以让搜索引擎更好地理解博客文章的内容,提高文章在搜索结果中的排名。
- 多页面应用:MPA 对 SEO 比较友好,每个页面都有独立的 HTML 文件,搜索引擎可以很容易地索引每个页面的内容。例如,在一个内容丰富的企业官网(MPA)中,产品介绍页面、公司新闻页面等都可以被搜索引擎独立索引,用户通过搜索相关关键词可以直接访问到这些页面。
怎么解决单页面只有第一个页面快的问题?
单页面应用(SPA)只有第一个页面加载快,主要是因为后续页面的资源加载和渲染可能没有得到优化。
- 代码分割(Code Splitting)
- 原理:将应用的代码按照功能模块或路由进行分割,这样在访问不同的页面(路由)时,只加载当前页面所需的代码,避免一次性加载所有代码导致首次加载后变慢。
- 实现方式:在 Webpack 等构建工具中,可以使用动态导入
import()来实现代码分割。例如,在 Vue 或 React 应用中,对于不同的路由组件,可以使用动态导入,像const HomeComponent = () => import('./HomeComponent.vue')。这样,只有当用户访问对应的 Home 这个路由时,才会加载HomeComponent.vue这个组件的代码。
- 懒加载(Lazy Loading)
- 原理:懒加载是代码分割的一种应用场景,主要针对图片、脚本、样式等资源。它延迟资源的加载,直到需要使用这些资源时才进行加载。
- 实现方式:
- 图片懒加载:可以使用 IntersectionObserver API 或者一些第三方库(如lazysizes)来实现。例如,使用 IntersectionObserver 时,当图片进入浏览器视口时才会加载。
- 组件懒加载:和代码分割类似,在路由级别或者组件级别进行懒加载。
- 预加载(Pre-loading)
- 原理:在浏览器空闲时间或者用户浏览当前页面时,预先加载可能会访问的下一个页面的部分关键资源,从而加快下一个页面的访问速度。
- 实现方式:
- 利用浏览器空闲时间预加载:可以通过 requestIdleCallback 函数来检测浏览器的空闲时间。在空闲时间里,使用动态导入来加载下一个可能访问页面的代码。
- 通过 link 标签预加载资源:例如,
<link rel="preload" href="next-page-script.js" as="script">可以预先加载下一个页面需要的脚本文件。不过要注意合理使用,避免预加载过多资源导致当前页面性能下降。
- 优化资源缓存策略
- 原理:合理设置资源的缓存策略,使得重复访问页面时,能够快速从缓存中获取资源,而不是重新请求和加载。
- 实现方式:对于不经常变化的脚本、样式和图片等资源,设置较长时间的缓存时间。可以通过服务器端的
Cache-Control头信息来设置,如Cache-Control: max-age = 31536000(缓存一年)。但对于经常更新的资源,要确保能够及时更新缓存,可以使用版本号或者哈希值来控制资源的更新,如script-1.0.1.js,每次更新版本号,浏览器就会重新加载新的脚本。
前端代码调试
- 浏览器开发者工具
- Elements(元素)面板:可以检查和修改页面的 HTML 和 CSS。可以实时查看对元素样式的更改效果,帮助确定样式问题。
- Console(控制台)面板:用于输出日志信息、错误和警告。可以使用
console.log()、console.error()、console.warn()等方法输出变量的值、调试信息等,以便追踪代码的执行情况。 - Sources(源代码)面板:在这里可以设置断点来暂停 JavaScript 代码的执行。当代码执行到断点处时,可以查看变量的值、调用栈等信息,逐行执行代码以查找问题。
- Network(网络)面板:查看页面加载过程中的所有网络请求。可以检查请求和响应的详细信息,包括请求头、响应头、响应体等,有助于排查网络相关的问题。
- 使用 debugger 语句 在 JavaScript 代码中,可以在特定位置插入 debugger 语句。当代码执行到这个位置时,浏览器会暂停执行,类似于在开发者工具中设置断点。
- 使用前端调试工具库
Vue.js Devtoolsaxios-interceptorsReact Developer ToolsRedux DevTools
- 单元测试 使用测试框架编写单元测试用例。针对特定的函数或模块进行测试,确保其功能正确。如果测试用例失败,说明对应的代码可能存在问题。
console.log()的缺点
使用console.log()的步骤:
- 找到需要调试代码的具体位置
- 写上一行
console.log()代码,传入需要打印的变量 - 保存,等待项目热更新完成
- 打开控制台,查看打印的变量值
- 调试结束,删掉打印的那一行代码
限定条件比较多,调试变量需要这五个步骤一个都不能省。遇到复杂的函数执行逻辑时,甚至要在每个函数中打印来确定函数的执行,这种情况下实在是不怎么方便。简单的调试还是可取的,较为复杂的调试就不推荐使用了。
浏览器 Sources(源代码)面板
开发环境
- 设置断点:
- 在 JavaScript 代码中,可以通过点击行号来设置断点。当代码执行到断点处时,浏览器会暂停执行,你可以查看变量的值、调用栈等信息。
- 可以设置条件断点,只有当满足特定条件时才会触发断点。例如,可以设置当某个变量的值等于特定值时触发断点。
- 单步执行代码:
当代码暂停在断点处时,可以使用调试工具栏上的按钮进行单步执行。包括
Step Over(单步执行,不进入函数内部)、Step Into(单步执行,进入函数内部)、Step Out(从当前函数中跳出)等按钮。 - 查看变量值: 在右侧的 Scope(作用域)选项卡中,可以查看当前作用域下的变量值。可以展开对象查看其属性值。
生产环境
需要在 Vue 项目部署的时候手动配置一下sourcemap。在vue.config.js中开启生产环境sourcemap,设置productionSourceMap: true,并且在 configureWebpack 中设置生产环境时config.devtool = 'hidden-source-map'。
前端调试的工具库
Vue.js Devtools- 针对 Vue 项目的调试工具。
- 功能:允许你检查 Vue 实例的状态、观察组件的层级结构、查看事件监听器等。在开发 Vue 应用时,如果出现数据绑定问题或者组件渲染异常,可以借助这个工具快速定位问题所在。
- 举例:当一个 Vue 组件的数据没有正确显示在页面上时,通过
Vue.js Devtools可以查看组件的 data 属性是否正确赋值,以及是否有错误的计算属性或方法影响了数据的显示。
axios-interceptors- 如果项目中使用 axios 进行网络请求,这个工具库很有帮助。
- 功能:可以在 axios 请求和响应中添加拦截器,用于记录请求和响应的详细信息,包括请求 URL、参数、响应状态码、响应数据等。这对于排查网络请求相关的问题非常有用。
- 举例:当一个网络请求失败时,可以通过
axios-interceptors记录的信息查看请求是否正确发送,服务器的响应是否符合预期,从而快速确定问题是在客户端还是服务器端。
React Developer Tools- 对于使用 React 框架开发的项目来说不可或缺。
- 功能:可以检查 React 组件的层次结构、查看组件的属性和状态。在开发过程中,能够实时查看组件的更新情况,帮助你理解组件的渲染过程和数据传递。当组件的状态或属性出现问题时,可以快速定位到相关组件进行调试。
- 举例:如果一个 React 组件没有按照预期渲染,你可以使用
React Developer Tools查看该组件的属性是否正确设置,以及状态是否正确更新。
Redux DevTools- 在使用 Redux 进行状态管理的项目中,
Redux DevTools非常有用。 - 功能:它可以记录 Redux store 的状态变化,让你查看每一个 action 被触发后的状态变化过程,方便追踪数据流转和排查问题。你可以轻松地回退和前进到不同的状态,以检查特定 action 对状态的影响。
- 举例:当你在一个复杂的 Redux 应用中,某个数据的变化出现了问题,通过
Redux DevTools可以快速确定是哪个 action 导致了状态的错误变化,从而更有针对性地进行调试。
- 在使用 Redux 进行状态管理的项目中,