移动端内嵌H5首屏加载优化问题探索
1.nginx强缓存部分静态资源
缓存加载图片和固定常用的js资源
server {
listen 8000;
server_name mas;
location / {
root E:/project/mas_web_app/dist;
index index.html index.htm;
}
location ~ ^/js/npm.*.js$ {
# return 404;
root E:/project/mas_web_app/dist;
index index.html index.htm;
add_header Cache-Control max-age=300; # 匹配以npm开头的js资源,并设置强缓存时间
}
location /loading.gif {
alias E:/project/mas_web_app/dist/loading.gif; # 用实际的文件路径替换此路径
expires 30d; # 设置缓存的过期时间,作用同Cache-Control max-age=300; expires优先级低
}
}
未实现原因:正式环境nginx不允许自定义修改
2.首页ssr+内容spa
首页分离出一个极简单的页面通过ssr的方式渲染,同时预加载其他资源,再次点击跳转到打包好的项目spa中
2.1 方案一:将首页内容用nuxtjs改写,在服务器跑ssr
未实现原因:
1.需要node服务器,且node与nginx端口不互通
2.对现有项目进行改造,因路由模式需要切换history模式,导致部分页面跳转无法实现,改动较大,成本很高
2.2 方案二:在现有项目基础上,在打包时单独渲染某个路由页面为静态页面,使用插件prerender-spa-plugin
const PrerenderSPAPlugin = require('prerender-spa-plugin')
const Renderer = PrerenderSPAPlugin.PuppeteerRenderer;
const PrerenderSPA = new PrerenderSPAPlugin({
// 要求-给的WebPack-输出应用程序的路径预渲染。
staticDir: path.join(__dirname, 'dist'),
// 必需,要渲染的路线。
routes: ['/welcom'],
// 必须,要使用的实际渲染器,没有则不能预编译
renderer: new Renderer({
inject: {
foo: 'bar'
},
headless: false, // 渲染时显示浏览器窗口。对调试很有用。
// 等待渲染,直到检测到指定元素。
// 例如,在项目入口使用`document.dispatchEvent(new Event('custom-render-trigger'))`
renderAfterDocumentEvent: 'render-event'
})
})
.....
module.exports = {
configureWebpack: {
// gzip压缩
plugins: [PrerenderSPA],
}
}
未实现原因:
1.需要分离用户登录鉴权,针对现有场景成本较高
2.打包后的页面仍然携带大量预加载js,css资源,文档流为从上至下加载,白屏时间依然很长
2.3.写一个静态简单的页面,只加载骨架屏,并把加载所用静态资源缓存进浏览器里
nginx匹配welcom.html,该页面预加载骨架屏,然后页面自跳转到路由页
同域名可以操作缓存
目前手写了welcom.html页面,当前可行
function go() {
var pathname = window.location.pathname;
var pathList = pathname.split('/')
pathList.pop()
window.open(pathList.join('/'),'_self');
}
3.双页面打包
1.打包双页面:原项目页面改名为main.html,缓冲页作为新的index.html
//vue.config.js的配置
module.exports = {
...
indexPath: 'main.html',
...
}
新增打包命令
// build/webpack.welcome.js
const path = require("path");
const htmlWebpackPlugin = require('html-webpack-plugin'); // 生成一个html文件,自动将js文件引入
const welcomePlugin = new htmlWebpackPlugin({
filename: 'index.html', // 打包后文件的名字
template: path.resolve(__dirname, "../public/welcome.html"), // 模板文件的名字
templateParameters: {
BASE_URL: `/`
}
})
module.exports = {
entry: path.resolve(__dirname, "../src/welcome.js"),
output: {
path: path.resolve(__dirname, "../dist"),
filename: "js/welcome.js"
},
plugins: [welcomePlugin],
mode: "development",
};
修改原打包命令
package.json
"scripts": {
"serve": "npm run serve:dev",
"build": "npm run build:dev",
"serve:test": "vue-cli-service serve --mode test",
"build:test": "vue-cli-service build --mode test && npm run build:welcome",
"serve:dev": "vue-cli-service serve --mode dev",
"build:dev": "vue-cli-service build --mode dev && npm run build:welcome",
"serve:prod": "vue-cli-service serve --mode prod",
"build:prod": "vue-cli-service build --mode prod && npm run build:welcome",
"build:welcome": "webpack --config build/webpack.welcome.js",
"update:icon": "node build/iconfont.js && git add src/assets/iconfont"
},
先打包原项目,再将打包好的index.html塞进dist中
这样项目加载顺序就变成 index.html -> main.html (原 index.html),index.html为预加载页
2.在预加载页主要做两件事:拉取加载图片,跳转到main
// welcome.js
function go() {
var pathname = window.location.pathname;
var pathList = pathname.split('/')
pathList.pop()
if (localStorage.getItem('loading')) return
localStorage.setItem('loading', 'data:image/gif;base64,R0lGOD......')
window.open(pathList.join('/') + '/main.html', '_self');
}
go()
//main页面
window.addEventListener('error', loadError, true)
// sessionStorage.setItem('loading','1231')
document.querySelector("img").setAttribute('src',localStorage.getItem('loading'))
3.未实现原因
- ios不兼容windows.open
- 安卓套壳回退返回存在问题
4.利用Service Worker缓存
采用workbox-webpack-plugin组件
//vue.config.js
// 将npm.**.js资源与loading.gif资源通过serviceWorker缓存下来
const { GenerateSW } = require('workbox-webpack-plugin');
const serviceWorkers = new GenerateSW({
clientsClaim: true,
skipWaiting: true,
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
include: [/js/npm..*.js$/, 'loading.gif']
})
...js
module.exports = {
...
configureWebpack: {
plugins: [serviceWorkers],
}
...
}
<!--模板html-->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js').then(registration => {
console.log('SW registered: ', registration);
}).catch(registrationError => {
console.log('SW registration failed: ', registrationError);
});
});
}
</script>
效果还是非常显著的
局限性:仅localhost与https环境下才有navigator.serviceWorker属性,测试环境为http暂不支持
5.把静态资源存进local storage(local storage有存储上限,5m左右,故放弃)
6.在不支持SW的环境中,用indexed存储存储loading图片和npm开头的缓存资源
6.1 首先,我们封装一个可以操作数据库的class,包括数据库常规的初始化、查询、插入、批量删除
// indexedDB.js
class IndexedDB {
constructor() {
this.indexedDB = window.indexedDB || window.webkitIndexedDB || window.mozIndexedDB || window.OIndexedDB || window.msIndexedDB;
this.dbVersion = 1.0;
this.db = null;
this.transaction = null;
}
// 单例设计模式,保证整个系统中只有一个IndexedDB实例,挂载在全局对象上
static getInstance() {
let indexeddb;
if (window.MyIndexedDB) {
indexeddb = window.MyIndexedDB;
} else {
indexeddb = new IndexedDB();
window.MyIndexedDB = indexeddb;
}
if (!IndexedDB.instance) {
IndexedDB.instance = indexeddb
}
return IndexedDB.instance
}
// 初始化数据库
initDB() {
return new Promise((resolve, reject) => {
this.request = this.indexedDB.open("FilesManager", this.dbVersion);
const that = this;
this.request.onerror = function (event) {
reject(new ErrorEvent("Error creating/accessing IndexedDB database"));
};
this.request.onsuccess = function (event) {
console.log("Success creating/accessing IndexedDB database");
that.db = that.request.result;
const db = that.db;
db.onerror = function (event) {
reject(new Error("Error creating/accessing IndexedDB database"));
};
// Interim solution for Google Chrome to create an objectStore. Will be deprecated
if (db.setVersion && (db.version !== this.dbVersion)) {
const setVersion = db.setVersion(this.dbVersion);
setVersion.onsuccess = function () {
that.createObjectStore(db);
resolve();
};
} else {
resolve();
}
}
// For future use. Currently only in latest Firefox versions
this.request.onupgradeneeded = function (event) {
that.createObjectStore(event.target.result);
};
})
}
// 创建一个事务
createObjectStore(dataBase) {
console.log("Creating objectStore")
dataBase.createObjectStore("fileList");
}
getAllKeys() {
const transaction = this.db.transaction(["fileList"], 'readwrite');
const store = transaction.objectStore("fileList");
const getAllKeysRequest = store.getAllKeys();
return new Promise((resolve, reject) => {
getAllKeysRequest.onsuccess = function(event) {
resolve(event.target.result || [])
};
getAllKeysRequest.onerror = function(event) {
reject(new ErrorEvent("Failed to retrieve all keys: ", event.target.errorCode))
// console.error("Failed to retrieve all keys: ", event.target.errorCode);
};
})
}
// 删除数据库里的数据项
deleteItems(items) {
const transaction = this.db.transaction(["fileList"], 'readwrite');
const store = transaction.objectStore("fileList");
const promiseList = [];
items.forEach(item => {
promiseList.push(new Promise(resolve => {
const request = store.getKey(item); // 调用get()方法来检查要删除的项是否存在
request.onsuccess = function(event) {
if (event.target.result) {
const deleteRequest = store.delete(item); // 如果找到,则删除
deleteRequest.onsuccess = function(event) {
console.log("Deleted item with key: ", event.target.result);
resolve()
};
deleteRequest.onerror = function(event) {
console.error("Error deleting item:", event.target.errorCode);
resolve()
};
} else {
console.log("Item not found!");
resolve()
}
};
}))
})
return Promise.all(promiseList)
}
// 向服务器请求文件
getFileFormService(path) {
const that = this;
// Create XHR
const xhr = new XMLHttpRequest();
let blob;
return new Promise(resolve => {
xhr.open("GET", path, true);
// Set the responseType to blob
xhr.responseType = "blob";
xhr.addEventListener("load", function () {
if (xhr.status === 200) {
// Blob as response
blob = xhr.response;
// Put the received blob into IndexedDB
that.putFileInDb(blob, path);
resolve(blob)
}
}, false);
// Send XHR
xhr.send();
})
}
// 把请求的文件放入数据库
putFileInDb(blob, fileName) {
// Open a transaction to the database
const transaction = this.db.transaction(["fileList"], 'readwrite');
console.log(blob, fileName)
// Put the blob into the dabase
transaction.objectStore("fileList").put(blob, fileName);
}
// 从数据库获取文件
getFileInDb(fileName) {
const transaction = this.db.transaction(["fileList"], 'readwrite');
return new Promise(resolve => {
// Retrieve the file that was just stored
transaction.objectStore("fileList").get(fileName).onsuccess = function (event) {
const file = event.target.result;
console.log("Got fileList!" + file);
resolve({ file, fileName })
// // Revoking ObjectURL
// URL.revokeObjectURL(imgURL);
};
})
}
}
6.2接下来把要缓存的文件以blob的形式存进数据库
// indexedDB.js
// 要缓存的文件列表
const fileList = window?.fileList ?? []
// 初始化数据库
const db = IndexedDB.getInstance();
db.initDB().then(() => {
// 从本地数据库去拉取加载文件
db.getFileInDb('loading.gif').then(({ file, fileName }) => {
// 如果文件存在先打印出来
if (file) {
getLoading(file)
} else {
// 文件不存在,先拉取请求放到数据库里,先不进行url替换,因为原资源已经开始加载
db.getFileFormService(fileName)
}
})
fileList.forEach(fileName => {
// 从本地数据库去拉取js文件
db.getFileInDb(fileName).then(async ({ file, fileName }) => {
// 如果文件存在先打印出来
if (file) {
getScript(file)
} else {
// 文件不存在,先拉取请求放到数据库里
const file = await db.getFileFormService(fileName)
getScript(file)
}
})
})
// 删除无用缓存
db.getAllKeys().then(items => {
const uselessItems = items.filter(item => ![...fileList, 'loading.gif'].includes(item))
db.deleteItems(uselessItems)
})
}).catch(() => {
console.log('不支持indexedDB,还原js配置')
fileList.forEach(url => {
const scriptElement = document.createElement('script')
scriptElement.setAttribute("src", url);
document.querySelector('html').appendChild(scriptElement)
})
})
const getLoading = (file) => {
// Get window.URL object
const URL = window.URL || window.webkitURL;
// Create and revoke ObjectURL
const imgURL = URL.createObjectURL(file);
// Set img src to ObjectURL
const imgElephant = document.getElementById("loading");
imgElephant.setAttribute("src", imgURL);
}
const getScript = (file) => {
// Get window.URL object
const URL = window.URL || window.webkitURL;
// Create and revoke ObjectURL
const fileURL = URL.createObjectURL(file);
// Set img src to ObjectURL
const scriptElement = document.createElement('script')
// const imgElephant = document.getElementById("loading");
scriptElement.setAttribute("src", fileURL);
document.querySelector('html').appendChild(scriptElement)
}
这里会有一个问题,window下的fileList从哪里来呢,所以在调用该js之前需要先给window把要缓存的数组完成赋值
6.3下面写一个webpack的plugin用来删除原页面需要缓存的js标签,已经做一些预处理的工作
const path = require('path')
const cheerio = require('cheerio');
const { exec } = require('child_process');
// IndexedDBPrePlugin indexedDB预处理插件
class IndexedDBPrePlugin {
constructor() {
}
// 1. webpack读取配置时,会执行插件 constructor 方法
// 2. webpack创建 compiler 对象
// 3. 遍历所有插件,调用插件的 apply 方法
apply(compiler) {
compiler.hooks.emit.tap("ServiceWorkerPlugin", (compilation) => {
this.write(compilation)
const htmlFile = compilation.assets['index.html'];
// 使用 cheerio 解析 HTML 内容
const $ = cheerio.load(htmlFile.source());
// 获取所有 script 标签
$("script").each((index, element) => {
// 获取 script 标签的 src 属性
const src = $(element).attr("src");
// 如果 src 属性存在并且匹配正则表达式,则删除该标签
if (src && /js/npm..*.js$/.test(src)) {
$(element).remove();
}
});
//html已有head标签
const headerDom = $("head")
if(headerDom&&headerDom.length>0){
$(headerDom[0]).prepend(`<script src="modules.js"></script><script src="js/indexedDB.js"></script>`)
}else {
//html本身没有head标签
$($("html")[0]).prepend(`<head><script src="modules.js"></script><script src="js/indexedDB.js"></script></head>`)
}
// 更新资源
compilation.updateAsset('index.html', () => ({
source: () => $.html(),
size: () => $.html().length,
}));
// const content = Buffer.concat([mainFile.source(), Buffer.from('\nwindow.MyGlobalVariable = true;')]);
});
exec('npm run indexedDB', (error, stdout, stderr) => {
if (error) {
console.error(`indexedDB编译执行失败: ${error}`);
}
/*console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);*/
});
}
// 获取需要缓存的模块名称
getModules(compilation){
const modules = compilation?.getAssets()?.map(item=>item.name)??[];
const reg = /js/npm..*.js$/
// 得到需要缓存的模块名称
return modules.filter(item => reg.test(item)) ?? []
}
// 将筛选出的需要缓存的模块写进modules.js里
write(compilation){
//路径发生变化,开始重新赋值,并重构
this.moduleNames = this.getModules(compilation);
const fileSave = require('file-save');
// 获取总样式文件 index.scss 路径
const collectPath = path.join(__dirname, '../../dist/modules.js');
// 读取文件原本的内容, 再添加写新组件样式的导入
let routerImportText = '/* eslint-disable */\n/* 该文件为自动生成 */\n';
routerImportText+='\nwindow.fileList = ['
this.moduleNames.forEach(moduleName=>{
routerImportText+=`'${moduleName}',\n`
})
routerImportText+='];'
// 写入最终结果
fileSave(collectPath)
.write(routerImportText, 'utf8')
.end('\n');
}
}
module.exports = IndexedDBPrePlugin;
这样,把需要缓存的文件存进modules.js文件中,把最后打包后的modules.js、js/indexedDB.js用cheerio再插入会head标签头,保证渲染时,一读取html就开始工作
6.4 indexedDB.js中有es6的class语法,以及es2020的语法,在老一些的浏览器可能会报错,我们在打包的时候顺带处理一下
npm i @babel/cli
配置一个node的脚本,这里偷点懒把打包后的js一起直接扔到dist/js里
"scripts": {
...
"indexedDB": "babel build/indexedDB/indexedDB.js --out-file dist/js/indexedDB.js --config-file ./build/indexedDB/.babelrc"
...
},
同时配置一个新的babel配置文件.babelrc,不配置也可以用项目上默认的,但我想转成es5语法,就自己配置了一下
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"ie": 11
}
}
]
],
"plugins": []
}
6.5最后是我们自己写的indexedDB.pre.js插件的使用
// vue.confing.js
const IndexedDBPrePlugin = require('./build/indexedDB/indexedDB.pre.js')
module.exports = {
...
configureWebpack: {
...
plugins: isProduction ? [compress, serviceWorkers, new IndexedDBPrePlugin()] : [],
...
optimization: {
// 打包分离
splitChunks: {
chunks: 'all',
minSize: 20000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: Infinity,
automaticNameDelimiter: '~',
name: true,
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
name(module) {
const packageName = module.context.match(/[\/]node_modules[\/](.*?)([\/]|$)/)[1]
return `npm.${packageName.replace('@', '')}`
}
},
default: {
minChunks: 2,
priority: 7,
reuseExistingChunk: true
}
}
}
...
}
}
...
}
6.6 打包后的结果
<!-- dist/index.html -->
<!DOCTYPE html>
<html lang="">
<head>
<script src="modules.js"></script>
<script src="js/indexedDB.js"></script>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="pragma" content="no-cache">
<meta http-equiv="Cache-Control" content="no-store, must-revalidate">
<meta http-equiv="expires" content="Wed, 26 Feb 1997 08:21:57 GMT">
<meta http-equiv="expires" content="0">
...
// modules.js
/* eslint-disable */
/* 该文件为自动生成 */
window.fileList = ['js/npm.axios.c55981a5.js',
'js/npm.buffer.76fab7fc.js',
'js/npm.core-js.4ea9ddad.js',
'js/npm.echarts.ad754a03.js',
'js/npm.hammerjs.e313c7d9.js',
'js/npm.moment.8e9782db.js',
'js/npm.ofd.js.8f748862.js',
'js/npm.pdfjs-dist.bf2c7936.js',
'js/npm.pdfjs-dist-sign.57779dcb.js',
'js/npm.vant.31407d60.js',
'js/npm.vue.a620e674.js',
'js/npm.vue-baidu-map.15124b2c.js',
'js/npm.vue-router.2a1e4e3d.js',
'js/npm.vue-virtual-scroll-list.7bbbb2c7.js',
'js/npm.vuex.57590a15.js',
'js/npm.zrender.dbd798d0.js',
];
indexedDB.js由于转成es5,加了好多垫片,看起来比较恶心就不展示了
7.其他优化点
1.打包后app.js有些大,1.3M左右,通过分析,公共组件占了九百多k,故公共组件改用异步加载形式,app.js缩减到300+k
2.关于异步加载公共组件,原来组件中没做隔离的样式有部分不会在第一时间生效了(用到了才生效),导致部分样式有变动的看法
- 首先在公共组件中不做样式隔离本身就是一种开发不规范的方式,是我们要规避的
- 老项目已经这么做了,污染了全局样式,后续也都是基于这个污染环境在开发,这就是经常说的跑在bug上的代码,后期维护起来会困难很多
- 我们既想减轻app.js的大小,又想保证样式生效,可以把这些样式摘出来,作为一个补充样式引入到全局样式里,虽然很麻烦,但是长痛不如短痛,你懂的
至此,我的首屏加载优化之路先告一段落,已经能满足需求,暂时不会再搞了,后续如果有新的思路会继续补充~