前端项目相关面试题整理

397 阅读46分钟

Vue 项目实现用户登录

在前后端完全分离的情况下,Vue 项目中实现 token 验证大致思路如下:

  1. 第一次登录的时候,前端调后端的登陆接口,发送用户名和密码。
  2. 后端收到请求,验证用户名和密码,验证成功,就给前端返回一个 token。
  3. 前端拿到 token,将 token 存储到 localStorage 和 Vuex 中,并跳转路由页面。
  4. 此后前端每次跳转路由,就判断 localStorage 中有无 token ,没有就跳转到登录页面,有则跳转到对应路由页面。
    • 导航守卫:使用router.beforeEach注册一个全局前置守卫,判断用户是否登陆
    • 导航守卫仅仅简单判断是否有 token 值存在(不管该 token 是否有效),如果不存在/失效就进行重定向
  5. 每次调后端接口,都要在请求头中加 token。
    • 请求拦截器,每次请求都会在请求头中携带 token
    • 请求拦截器是向后端发送请求并校验,如果 token 合法就访问成功,否则访问失败并进行重定向
  6. 后端判断请求头中有无 token,有的话就拿到 token 并验证 token,验证成功就返回数据,验证失败(例如:token 过期)就返回 401,请求头中没有 token 也返回 401。
  7. 如果前端拿到状态码为 401,就清除 token 信息并跳转到登录页面。
  8. 调取登录接口成功,会在回调函数中将 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 秒
      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);
      });
      
      在这个示例中,首先获取当前时间 currentTime。如果当前时间与上一次请求时间 lastRequestTime 的间隔小于 1000 毫秒(1 秒),则创建一个新的 Promise。在这个 Promise 中,使用 setTimeout 来延迟请求,延迟的时间是剩余的间隔时间(1000 - (当前时间 - 上一次请求时间))。当延迟时间结束后,更新 lastRequestTime 为当前时间,并通过 resolve 继续发送请求。如果间隔时间大于等于 1 秒,则直接更新 lastRequestTime 为当前时间并发送请求。
  • 基于令牌桶算法的限制
    • 原理:令牌桶算法是一种常用的流量控制算法。它有一个固定容量的桶,按照固定的速率往桶里添加令牌。每个请求需要获取一个令牌才能被处理。如果桶里没有令牌,请求就会被限制。
    • 示例代码(使用 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 个请求
      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);
      });
      
      在这个示例中,首先定义了一个请求队列 requestQueue、时间窗口 timeWindow(5000 毫秒,即 5 秒)和时间窗口内的最大请求数量 maxRequestsInWindow(3 个)。当一个请求到来时,将其时间戳添加到请求队列 requestQueue 中。然后,检查队列中是否有时间窗口外的请求,如果有则将其从队列头部移除。接着,检查队列中的请求数量是否超过限制。如果超过限制,则创建一个新的 Promise,并使用 setInterval 定期检查队列。当队列中的请求数量小于等于限制时,清除定时器并发送请求(通过 resolve)。如果队列中的请求数量没有超过限制,则直接发送请求(返回 config)。

前端应该如何存储 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
            })
          }
        })
      }
      
      由于每个切片都需要触发独立的监听事件,所以需要一个工厂函数,根据传入的切片返回不同的监听函数。在原先的前端上传逻辑中新增监听函数部分:
      // 上传切片,同时过滤已上传的切片,新增 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))
        }
      }
      
      每个切片在上传时都会通过监听函数更新 data 数组对应元素的 percentage 属性,之后把将 data 数组放到视图中展示即可。
    • 总进度条 将每个切片已上传的部分累加,除以整个文件的大小,就能得出当前文件的上传进度,所以这里使用 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))
        }
      }
      

断点续传

断点续传的原理在于前端或者服务端需要记住已上传的切片,这样下次上传就可以跳过之前已上传的部分,有两种方案实现记忆的功能:前端使用 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 实例:
    // 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)
        }
      }
    }
    
    当组件被挂载时,它将启动 worker,发送一个消息,并在收到 worker 的响应时打印结果。

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显示计算结果
      }
      
  • 结合事件机制(自定义事件)
    • 在主线程中定义自定义事件和监听器:
      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-Controlmax-age = 31536000(1 年,单位是秒)左右。这样用户再次访问包含相同资源的页面时,浏览器可以直接从缓存中获取,减少网络请求,提高加载速度。
      • 添加版本号或哈希值:当这些文件内容更新时,改变文件名中的版本号或哈希值。例如,从styles.css变为styles-v2.css或者styles-1234abcd.css(其中 1234abcd 是文件内容的哈希值)。这样浏览器会将其视为新的文件进行下载,避免用户一直使用旧的缓存文件。
    • 图片资源
      • 缓存时间根据情况设置:对于网站图标、背景图等很少改变的图片,可设置较长缓存时间,如Cache-Control: max-age = 2592000(30 天)。对于产品图片等可能会定期更新的,缓存时间可以适当缩短。
      • 利用浏览器缓存机制:如果图片是通过 HTML 的<img>标签加载的,浏览器会自动根据服务器返回的缓存策略进行缓存。
  • 对于经常更新的资源
    • 动态生成的脚本或样式
      • 不缓存或短缓存时间:如果是每次页面加载都需要重新生成的脚本(如包含实时数据的脚本),可以设置Cache-Control: no-cache,让浏览器每次都从服务器获取最新的内容。如果是更新频率稍低(如一天内可能会更新几次)的资源,可以设置较短的缓存时间,如Cache-Control: max-age = 3600(1 小时)。
    • HTML 页面本身
      • 考虑用户体验和更新频率:如果是新闻类网站,内容更新频繁,可能希望用户每次访问都获取最新内容,可以设置Cache-Control: no-cache。但对于一些功能型网站,页面结构和大部分内容不变,只有部分模块更新(如用户的未读消息数量),可以缓存整个页面,通过 JavaScript 来更新动态部分,同时设置Cache-Controlmax-age为一个合适的值,如max-age = 600(10 分钟)。
  • 利用本地存储(LocalStorage 和 SessionStorage)作为缓存补充
    • 对于一些小型数据资源:如网站的主题配置、用户的简单偏好设置等。这些数据更新频率较低,且对实时性要求不高。可以将其存储在 LocalStorage 中,在页面加载时先从本地存储读取,减少对服务器的请求。例如,用户选择了网站的深色主题,这个信息存储在 LocalStorage 中,下次打开网站时可以直接使用,直到用户再次修改主题。

对于经常更新的文件,如果设已经设置了哈希值,还有没有必要设置一些别的缓存策略?

即使文件设置了哈希值,同时设置其他缓存策略也是有必要的。

  • 提升性能方面,能合理利用缓存时间设置进一步优化性能:哈希值主要用于确保文件更新时浏览器能获取新文件,但缓存时间(Cache-Control中的max-age)设置可以影响浏览器多久检查一次文件是否更新。如果不设置缓存时间,浏览器每次请求都要向服务器发送请求验证文件是否更新(通过If-Modified-SinceIf-None-Match请求头),会产生额外的网络开销。而适当设置缓存时间,如对于更新相对不那么频繁(一天更新几次)的文件设置max-age = 3600(1 小时),可以让浏览器在这段时间内直接使用缓存文件,减少验证请求次数,提高加载效率。
  • 资源控制方面,利用no-cachemust-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 Devtools
    • axios-interceptors
    • React Developer Tools
    • Redux DevTools
  • 单元测试 使用测试框架编写单元测试用例。针对特定的函数或模块进行测试,确保其功能正确。如果测试用例失败,说明对应的代码可能存在问题。

console.log()的缺点

使用console.log()的步骤:

  1. 找到需要调试代码的具体位置
  2. 写上一行console.log()代码,传入需要打印的变量
  3. 保存,等待项目热更新完成
  4. 打开控制台,查看打印的变量值
  5. 调试结束,删掉打印的那一行代码

限定条件比较多,调试变量需要这五个步骤一个都不能省。遇到复杂的函数执行逻辑时,甚至要在每个函数中打印来确定函数的执行,这种情况下实在是不怎么方便。简单的调试还是可取的,较为复杂的调试就不推荐使用了。

浏览器 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 导致了状态的错误变化,从而更有针对性地进行调试。