移动端内嵌H5首屏加载优化问题探索

314 阅读6分钟

移动端内嵌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>

转存失败,建议直接上传图片文件

image.png 效果还是非常显著的

局限性:仅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的大小,又想保证样式生效,可以把这些样式摘出来,作为一个补充样式引入到全局样式里,虽然很麻烦,但是长痛不如短痛,你懂的

至此,我的首屏加载优化之路先告一段落,已经能满足需求,暂时不会再搞了,后续如果有新的思路会继续补充~