• SPA项目容器化后出现白屏问题的原因分析
  • 发布于 2个月前
  • 340 热度
    0 评论
背景是我们团队有个项目,上线后,偶尔会听到有人反馈说页面白屏了,刷新页面才好,因为有相当的机率复现,就排查原因。最后发现与SPA有关。

什么是SPA
SPA是"单页应用程序"(Single Page Application)的缩写,与传统的多页应用程序(Multi-Page Application,就是原生的一个个的HTML)相对应。在传统的多页应用程序中,每次用户与应用程序交互时,浏览器都会向服务器发送请求,服务器响应并返回一个完整的HTML页面。每个页面之间的切换都需要从服务器加载新的页面,这可能会导致页面之间的加载时间较长,交互体验较差。

而在单页应用程序中,整个应用程序只有一个HTML页面,所有的交互和内容更新都通过JavaScript动态地加载和渲染。当用户与应用程序交互时,只会加载或更新页面的一部分,而不需要整页刷新。这种架构可以显著提高用户体验,减少不必要的页面刷新,同时也可以更有效地处理数据和交互。

SPA通常使用前端框架(如Angular、React、Vue等)来管理应用程序的状态、路由和视图渲染。虽然SPA在提供动态交互和更快的用户体验方面具有优势,但也需要注意一些问题,如SEO优化、初始加载时间等。总之,SPA是一种通过动态加载和更新内容来提供更流畅用户体验的Web应用程序架构模式。

由于SPA的这种特性(就一个页面,路由也是前端路由),如果要使用history模式的话,服务器需要额外配置。以Nginx为例,需要添加配置try_files:
location / {
    root /usr/share/nginx/html;
    try_files $uri $uri/ /index.html;
}
比如你的网站是https://example.com/,你的路由是https://example.com/blog.html,SPA是没有blog.html的,它需要降级到https://example.com/index.html。这样页面才能请求到,前端路由才能生效。

白屏原因
正常来说,一个页面出现白屏的原因可能是多样的:
1.代码错误:如果页面出现JS错误(语法错误、逻辑错误或运行时错误),可能会导致页面无法正确加载,继而引发白屏。在我们的场景中,页面刷新后就正常工作,所以不是这个原因。
2.网络问题:如果服务器或网络出现问题,可能导致资源无法加载,从而导致白屏。我们确实怀疑过这个问题,所以捕获全局错误发送到日志平台,最终发现不是这个原因。
3.资源加载错误:如果在页面加载过程中某个资源(如 JavaScript文件、CSS文件或图片)加载失败,可能会导致白屏。我们的场景并不是首屏白屏,所以也不是这个原因。

4.异步加载问题:如果SPA使用了延迟加载或异步加载的模块,可能会导致页面白屏,特别是在加载过程中出现错误。仔细分析后,认为这是最可能的原因。再结合日志平台,确定了是它引起的。


到底是怎么回事呢?

为了更好的SPA体验,我们通常会根据路由分包,不同路由的代码延迟(懒)加载,首页只加载首页相关的代码,等到切换不同路由时再用异步加载的方式加载对应路由的JS文件或CSS文件。

而我们的线上服务部署方式是K8S+Docker,前端代码在一个Nginx镜像里,每次新的服务上线后,旧的大部分资源都请求不到了(因为JS、CSS文件合并压缩后的文件都带了hash值,重新打包后就变化了)。

如果一个用户停留在一个页面上,从来没有访问过B路由(本地浏览器没有缓存),这时想要切换到B路由,请求B路由的chunk-b.js,但服务重新上线后这个JS文件已经变成了chunk-c.js,这时请求就会失败,页面就会白屏。

报错信息如下:

而用户刷新页面后,请求到了新的HTML,HTML里引用的JS的地址都是新的可用的,路由又能重新工作了。

总结下,这个白屏问题出现需要同时满足以下几个条件:
1.项目为SPA
2.开启了路由懒加载或者组件懒加载
3.用户在某页面停留期间,新的服务上线了
4.新的服务中丢失了hash过的chunk文件

5.用户未访问过将要切换的路由(浏览器没有该路由所需的缓存文件)


解决方案
知道了问题的原因,解决方案就比较简单了。思路有几种:
1.保留hash的chunk文件。这是最稳妥的方案,旧有的静态资源都仍保留一段时间,但是只适用于早年的非容器化方案,因为这个问题而改造我们的生产流有些麻烦。
2.全局监听错误,捕获到这个错误后,刷新页面。这是目前比较可行的方案,只是需要各个应用改造代码,确实不够优雅。
3.通知用户页面有更新。考虑过往public目录下注入一个JSON文件,内含版本号,定时获取这个文件,发现版本号有变化了,提示用户进行刷新。这样用户体验应该能好些,但也有些麻烦,定时请求版本的频率需要商榷,如果正好在请求之前上线了服务,用户这时又点击了页面,仍会白屏,还得用上述处理。
所以,结合下来,我们选用第二种,最省事的方案。

什么?你说对用户体验不够友好?

是的。确实不好。如果有工程化的手段能做到第一点就更好了。现在只能退而求其次。根据复现的条件,限制上线的时间段和频率也是有必要的。

Webpack打包
以我们的React项目(用create-react-app脚手架创建的)为例,默认用的是Webpack打包,打包后文件中有文件对应的hash表,根据这个hash表来找对应的chunk文件:

所以当Webpack打包加载失败时,会提示 Loading chunk failed:

定义一个错误组件,捕获到错误后使用location.reload。
// 堆代码 duidaima.com
// 目前,错误边界组件只支持通过 class 组件定义。
class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };
  static getDerivedStateFromError(error) {
    return {
      hasError: true,
      error,
    };
  }

  render() {
    if (this.state.hasError) {
      const reg = /Loading.*chunk.*failed\./;
      const isLoadingError = reg.test((this.state.error as any).message);
      if (isLoadingError) {
        window.location.reload();
        return;
      }
      return (this.props as any).fallback;
    }
    return this.props.children;
  }
}
上面之所以用正则匹配,是因为除了JS chunk外,还有CSS chunk一样会报错。
需要注意的一点是,这个正则只适用于Webpack打包的情况,而使用Rollup(包括Vite)或者其它打包方案则报错信息需要修改。

在App.tsx中使用:

备注:reload能够生效的前提是你的index.html配置的是协商缓存(我测试的Nginx对HTML默认就是,在网络中能看到304状态码),也就是说,不要配置为强缓存,cache-control一般设置为no-cache。

Vite打包
Vite 打包后直接对需要引用文件路径进行懒加载,有别于Webpack的JSONP方式,它用的是import:

因为是import对应的路径,所以当找不到文件会提示找不到对应文件。

比如我的Vite项目(用的Vue3,React也不例外,当然,它也可以继续用上面的ErrorBoundary+正则)是这样处理的,监听全局的unhandledrejection事件:
addEventListener("unhandledrejection", (evt) => {
  evt.preventDefault();
  console.error(`unhandledrejection`, evt.reason);
  if (
    evt.reason
    .toString()
    .includes("Failed to fetch dynamically imported module") 
  ) {
    location.reload();
  }
});
额外的注意项
经过验证,已经完美解决了,本来以为万事大吉,谁想过了一段时间,又有同事反馈复现了这个问题。真让人头秃啊。又经过一番排查,发现原来是团队的小伙伴为了美观,新加了404路由页面:

导致所有没找到的JS/CSS资源都响应成了HTML:

看网络:

真是步步是坑啊。

没有研究React Router怎么排除JS和CSS,在Nginx中将所有JS和CSS文件改为原始的404响应,顺便把强缓存加上了(其实hash化的文件强缓存完全可以设置为永久,通常是一年):
location ~* \.(?:css|js)$ {
  root /usr/share/nginx/html;
  try_files $uri =404;
  expires 30d;
  access_log off;
  add_header Cache-Control "public";
}

总结
本文讲述了SPA项目在使用容器化部署后非常容易出现的一个白屏问题,其复现的条件为使用了路由懒加载,用户在旧页面停留时点击路由跳转,这时请求到旧路由的hash资源(JS或CSS),导致错误。有多种解决方案,我们选择对我们而言实现难度最低的一种——在代码中捕获该错误,重新刷新页面。如果使用路由404的话,注意下不要影响了这个功能,可以添加Nginx配置解决。
用户评论