普通视图

发现新文章,点击刷新页面。
昨天 — 2025年8月16日首页
昨天以前首页

浏览器跨标签页通信方案详解

作者 gnip
2025年8月15日 00:03

前言

在现代Web应用开发中,多标签页协作变得越来越常见。用户可能会同时打开应用的多个标签页,而这些标签页之间往往需要进行数据同步或状态共享。本文将全面介绍浏览器环境下实现跨标签页通信的各种方案,分析它们的优缺点,并探讨典型的使用场景。

一、为什么需要跨标签页通信?

在单页应用(SPA)盛行的今天,我们经常会遇到这样的需求:

  1. 用户在标签页A中登录后,其他打开的标签页需要同步更新登录状态
  2. 在标签页B中修改了某些数据,标签页C需要实时显示这些变更
  3. 避免用户在不同标签页中执行冲突操作
  4. 同域名下消息通知同步不同标签页

这些场景都需要不同标签页之间能够进行通信和数据交换。以下介绍几种处理方案。

二、跨标签页通信方案

1. localStorage事件监听

原理:利用localStorage的存储事件,当某个标签页修改了localStorage中的数据时,其他标签页可以通过监听storage事件来获取变更。

// 发送消息的标签页
localStorage.setItem('message', JSON.stringify({ 
  type: 'LOGIN_STATUS_CHANGE',
  data: { isLoggedIn: true }
}));

// 接收消息的标签页
window.addEventListener('storage', (event) => {
  if (event.key === 'message') {
    const message = JSON.parse(event.newValue);
    console.log('收到消息:', message);
    // 处理消息...
  }
});

优点

  • 实现简单,兼容性好
  • 无需额外的服务或依赖

缺点

  • 只能监听其他标签页的修改,当前标签页的修改不会触发自己的事件
  • 传输的数据必须是字符串,需要手动序列化和反序列化
  • 容量限制,几M

2. Broadcast Channel API

原理:Broadcast Channel API允许同源的不同浏览器上下文(标签页、iframe、worker等)之间进行通信。

// 创建或加入频道
const channel = new BroadcastChannel('app_channel');

// 发送消息
channel.postMessage({
  type: 'DATA_UPDATE',
  payload: { /* 数据 */ }
});

// 接收消息
channel.onmessage = (event) => {
  console.log('收到消息:', event.data);
  // 处理消息...
};

// 关闭连接
channel.close();

优点

  • 专为跨上下文通信设计,API简洁
  • 支持任意可序列化对象
  • 性能较好

缺点

  • 兼容性有限(不支持IE和旧版Edge)
  • 需要手动管理频道连接

3. window.postMessage + window.opener

原理:通过window.open()或window.opener获得其他窗口的引用,直接使用postMessage通信。

// 父窗口打开子窗口
const childWindow = window.open('child.html');

// 父窗口向子窗口发送消息
childWindow.postMessage('Hello from parent!', '*');

// 子窗口接收消息
window.addEventListener('message', (event) => {
  // 验证来源
  if (event.origin !== 'https://yourdomain.com') return;
  
  console.log('收到消息:', event.data);
  
  // 回复消息
  event.source.postMessage('Hello back!', event.origin);
});

优点

  • 可以实现跨域通信(需双方配合)
  • 点对点通信效率高

缺点

  • 需要维护窗口引用
  • 安全性需要考虑来源验证
  • 只适用于有明确父子或兄弟关系的窗口

4. Service Worker + MessageChannel

原理:利用Service Worker作为中间人,配合MessageChannel实现双向通信。

// 页面代码
navigator.serviceWorker.controller.postMessage({
  type: 'BROADCAST',
  payload: { /* 数据 */ }
});

// Service Worker代码
self.addEventListener('message', (event) => {
  if (event.data.type === 'BROADCAST') {
    self.clients.matchAll().then(clients => {
      clients.forEach(client => {
        client.postMessage(event.data.payload);
      });
    });
  }
});

// 其他页面接收
navigator.serviceWorker.addEventListener('message', (event) => {
  console.log('收到广播:', event.data);
});

优点

  • 可以实现后台同步
  • 支持推送通知
  • 功能强大

缺点

  • 必须使用HTTPS(本地开发除外)
  • 实现复杂度高
  • 需要处理Service Worker生命周期

5. IndexedDB + 轮询

原理:使用IndexedDB作为共享数据库,各标签页定期检查数据变化。

// 写入数据
function writeMessage(db, message) {
  const tx = db.transaction('messages', 'readwrite');
  tx.objectStore('messages').put({
    id: Date.now(),
    message
  });
}

// 读取新消息
function pollMessages(db, lastId, callback) {
  const tx = db.transaction('messages', 'readonly');
  const store = tx.objectStore('messages');
  const index = store.index('id');
  const request = index.openCursor(IDBKeyRange.lowerBound(lastId, true));
  
  request.onsuccess = (event) => {
    const cursor = event.target.result;
    if (cursor) {
      callback(cursor.value);
      cursor.continue();
    }
  };
}

// 初始化数据库
const request = indexedDB.open('messaging_db', 1);
request.onupgradeneeded = (event) => {
  const db = event.target.result;
  if (!db.objectStoreNames.contains('messages')) {
    const store = db.createObjectStore('messages', { keyPath: 'id' });
    store.createIndex('id', 'id', { unique: true });
  }
};

优点

  • 存储容量大
  • 可以存储复杂数据结构
  • 数据持久化

缺点

  • 需要手动实现轮询机制
  • API较复杂
  • 性能不如即时通信方案

三、方案对比

方案 兼容性 实时性 复杂度 数据容量 适用场景
localStorage事件 优秀 小(5MB) 简单状态同步
BroadcastChannel 中等 同源多标签通信
postMessage 优秀 无限制 有窗口引用关系
ServiceWorker 中等 无限制 PWA/后台同步
IndexedDB 良好 大数据量共享

四、典型使用场景

1. 用户登录状态同步

场景描述:当用户在某个标签页完成登录或退出操作时,其他打开的标签页需要立即更新认证状态。

实现方案

// 登录成功后
localStorage.setItem('auth', JSON.stringify({
  isAuthenticated: true,
  user: { name: 'John', token: '...' }
}));

// 所有标签页监听
window.addEventListener('storage', (event) => {
  if (event.key === 'auth') {
    const auth = JSON.parse(event.newValue);
    if (auth.isAuthenticated) {
      // 更新UI显示已登录状态
    } else {
      // 更新UI显示未登录状态
    }
  }
});

2. 多标签页数据编辑冲突避免

场景描述:当用户在多个标签页编辑同一份数据时,需要防止冲突提交。

实现方案

// 使用BroadcastChannel
const editChannel = new BroadcastChannel('document_edit');

// 开始编辑时发送锁定请求
editChannel.postMessage({
  type: 'LOCK_REQUEST',
  docId: 'doc123',
  userId: 'user456'
});

// 接收锁定状态
editChannel.onmessage = (event) => {
  if (event.data.type === 'LOCK_RESPONSE') {
    if (event.data.docId === currentDocId && !event.data.success) {
      alert('文档正在被其他标签页编辑,请稍后再试');
    }
  }
};

3. 多标签页资源预加载

场景描述:主标签页加载的资源可以被其他标签页共享,避免重复加载。

实现方案

// 使用Service Worker缓存资源
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(response => {
      if (response) {
        // 从缓存返回
        return response;
      }
      
      // 获取并缓存
      return fetch(event.request).then(res => {
        return caches.open('shared-cache').then(cache => {
          cache.put(event.request, res.clone());
          return res;
        });
      });
    })
  );
});

五、总结

浏览器提供了多种跨标签页通信的方案,各有其适用场景:

  • 对于简单的状态同步,localStorage事件是最简单直接的选择
  • 需要更强大的通信能力时,BroadcastChannel API是现代化解决方案
  • 复杂应用可以考虑使用Service Worker作为通信中枢
  • 有明确窗口关系的场景可以使用window.postMessage
  • 大数据量或需要持久化的场景适合使用IndexedDB

运行时模块批量导入

作者 gnip
2025年8月14日 23:50

概述

在工程化项目当中,有时候我们可能需要在运行时态自动批量处理某个文件夹下的所有文件,比如有个如下功能要做:

在一个有不同模块的多语言文件分割的文件中,需要将moudle文件夹下的多语言模块合并到index中进行合并,然后通过i18工具进行多语言注册,常规做法在index中一个个导入对于模块,然后合并,但是随着模块文件越来越多,index里面导入的文件就会变得越来越庞大,并且没有自动化的程度,导致每加一个模块都需要手动导入(很麻烦)。

image.png 针对上面的问题,引入模块运行时自动导入的概念,在工程化项目中(vue、react等),如果需要再运行时获取到工程项目文件的目录相关信息(编译时态),最终经过打包工具(webpack、vite)打包后(运行时态),处理对于文件相关逻辑,因此,根据环境webpack、vite,能够找到两个对于的api

  • require.context
  • import.meta.glob

介绍

(1)require.context(Webpack 特有)

require.context 是 Webpack 提供的一个 API,允许在编译时创建一个上下文,用于匹配指定目录下的文件,并支持动态导入。

(2)import.meta.glob(Vite/Rollup 特有)

import.meta.glob 是 Vite 和 Rollup 提供的功能,用于实现类似 require.context 的模块批量导入,但语法更现代化,支持 ESM(ES Module)。

特性 require.context import.meta.glob
所属工具 Webpack Vite / Rollup
加载方式 同步/动态导入 默认懒加载
返回值 函数 对象(Promise)
适用场景 Webpack 项目 Vite / Rollup 项目

核心用途:为什么需要它们?

(1)自动注册全局组件

在 Vue 项目中,我们通常需要手动注册全局组件:

import Button from './components/Button.vue';
import Input from './components/Input.vue';
// ... 其他组件

app.component('Button', Button);
app.component('Input', Input);
// ... 重复注册

使用 require.context 或 import.meta.glob 可以自动扫描目录并注册,避免重复代码!

(2)动态加载路由

在大型项目中,路由可能非常多,手动导入会很麻烦:

// 传统方式
import Home from './views/Home.vue';
import About from './views/About.vue';
// ... 其他路由

使用批量导入,可以自动生成路由表,提高可维护性。

(3)按需加载语言包/配置文件

例如国际化(i18n)场景,不同语言包可以动态加载:

// 自动加载所有语言包
const locales = import.meta.glob('./locales/*.json');

使用方式对比

(1)require.context(Webpack)

基本语法:

const context = require.context(
  directory,       // 要搜索的目录
  useSubdirectories, // 是否搜索子目录
  regExp,          // 匹配文件的正则表达式
  mode             // 加载模式(可选)
);

示例:自动注册 Vue 组件

const ctx = require.context('./components', true, /.vue$/);

ctx.keys().forEach(path => {
  const component = ctx(path).default;
  const name = path.split('/').pop().replace('.vue', '');
  app.component(name, component);
});

(2)import.meta.glob(Vite)

基本语法:

const modules = import.meta.glob(globPattern, options);

示例 1:懒加载(默认)

const modules = import.meta.glob('./components/*.vue');

for (const path in modules) {
  modules[path]().then((mod) => {
    const name = path.split('/').pop().replace('.vue', '');
    app.component(name, mod.default);
  });
}

示例 2:直接导入(非懒加载)

const modules = import.meta.glob('./components/*.vue', { eager: true });

Object.entries(modules).forEach(([path, mod]) => {
  const name = path.split('/').pop().replace('.vue', '');
  app.component(name, mod.default);
});

核心区别与如何选择?

对比项 require.context import.meta.glob
构建工具 Webpack Vite / Rollup
加载方式 同步(默认) 懒加载(默认)
返回值 函数(context() 对象({ path: Promise }
适用场景 旧项目(Webpack) 新项目(Vite)

如何选择?

  • 如果你的项目使用 Webpack,用 require.context
  • 如果是 Vite / Rollup 项目,用 import.meta.glob
  • import.meta.glob 更现代化,推荐新项目使用。

高级用法:动态路由、国际化等

(1)动态路由(Vite + Vue Router)

const pages = import.meta.glob('../views/**/*.vue');

const routes = Object.entries(pages).map(([path, component]) => {
  const name = path.replace('../views/', '').replace('.vue', '');
  return { path: `/${name}`, component };
});

const router = createRouter({
  history: createWebHistory(),
  routes,
});

(2)国际化(i18n 自动加载语言包)

const locales = import.meta.glob('./locales/*.json', { eager: true });

const messages = {};
Object.entries(locales).forEach(([path, mod]) => {
  const lang = path.split('/').pop().replace('.json', '');
  messages[lang] = mod.default;
});

const i18n = createI18n({
  locale: 'zh',
  messages,
});

总结

  • require.context 是 Webpack 提供的批量导入方案,适用于传统项目。
  • import.meta.glob 是 Vite/Rollup 的现代化替代方案,默认懒加载,更灵活。
  • 两者都能用于 自动注册组件、动态路由、国际化 等场景。
  • 新项目推荐使用 Vite + import.meta.glob,体验更佳!
  • 对于工程化项目(组件库)使用可以大大提高开发效率(组件注册)。

axios 拦截器实现用户无感刷新 access_token

作者 gnip
2025年8月13日 23:52

概述

公司网站登录过期时间都通常有长有短(token 过期时间),有的很短(几个小时),但又想让经常活跃的用户不再次登录,于是才有这样需求,避免了用户再次输入账号密码登录。

为什么要专门用一个 refresh_token 去更新 access_token 呢?首先access_token会关联一定的用户权限,如果用户授权更改了,这个access_token也是需要被刷新以关联新的权限的,如果没有 refresh_token,也可以刷新 access_token,但每次刷新都要用户输入登录用户名与密码,多麻烦。有了 refresh_ token,可以减少这个麻烦,客户端直接用 refresh_token 去更新 access_token,无需用户进行额外的操作。

  • 需求
  1. access_token过期的时候,要用refresh_token去请求获取新的access_token,前端需要做到用户无感知的刷新access_token。比如用户发起一个请求时,如果判断access_token已经过期,那么就先要去调用刷新 token 接口拿到新的access_token,再重新发起用户请求。
  2. 如果同时发起多个用户请求,第一个用户请求去调用刷新 token 接口,当接口还没返回时,其余的用户请求也依旧发起了刷新 token 接口请求,就会导致多个请求,这些请求都要合理处理

思路

写在响应拦截器里,拦截返回后的数据。先发起用户请求,如果接口返回access_token过期,先刷新access_token,再进行一次重试。

  • 优点:无需判断时间
  • 缺点: 会消耗多一次 http 请求

实现

这里使用 axios,其中做的是请求后拦截,所以用到的是 axios 的响应拦截器

方法介绍

有用到cookie,也可以不用,目前localstorage用的最多,根据自己项目决定。

  • @util/auth.js
import Cookies from 'js-cookie'

const TOKEN_KEY = 'access_token'
const REGRESH_TOKEN_KEY = 'refresh_token'
export const REFRESH_TOKEN_STATUS_CODE='402'
export const REFRENSH_UIR='/refresh-token'
export const getToken = () => Cookies.get(TOKEN_KEY)

export const setToken = (token, params = {}) => {
  Cookies.set(TOKEN_KEY, token, params)
}

export const setRefreshToken = token => {
  Cookies.set(REGRESH_TOKEN_KEY, token)
}
  • request.js
import axios from 'axios'
import { getToken, setToken, getRefreshToken,REFRESH_TOKEN_STATUS_CODE,REFRENSH_UIR } from '@util/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post(REFRENSH_UIR, { refresh_token: getRefreshToken() }, true)
}

const instance = axios.create({
  baseURL: 'xxxx.aa/cc',
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
})

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    // token 过期或无效,在此处理逻辑
    return Promise.reject(error)
  }
)

// 给请求头添加 access_token
const setHeaderToken = isNeedToken => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) {
    // api 请求需要携带 access_token
    if (!accessToken) {
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则不携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

接下来改造 request.js 中 axios 的响应拦截器

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE) {
      const { config } = error
      return refreshToken()
        .then(res => {
          const { access_token } = res.data
          setToken(access_token)
          config.headers.Authorization = `Bearer ${access_token}`
          return instance(config)
        })
        .catch(err => {
          console.log('登录状态已失效,请重新登录!')
          return Promise.reject(err)
        })
    }
    return Promise.reject(error)
  }
)

约定返回 402(根据情况定) 状态码表示access_token过期或者无效,如果用户发起一个请求后返回结果是access_token过期,则请求刷新access_token的接口。请求成功则进入then里面,重置配置,并刷新access_token并重新发起原来的请求。

但如果refresh_token也过期了,则请求也是返回 402。此时调试会发现函数进不到refreshToken()catch里面,那是因为refreshToken()方法内部是也是用了同个instance实例,重复响应拦截器 402 的处理逻辑,但该函数本身就是刷新access_token,故需要把该接口排除掉,即:

if (error.response.status === 401 && !error.config.url.includes('/auth/refresh')) {
}

上述代码就已经实现了无感刷新access_token了,当access_token没过期,正常返回;过期时,则 axios 内部进行了一次刷新 token 的操作,再重新发起原来的请求。

优化

防止多次刷新 token

如果 token 是过期的,那请求刷新access_token的接口返回也是有一定时间间隔,如果此时还有其他请求发过来,就会再执行一次刷新access_token的接口,就会导致多次刷新access_token。因此,我们需要做一个判断,定义一个标记判断当前是否处于刷新access_token的状态,如果处在刷新状态则不再允许其他请求调用该接口。

let isRefreshing = false // 标记是否正在刷新 token
instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      }
    }
    return Promise.reject(error)
  }
)

同时发起多个请求的处理

上面做法还不够,因为如果同时发起多个请求,在 token 过期的情况,第一个请求进入刷新 token 方法,则其他请求进去没有做任何逻辑处理,单纯返回失败,最终只执行了第一个请求,这显然不合理。

比如同时发起三个请求,第一个请求进入刷新 token 的流程,第二个和第三个请求需要存起来,等到 token 更新后再重新发起请求。

在此,我们定义一个数组requests,用来保存处于等待的请求,之后返回一个Promise,只要不调用resolve方法,该请求就会处于等待状态,则可以知道其实数组存的是函数;等到 token 更新完毕,则通过数组循环执行函数,即逐个执行 resolve 重发请求。

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            // token 刷新后将数组的方法重新执行
            requests.forEach(cb => cb(access_token))
            requests = [] // 重新请求完清空
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      } else {
        // 返回未执行 resolve 的 Promise
        return new Promise(resolve => {
          // 用函数形式将 resolve 存入,等待刷新后再执行
          requests.push(token => {
            config.headers.Authorization = `Bearer ${token}`
            resolve(instance(config))
          })
        })
      }
    }
    return Promise.reject(error)
  }
)

最终 request.js 代码

import axios from 'axios'
import { getToken, setToken, getRefreshToken ,REFRESH_TOKEN_STATUS_CODE,REFRENSH_UIR} from '@util/auth'

// 刷新 access_token 的接口
const refreshToken = () => {
  return instance.post(REFRENSH_UIR, { refresh_token: getRefreshToken() }, true)
}

// 创建 axios 实例
const instance = axios.create({
  baseURL: process.env.GATSBY_API_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
})

let isRefreshing = false // 标记是否正在刷新 token
let requests = [] // 存储待重发请求的数组

instance.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (!error.response) {
      return Promise.reject(error)
    }
    if (error.response.status === REFRESH_TOKEN_STATUS_CODE && !error.config.url.includes(REFRENSH_UIR)) {
      const { config } = error
      if (!isRefreshing) {
        isRefreshing = true
        return refreshToken()
          .then(res => {
            const { access_token } = res.data
            setToken(access_token)
            config.headers.Authorization = `Bearer ${access_token}`
            // token 刷新后将数组的方法重新执行
            requests.forEach(cb => cb(access_token))
            requests = [] // 重新请求完清空
            return instance(config)
          })
          .catch(err => {
            console.log('登录状态已失效,请重新登录!')
            return Promise.reject(err)
          })
          .finally(() => {
            isRefreshing = false
          })
      } else {
        // 返回未执行 resolve 的 Promise
        return new Promise(resolve => {
          // 用函数形式将 resolve 存入,等待刷新后再执行
          requests.push(token => {
            config.headers.Authorization = `Bearer ${token}`
            resolve(instance(config))
          })
        })
      }
    }
    return Promise.reject(error)
  }
)

// 给请求头添加 access_token
const setHeaderToken = isNeedToken => {
  const accessToken = isNeedToken ? getToken() : null
  if (isNeedToken) {
    // api 请求需要携带 access_token
    if (!accessToken) {
      console.log('不存在 access_token 则跳转回登录页')
    }
    instance.defaults.headers.common.Authorization = `Bearer ${accessToken}`
  }
}

// 有些 api 并不需要用户授权使用,则无需携带 access_token;默认不携带,需要传则设置第三个参数为 true
export const get = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'get',
    url,
    params,
  })
}

export const post = (url, params = {}, isNeedToken = false) => {
  setHeaderToken(isNeedToken)
  return instance({
    method: 'post',
    url,
    data: params,
  })
}

前端实现即时通讯,常用的技术

作者 gnip
2025年8月13日 23:29

概述

前面文章介绍了websocket,sse,轮询这些前后端通讯方案,本期做一期汇总和对比。

短轮询

短轮询(Short Polling)是一种通过定期向服务器发送请求来获取最新数据的实时通讯方法。与长轮询(Long Polling)不同,短轮询不会保持持久连接,而是在每次请求时向服务器询问是否有新数据。

以下是短轮询的基本工作流程:

  1. 客户端发送请求:前端通过定时发送 HTTP 请求到服务器,以获取最新数据。这个请求通常是一个简单的 AJAX 请求。
  2. 服务器响应:服务器接收到请求后,会立即返回当前的数据状态给客户端。如果有新数据,服务器会将其包含在响应中返回;否则,服务器会返回一个空响应或者一个指示没有新数据的状态码。
  3. 客户端处理响应:客户端收到服务器的响应后,解析数据并进行相应的处理。如果服务器返回了新数据,客户端可能会更新界面显示或者执行其他逻辑。
  4. 重复请求:客户端在处理完服务器响应后,会等待一段时间(通常是几秒钟),然后再次发送新的请求,以获取最新的数据状态。
  5. 循环进行:这个过程会不断循环执行,客户端定期发送请求,服务器定期响应,从而实现实时通讯的效果。

在短轮询的实现中,客户端可以使用setInterval()函数周期性地发送请求,然后在收到响应后更新界面。以下是一个简单的示例:

function pollServerForUpdates() {
  setInterval(function() {
    fetch('http://example.com/update')
      .then(response => response.json())
      .then(data => {
        // 处理从服务器返回的数据
        console.log('收到新数据:', data);
      })
      .catch(error => {
        console.error('请求错误:', error);
      });
  }, 5000); // 每5秒发送一次请求
}

pollServerForUpdates();

在实际应用中,需要根据具体情况调整轮询的频率,并且考虑到服务器和网络的性能,以避免对系统造成过大的负担。

优缺点

优点:

  • 简单易实现:短轮询的实现相对简单,不需要特殊的服务器支持,使用简单的HTTP请求即可。
  • 兼容性好:与长轮询相比,短轮询在各种浏览器和网络环境下都能够正常工作。

缺点:

  • 效率较低:因为客户端需要频繁地发送请求,可能会造成网络流量浪费,同时也会增加服务器压力。
  • 实时性差:由于客户端需要等待固定的时间间隔才能获取更新,因此实时性不如长轮询或WebSocket。

总的来说,短轮询适用于一些对实时性要求不高,且对服务器压力要求相对较低的场景。虽然实时性和效率不如长轮询或WebSocket,但在一些简单的应用中,短轮询可以是一种简单有效的实现方式。 在前端实现即时通讯,常用的技术包括短轮询,WebSocket和SSE。

长轮询

长轮询(Long Polling)是一种改进的轮询技术,它在某些方面优于传统的短轮询。

长轮询通过客户端向服务器发送一个持续连接的请求,在有新数据可用时立即返回响应,否则保持连接直到超时或者有新数据到达。

以下是长轮询的基本工作流程

  1. 客户端发送请求:客户端向服务器发送一个长轮询请求,通常是一个普通的 HTTP 请求,但在服务器端会保持连接打开。
  2. 服务器处理请求:服务器接收到长轮询请求后,会检查是否有新的数据可用。如果有新数据,服务器会立即将其包含在响应中返回给客户端。
  3. 响应返回:如果服务器有新数据,它会立即返回响应给客户端,并关闭连接。如果没有新数据可用,服务器会保持连接打开,等待一段时间直到超时或者有新数据到达。
  4. 客户端处理响应:客户端收到服务器的响应后,解析数据并进行相应的处理。然后,客户端会立即发送一个新的长轮询请求,以便在服务器端保持持续连接。
  5. 循环进行:这个过程会不断循环执行,客户端和服务器之间保持持续连接,从而实现实时通讯的效果。

长轮询的优点是可以实现实时通讯的效果,并且相对于短轮询来说减少了不必要的 HTTP 请求次数,减少了网络流量和服务器负载。但是长轮询仍然存在一定的延迟,因为客户端需要等待服务器响应或者超时才能发送下一个请求。同时,长轮询需要服务器支持保持长连接,因此对服务器的资源消耗较大。

以下是使用 Node.js 实现的简单长轮询服务器和客户端的代码示例:

首先是服务器端的代码 (server.js):

const express = require('express');

const app = express();
const port = 3000;

let data = "Initial Data";

app.get('/data', (req, res) => {
  const timeout = 10; // 设置超时时间,单位为秒
  const startTime = new Date().getTime();

  const checkData = () => {
    // 检查数据是否发生变化或特定事件是否发生,这里假设直接使用一个全局变量 data 来模拟数据的变化
    if (data !== "Initial Data") {
      res.json({ data: data });
    } else if ((new Date().getTime() - startTime) > timeout * 1000) {
      res.json({ data: null }); // 如果超时,则返回空响应
    } else {
      setTimeout(checkData, 1000); // 等待一段时间后再次检查
    }
  };

  checkData();
});

app.post('/update', express.json(), (req, res) => {
  data = req.body.data;
  res.json({ message: "Data updated successfully" });
});

app.listen(port, () => {
  console.log(`Server is listening at http://localhost:${port}`);
});

接下来是客户端的代码 (client.js):

const fetch = require('node-fetch');

function longPolling() {
  setInterval(() => {
    fetch('http://localhost:3000/data')
      .then(response => response.json())
      .then(data => {
        if (data.data !== null) {
          console.log("Received updated data:", data.data);
        }
      })
      .catch(error => {
        console.error('Error:', error);
      });
  }, 1000); // 每秒重新发起请求
}

longPolling();

在这个示例中,服务器端使用 Express 框架创建了一个简单的 API,其中 /data 路由实现了长轮询的逻辑。客户端通过不断发送 GET 请求到 /data 路由来获取最新的数据。当数据发生变化时,服务器会立即返回响应。如果超过了设置的超时时间而没有新的数据可用,服务器会返回一个空响应,客户端会在收到空响应后立即重新发起请求。

优缺点

优点

  1. 即时性(Real-Time) 长轮询允许服务器在有新数据时立即向客户端推送信息,从而实现近乎实时的通信效果。
  2. 简单实现:与WebSocket相比,长轮询更容易实现,因为它利用了HTTP协议,无需特殊的服务器支持。
  3. 兼容性:由于长轮询基于HTTP协议,因此在大多数现代浏览器和服务器上都能够正常工作,而不需要特殊的配置或兼容性处理。

缺点

  1. 资源浪费 长轮询会造成服务器维持大量的长期连接,这可能会导致服务器资源的浪费,尤其是在大规模部署的情况下。
  2. 延迟较大 即使在有新数据时,客户端也需要等待一段时间才能收到响应,因此长轮询的延迟相对较大,不如 WebSocket 或 Server-Sent Events 实时。
  3. 连接维持开销 由于长轮询需要维持较长时间的连接,因此会增加网络和服务器的连接维持开销,尤其是在高并发情况下。
  4. 不支持全双工通信 长轮询是一种单向通信方式,即客户端发送请求等待服务器响应,而服务器不能直接向客户端发送消息。这意味着在某些场景下,长轮询无法满足双向通信的需求。

综上所述,长轮询适用于需要实时通信但对延迟要求不是很高的场景,但在高并发和大规模部署的情况下,可能会带来一定的性能和资源开销。在选择长轮询还是其他实时通信技术时,需要根据具体的业务需求和技术环境进行权衡。

SSE

SSE(Server-Sent Events) 服务器推送事件,简称 SSE,是一种服务端实时主动向浏览器推送消息的技术。

SSEHTML5 中一个与通信相关的 API,主要由两部分组成:服务端与浏览器端的通信协议(HTTP 协议)及浏览器端可供 JavaScript 使用的 EventSource 对象。

SSE是一种用于实现服务器向客户端推送数据的技术,它允许服务器端实时地向客户端发送事件流。相比于长轮询,SSE 更加轻量级且易于实现,但也有一些限制。

SSE 协议非常简单,本质是浏览器发起 http 请求,服务器在收到请求后,返回状态与数据,并附带以下 headers:

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
  • SSE API规定推送事件流的 MIME 类型为 text/event-stream
  • 必须指定浏览器不缓存服务端发送的数据,以确保浏览器可以实时显示服务端发送的数据。
  • SSE 是一个一直保持开启的 TCP 连接,所以 Connection 为 keep-alive

下面是一个简单的使用 Node.js 实现 SSE 的示例代码:

const express = require('express');

const app = express();
const port = 3000;

let clients = [];

app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  const clientId = Date.now();
  clients.push({ id: clientId, res });

  // 发送一个初始化消息
  res.write(`data: Connected\n\n`);

  // 客户端断开连接时移除对应的客户端
  req.on('close', () => {
    clients = clients.filter(client => client.id !== clientId);
  });
});

// 路由用于发送数据到所有连接的客户端
app.post('/update', express.json(), (req, res) => {
  const newData = req.body.data;

  clients.forEach(client => {
    client.res.write(`data: ${JSON.stringify({ data: newData })}\n\n`);
  });

  res.json({ message: "Data sent successfully" });
});

app.listen(port, () => {
  console.log(`Server is listening at http://localhost:${port}`);
});

在这个示例中,客户端通过向 /events 路由发送 GET 请求来订阅服务器端的事件流。服务器端会维护一个客户端列表,当有新数据需要推送时,会遍历客户端列表将数据发送给所有客户端。

客户端可以使用 JavaScript 来监听事件流:

const eventSource = new EventSource('http://localhost:3000/events');

eventSource.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log("Received updated data:", data.data);
};

eventSource.onerror = function(error) {
  console.error('Error:', error);
};

优缺点

优点:

  1. 实时性:SSE 提供了更接近实时的数据推送,因为它是基于单个持久连接,服务器端可以实时向客户端发送数据。
  2. 轻量级:SSE 使用标准的 HTTP 协议,不需要额外的库或协议,因此实现更加轻量级。
  3. 简单易用:相比于 WebSockets,SSE 的实现和使用更加简单,无需处理复杂的连接管理和协议。

但是,SSE 也有一些限制:

  1. 单向通信:SSE 只支持服务器向客户端的单向通信,客户端无法直接向服务器发送数据。
  2. 兼容性:虽然大多数现代浏览器都支持 SSE,但并不是所有浏览器都支持。特别是一些旧版本的浏览器可能不支持 SSE。
  3. 连接断开重连:在某些情况下,连接可能会断开,需要客户端重新连接。这就需要客户端实现重连逻辑来保持连接的持久性。

总的来说,SSE 适合那些需要实时推送数据且对双向通信要求不高的应用场景,例如实时通知、实时数据更新等。在选择实时通信技术时,开发人员应根据具体的需求和场景来选择合适的技术。

应用场景

  1. 即时通知和提醒: SSE 可用于向用户发送即时通知和提醒,例如社交媒体应用中的新消息通知、电子邮件应用中的新邮件提醒等。
  2. 实时数据更新: SSE 可以用于在网页应用程序中实时更新数据,例如股票市场应用程序中的股票价格、即时聊天应用程序中的新消息等。
  3. 在线游戏: SSE 可以用于实现在线游戏中的实时通信和事件更新,例如多人在线游戏中的玩家位置更新、游戏状态变化等。
  4. 监控和警报系统: SSE 可以用于实时监控系统和警报系统,例如监控服务器状态、网络流量等,并在发生异常时向管理员发送警报。
  5. 实时数据可视化: SSE 可以用于在网页应用程序中实时更新数据可视化图表,例如实时股票走势图、实时天气预报等。
  6. 在线交易系统: SSE 可以用于在线交易系统中实时更新交易价格和订单状态,以及向用户发送交易确认和通知。
  7. 事件流处理: SSE 可以用于处理事件流数据,例如日志记录、数据分析等,实时将处理结果推送给客户端。
  8. 客服支持: SSE 可以用于实现客服支持系统,客户可以在网页上与 ChatGPT 进行实时交流,向其提出问题并获得即时的回复。

兼容性

Websocket

Websocket 是一种在客户端和服务器之间实现双向通信的技术,它通过一个持久连接客户端和服务器之间建立实时、高效的通信机制。

相比传统的 HTTP 请求,它能够提供更低的延迟更高的效率,使得实时通讯成为可能。

下面是使用 JavaScript 的简单示例:

服务端代码

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
  console.log('A client connected');

  ws.on('message', function incoming(message) {
    console.log('Received message:', message);

    // 发送数据给所有连接的客户端
    wss.clients.forEach(function each(client) {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message);
      }
    });
  });

  ws.on('close', function close() {
    console.log('Client disconnected');
  });
});

客户端代码

const ws = new WebSocket('ws://localhost:8080');

ws.onopen = function() {
  console.log('Connected to the server');
};

ws.onmessage = function(event) {
  console.log('Received message:', event.data);
};

ws.onclose = function() {
  console.log('Disconnected from the server');
};

// 发送数据给服务器
ws.send('Hello, server!');

特点

  • 支持双向通信,实时性更强
  • 可以发送文本,也可以发送二进制数据
  • 建立在TCP协议之上,服务端的实现比较容易
  • 数据格式比较轻量性能开销小,通信高效
  • 没有同源限制,客户端可以与任意服务器通信
  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL
  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

优缺点

优点包括:

  1. 实时性:Websocket 提供了双向通信的能力,可以实现实时的数据传输,适用于需要及时更新的应用场景,如实时聊天、在线游戏等。
  2. 高性能:Websocket 使用单个 TCP 连接,减少了握手和请求头的开销,降低了延迟,提高了性能。
  3. 跨平台支持:Websocket 是一种标准化的协议,并且受到现代浏览器的广泛支持,适用于多种平台和设备。
  4. 持久连接:Websocket 使用长期的连接,可以保持连接状态,服务器端可以主动向客户端推送数据,而不需要客户端主动发起请求。

然而,Websocket 也有一些缺点:

  1. 跨域限制 与其他跨域通信技术一样,WebSocket 也受到浏览器的同源策略限制,需要通过 CORS 或代理等方式解决跨域访问的问题。
  2. 安全性考虑 WebSocket 的持久性连接可能会增加一些安全风险,例如长时间的连接可能会增加 DoS 攻击的风险,因此需要采取相应的安全措施,如限制连接数、实施身份验证等。
  3. 网络代理限制 一些网络代理可能会阻止 WebSocket 连接,导致部分用户无法使用 WebSocket 进行通信,需要考虑兼容性和容错性。
  4. 状态管理复杂 WebSocket 的持久性连接需要服务器端维护连接状态,可能会增加服务器端的状态管理复杂性,需要考虑连接的管理和维护。
  5. 协议版本兼容性 不同的浏览器和服务器可能支持不同版本的 WebSocket 协议,需要确保客户端和服务器端之间的协议版本兼容性,以确保通信的稳定性和可靠性。

总的来说,Websocket 是一种功能强大、实时性高的通信技术,适用于需要实时双向通信的应用场景。在选择实时通信技术时,开发人员可以根据需求和情况权衡各种通信技术的优缺点来选择最适合的技术。

应用场景

WebSocket 适用于需要实时双向通信的各种应用场景,包括但不限于:

  1. 在线聊天应用: 实时性是在线聊天应用的关键需求之一,WebSocket 可以实现客户端和服务器之间的即时通信,使得用户能够实时收发消息,提高用户体验。
  2. 实时协作编辑: 对于需要多人协作编辑的应用,如 Google Docs、在线白板等,WebSocket 可以实现实时的文档同步,保持所有参与者的编辑内容同步更新。
  3. 实时游戏: 在线多人游戏需要快速、实时的数据传输,WebSocket 可以实现游戏客户端和服务器之间的实时通信,支持游戏中的角色移动、游戏事件等实时更新。
  4. 实时监控和通知: 对于需要实时监控数据或发送实时通知的应用,如监控系统、实时报警系统等,WebSocket 可以实现服务器向客户端实时推送监控数据或通知信息。
  5. 实时数据可视化: 在需要实时展示数据的应用中,如股票行情、实时交通情况等,WebSocket 可以实现数据的实时更新和展示,使用户能够及时获取最新的信息。

总的来说,Websocket 在需要实时双向通信的各种应用场景中都有广泛的应用前景,特别是对于那些需要及时更新数据、实时交互的场景来说,是一种非常值得选择的通信技术。

兼容性

与SSE对比

好的,下面是一个比较 WebSocket 和 Server-Sent Events (SSE) 的表格:

特性 WebSocket Server-Sent Events (SSE)
通信方向 双向通信 单向通信
协议 基于 TCP 基于 HTTP
使用难度 相对复杂 轻量级,使用简单
实时性 实时通信,适用于需要即时更新的应用场景 实时推送,适用于只需要服务器向客户端推送数据的简单应用场景
延迟 通常较低,由于建立了持久性连接 可能较高,受限于 HTTP 请求-响应模式
数据格式 任意类型的数据 只能传输文本数据
自动重连 不支持,需要客户端手动重连 支持,客户端断开连接时会自动尝试重新连接
连接个数 可同时支持大量连接 连接数 HTTP/1.1 6 个,HTTP/2 可协商(默认 100)
应用场景 实时双向通信、即时更新的应用 只需要服务器向客户端推送数据的简单应用

首页加载、白屏优化方案

作者 gnip
2025年8月12日 00:32

概述

继续上一篇的性能优化,结合实际工作,这一篇总结一些关于首页加载和长时间白屏的一些处理方案。

方案

1、路由懒加载

对于现在的SPA 项目,一个路由对应一个页面,如果不做处理,项目打包后,会把所有页面打包成一个文件,当用户打开首页时,会一次性加载所有的资源,造成首页加载很慢,降低用户体验
列一个实际项目的打包详情:

  • app.js 初始体积: 1175 KB

  • app.css 初始体积: 274 KB

将路由全部改成懒加载
重新打包后,首页资源拆分为 app.js 和 home.js,以及对应的 css 文件
●app.js:244 KB、 home.js: 35KB

image.png

  • app.css:67 KB、home.css: 15KB

通过路由懒加载,该项目的首页资源压缩约 50 %

懒加载前提的实现:ES6的动态地加载模块——import()

2、组件懒加载

home 页面 和 about 页面,都引入了 dialogInfo 弹框组件,该弹框不是一进入页面就加载,而是需要用户手动触发后才展示出来

home 页面示例:

<template>
  <div class="homeView">
    <p>home 页面</p>
    <el-button @click="dialogVisible = !dialogVisible">打开弹框</el-button>
    <dialogInfo v-if="dialogVisible" />
  </div>
</template>
<script>
import dialogInfo from '@/components/dialogInfo';
export default {
  name: 'homeView',
  components: {
    dialogInfo
  }
}
</script>

项目打包后,发现 home.js 和 about.js 均包括了该弹框组件的代码(在 dist 文件中搜索dialogInfo弹框组件)

当用户打开 home 页时,会一次性加载该页面所有的资源,我们期望的是用户触发按钮后,再加载该弹框组件的资源

这种场景下,就很适合用懒加载的方式引入

弹框组件懒加载:

<script>
const dialogInfo = () => import(/* webpackChunkName: "dialogInfo" */ '@/components/dialogInfo');
export default {
  name: 'homeView',
  components: {
    dialogInfo
  }
}
</script>

重新打包后,home.js 和 about.js 中没有了弹框组件的代码,该组件被独立打包成 dialogInfo.js,当用户点击按钮时,才会去加载 dialogInfo.js 和 dialogInfo.css

最终,使用组件路由懒后,该项目的首页资源进一步减少约 10 %

组件懒加载的场景:

1)该页面的 JS 文件体积大,导致页面打开慢,可以通过组件懒加载进行资源拆分,利用浏览器并行下载资源,提升下载速度(比如首页)

2)该组件不是一进入页面就展示,需要一定条件下才触发(比如弹框组件)

3)该组件复用性高,很多页面都有引入,利用组件懒加载抽离出该组件,一方面可以很好利用缓存,同时也可以减少页面的 JS 文件大小(比如表格组件、图形组件等)

3、骨架屏优化白屏时长

用骨架屏,可以缩短白屏时间,提升用户体验。国内大多数的主流网站都使用了骨架屏,特别是手机端的项目

SPA 单页应用,无论 vue 还是 react,最初的 html 都是空白的,需要通过加载 JS 将内容挂载到根节点上,这套机制的副作用:会造成长时间的白屏

常见的骨架屏插件就是基于这种原理,在项目打包时将骨架屏的内容直接放到 html 文件的根节点中

使用骨架屏插件,打包后的 html 文件(根节点内部为骨架屏):

image.png 同一项目,对比使用骨架屏前后的 FP 白屏时间: 1s左右

有骨架屏:白屏时间 140ms左右

骨架屏插件

vue-skeleton-webpack-plugin 

配置

const SkeletonWebpackPlugin = require("vue-skeleton-webpack-plugin");
module.exports = {
  configureWebpack: {
    plugins: [
      new SkeletonWebpackPlugin({
        // 实例化插件对象
        webpackConfig: {
          entry: {
            app: path.join(__dirname, './src/skeleton.js') // 引入骨架屏入口文件
          }
        },
        minimize: true, // SPA 下是否需要压缩注入 HTML 的 JS 代码
        quiet: true, // 在服务端渲染时是否需要输出信息到控制台
        router: {
          mode: 'hash', // 路由模式
          routes: [
            // 不同页面可以配置不同骨架屏
            // 对应路径所需要的骨架屏组件id,id的定义在入口文件内
            { path: /^\/home(?:\/)?/i, skeletonId: 'homeSkeleton' },
            { path: /^\/detail(?:\/)?/i, skeletonId: 'detailSkeleton' }
          ]
        }
      })        
    ]
  }
}

4、JS加载模式

js加载资源模式的不同也会影响页面渲染时间

1)正常模式

<script src="index.js"></script>

这种情况下 JS 会阻塞 dom 渲染,浏览器必须等待 index.js 加载和执行完成后才能去做其它事情

2)async 模式

<script async src="index.js"></script>

async 模式下,它的加载是异步的,JS 不会阻塞 DOM 的渲染,async 加载是无顺序的,当它加载结束,JS 会立即执行

使用场景:若该 JS 资源与 DOM 元素没有依赖关系,也不会产生其他资源所需要的数据时,可以使用async 模式,比如埋点统计

3)defer 模式

<script defer src="index.js"></script>

defer 模式下,JS 的加载也是异步的,defer 资源会在 DOMContentLoaded 执行之前,并且 defer 是有顺序的加载

如果有多个设置了 defer 的 script 标签存在,则会按照引入的前后顺序执行,即便是后面的 script 资源先返回

所以 defer 可以用来控制 JS 文件的执行顺序,比如 element-ui.js 和 vue.js,因为 element-ui.js 依赖于 vue,所以必须先引入 vue.js,再引入 element-ui.js

<script defer src="vue.js"></script>
<script defer src="element-ui.js"></script>

defer 使用场景:一般情况下都可以使用 defer,特别是需要控制资源加载顺序时

4)module 模式

<script type="module">import { a } from './a.js'</script>

在主流的现代浏览器中,script 标签的属性可以加上 type="module",浏览器会对其内部的 import 引用发起 HTTP 请求,获取模块内容。这时 script 的行为会像是 defer 一样,在后台下载,并且等待 DOM 解析

Vite 就是利用浏览器支持原生的 es module 模块,开发时跳过打包的过程,提升编译效率

5) preload

<link rel="preload" as="script" href="index.js">

link 标签的 preload 属性:用于提前加载一些需要的依赖,这些资源会优先加载(如下图红框)

async、defer 是 script 标签的专属属性,对于网页中的其他资源,可以通过 link 的 preload、prefetch 属性来预加载

如今现代框架已经将 preload、prefetch 添加到打包流程中了,通过灵活的配置,去使用这些预加载功能,同时我们也可以审时度势地向 script 标签添加 async、defer 属性去处理资源,这样可以显著提升性能

5 、图片的懒加载

使用图片懒加载也能够在页面加载效率和用户体验上提升,可以查阅不同框架的实现方案

总结

上面所列举的也不完全,提供一个大体的方向和思路,其实具体的不同项目可以根据实际的情况,根据项目定制不同的优化方案。

包体积,打包速度优化

作者 gnip
2025年8月12日 00:08

概述

项目开发中,最好必不可少的一环就是对已上线项目进行优化处理,压缩项目打包后的体积大小、提升打包速度,是前端性能优化中非常重要的环节,结合工作中的实践总结,梳理出一些 常规且有效 的性能优化建议。

初始体积 2.25M

配置如下

在 vue.config.js 中 引入添加 配置 webpack-bundle-analyzer

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
  configureWebpack: {
    plugins: [new BundleAnalyzerPlugin()]
  }
};

externals 提取项目依赖

从上面的打包分析页面中可以看到,chunk-vendors.js 体积为 2.21M,其中最大的几个文件都是一些公共依赖包,那么只要把这些依赖提取出来,就可以解决 chunk-vendors.js 过大的问题

可以使用 externals 来提取这些依赖包,告诉 webpack 这些依赖是外部环境提供的,在打包时可以忽略它们,就不会再打到 chunk-vendors.js 中

1)vue.config.js 中配置:

module.exports = {
  configureWebpack: {
    externals: {
      vue: 'Vue',
      'vue-router': 'VueRouter',
      axios: 'axios',
      echarts: 'echarts'
    }
}

2)在 index.html 中使用 CDN 引入依赖

<body>
  <script src="http://lib.baomitu.com/vue/2.6.14/vue.min.js"></script>
  <script src="http://lib.baomitu.com/vue-router/3.5.1/vue-router.min.js"></script>
  <script src="http://lib.baomitu.com/axios/1.2.1/axios.min.js"></script>
  <script src="http://lib.baomitu.com/echarts/5.3.2/echarts.min.js"></script>
</body>

验证 externals 的有效性:

重新打包,最新数据如下:

打包体积:1.12M

打包速度:18879ms

使用 externals 后,包体积压缩 50%、打包速度提升 26%

组件库的按需引入

elementUI 需要借助 babel-plugin-component 插件实现,插件的作用如下:

如按需引入 Button 组件:

import { Button } from 'element-ui';

Vue.component(Button.name, Button);

编译后的文件(自动引入 button.css):

import _Button from 'element-ui/lib/button';
import _Button2 from 'element-ui/lib/theme-chalk/button.css';
// base.css是公共的样式
import 'element-ui/lib/theme-chalk/base.css';

Vue.component(_Button.name, _Button);

通过该插件,最终只引入指定组件和样式,来实现减少组件库体积大小

1)安装 babel-plugin-component

npm install babel-plugin-component -D

2)babel.config.js 中引入

module.exports = {
  presets: ['@vue/app'],
  plugins: [
    [
      'component',
      {
        libraryName: 'element-ui',
        styleLibraryName: 'theme-chalk'
      }
    ]
  ]
};

验证组件库按需引入的有效性:

重新打包,最新数据如下:

打包体积:648KB

打包速度:15135ms

组件库按需引入后,包体积压缩 72%、打包速度提升 40%

同时 chunk-vendors.css 的体积也有了明显的减少,从206KB降到了82KB

原始体积:

按需引入后

减小三方依赖的体积

继续分析打包文件,项目中使用了 momentjs,发现打包后有很多没有用到的语言包

使用 moment-locales-webpack-plugin 插件,剔除掉无用的语言包

1)安装

npm install moment-locales-webpack-plugin -D

2)vue.config.js 中引入



const MomentLocalesPlugin = require('moment-locales-webpack-plugin');

module.exports = {
  configureWebpack: {
    plugins: [new MomentLocalesPlugin({ localesToKeep: ['zh-cn'] })]
  }
};

验证插件的有效性:

重新打包,最新数据如下:

打包体积:407KB

打包速度:10505ms

减小三方依赖体积后,包体积压缩 82%、打包速度提升 59%

Gzip 压缩

线上的项目,一般都会结合构建工具 webpack 插件或服务端配置 nginx,来实现 http 传输的 gzip 压缩,目的就是把服务端响应文件的体积尽量减小,优化返回速度

html、js、css 资源,使用 gzip 后通常可以将体积压缩 70%以上

这里介绍下使用 webpack 进行 gzip 压缩的方式,使用 compression-webpack-plugin 插件

1)安装

npm install compression-webpack-plugin -D

2)vue.config.js 中引入

const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
  configureWebpack: {
    plugins: [
      new CompressionPlugin({
        test: /.(js|css)(?.*)?$/i, //需要压缩的文件正则
        threshold: 1024, //文件大小大于这个值时启用压缩
        deleteOriginalAssets: false //压缩后保留原文件
      })
    ]
  }
};

验证插件的有效性:

重新打包,原来 407KB 的体积压缩为 108KB

总结

经过上面的一系列优化,可以看到:

  • 包体积由原来的 2.25M 减少到 407KB,压缩了 82%
  • 打包速度由原来的 25386ms减少到 8949ms,提升了 65%

这些方式虽然很常规,但确实可以有效地提升项目的性能。

❌
❌