代码分割导致单页应用在新部署后块无法加载

50

我有一个单页面应用程序,每个路由都进行了代码分割。当我部署新版本的应用程序时,如果用户仍然打开页面并访问他们以前未访问过的路由,通常会出现错误。

另一种可能发生这种情况的场景是,如果应用程序启用了服务工作者,则用户访问新部署后的页面后,服务工作者将从缓存中提供服务。然后,如果用户尝试访问不在其缓存中的页面,则会出现块加载失败。

目前,我禁用了应用程序中的代码分割以避免此问题,但我一直很好奇如何处理这个问题的最佳方法。我考虑过在用户完成初始页面的加载后预加载所有其他路线,我认为这可能会解决路由上的代码拆分问题。但是,假设我想对组件进行代码拆分,那么这意味着我必须尝试找出何时以及如何预加载所有这些组件。

所以我想知道人们如何处理单页面应用程序的这个问题?谢谢!(我当前正在使用create-react-app)


你找到本地重现这个问题的方法了吗? - R. de Ruijter
1
我还没有尝试在本地重现它,但是我认为可以尝试一种选项来复制它(注意:我没有测试过,所以可能不起作用),那就是创建两个具有不同块的不同构建版本。提供第一个版本并在浏览器上加载它。停止提供第一个版本并开始提供第二个版本时,请保持在该页面上。现在尝试导航到另一个页面。 - Kenneth Truong
1
这就是我最终做的!我构建了两个不同的Docker镜像(甚至可能不需要Docker),它们包含不同版本的代码。在浏览应用程序时,切换应用程序,在下一个“块加载”(对我来说是导航到不同的URL)时出现了这个错误。 - R. de Ruijter
我通过每次发布生产时重启IIS服务器来解决问题,我知道这不是一个好的解决方案,但我还没有找到其他的方法。 - Dilmurod Ismoilov
5个回答

39

我更喜欢让用户手动刷新而不是自动刷新(这样可以防止无限刷新循环错误)。
对于React应用程序,以下策略效果很好,在路由上进行代码分割:

策略

  1. 将您的index.html设置为永不缓存。这确保了请求初始资产的主要文件始终是新鲜的(通常它不大,因此不缓存它不应该成为问题)。请参见MDN Cache Control

  2. 为您的块使用一致的块哈希。这样可以确保只有更改的块才会具有不同的哈希值。(请参见下面的webpack.config.js片段)

  3. 不要在部署时使CDN的缓存失效,这样旧版本在部署新版本时不会失去其块。

  4. 在导航到其他路由时检查应用程序版本,以便在用户运行旧版本时通知他们并请求刷新。

  5. 最后,以防发生ChunkLoadError:添加一个错误边界。 (请参见下面的错误边界)

webpack.config.js的片段(Webpack V4)

来自Uday Hiwarale

optimization: {
  moduleIds: 'hashed',
  splitChunks: {
      cacheGroups: {
          default: false,
          vendors: false,
          // vendor chunk
          vendor: {
              name: 'vendor',
              // async + async chunks
              chunks: 'all',
              // import file path containing node_modules
              test: /node_modules/,
              priority: 20
          },
      }
  }

错误边界

React 错误边界文档

import React, { Component } from 'react'

export default class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.    
    return { hasError: true, error };
  }
  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service    
    console.error('Error Boundary Caught:', error, errorInfo);
  }
render() {
    const {error, hasError} = this.state
    if (hasError) {
      // You can render any custom fallback UI      
      return <div>
      <div>
        {error.name === 'ChunkLoadError' ?
          <div>
            This application has been updated, please refresh your browser to see the latest content.
          </div>
          :
          <div>
            An error has occurred, please refresh and try again.
          </div>}
      </div>
      </div>
    }
    return this.props.children;
  }
}

注意:确保在内部导航事件(例如使用react-router时)清除错误,否则错误边界将在内部导航之后继续存在,并且只有在实际导航或页面刷新时才会消失。


请注意,除非发生“真正”的页面更改或刷新,否则ErrorBoundary将通过导航操作保留错误。 - Jordan
很棒的解决方案,Jordan。我会研究类似的东西。 - Eric Holmes
@Jordan 感谢您详尽的回答!我有一个问题是关于(4)的,如何在路由之间检查版本?(我们目前存储用户所在的当前版本。如何验证最新版本是什么?) - Kenneth Truong
1
@KennethTruong 我怀疑有比我想出来的更好的方法; 但是我做的是在后端创建一个端点,返回预期的前端版本(然后进行比较,看是否过时),当用户启动导航时,我会对这个端点发出请求。这一步实际上只对长时间存在的标签页才有必要,因此可能只在活动时间超过指定持续时间的标签页上进行该请求,以防止每个用户在每次导航时都进行额外的请求。 - Jordan
@Jordan 关于你的策略第三点 - “在部署时不要使CDN缓存失效,这样新版本部署时旧版本的块就不会丢失”,可能存在一种情况,即一些旧块从未被获取,因此也没有被CDN缓存。如何解决这个问题呢?一个可能的解决方案是在部署后对每个块进行cURL请求,以强制进行缓存。 - Vaibhav Nigam

8
我们create-react-app的问题是脚本标签引用的代码块不存在,因此在我们的index.html中会发生错误。以下是我们遇到的错误信息。
Uncaught SyntaxError: Unexpected token <    9.70df465.chunk.js:1 

更新

我们解决这个问题的方法是将我们的应用程序变为渐进式Web应用程序,以便利用服务工作者。

将create react app转换成PWA很容易。 有关PWA的CRA文档

然后要确保用户始终使用最新版本的服务工作者,我们确保每当有等待更新的工作者时,我们会告诉它SKIP_WAITING,这意味着下次刷新浏览器时,他们将获得最新的代码。

import { Component } from 'react';
import * as serviceWorker from './serviceWorker';

class ServiceWorkerProvider extends Component {
  componentDidMount() {
    serviceWorker.register({ onUpdate: this.onUpdate });
  }

  onUpdate = (registration) => {
    if (registration.waiting) {
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  }

  render() {
    return null;
  }
}

export default ServiceWorkerProvider;

以下是我们尝试的第一件事情,但遇到了一些无限循环的问题。
我让它工作的方法是在 index.html 文件中所有脚本标签之上添加一个 window.onerror 函数。
<script>
  window.onerror = function (message, source, lineno, colno, error) {
    if (error && error.name === 'SyntaxError') {
      window.location.reload(true);
    }
  };
</script>

我希望有更好的方式,但这是我能想到的最好的方法,并且感觉这是一个相当安全的解决方案,因为 create-react-app 不会编译或构建任何语法错误,这应该是我们唯一会遇到语法错误的情况。


以下解决方案现在是否有效?我目前有PWA,但仍然出现错误。 - jsishere

3
我们用一种有点丑陋但真的很简单的方法解决了这个问题。现在可能只是暂时的,但可以帮助某些人。
我们创建了一个AsyncComponent来加载块(即路由组件)。当此组件加载一个块并接收到一个错误时,我们只需简单地重新加载页面以更新index.html及其对主块的引用。之所以它不太好看,是因为根据您的页面外观或加载方式,用户可能会在刷新前瞥见空白页面的短暂闪烁。这可能有点让人震惊,但这也可能是因为我们不希望SPA自发地刷新。
App.js
// import the component for the route just like you would when
// doing async components
const ChunkedRoute = asyncComponent(() => import('components/ChunkedRoute'))

// use it in the route just like you normally would
<Route path="/async/loaded/route" component={ChunkedRoute} />

asyncComponent.js

import React, { Component } from 'react'

const asyncComponent = importComponent => {
  return class extends Component {
    state = {
      component: null,
    }

    componentDidMount() {
      importComponent()
        .then(cmp => {
          this.setState({ component: cmp.default })
        })
        .catch(() => {
          // if there was an error, just refresh the page
          window.location.reload(true)
        })
    }

    render() {
      const C = this.state.component
      return C ? <C {...this.props} /> : null
    }
  }
}

export default asyncComponent

我没有尝试过这个,但是你可不可以递归运行componentDidMount函数,直到组件正确导入,而不是刷新页面? - Brandon Keith Biggs
2
@BrandonKeithBiggs,问题在于index.html中指定的块ID不再有效,因此componentDidMount将无限运行,因为组件将永远不会再次存在(它曾经存在过)。刷新页面会导致index.html更新,具有新的块ID,然后一切都会正常工作。 - coblr

1
我正在使用AsyncComponent HOC来延迟加载代码块,但遇到了同样的问题。 我的解决方法是识别错误并进行强制刷新。
.catch(error => {
        if (error.toString().indexOf('ChunkLoadError') > -1) {
          console.log('[ChunkLoadError] Reloading due to error');
          window.location.reload(true);
        }
      });

完整的HOC文件如下所示,
export default class Async extends React.Component {
  componentWillMount = () => {
    this.cancelUpdate = false;
    this.props.load
      .then(c => {
        this.C = c;
        if (!this.cancelUpdate) {
          this.forceUpdate();
        }
      })
      .catch(error => {
        if (error.toString().indexOf('ChunkLoadError') > -1) {
          console.log('[ChunkLoadError] Reloading due to error');
          window.location.reload(true);
        }
      });
  };

  componentWillUnmount = () => {
    this.cancelUpdate = true;
  };

  render = () => {
    const props = this.props;
    return this.C ? (
      this.C.default ? (
        <this.C.default {...props} />
      ) : (
        <this.C {...props} />
      )
    ) : null;
  };
}

0

我有一个问题的解决方案!

我遇到了相同的情况。在我的情况下,我在React项目中使用Vite,并且每次rollup生成包块时,都会生成不同的哈希值,这些哈希值包含在文件名中(例如:LoginPage.esm.16232.js)。我还在我的路由中使用了很多代码分割。因此,每次部署到生产环境时,块名称都会更改,这将导致客户端每次单击指向旧块的链接时生成空白页面,并且只有当用户刷新页面时(强制页面使用新块)才能解决该问题。

我的解决方案是为我的React应用程序创建一个ErrorBoundary包装器,它会“拦截”错误并显示漂亮的错误页面,解释问题并提供重新加载页面(或自动重新加载)的选项。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接