代码拆分导致 SPA 新部署后无法加载块
Code splitting causes chunks to fail to load after new deployment for SPA
我有一个单页应用程序,我在每条路线上进行代码拆分。当我部署我的应用程序的新版本时,如果用户仍然打开页面并访问他们之前未访问过的路由,用户通常会收到错误消息。
另一种可能发生这种情况的情况是应用程序启用了服务工作者。当用户在新部署后访问页面时,service worker 将从缓存中提供服务。然后,如果用户尝试访问不在其缓存中的页面,他们将导致块加载失败。
目前我在我的应用程序中禁用了代码拆分来避免这种情况,但我一直很好奇处理这个问题的最佳方法是什么。我考虑过在用户完成加载初始页面后预加载所有其他路由,我相信这可能会解决路由代码拆分的问题。但是假设我想在组件上进行代码拆分,那么这意味着我必须设法弄清楚何时以及如何预加载所有这些组件。
所以我想知道人们如何处理单页应用程序的这个问题?谢谢! (我目前正在使用 create-react-app)
我们以一种略显丑陋但非常简单的解决方案解决了这个问题。目前可能是暂时的,但可能会对某人有所帮助。
我们创建了一个 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
我们 create-react-app 中的问题是脚本标签引用的块不存在,因此它在我们的 index.html 中抛出错误。这是我们得到的错误。
Uncaught SyntaxError: Unexpected token < 9.70df465.chunk.js:1
更新
我们解决这个问题的方法是让我们的应用程序成为一个渐进式网络应用程序,这样我们就可以利用服务工作者。
将创建的 React 应用程序转换为 PWA 很容易。 CRA Docs on PWA
然后为了确保用户始终使用最新版本的 service worker,我们确保只要有更新的 worker 在等待,我们就会告诉它 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 不会编译或构建任何语法错误,这应该是我们遇到语法错误的唯一情况。
我正在使用 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;
};
}
我更喜欢让用户刷新而不是自动刷新(这样可以防止出现无限刷新循环错误)。
以下策略适用于 React 应用程序,代码在路由上拆分:
策略
将您的 index.html 设置为从不缓存。这可确保请求您的初始资产的主文件始终是最新的(通常它不大,因此不缓存它应该不是问题)。参见 MDN Cache Control。
对您的块使用一致的块散列。这确保只有更改的块才会具有不同的哈希值。 (请参阅下面的 webpack.config.js 片段)
不要在部署时使 CDN 的缓存失效,这样旧版本在部署新版本时不会丢失它的块。
在路由之间导航时检查应用程序版本,以便在用户运行使用旧版本时通知用户并请求他们刷新。
最后,以防 ChunkLoadError 发生:添加错误边界。 (请参阅下面的错误边界)
来自 webpack.config.js 的代码段(Webpack v4)
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
},
}
}
错误边界
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),否则错误边界将持续超过内部导航,并且只会在真正的导航或页面刷新。
我有一个单页应用程序,我在每条路线上进行代码拆分。当我部署我的应用程序的新版本时,如果用户仍然打开页面并访问他们之前未访问过的路由,用户通常会收到错误消息。
另一种可能发生这种情况的情况是应用程序启用了服务工作者。当用户在新部署后访问页面时,service worker 将从缓存中提供服务。然后,如果用户尝试访问不在其缓存中的页面,他们将导致块加载失败。
目前我在我的应用程序中禁用了代码拆分来避免这种情况,但我一直很好奇处理这个问题的最佳方法是什么。我考虑过在用户完成加载初始页面后预加载所有其他路由,我相信这可能会解决路由代码拆分的问题。但是假设我想在组件上进行代码拆分,那么这意味着我必须设法弄清楚何时以及如何预加载所有这些组件。
所以我想知道人们如何处理单页应用程序的这个问题?谢谢! (我目前正在使用 create-react-app)
我们以一种略显丑陋但非常简单的解决方案解决了这个问题。目前可能是暂时的,但可能会对某人有所帮助。
我们创建了一个 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
我们 create-react-app 中的问题是脚本标签引用的块不存在,因此它在我们的 index.html 中抛出错误。这是我们得到的错误。
Uncaught SyntaxError: Unexpected token < 9.70df465.chunk.js:1
更新
我们解决这个问题的方法是让我们的应用程序成为一个渐进式网络应用程序,这样我们就可以利用服务工作者。
将创建的 React 应用程序转换为 PWA 很容易。 CRA Docs on PWA
然后为了确保用户始终使用最新版本的 service worker,我们确保只要有更新的 worker 在等待,我们就会告诉它 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 不会编译或构建任何语法错误,这应该是我们遇到语法错误的唯一情况。
我正在使用 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;
};
}
我更喜欢让用户刷新而不是自动刷新(这样可以防止出现无限刷新循环错误)。
以下策略适用于 React 应用程序,代码在路由上拆分:
策略
将您的 index.html 设置为从不缓存。这可确保请求您的初始资产的主文件始终是最新的(通常它不大,因此不缓存它应该不是问题)。参见 MDN Cache Control。
对您的块使用一致的块散列。这确保只有更改的块才会具有不同的哈希值。 (请参阅下面的 webpack.config.js 片段)
不要在部署时使 CDN 的缓存失效,这样旧版本在部署新版本时不会丢失它的块。
在路由之间导航时检查应用程序版本,以便在用户运行使用旧版本时通知用户并请求他们刷新。
最后,以防 ChunkLoadError 发生:添加错误边界。 (请参阅下面的错误边界)
来自 webpack.config.js 的代码段(Webpack v4)
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
},
}
}
错误边界
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),否则错误边界将持续超过内部导航,并且只会在真正的导航或页面刷新。