产品: 怎么页面又加载不出来了? 我: 额,你刷新下

3,317 阅读6分钟

产品: 怎么页面又加载不出来了?
我: 额,你刷新下。
产品: 你不能总是叫用户刷新吧!
我: ...

哎,难顶呀!用户的自适应能力太差了,刷新解决99%的问题不知道吗!

众所周知的原因

通常来说,对于spa应用的路由页面,都是通过懒加载去处理的(为了增加点加载速度)。而且为了保证不受缓存的影响,打包后的bundle和chunk的名称,一般都会加上hash、contentHash这些随文件内容变化的值。

这样就会出现一种情况,就是当用户在使用web系统过程中,我们发布了新版本,然后用户去访问新页面,会发现新页面对应的文件找不到,然后页面就白屏了。

半夜发布的话,确实能很大程度避免这种情况,尤其是toB的系统,选一个不会有用户访问的时间发。

vite打包后所有文件名都会改变吗

可能大家都听说过这个说法:一个文件改变,打包后文件名都变了,而且还是rollup的问题。

vite issues#6773 github.com/vitejs/vite…
rollup issues#4394 github.com/rollup/roll…

从时间上来看,这是2022.2提的issue,而且都是关闭状态,看起来是被修复了。从实际项目的打包情况来看,假如说vite.config.ts中,没有配置build.rollupOptions的话,那打包后所有文件名都会改变;进行了一定的分包配置,打包后未修改的文件的文件名也有可能改变。

以vue举例,vue2的时候没有按需加载,vue3为了减少打包后的体积,使用了按需加载。如果在某次修改中,使用了vue3中其他页面没用过的api,那包含vue3的chunk的文件名肯定得改变,其他vue文件都会引入这个chunk,导致其他文件打包后的内容也发生了改变,生成的hash值就产生了变化。

rollup的官方文档中对于hash的说明:仅基于最终生成的 chunk 内容的哈希值,其中包括 renderChunk中的转换部分和其依赖文件哈希值

rollup#4394中可以看出,啥都不改变,再次打包,文件名都会变化,这个问题是被修复了。但rollup中hash的生成规则表明,文件的hash受引入文件的影响,引入文件可能会受其他多个文件影响(比如我们上面举得vue3的例子)。

使用hack手段处理

vite#6773可以找到一些其他人解决这种情况的方法,比如这种hacky solution:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { createHash } from 'node:crypto'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: chunkInfo => {
          if (chunkInfo.isDynamicEntry) {
            const hash = createHash('md5')
              .update(Object.values(chunkInfo.modules).map(m => m.code).join())
              .digest('hex')
              .slice(0, 8)
            return 'assets/[name].' + hash + '.js'
          } else {
            return 'assets/[name].[hash].js'
          }
        }
      }
    }
  }
})

由于使用了chunkInfo.modules,但是在某个版本中,rollup已经去掉modules了,只有moduleIds。

chunkInfo.moduleIds代替chunkInfo.modules
const hash = createHash('md5')
    .update(chunkInfo.moduleIds.join())
    .digest('hex')
    .slice(0,8)

image.png
从moduleIds中可以看到,如果使用此方式,moduleIds多项或少项和scoped变化,会使生成的hash改变;假如某个子组件(比如Child2),没有<style>部分,修改Child2,在moduleIds中是体现不出来的。

由此可知,大部分情况下,使用moduleIds是没啥问题的,毕竟没有<style>的vue组件很少。

fs.readFileSync
const ids = chunkInfo.moduleIds.map(id => id.split('?')[0])
const code = [...new Set(ids)].map(id => fs.readFileSync(id, 'utf-8')).join()
const hash = createHash('md5')
    .update(code)
    .digest('hex')
    .slice(0,8)

从上图的moduleIds中可以看出,moduleIds中是文件地址,我们只需要把所有的文件内容都读出来(源码),传给hash.update就行了

fs.readFileSync毕竟是同步的,这种方式会增加打包时间,但是准确。

jy们如果有好的方法,可以在评论区讨论

监听error事件

找不到文件,加载报错,可以通过监听error事件处理:

// main.ts
// 文件加载报错
let errorTime = 0
window.addEventListener(
  "error",
  (event: ErrorEvent) => {
    if (Date.now() - errorTime > 1000) {
      if (
        (event.target as HTMLElement).tagName === "LINK" &&
        ["js", "css"].includes((event.target as HTMLLinkElement).href.split(".").pop() as string)
      ) {
        errorTime = Date.now()
        showDialog({
          message: '监测到系统进行了更新,重新加载以访问新内容',
          theme: "round-button",
          confirmButtonText: '确定'
        }).then(() => {
          window.location.reload()
        })
      }
    }
  },
  true
)

当加载js、css文件报错的时候,我们就认为发布了新版本,然后刷新页面。

使用vite插件,生成version.json文件,记录版本号

每次重新打包,都生成一个版本号,beforeEach中去检查版本号

// versionUpdatePlugin.js 
import fs from 'node:fs'
import path from 'node:path'

const writeVersion = (versionFile, content) => {
    // 通过fs模块写入文件  
    fs.writeFile(versionFile, content, (err) => {
        if (err) throw err 
    })
} 
export default (options: { version: string }) => {
    let config  
    return {
        name: 'version-update',
        apply: 'build',
        configResolved(resolvedConfig) {
            // 存储最终解析的配置
            config = resolvedConfig   
        },
        buildStart() {
            // 生成版本信息文件路径存到dist的version.json
            const file = config.publicDir + path.sep + 'version.json'
            // 这里使用编译时间作为版本信息
            const content = JSON.stringify({ version: options.version })
            if (fs.existsSync(config.publicDir)) {
                writeVersion(file, content)
            } else {
                fs.mkdir(config.publicDir, (err) => {
                    if (err) throw err
                    writeVersion(file, content)
                })
            }
        },
    }
}
// vite.config.ts
export default defineConfig((config) => {
    // 获取当前的时间
    const now = new Date().getTime()
    return {
        ...
        define: {
            // 定义全局变量
            __APP_VERSION__: now,
        },
        plugins: [
            ...
            versionUpdatePlugin({
                version: now,
            }),
        ],
        ...
    }
})
// 路由拦截
router.beforeEach((to, from) => {
    checkUpdate()
    //...
}
function checkUpdate() {
    if (import.meta.env.DEV)
        return fetch('/version.json').then((res) => {
            if (__APP_VERSION__ !== res.data.version) {
            showDialog({
              message: '监测到系统进行了更新,重新加载以访问新内容',
              theme: "round-button",
              confirmButtonText: '确定'
            }).then(() => {
              window.location.reload()
            })
    }
    })
}

引用:你不会困:# vite实现前端项目打包更新通知用户更新

非覆盖式更新

如果我们能保证旧文件和新文件能同时存在,那用户访问的时候,不就不会出现问题了吗。
vite可以通过简单的配置,使得打包后,不删除旧文件(同名文件,新文件替换旧文件)

export default defineConfig({
  plugins: [vue()],
  build: {
      emptyOutDir: false,
      ...
  }
})

非覆盖式更新会有一个问题:我们的打包次数一直累计,那么打包后的文件也会越来越多。尤其在测试环境中,一天能发18次,文件体积会急速增加。

所以我们需要制定一个策略:啥时候删除旧文件。毕竟生产环境的话,2个版本之间会有一定的间隔时间,用户不可能从好几个版本之前就没退出过系统,并且token也有失效时间。

比如说策略可以定为:最多存在3个或5个版本,或者打包前/后的总文件大小超过100M/200M,就删除最早的版本。

此时,为了好区分版本,需要设置assetsDir为一个变量,比如:

// vite.config.ts
plugins: [vue(), versionDeletePlugin()],
build: {
    assetsDir: `assets/${new Date().toISOString().replace(/:/g, '-')}`
    //...
}

image.png
不区分版本的话,删除的时候,不好区分应该删除哪些文件。
这样的话,我们前面为了保证未改变的文件,打包后hash不变,其实就没那么必要了。

// versionDeletePlugin.ts
import fs from 'node:fs'
import path from 'node:path'
import { rimraf } from 'rimraf'
import { ResolvedConfig } from 'vite'

const defaultOptions = {
  size: 100, // MB
  assetsDir: 'assets',
}
const versionList:string[] = []
// 异步获取文件夹体积的函数
async function getFolderSize(dir: string): Promise<number> {
  let size = 0
  
  try {
    const files = await fs.promises.readdir(dir, { withFileTypes: true })

    const promises = files.map(async file => {
      const filePath = path.join(dir, file.name)
      if (file.isDirectory()) {
        versionList.push(filePath)
        // 如果是文件夹,递归调用
        return getFolderSize(filePath)
      } else {
        // 如果是文件,增加文件大小
        const stats = await fs.promises.stat(filePath)
        return stats.size
      }
    })

    const sizes = await Promise.all(promises)
    size = sizes.reduce((acc, curr) => acc + curr, 0)
  } catch (error) {
    console.error(`Error reading directory ${dir}:`, error)
    throw error
  }

  return size // Byte
}
export default (options?: { size: number; assetsDir: string }) => {
    let config: ResolvedConfig
    options = Object.assign(defaultOptions, options)
    
    return {
        name: 'version-delete',
        apply: 'build',
        configResolved(resolvedConfig: ResolvedConfig) {
            config = resolvedConfig   
        },
        buildStart: async() => {
            const folder = path.join(process.cwd(), config.publicDir, options.assetsDir)
            const size = await getFolderSize(folder)
            if (size / 1024 / 1024 > options.size) {
              rimraf(versionList.sort()[0])
                .then(() => {
                  console.log('删除文件夹成功')
                })
                .catch(() => {
                  console.log('删除文件夹失败')
                })
            }
        },
    }
}

总结

非覆盖式更新可以实现多版本共存,用户不刷新时,访问老版本;用户刷新,就访问最新版本。而且大厂一般都有灰度系统,天然要求多版本。

为了保证测试环境1天发18次,也不出现加载失败的情况,可以把错误监听或者版本检测,配合非覆盖式更新一起使用。

参考

你不会困:# vite实现前端项目打包更新通知用户更新