开篇
目前团队内的项目所使用的技术为 SPA
单页面应用(React
)客户端方式渲染。
客户端渲染
方式相较于服务端渲染
,能够轻松实现局部渲染效果(无需请求完整页面),用户体验更好。
但客户端渲染也存在一个很大的弊端
:首屏加载缓慢,因为要等待 JS 主文件资源加载完毕后,执行 render
渲染页面,这样就会造成等待白屏
时间太长。
这对于一个产品化的应用来说,面对大量的用户,若打开应用白屏时间过长,非常容易造成用户流失。
对于 首屏渲染优化
的思路和方法网上资料也有很多,下面,笔者将自己在公司产品项目上所做的首屏优化实践分享于大家。
- 延迟(动态)加载 辅助 资源;
- 页面路由按需加载;
- 代码分割(打包层面);
- 静态图片资源采用 cnd 方式。
一、延迟(动态)加载 辅助 资源
通常,在 HTML 文件中除了要引入应用程序的打包资源,可能还会涉及引入其他功能脚本
资源。
比如,公司内有一个云文件应用,里面的文件需要支持进行预览,而预览的具体实现是在一个独立的工程中,提供脚本文件来覆盖使用所有产品化预览场景。
这时候,云文件应用就需要引入预览相关的 JS 资源,在点击文件时,调用预览 API 进行预览。
最初,预览资源是在主程序的打包资源之前引入的。我们都知道 script
标签会同步执行并阻塞后面的资源加载,这就会导致主程序的资源被延后加载
,从而增加了白屏
时长。
其实在初始化时加载预览资源
可能意义不大(除非是程序初始化后立刻预览一个文件),在我们这个场景下,云文件应用初始化后进入首页,显示的是文件列表,只有用户点击列表中的文件后,开始进行预览。
那我们其实可以借助 JS 动态创建标签
的方式,将预览资源的加载时机移动在点击文件时(仅在第一次预览时动态加载资源),这样,就不会影响主应用的资源加载,减少白屏时长。
对于 动态加载资源
,你的代码实现可能如下:
const usePreviewApi = () => {
const loadResourceFlag = useRef<{ [key: string]: boolean }>({ css: false, js: false });
...
const seePreview = (previewParams: SeePreviewParams) => {
// 避免重复的加载
if (Object.keys(loadResourceFlag.current).some(key => loadResourceFlag.current[key])) return;
// 第一次加载预览资源
if (!window.JwPreviewFile) {
// 设置重复开关
Object.keys(loadResourceFlag.current).forEach(key => loadResourceFlag.current[key] = true);
// Message.open -> 正在加载程序资源...
// 动态加载资源完成后的处理
const onLoaded = (resource: string) => {
loadResourceFlag.current[resource] = false;
if (Object.keys(loadResourceFlag.current).every(key => loadResourceFlag.current[key] === false)) {
previewFile(previewParams); // 调用预览 API 进行预览文件
// Message.close
}
}
// 加载 css
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = location.origin + `/preview/main.css?v=${window.resourceVersion}`;
link.onload = () => onLoaded('css');
document.body.appendChild(link);
// 加载 js
const script = document.createElement('script');
script.src = location.origin + `/preview/main.js?v=${window.resourceVersion}`;
script.onload = () => onLoaded('js');
document.body.appendChild(script);
} else {
// 预览资源已加载过,调用预览 API 即可
previewFile(previewParams);
}
};
return {
seePreview,
}
})
二、页面路由按需加载
通常我们在定义页面路由 Path 和路由组件时,使用方式可能如下:
import React from 'react';
import { HashRouter, Switch, Route } from 'react-router-dom';
import Home from '@/pages/Home';
const App = () => {
return (
<HashRouter basename="/">
<Switch>
<Route exact path='/' render={props => <Home {...props} />} />
...
</Switch>
</HashRouter>
)
}
我们一个应用一般会有很多 <Route />
路由页面,这种路由引用方式在经过 Webpack 打包
后会生成一个 bundle.js
文件。
但其实,对于首屏渲染,我们只是期望加载 Home
页面相关的资源进行渲染,其他页面的资源可以在访问其他路由 Path
时进行加载。
而现在将所有路由页面打包到一个 bundle.js
后,会增加加载资源的时长,延后了程序 render
渲染时机。
那么路由的按需加载如何实现呢?
我们需要将上例中的 ESModule import
模块方式改为 webpack import()
引入方式。
它会将模块看做一个分割点并将其打包为一个独立的 chunk
文件,在程序匹配到指定路由后,动态加载 chunk
文件进行视图呈现。import()
会以模块名称作为参数名并且返回一个 Promise对象。
我们编写一个 高阶组件
,封装 webpack import()
来按需加载路由组件。
// src/components/HighComponents/LazyComponent.tsx
import React from 'react';
const lazyCaches: { [key: string]: React.ComponentType<any> } = {};
function lazyComponent(lazyName: string, loadComponent: () => Promise<any>) {
lazyCaches[lazyName] = lazyCaches[lazyName] || (
class AsyncComponent extends React.PureComponent<any, { Component: React.ComponentType<any> | null }> {
constructor(props: any) {
super(props);
this.state = { Component: null };
}
async componentDidMount() {
const { default: Component } = await loadComponent();
this.setState({ Component });
}
render() {
const Component = this.state.Component;
return (
Component ? <Component {...this.props} /> : null
)
}
}
)
return lazyCaches[lazyName];
}
export default lazyComponent;
lazyComponent
第一个参数是模块名称(用于 cache
),第二参数是一个函数,用于执行并返回 webpakc import()
,得到的将是一个 Promise
。
路由定义改造如下:
// src/App.tsx
import React from 'react';
import { HashRouter, Switch, Route, Redirect } from 'react-router-dom';
import lazyComponent from '@/components/HighComponents/LazyComponent';
const App = () => {
return (
<HashRouter basename="/">
<Switch>
...
<Route exact path='/outerShare/:shareId' component={lazyComponent('outerShare', () => import(/* webpackChunkName: "outerShare" */ '@/pages/OuterShare'))} />
<Route path='/' component={lazyComponent('home', () => import(/* webpackChunkName: "home" */ '@/pages/home'))} />
<Redirect from="/*" to="/" />
</Switch>
</HashRouter>
)
}
三、代码分割
上面我们提到,webpack 将打包资源都打包在了一个 bundle.js
中,其中主要包含了开发的源代码
和 第三方依赖 node_modules
。
我们可以对 node_modules 第三方依赖
打包资源拆分细化成多个资源文件,借助浏览器支持 HTTP 同时发起多个请求特性,使得资源异步并行加载,从而提高资源加载速度。
webpack
提供了 splitChunks
来支持这一配置。你的配置可能如下:
// webpack.config.js
module.exports = function ({ production = false, development = false }) {
...
output: {
path: path.resolve(__dirname, 'build'),
filename: 'static/js/[name].[contenthash:8].js', // 主文件分割出的文件命名
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js', // splitChunks 分割出的文件命名
},
optimization: {
minimize: true,
...
splitChunks: {
chunks: 'all',
cacheGroups: {
default: false,
vendors: false,
// 多页面应用,或者 webpack import() 多个 chunk 文件中,有 import 其他模块两次或者多次时,会打包生成 common
common: {
chunks: "all",
minChunks: 2,
name: 'common',
enforce: true,
priority: 5
},
// node_modules 中的公共模块抽离
vendor: {
test: /[\/]node_modules[\/]/,
chunks: 'initial',
enforce: true,
priority: 10,
name: 'vendor'
},
// @materual-ui
material: {
name: 'chunk-material',
priority: 20, // 优先级高于 vendor
test: /[\/]node_modules[\/]_?@material-ui(.*)/
},
}
},
runtimeChunk: { // 运行时代码(webpack执行时所需的代码)从主文件中抽离
name: entrypoint => `runtime-${entrypoint.name}`,
},
},
})
四、静态图片资源采用 cdn 方式
最初,项目工程下对 import
的图片资源模块有这样一条打包规则:
- 小于
10kb
的图片,打包成base64
字符串形式; - 大于
10kb
的图片,打包生成新的静态资源文件使用。
采用 Base64
字符串格式特点虽然可以节省 HTTP 请求发起次数,但它的体积大也是一大缺陷。
而在上述提到的 云文件应用
这个工程中,会使用到几十个文件后缀类型 PNG 图片,它们的大小在 8-9 kb 左右。
这样一来,这些图片资源都会被打包成 Base64
格式存放在 bundle.js
中,这无疑是增大了打包资源的体积,影响首次加载资源的速度。
因此,对于这类图片资源,应该将 import
方式(src={module}
)改为 src="https://juejin.im/post/7118587527673413663/url"
静态资源服务上的图片地址更为合适。
这里推荐一个 webpack
打包体积和内容可视化分析插件 webpack-bundle-analyzer
,分析打包资源从而选择合适的方式去优化处理。
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // 打包资源分析工具
module.exports = function ({ production = false, development = false }) {
...
plugins: [
...
new BundleAnalyzerPlugin(),
].filter(Boolean),
})
最后
感谢阅读,如有不足之处,欢迎指正。
参考:
React router 动态加载组件