系列第二篇,来看看基于 React 路由分块的页面加载优化。
- 原文地址:Progressive Web Apps with React.js: Part 2 — Page Load Performance
移动 Web 的速度很关键。平均地,更快的体验会带来 70% 更长的会话 以及两倍以上更多的移动广告收益。Web 性能的投资像是基于 React 的 Flipkart Lite 获得了三倍网站浏览时间, GQ 在流量上得到了 80% 增长,Trainline 在 年收益上增长了 11M 并且 Instagram 增长了 33% 的印象.
在你的 web app 加载时有一些 关键的用户时刻:
测量然后优化总是关键的。Lighthouse 的页面加载检测会关注:
顺带一提,Paul Irish 做了很了不起的相关总结 PWAs 的有趣指标值得一看。
良好性能的目标:
让我们再说说,关于通过 TTI 关注交互性。
为交互性优化,也就是使得 app 尽快能对用户可用(比如让他们可以四处点击,app 可以相应)。这对试图在移动设备上提供一流用户体验的现代 web 体验很关键。
Lighthouse 现在将 TTI 测量为布局稳定,web 字体可见,并且主线程可以响应用户输入的时间。有很多方法来手动跟踪 TTI,重要的是根据指标进行优化会提升你用户的体验。
对于像 React 这样的库,你应该关心的是在移动设备上 启用库的代价 因为这会让人们有感知。在 ReactHN,我们达到了 1700毫秒 内可交互,通过保持整个 app 的大小和执行代价相对小,撇开有多个视图:app bundle gzipped 压缩后 11KB,107KB 用于我们的 vendor/React/库 bundle,实践中有点像这样:
之后,对于功能颗粒状的 apps,我们会看看性能模式像是 PRPL,通过在 HTTP/2 服务器 Push 下利用颗粒状的 “基于路由的分块” 来得到快速的可交互时间。(可以试试 Shop demo 来看看我们说的是什么)。
Housing.com 最近使用了类 PRPL 模式搭载 React 体验,获得了很多赞扬:
Housing.com 利用 Webpack 路由分块,来推迟入口页面的部分启动消耗(仅加载 route 渲染所需要的)。更多细节请查看 Sam Saccone 的优秀 Housing.com 性能检测.
Flipkart 也做了类似的:
注意:关于什么是 “到可交互时间”,有很多不同的看法,Lighthouse 对 TTI 的定义也可能会演变。其他跟踪的方法有导航后第一个 5 秒内 window 没有长任务的时刻,或者一次文本/内容绘制后第一次 5 秒内 window 没有长任务的时刻。基本上,就是页面稳定后多快,用户可以和 app 交互的。
注意:尽管不是强制的要求,你可能也需要提高视觉完整度(速度指数),通过 优化关键渲染路径。关键路径 CSS 优化工具的存在 以及其优化在 HTTP/2 的世界中依然有效。
如果你第一次接触模块打包工具像是 Webpack,看看 JS 模块化打包器(视频) 可能会有帮助。
一些今天的 JavaScript 工具使得将你的所有脚本打包成一个 bundle.js 文件并包含所有页面变得简单。这意味着很多时候,你可能要加载很多对当前路由来说并不需要的代码。为什么一次路由需要加载 500KB 的 JS,而事实上 50KB 就够了呢?我们应该丢开那些无助于获得更快体验的脚本,来加速获得可交互的路由。
当仅提供用户一次 route 所需要的最小功能可达代码就可以的时候,避免提供庞大整块的 bundles(像上图)。
代码分割是解决整块的 bundles 的一个方法。想法大致是在你的代码中定义分割点,然后分割成不同的文件进行按需懒加载。这会提升启动时间,帮助我们更快地可交互。
想象使用一个公寓列表 app。如果我们登陆的路由是列出我们所在区域的地产(route-1)—— 我们不需要查看完整的地产详情的代码(route-2)或者预约一次看房(route-3),所以我们可以仅提供用户列表路由所需要的 JavaScript,然后动态加载剩下的。
这些年来,代码分割的想法已经被很多 apps 使用,但现在用 “基于路由的分块” 来称呼它。通过 Webpack 模块打包器,我们可以启用 React 上的安装。
Webpack 支持当它发现一个 require.ensure() 被使用的时候将你的 app 代码分割成块(或者在 Webpack 2,一个 System.import)。这些被称为 “分割点”,Webpack 会对它们的每一个都生成一个分开的 bundlea,按需解决依赖。
// 定义一个 "split-point"
require.ensure([], function () {
const details = require('./Details');
// 所有被 require() 需要的都会成为分开的 bundle
// require(deps, cb) 是异步的。它会异步加载,并且评估
// 模块,通过你的 deps 的 exports 调用 cb。
});
当你的代码需要某些东西,Webpack 会发起一个 JSONP 请求来从服务器获得它。这个和 React Router 结合工作得很好,我们可以在对用户渲染视图之前在依赖(块)中懒加载一个新的路由。
Webpack 2 支持 使用 React Router 的自动代码分割 因为它可以处理模块上的 System.import 调用为 import 语句,将导入的文件和它们的依赖一起打包。依赖不会与你在 Webpack 设置中的初始入口冲突。
import App from '../containers/App';
function errorLoading(err) {
console.error('Lazy-loading failed', err);
}
function loadRoute(cb) {
return (module) => cb(null, module.default);
}
export default {
component: App,
childRoutes: [
// ...
{
path: 'booktour',
getComponent(location, cb) {
System.import('../pages/BookTour')
.then(loadRoute(cb))
.catch(errorLoading);
}
}
]
};
在我们继续之前,除了刚才的方法,另一个可选的是 来自 Resource Hints。这个提供给我们一个方法来宣告式地获取资源而不执行它们。预加载可以用来预加载用户可能要去的路由所需要的 Webpack 块,于是缓存已经为他们准备好了,可以在需要的时候立即可用。
在写的时候,预加载只能在 Chrome 中进行,但是在支持的浏览器中被处理为渐进式增加。
注意:html-webpack-plugin 的 模板和自定义事件 可以使用最小的改变来让简化这个过程。然后你应该保证预加载的资源真正会对你大部分的用户浏览过程有用。
让我们回到代码分割(code-splitting)—— 在一个使用 React 和 React Router 的 app 里,我们可以使用 require.ensure() 以在 ensure 被调用的时候异步加载一个组件。顺带一提,如果任何人在探索服务器渲染,这个在 Node 里需要被 node-ensure 包来填充。Pete Hunt 在 Webpack How-to 涵盖了异步加载。
在下面的例子里,require.ensure() 使我们可以按需懒加载路由,在组件被使用前等待拉取:
const rootRoute = {
component: Layout,
path: '/',
indexRoute: {
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./Landing'))
})
}
},
childRoutes: [
{
path: 'book',
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./BookTour'))
})
}
},
{
path: 'details/:id',
getComponent (location, cb) {
require.ensure([], () => {
cb(null, require('./Details'))
})
}
}
]
}
注意:我经常通过 CommonChunksPlugin(minChunks:
Infinity)来进行上面的安装,所以在我不同的入口点之间有一个带有通用模块的 chunk。这也 极力降低了 陷入缺省 Webpack runtime 的可能。
Brian Holt 在 React 的完整介绍 对异步路由加载涵盖得很好。通过异步路由的代码分割在 React Router 的目前版本和 新的 React Router V4 都可以使用。
这有有一个小贴士,可以使代码分割的安装更快。在 React Router,一个 宣告式的路由 来将路由 “/” 映射到组件 App
看上去像
.
React Router 也支持一个方便的 [getComponent](https://github.com/ReactTraining/react-router/blob/master/docs/API.md#getcomponentnextstate-callback)
属性,类似于 component
但却是异步的,对快速安装上代码分割超级有用:
{
// 异步地查找 components
cb(null, Stories)
}} />
getComponent
函数参数包括下一个状态(我设置为 null)和一个回调。
让我们添加一些基于路由的代码分割到 ReactHN。我们会从 routes 文件中的一段开始 —— 它定义了组件的 require 调用和对每个路由的 React Router 路由(比如 news, item, poll, job, comment 永久链接等):
var IndexRoute = require('react-router/lib/IndexRoute')
var App = require('./App')
var Item = require('./Item')
var PermalinkedComment = require('./PermalinkedComment') <--
var UserProfile = require('./UserProfile')
var NotFound = require('./NotFound')
var Top = stories('news', 'topstories', 500)
// ....
module.exports =
<---
ReactHN 现在提供给用户一个整块包含所有路由的 JS bundle。让我们将它转换为路由分块,只提供一次路由真正需要的代码,从 comment 的永久链接开始(comment/:id):
所以我们首先删了对永久链接组件的隐式 require:
var PermalinkedComment = require(‘./PermalinkedComment’)
然后开始我们的路由..
然后使用宣告式的 getComponent 来更新它。我们在路由中使用 require.ensure() 调用来懒加载,而这就是我们所需要做的一切了:
{
require.ensure([], require => {
callback(null, require('./PermalinkedComment'))
}, 'PermalinkedComment')
}}
/>
Orz 太美了。这..就搞定了。不骗你。我们可以把这个用到剩下的路由上,然后运行 webpack。它会正确地找到 require.ensure() 调用,然后如我们想要地区分隔代码。
在应用宣告式的代码分割到更多我们的路由后,可以看到路由分块在运作,仅仅会加载一次路由所需要的代码(在 Service Worker 可以预缓存起来):
提醒:有许多可用于 Service Worker 的简单 Webpack 插件:
为了识别出在不同路由使用的通用模块并把它们放在一个通用的分块,需要使用 CommonsChunkPlugin。它需要在每个页面 requires 两个 script 标签,一个用于 commons 分块,另一个用于一次路由的入口分块。
const CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin");
module.exports = {
entry: {
p1: "./route-1",
p2: "./route-2",
p3: "./route-3"
},
output: {
filename: "[name].entry.chunk.js"
},
plugins: [
new CommonsChunkPlugin("commons.chunk.js")
]
}
Webpack 的 — display-chunks 标志 对于查看模块在哪个分块中出现很有用。这个帮助我们减少分块中重复的依赖,并且能提示在你的项目中开启 CommonChunksPlugin 是否值得。这是一个带有多个组件的项目,在不同分块间检测到重复的 Mustache.js 依赖:
Webpack 1 也支持通过 DedupePlugin 以在你的依赖树中进行依赖库的去重。在 Webpack 2,tree-shaking 应该淘汰了这个的需求。
更多 Webpack 的小贴士
在 Webpack 编译中检测臃肿
Webpack 社区有很多建立在 Web 上的编译分析器包括 http://webpack.github.io/analyse/,https://chrisbateman.github.io/webpack-visualizer/,和 https://alexkuz.github.io/stellar-webpack/。这些对于理解你最大的模块是什么很有用。
source-map-explorer (来自 Paul Irish) 通过 source maps 来理解代码臃肿,也超级棒的。看看这个对 ReactHN Webpack bundle 的 tree-map 可视化,带有每个文件的代码行数,以及百分比的统计分析:
你可能也会对来自 Sam Saccone 的 coverage-ext 感兴趣,它可以用来对任何 webapp 生成代码覆盖率。这个对于理解你的代码中有多少实际会被执行到很有用。
Polymer 发现了一个有趣的 web 性能模式,用于精细服务的 apps,称为 PRPL(看看 Kevin 的 I/O 演讲)。这个模式尝试为交互性优化,并且代表了:
在这里,我们必须给予 Polymer Shop demo 大大的赞赏,因为它向我们展示了真实移动设备上的道路。使用 PRPL(在这种情况下通过 HTML Imports,从而利用浏览器的后台 HTML parser 的好处)。屏幕上的像素你都可以使用。这里额外的工作在于分块和保持可交互。在一台真实移动设备上,我们可以在 1.75 秒内达到可交互。1.3 秒用于 JavaScript,但它都被打散了。在那以后所有功能都可以用了。
你到现在应该已经成功享受到讲应用打碎到更精细的分块的好处了。当用户第一次访问我们的 PWA,假设说他们去到一个特定的路由。服务器(使用 H/2 Push)能够推送下来仅仅那次路由需要的分块 —— 这些是用来启动应用的必要资源,并会进入网络缓存中。
一旦它们被推送下来了,我们就能高效地准备好未来会被加载的页面分块到缓存中。当应用启动后,检查路由并指导我们想要的已经在缓存中了,所以我们就能使得应用的首次加载非常快 —— 不仅仅是闪屏 —— 而是用户请求的可交互内容。
下一部分是尽快渲染这个视图的内容。第三部分是,当用户在看当前的视图的时候,使用 Service Worker 来开始预缓存所有其他用户还没有请求的分块和路由,将它们安装到 Service Worker 的缓存中。
此时,整个应用(或者大部分)都已经可以离线使用了。当用户导航到应用的不同部分,我们可以从 Service Worker 的缓存中懒加载下面的部分。不需要网络加载 —— 因为它们已经被预缓存了。瞬间加载碉堡了!❤
PRPL 可以被应用到任何 app,正如 Flipkart 最近在他们的 React 栈上所展示的。完全使用 PRPL 的 Apps 可以利用 HTTP/2 服务器推送的快速加载,通过产生两种编译版本,并根据浏览器的支持提供不同版本:
一个 bundled 编译,为没有 HTTP/2 推送支持的服务器/浏览器优化以最小化往返。For most of us, this is what we ship today by default.
一个没有 bundled 编译,用于支持 HTTP/2 推送的服务器/浏览器,使得首次绘制更快。
这个部分基于我们在之前讨论的路由分块的想法。通过 PRPL,服务器和我们的 Service Worker 协作来为非活动路由预缓存资源。当一个用户在你的 app 中浏览并改变路由,我们对尚未缓存的路由进行懒加载,并创建请求的视图。
太长了,所以没有看:Webpack 的 require.ensure() 以及异步的 ‘getComponent’,还有 React Router 是到 PRPL 风格性能模式的最小摩擦路径
PRPL 的一大部分在于将 JS 捆包思维方式放下,并像编写时候那样精细地传输资源(至少从功能独立模块角度上)。通过 Webpack,这就是我们已经说过的路由分块。
对于初始路由推送关键资源。理想情况下,使用 HTTP/2 服务端推送,但即便没有它,也不会成为实现类 PRPL 路径的阻碍。即便没有 H/2 推送,你也可以实现一个大致和“完整” PRPL 类似的结果,只需要发送 预加载头 而不需要 H/2。
看看 Flipkart 他们前后的生产瀑布:
Webpack 已经通过 AggressiveSplittingPlugin 的形式支持了 H/2。
AggressiveSplittingPlugin 分割每个块直到它到达了指定的 maxSize,正如我们在下面的例子里可见的:
module.exports = {
entry: "./example",
output: {
path: path.join(__dirname, "js"),
filename: "[chunkhash].js",
chunkFilename: "[chunkhash].js"
},
plugins: [
new webpack.optimize.AggressiveSplittingPlugin({
minSize: 30000,
maxSize: 50000
}),
// ...
查看官方 plugin page,以获得关于更多细节的例子。学习 HTTP/2 推送实验的课程 和 真实世界 HTTP/2 也值得一读。
为什么关心静态资源版本?
静态资源指的是我们页面中像是脚本,stylesheets 和图片这样的资源。当用户第一次访问我们页面的时候,他们需要其需要的所有资源。比如说当我们落到一个路由的时候,JavaScript 块和上次访问之际并没有改变 —— 我们不必重新抓取这些脚本因为他们已经在浏览器缓存中存在了。更少的网络请求对 web 性能来说是收益。
通常地,我们使用对每个文件设置 expires 头 来达到目的。一个 expires 头只意味着我们可以告诉浏览器,避免在指定时间内(比如说1年)发起另一个对该文件的请求到服务器。随着代码演变和重新部署,我们想要确保用户可以获得最新的文件,如果没有改变的话则不需要重新下载资源。
Cache-busting 通过在文件名后面附加字符串来完成这个 —— 他可以是一个编译版本(比如 src=”chunk.js?v=1.2.0”),一个 timestamp 或者别的什么。我倾向于添加一个文件内容的 hash 到文件名(比如 chunk.d9834554decb6a8j.js)因为这个在文件内容发生改变的时候总是会改变。MD5 hashing 在 Webpack 社区经常被用来做这个来生成 16 字节长的 ‘概要’。
通过 Webpack 的静态资源长期缓存 是关于这个主题的优秀读物,你应该去看一看。我试图在下面涵盖其涉及到的主要内容。
在 Webpack 中通过内容哈希来做资源版本
在 Webpack 设置中加上如下内容来启用基于内容哈希的资源版本 [chunkhash]:
filename: ‘[name].[chunkhash].js’,
chunkFilename: ‘[name].[chunkhash].js’
我们也想要保证常规的 [name].js 和 内容哈希 ([name].[chunkhash].js) 文件名在我们的 HTML 文件被正确引用。不同之处在于引用 和
。
下面是一个注释了的 Webpack 设置样例,包括了一些其他的插件来使得长期缓存的安装更优雅。
const path = require('path');
const webpack = require('webpack');
// 使用 webpack-manifest-plugin 来生成包含了源文件到对应输出的映射的资源 manifest。Webpack 使用 IDs 而不是模块名来保持生成的文件尽量小。IDs 在它们被放进 chunk manifest 之前被生成并映射到 chunk 的文件名(会跑到我们的入口 chunk)。不幸的是,任何对代码的改变都会更新入口 chunk 包括新的 manifest,并刷新我们的缓存。
const ManifestPlugin = require('webpack-manifest-plugin');
// 我们通过 chunk-manifest-webpack-plugin 来修复这个问题,它会将 manifest 放到一个完全独立的 JSON 文件。
const ChunkManifestPlugin = require('chunk-manifest-webpack-plugin');
module.exports = {
entry: {
vendor: './src/vendor.js',
main: './src/index.js'
},
output: {
path: path.join(__dirname, 'build'),
filename: '[name].[chunkhash].js',
chunkFilename: '[name].[chunkhash].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: "vendor",
minChunks: Infinity,
}),
new ManifestPlugin(),
new ChunkManifestPlugin({
filename: "chunk-manifest.json",
manifestVariable: "webpackManifest"
}),
// 对非确定的模块顺序的权宜之计。在通过 Webpack 的静态资源长期缓存文章中有更多介绍
new webpack.optimize.OccurenceOrderPlugin()
]
};
现在我们有了这个 chunk-manifest JSON 的编译,我们需要把它内联(inline)到我们的 HTML,那么 Webpack 就能实际在页面启动时真正对其有访问权。所以在 标签中加上上面的输出。
通过使用 html-webpack-plugin 可以实现自动将脚本内联到 HTML 中。
注意:Webpack 理想上可以通过 no shared ID range 来简化启用长期缓存的步骤(见~4–1)。
如果要学习更多 HTTP 的 缓存最佳实践,可以阅读 Jake Archibald 的优秀文章。
在系列文章第三篇中,我们会来看看 怎么使你的 React PWA 能离线和断续的网络状态下工作.
如果你新接触 React,我发现 Wes Bos 写的 给新手的 React 很棒。
感谢 Gray Norton, Sean Larkin, Sunil Pai, Max Stoiber, Simon Boudrias, Kyle Mathews 和 Owen Campbell-Moore 的校对。
原文见:http://blog.zhaiyifan.cn/2016/11/16/pwa-react-p2/