如今的前端页面越来越丰富了,承载着各种功能。而随之增长的则是相应的代码量,加上三方 SDK 的接入以及单页应用(SPA)的特性,一次页面访问会出现慢的感觉,是时候来关注页面的加载优化了。
本文简略描述关于 React 单页应用的加载优化,下文所指加载一般包括下载、执行两个步骤。
针对上述过程以及页面特性,以前端手段,接下来从以下四个个方面入手优化:
性能提升很多时候就是一门做减法的艺术。
一般情况下,SPA 的一个特点就是借助 webpack 等工具将最终代码打包在一个或两类(main.js、vendor.js)文件中,这些文件实际上包含了一整个应用的内容。这样带来了一个问题,当只访问其中一个路由页面时,加载了很多不必要的代码。
那么,很自然地,可以选择把不属于当前页面的模块分离出去,等用到之时,再进行获取。幸运的是,在 webpack 的打包环境中,只需要使用动态导入的语法就能自动实现代码分离:import x from 'x'
=> import('x').then(x => { /* */ })
。
对于 React 应用,有一个十分好用的库 react-loadable,基于动态导入语法封装。使用官方的介绍,用于装载具有动态导入组件的高阶组件。该库实现基于组件粒度的拆分,将动态导入的过程、结果都包装成了组件,使用也十分简单,来看下具体使用:
import Loadable from 'react-loadable';
import Loading from './my-loading-component';
// 结果是个组件,可直接使用
// 加载时自动展示 loading 样式
const LoadableComponent = Loadable({
loader: () => import('./my-component'),
loading: Loading,
});
export default class App extends React.Component {
render() {
return <LoadableComponent/>;
}
}
如此一来,再使用 webpack 打包,会发现多了不少用于动态引入的文件。另外,代码的组织对于拆分是有一定影响的。以下分别是未拆分、基于路由拆分、整理代码(期间删了部分无用代码)后基于路由拆分情况下的打包情况。可对比看出每个页面加载的总资源大小减少了。
webpack-bundle-analyzer
是一个十分有用的打包结果分析插件,能帮助我们直观看出打包结果的具体组成,从而作出进一步调整优化。
温馨提示:
import()
生效,得在 tsconfig.json 中设置 "module": "esnext"
,更多详情。这里所说的主要是一些细节方面的处理,一般效果可能会比较小,往往不需要要太多的改动,能优化一点是一点,秉承蚊子腿也是肉的原则。
import { throttle } from 'lodash'
。在此,引入一个概念:关键请求链(Critical-Request-Chains)。
关键请求链:可视区域渲染完毕(首屏),并对于用户来说可用时,必须加载的资源请求队列,就叫做关键请求链。
简单来说,对于直接在 index.html 文档中引用的 JS、CSS、图片等资源被认为是关键请求链,浏览器会优先进行处理。
以之前提到的拆分结果为例,拆分后单个页面需要加载的资源大小变化不显著,这时候去测量页面的加载性能得到的结果也许会是不升反降,降的主要是首次有效绘制。
这是单个页面的加载情况,动态加载了两个模块,而这两个模块是不属于关键请求链的。这种情况下,该页面的加载情况简述如下:
该步骤相比初始状态多了一个步骤,当这些新增的开销大于初始状态的时候,页面加载的整体性能是下降的。
要在当前情况下发挥拆分代码、动态加载的意义,有以下一种思路:
假设一个 SPA 包含列表页、详情页,列表页是一级关键页面,可以只对详情页相关的内容进行分离。如此一来,对于列表页依旧只有 app.js、vendor.js 部分,但是少了详情页相关的东西,因此做到了关键页面的性能加载提升。
除了必要的框架、业务代码,页面中常常引入了一些 SDK,比如打点功能。
以打点为例,这类资源实际上跟首屏的渲染没有关系,即使在页面全部加载完毕三秒后再引入也是无伤大雅的。
而我们的现状是,这类资源被直接写在 head 部分引入,被浏览器认为是关键资源优先处理,势必对页面的加载有一定的影响。
对于这类非关键资源,不需要列在关键请求链当中。以此处的神策打点为例,我们将他的顺序写在文档底部,并添加了 defer
属性,告诉浏览器神策的下载不是阻塞的,并且将它的执行时机进行延后。
回顾之前的加载瀑布图,可以看到 sensorsdata.min.js
的加载优先级(Priority)是Low
,此时神策的加载是不影响首屏的渲染了。
script 异步加载属性 async
、defer
介绍:
早期情况,浏览器对于 JS 的下载、执行可能是严格串行模式处理的,只有上一个 JS 加载完毕后才会开始下一个 JS 的下载、执行。
但实际上,目前的浏览器对于资源加载都有自己的策略,基本上都会去并行下载资源,即使是单线程执行的 JS 脚本(观察上述加载瀑布图也能发现这点)。因为这样做能够减少 JS 的下载时间,不过有一点是不变的,严格按照 JS 的顺序进行执行。
此处排除图片等媒体资源进行讨论。
往往为了缓存考虑,我们会把 React、ReactDOM 单独抽离,打包时也会采取一种策略将第三方不经常改动的部分抽离出来。因此,一个页面的请求数会变多。但浏览器对于一个域名的并发请求下载数量是有限制的。再加上 SDK、polyfill 等可能就突破上限了,那么多余的只能等待。
针对这种情况一般有几种方式:
即使优化了资源加载,SPA 首屏空白的问题是依旧存在的,因为要等待 JS 的下载、执行。
问题的根本是 HTML 文档中只有一个空节点,那么只要预先插入一些内容就能解决白屏问题了,处理方法很简单。
处理起来最方便的一种,只要在 HTML 模板文件中插入内容即可。写在 #root
中是因为在 React 渲染后会自动顶掉了 Loading,免去了手动的处理。
<div id="root">
<div id="loading">
<!-- ... 一些 loading 代码 -->
</div>
</div>
这里的多页面指的是前端路由的多页面,都在一个 React 单页应用的范围内。在此基础上要实现这个效果稍微复杂一些。
首先介绍一下 URL 中 Hash 作用。#
代表网页中的一个位置,其右面的字符,就是该位置的标识符。浏览器读取这个 URL 后,会自动将 Hash 标注的内容位置滚动至可视区域。#
是用来指导浏览器动作的,浏览器发出的 HTTP 请求中不包括 #
,所以 Hash 对服务器端完全无用。
之后再来看 Hash 模式的前端路由:
const url1 = 'http://example.com/project/#/page1'
const url2 = 'http://example.com/project/#/page2'
const url3 = 'http://example.com/project/#/page1/subpage2'
访问上述任意 URL,实际上浏览器发出的请求都是http://example.com/project/
,服务端就会返回http://example.com/project/index.html
。之后,代码执行,路由框架匹配前端路由,最终渲染出对应页面。
对此,实现目的,有三种想法:
下面主要介绍方式 3.
想要根据不同的路由返回对应不同的 HTML 文档
第一步要改变前端路由方式(Hash => Browser)。
// Browser 模式下的 URL 以及服务端返回的内容
const url1 = 'http://example.com/project/page1'
// http://example.com/project/page1/index.html
const url2 = 'http://example.com/project/page2'
// http://example.com/project/page2/index.html
const url3 = 'http://example.com/project/page1/subpage2'
// http://example.com/project/page1/subpage2/index.html
第二步,在服务器的对应目录下提供对应的 HTML 文档。 但很多时候我们并没有需求为每个路由页面提供 HTML 文档,这时候去访问一个前端路由而后端没提供文档时会出现 404,这不是我们想要看到的。因为这个路由页面是由前端渲染、在前端是存在的。 这时候就要做一个处理,就是常说的 Nginx 重定向(以 Nginx 为例),表示该路由后端处理不了,交给前端处理。
location /project {
try_files $uri $uri/ index.html;
}
第三步,开发时应该怎么处理?总不能手动写好这些 HTML 文档再上传到服务器。介绍两种方式,都是基于 Webpack 进行打包生成:
使用多个 html-webpack-plugin,因为我们的项目分为了多个 React 项目,每个项目的页面不会很多,因此使用多个 html-webpack-plugin
没有打包性能的问题。
const routesCfg = [
{
path: '/user',
template: './documents/user.html'
},
{
path: '/user/profile',
template: './documents/user.html'
},
]
routesCfg.forEach(r => {
webpackConfig.plugins.push(
new HtmlWebpackPlugin({
filename: `${r.path.replace(/^\//, '')}/index.html`,
template: path.join(__dirname, r.template),
})
)
})
该方式需要手动编写预渲染的内容,但能做到精细的控制。
另外,有个小问题:如果 publicPath 是相对路径的话,打包结果子目录下的资源引用的路径会有问题。
使用 prerender-spa-plugin。提供配置,打包完成后会启动一个本地服务,使用无头浏览器(puppeteer)访问传入配置中的路径,接着抽取页面的文档内容,重新生成新的 HTML 文件。
该方式生成的预渲染内容是页面最终的样式,适合一些静态类型页面的显示,切忌用于生成动态内容的预渲染(比如用户个人信息页面,你肯定不希望打开页面时先显示的是其他人的信息再变成自己的,这个其他人的信息就是在抽取页面内容的时候注入的)。
当使用 CDN 的时候需要注意了,该插件启动本地服务访问页面时,资源是还没上传至 CDN 的(这还属于 Webpack 的打包流程,一般是在打包流程结束后进行 CDN 上传)。
此处提供两个思路解决该问题:
以上,是新人对页面加载优化的探索,实际上一定还存在不少可优化、提升用户体验的点,接下来还需要更多的实践。
参考链接 & 相关阅读: