本文由eJayYoung在众成翻译平台翻译,原文作者:来自Treebo的Lakshya Ranganath,和来自Chrome的Addy Osmani
Treebo是一家印度家喻户晓的经济型连锁酒店,在旅游业中占据了价值200亿美元的市场。他们最近开发了一个新的渐进式应用(PWA)作为默认的移动端体验,最开始使用React,但最后在生产环境转向了Preact。
对比之前的移动端可以看到,新版本在首屏渲染时间上提升了 70%,初始交互时间减少了 31%。大部分用户在3G环境下使用自己的移动设备只需不到4s即可浏览完整内容。使用WebPageTest模拟印度超慢的3G网络也只需要不到5s。
从React迁移到Preact也使初始交互时间缩短了15%。你可以打开Treebo.com完整体验一下,但是今天我们想深入探讨分析这个PWA的过程中的一些技术实现。
这就是Treebo 新版的PWA
老版的Treebo移动端是基于Django框架搭建的。用户在跳转页面时必须等待服务端请求。这个版本的首屏渲染时间为1.5s,首屏完整渲染时间为5.9s,初始交互时间为6.5s。
它们第一次迭代重构Treebo是用React和简单的webpack来构建一个单页应用。
你可以看下之前写的代码。这导致生成了简单(巨大)的Javascript和CSS包(bundles)。
/* webpack.js */
entry: {
main: './client/index.js',
},
output: {
path: path.resolve('./build/client'),
filename: 'js/[name].[chunkhash:8].js',
},
module: {
rules: [
{ test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },
{ test: /\.css$/, loader: ExtractTextPlugin.extract({ fallback: ['style-loader'], use: ['css-loader'] }) },
],
}
new ExtractTextPlugin('css/[name].[contenthash:8].css'),
这次版本的首屏渲染时间为4.8s,初始交互时间大约5.6s,完整的首屏图片加载时间在7.2s。
接着,他们着手优化首屏渲染时间,所以他们尝试了服务端渲染。有一点值得注意,服务端渲染并不是没有副作用。它优化的同时也会消耗其他性能。
使用服务端渲染,你服务端给浏览器的返回就是你即将重绘页面的HTML,这样浏览器可以不需要等待所有Javascript加载和执行才能渲染页面。
Treebo使用React的renderToString()将组件渲染为一段HTML字符串,并在应用初始化的时候注入state。
// reactMiddleware.js
const serverRenderedHtml = async (req, res, renderProps) => {
const store = configureStore();
//call, wait, and set api responses into redux store's state (ghub.io/redux-connect)
await loadOnServer({ ...renderProps, store });
//render the html template
const template = html(
renderToString(
,
),
store.getState(),
);
res.send(template);
};
const html = (app, initialState) => `
太阳集团城8722(中国·Macau)有限公司-Official website
${app}
``
`
XML 地图
`;
在Treebo的例子中,使用服务端渲染,首屏渲染时间减少到1.1s,首屏完整渲染时间减少到2.4s - 这提高了用户在页面加载速度的感知,他们可以更提前获取内容,而且在测试中显示在SEO也略微改善。但是缺点就是在初始交互时间有糟糕的影响。
尽管用户可以看到网站内容,但是当初始化加载javascript时主线程被阻塞了,并且就堵在那里。
使用SSR,浏览器需要比之前请求处理更大的HTMl负载,并且接着请求,解析/编译,执行Javascript。虽然这样高效的做了更多工作。
但这意味着第一次交互时间需要6.6s,反而不如之前了。
SSR也可以通过锁定下游设备的主线程来缩短TTI。(译者注:Transmission Time Interval传输时间间隔)
基于路由的代码分割和按需加载
接下来Treebo要做的就是按需加载,可以减少初始交互时间。
按需加载目的在于给一个路由页面的交互提供其所需要的最少代码,通过code-splitting将路由分割成按需加载的“块”。这样让加载的资源更接近于开发者写的模块粒度。
他们在这块的做法是,把他们的第三方依赖库,Webpack runtime manifests,和他们的路由分割成单独的块。(译者注:需要理解webpack 的 runtime 和 manifest,可以点进来看看)
// reactMiddleware.js
//add the webpackManifest and vendor script files to your html
太阳集团城8722(中国·Macau)有限公司-Official website
${app}
``
`
`
`
XML 地图
// vendor.js
import 'redux-pack';
import 'redux-segment';
import 'redux-thunk';
import 'redux';
// import other external dependencies
// webpack.js
entry: {
main: './client/index.js',
vendor: './client/vendor.js',
},
new webpack.optimize.CommonsChunkPlugin({
names: ['vendor', 'webpackManifest'],
minChunks: Infinity,
}),
// routes.js
import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */)
.then((module) => cb(null, module.default))
.catch((error) => cb(error, null))
}
>
// webpack.js
//extract css from all the split chunks into main.hash.css
new ExtractTextPlugin({
filename: 'css/[name].[contenthash:8].css',
allChunks: true,
}),
这直接将初始交互时间减少到4.8s了。帅呆了!
唯一不够理想的是需要在初始化的bundles被执行完才会开始下载当前页面的Javascript。
但它至少在体验上提升了不少。对于按需加载,代码分割和这次体验的提升,他们做了一些更隐性的改进。他们通过webpack 的import方法调用React Router声明支持的getComponent来异步加载到各个模块中。(译者注:想了解getComponent可以点进来)
PRPL性能模式
按需加载对于代码更颗粒化的运行和缓存是非常赞的第一步。Treebo想再优化,并在PRPL 模式上找到了灵感。
PRPL是一种用于结构化和提供 Progressive Web App (PWA) 的模式,该模式强调应用交付和启动的性能。
它代表:
推送 - 为初始网址路由推送关键资源。
渲染 - 渲染初始路由。
预缓存 - 预缓存剩余路由。
延迟加载 - 延迟加载并按需创建剩余路由。
Jimmy Moon做的一份PRPL的结构图
“推送”部分推荐给服务器/浏览器组合设计一个离散的结构,以便在优化缓存的同时,支持HTTP/2传递给浏览器首屏光速渲染所需的资源。这些资源的传递可以通过或者HTTP/2 Push来高效完成。
Treebo选择使用加载当前路由模块。当初始模块执行完后,webpack回调获取当前路由,当前路由模块已经在缓存中了,这样就减少初始交互时间。所以现在初始交互时间在4.6s时就开始了。
使用preload唯一不好的就是它并没有支持跨浏览器。目前,Safari已经支持link rel preload特性。我希望今年它会持续落实。目前Firefox也正在落实进行中。
HTML流
使用renderToString()的缺点之一是它是异步的,这会成为React项目中服务端渲染的性能瓶颈。服务器直到全部HTML被创建后才会发送 请求。当web服务器输出网站内容时,浏览器会在全部请求完成之前渲染页面给用户。类似react-dom-stream这样的项目可以对此有所帮助。
为了提高他们的app感知性能,并引入一种渐进式渲染的感觉,Treebo使用了HTML流。他们会优先输出那些带有link rel preload的头部标签,这样可以预加载CSS和Javascript。然后再执行服务端渲染,并把剩下的资源发送给浏览器。
这样做的好处是资源比之前更早开始下载,将首屏渲染时间降低到0.9s,初始交互时间降低到4.4s。app始终保持在4.9/5秒的节点才开始交互。
缺点是它在客户端和服务器之间连接会保持一段时间,如果遇到稍长点的延迟时间,可能会出现问题。 针对HTML流,Treebo将传输内容定义成预加载模块,主内容模块和将要加载的模块。 所有这些都被插入到页面中。 就像这样:
// html.js
earlyChunk(route) {
return `
${!assets[route.name] ? '' : ``}
`;
},
lateChunk(app, head, initialState) {
return `
太阳集团城8722(中国·Macau)有限公司-Official website
${app}
``
`
`
`
XML 地图
`;
},
// reactMiddleware.js
const serverRenderedChunks = async (req, res, renderProps) => {
const route = renderProps.routes[renderProps.routes.length - 1];
const store = configureStore();
//set the content type since you're streaming the response
res.set('Content-Type', 'text/html');
//flush the head with css & js resource tags first so the download starts immediately
const earlyChunk = html.earlyChunk(route);
res.write(earlyChunk);
res.flush();
//call & wait for api's response, set them into state
await loadOnServer({ ...renderProps, store });
//flush the rest of the body once app the server side rendered
const lateChunk = html.lateChunk(
renderToString(
,
),
Helmet.renderStatic(),
store.getState(),
route,
);
res.write(lateChunk);
res.flush();
//let client know the response has ended
res.end();
};
对于所有不同的脚本标签,预加载模块已经获取到它们的rel=preload声明。将要加载的模块则获取了服务端返回的html和其他包含state的内容,或者正在使用已经加载的Javascript。
内联对应路径CSS
CSS样式表会阻塞页面的渲染。页面会在浏览器发起请求,接收,下载,并且解析你的样式表之前保持空白。通过减少浏览器需要加载的CSS数量,并把对应路径样式内联到页面中,这样就减少了一个HTTP请求,页面就可以更快的渲染。
Treebo在当前路由支持了内联对应路径的样式,并在DOMContentLoaded时使用loadCSS异步加载剩余的CSS。
这消除了标签对对应路径页面渲染的阻塞,并加入了少量的核心CSS,将首屏渲染时间减少至0.4s。
// fragments.js
import assetsManifest from '../../build/client/assetsManifest.json';
//read the styles into an assets object during server startup
export const assets = Object.keys(assetsManifest)
.reduce((o, entry) => ({
...o,
[entry]: {
...assetsManifest[entry],
styles: assetsManifest[entry].css ? fs.readFileSync(`build/client/css/${assetsManifest[entry].css.split('/').pop()}`, 'utf8') : undefined,
},
}), {});
export const scripts = {
//loadCSS by filamentgroup
loadCSS: 'var loadCSS=function(e,n,t){func...',
loadRemainingCSS(route) {
return Object.keys(assetsManifest)
.filter((entry) => assetsManifest[entry].css && entry !== route.name && entry !== 'main')
.reduce((s, entry) => `${s}loadCSS("${assetsManifest[entry].css}");`, this.loadCSS);
},
};
// html.js
//use the assets object to inline styles into your lateChunk template generation logic during runtime
lateChunk(route) {
return `
太阳集团城8722(中国·Macau)有限公司-Official website
${app}
``
`
`
`
``