分类
中篇 前端 技术 未分类 移动互联网

Treebo基于React/Preact的PWA性能调优案例分析[译文]

Treebo是在印度名列前茅的连锁经济酒店,在200亿美元规模的旅游产业中占据了重要位置。他们最近发布了全新的、先后基于React和Preact构建的PWA(Progressive Web App,渐进式网页应用),为其客户提供移动端体验。

相比他们的上一代移动端网站,新PWA的白屏(First Paint)时间缩短了70%,可交互时间(Time-to-Interactive)缩短了31%,3G网络下加载时间短于4秒。在WebPageTest中模拟印度3G网络测试(译者按:在本文“由React切换至Preact”章节末尾介绍了测试详情),页面5秒内即可交互。

单从React更换为Preact这一项就为可交互时间贡献了15%的提升。你可以访问Treebo.com体验这一PWA,本文将着重介绍其技术演进的过程。

Treebo的渐进式网页应用PWA

改进性能的过程

上一代移动网站

Treebo的上一代移动网站是用Django开发的单体应用。用户的每个页面跳转都需要一个服务器端请求。上一代的白屏时间为1.5秒,首次有效绘制(first meaningful paint)时间为5.9秒,可交互时间为6.5秒。

基础的React单页应用

重写Treebo的第一步,是基于React和简单Webpack配置的单页应用(SPA,Single Page Application)

请参考如下代码。该代码会构建简单的单体Javascript和CSS捆绑包(bundle)。

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.8秒,可交互时间为5.6秒,头图绘制完成于7.2秒。

服务器端渲染

下一步,他们采用了服务器端渲染来缩短白屏时间。要强调一点,服务器端渲染并非毫无代价,浏览器端得到了优化,而相应的代价转移到了服务器端

采用服务器端渲染,服务器将可以直接渲染的页面HTML作为响应返回给浏览器,浏览器无需等待Javascript下载和执行即可开始渲染页面。

Treebo调用了React的renderToString()接口,将组件渲染成HTML字符串,然后在JS初始化之后才注入应用状态(application state)。

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(
      <Provider store={store} key="provider">
        <ReduxAsyncConnect {...renderProps} />
      </Provider>,
    ),
    store.getState(),
  );
  res.send(template);
};

const html = (app, initialState) => `
  <!doctype html>
  <html lang="en">
    <head>
      <link rel="stylesheet" href="${assets.main.css}">
    </head>
    <body>
      <div id="root">${app}</div>
      <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
      <script src="${assets.main.js}"></script>
    </body>
  </html>
`;

Treebo在使用服务器端渲染后,他们的白屏时间减少至1.1秒,首次有效绘制则减少至2.4秒——这令用户能在更短时间内接收到该页面,并更早读到页面内容,顺便的,这对SEO效果也有稍许提高(译者按:现代搜索引擎已具有爬取含有AJAX内容页面的能力,为单页应用提供静态HTML已不再是SEO的必要项目,详情可参考这里)。但这一改动也导致页面的可交互时间明显增加。

虽然用户可以看到页面内容,但浏览器主线程会因启动Javascript繁忙而无法响应。

在服务器端渲染方案中,浏览器需要获取并处理比以前更多的HTML数据,且仍需要获取、解析/编译并执行Javascript。这实际上带来更多计算量。

可交互时间6.6秒,这是一个退步。

服务器端渲染也有可能导致低端设备的浏览器主线程被大量占用,进而使可交互时间进一步增加。

代码分割和基于路由的组块

Treebo的下一步是利用基于路由的组块(chunking)降低可交互时间。

基于路由的组块,是将各路由(route)依赖的代码分割为相应的、可按需加载的组块(chunk),其目标是只加载最少量的代码即可令一条路由可交互。这要求可下载资源具有更细的颗粒度。

他们采取的方式是,将代码分割成第三方依赖、Webpack运行时代码(runtime manifests)以及路由等三个独立的组块。

//add the webpackManifest and vendor script files to your html
<body>
  <div id="root">${app}</div>
  <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
  <script src="${assets.webpackManifest.js}"></script>
  <script src="${assets.vendor.js}"></script>
  <script src="${assets.main.js}"></script>
</body>
import 'redux-pack';
import 'redux-segment';
import 'redux-thunk';
import 'redux';
// import other external dependencies
entry: {
  main: './client/index.js',
  vendor: './client/vendor.js',
},
new webpack.optimize.CommonsChunkPlugin({
  names: ['vendor', 'webpackManifest'],
  minChunks: Infinity,
}),
<Route
  name="landing"
  path="/"
  getComponent={
    (_, cb) => import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */)
      .then((module) => cb(null, module.default))
      .catch((error) => cb(error, null))
  }
</Route>
//extract css from all the split chunks into main.hash.css
new ExtractTextPlugin({
  filename: 'css/[name].[contenthash:8].css',
  allChunks: true, 
}),

这次的可交互时间降到了4.8秒。干得漂亮!

唯一的缺点是,浏览器在初始化JS捆绑包执行完毕后才开始下载当前路由的Javascript,这并不理想。

但它至少对用户体验有着积极作用。在基于路由的代码分割这个方向上,他们的代码更为显式。他们利用了React Router的getComponent声名式API,以及Webpack的import()异步加载组块。

PRPL模式

基于路由的组块是非常重要的第一步,在这之后将是更加智能的代码打包方式,旨在提供更细粒度的服务和缓存。Treebo从Google的PRPL模式中获得了启发。

PRPL 是一种用于结构化和提供 渐进式网页应用 (PWA) 的模式,该模式强调应用交付和启动的性能。

PRPL代表:

  • 推送(Push)用于初始URL路由的关键资源;
  • 渲染(Render)初始路由;
  • 预缓存(Pre-cache)其他路由;
  • 懒加载(Lazy-load)并按需创建其他路由。
PRPL示意图(由Jimmy Moon绘制)

“推送”意味着服务器浏览器两端需要支持HTTP/2,服务器端提供未经捆绑(unbundled)的资源,这些资源传输到浏览器端并经过缓存优化,会带来更短的白屏时间。可以使用<link rel=”preload” />HTTP/2 Push触发这些资源的传输。

Treebo选择使用<link rel=”preload” />来预加载当前路由的组块。这一做法会有效减少可交互时间,因为当初始捆绑包完成执行、Webpack尝试获取当前路由的组块时,会发现这些组块已经在缓存中了。这次的成果是可交互时间稍有下降,落在4.6秒。

上述方式唯一的缺点是,目前并不是所有浏览器都支持预加载。好消息是,在Safari技术预览版(Tech Preview)中已实现了<link rel=”preload” />,我很希望它今年内能正式发布;Firefox也正在尝试实现该功能。

HTML串流(Streaming)

React中renderToString()接口的一个难题是,它是同步执行的,而且它有可能成为React服务器端渲染的一个性能瓶颈。服务器端直到整个HTML创建完成时才会发出响应(response)。当Web服务器改为串流输出内容时,浏览器端得以在整个响应完成之前就开始为用户渲染页面。这就是react-dom-stream项目的用武之地。

为了提升用户可察觉的性能,并带给用户一个应用逐步加载的体验,Treebo采用了HTML串流(Streaming)技术。他们将<head />标签及其包含的<link rel=”preload” />标签通过串流传输,使浏览器能更早地预加载CSS和Javascript。接下来服务器端进行组件渲染,并将剩余内容传输至浏览器端。

这一做法的好处是能更早开始资源下载,这令他们的白屏时间缩短至0.9秒,可交互时间缩短至4.4秒。应用在4.9~5秒时即可达到持续可交互的状态。

然而这种方式也有缺点,它需要将浏览器端与服务器端的HTTP连接保持地更久一些,如果网络延迟较高时会有问题。对于HTML串流,Treebo将<head />内容定义为前置组块,紧接着是包含主体内容的后置组块。所有这些都被注入到页面中。请参考以下代码:

earlyChunk(route) {
  return `
    <!doctype html>
    <html lang="en">
      <head>
        <link rel="stylesheet" href="${assets.main.css}">
        <link rel="preload" as="script" href="${assets.webpackManifest.js}">
        <link rel="preload" as="script" href="${assets.vendor.js}">
        <link rel="preload" as="script" href="${assets.main.js}">
        ${!assets[route.name] ? '' : `<link rel="preload" as="script" href="${assets[route.name].js}">`}
      </head>`;
},
lateChunk(app, head, initialState) {
  return `
      <body>
        <div id="root">${app}</div>
        <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
        <script src="${assets.webpackManifest.js}"></script>
        <script src="${assets.vendor.js}"></script>
        <script src="${assets.main.js}"></script>
      </body>
    </html>
  `;
},
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(
      <Provider store={store} key="provider">
        <ReduxAsyncConnect {...renderProps} />
      </Provider>,
    ),
    Helmet.renderStatic(),
    store.getState(),
    route,
  );
  res.write(lateChunk);
  res.flush();
  //let client know the response has ended
  res.end();
};

从以上代码中可以看到,前置组块中包含了所需的所有以<link rel=”preload” />方式声明的Javascript标签;而后置组块则包含了服务器端渲染的HTML,以及其他需要包含状态、或是需要调用已加载的Javascript的代码。

内联关键路径CSS

CSS样式表可以阻塞渲染过程。直到浏览器完成请求、接收、下载并解析样式表之前,页面可能一直是空白的样子。减少浏览器需要处理的CSS数量,并将其内联(指关键路径样式,critical-path styles)到页面中,进而减少一次HTTP请求,我们得以让页面渲染地更快一些。

Treebo将当前路由的关键路径CSS以内联方式嵌入页面中,然后在DOMContentLoaded时,用loadCSS异步读取其他剩余的CSS。

这一改动的效果是,它移除了会阻塞渲染的关键路径<link />标签,将少数核心CSS内联到页面中,使得白屏时间进一步减少了0.4秒。

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);
  },
};
//use the assets object to inline styles into your lateChunk template generation logic during runtime
lateChunk(route) {
  return `
        <style>${assets.main.styles}</style>
        <style>${assets[route.name].styles}</style>
      </head>
      <body>
        <div id="root">${app}</div>
        <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
        <script src="${assets.webpackManifest.js}"></script>
        <script src="${assets.vendor.js}"></script>
        <script src="${assets.main.js}"></script>
        <script>${scripts.loadRemainingCSS(route)}</script>
      </body>
    </html>
  `;
},
//replace ExtractTextPlugin with ExtractCssChunks from 'extract-css-chunks-webpack-plugin'
module: {
  rules: isProd ? [
    { test: /\.js$/, exclude: /node_modules/, use: ['babel-loader'] },
    { test: /\.css$/, loader: ExtractCssChunks.extract({ use: [{ loader: 'css-loader', options: { importLoaders: 1 } }, 'postcss-loader'] }) },
    //...
    
plugins: [
  new ExtractCssChunks('css/[name].[contenthash:8].css'),
  //this generates a css chunk alongside the js chunk for each dynamic import() call (route-split path in our case) for eg,
  //main.hash.js, main.hash.css
  //landing.hash.js, landing.hash.css
  //cities.hash.js, cities.hash.css

  //the landing.hash.css and cities.hash.css will contain the css rules for their respective chunks 
  //but will also contain shared rules between them like button, grid, typography css and so on
  //to extract these shared rules to the main.hash.css use the CommonsChunkPlugin
  //bonus: this also extracts the common js code shared between landing.hash.js and cities.hash.js into main.hash.js
  new webpack.optimize.CommonsChunkPlugin({
    children: true,
    minChunks: 2,
  }),

  //use the assets-webpack-plugin to get a manifest of all the generated files
  new AssetsPlugin({
    filename: 'assetsManifest.json',
    path: path.resolve('./build/client'),
    prettyPrint: true,
  }),
  //...

这一改动的问题是,内联样式使页面内容大小有所增长,而且也需要一定时间解析这些样式,所以推迟了Javascript的执行,这导致可交互时间有少量增加,达到4.6秒。

离线缓存静态资源

服务工作线程(Service Worker)是一种可编程网络代理,让您能够控制页面所发送网络请求的处理方式。

Treebo采用服务工作线程来缓存他们的静态资源和定制的离线页面。从如下代码中我们可以看到他们如何注册服务工作线程,以及他们如何利用sw-precache-webpack-plugin缓存资源。

// register the service worker after the onload event to prevent 
// bandwidth resource contention during the main and vendor js downloads
export const scripts = {
  serviceWorker:
    `"serviceWorker" in window.navigator && window.addEventListener("load", function() {
      window.navigator.serviceWorker.register("/serviceWorker.js")
        .then(function(r) {
          console.log("ServiceWorker registration successful with scope: ", r.scope)
        }).catch(function(e) {
          console.error("ServiceWorker registration failed: ", e)
        })
    });`,
};
<script src="${assets.webpackManifest.js}"></script>
<script src="${assets.vendor.js}"></script>
<script src="${assets.main.js}"></script>
<script>${scripts.loadRemainingCSS(route)}</script>
//add the serviceWorker script to your html template
<script>${scripts.serviceWorker}</script>
//serve it at the root level scope
app.use('/serviceWorker.js', express.static('build/client/serviceWorker.js'));
new SWPrecacheWebpackPlugin({
  cacheId: 'app-name',
  filename: 'serviceWorker.js',
  staticFileGlobsIgnorePatterns: [/\.map$/, /manifest/i],
  dontCacheBustUrlsMatching: /./,
  minify: true,
}),

缓存像CSS、Javascript捆绑包这类静态资源,意味着当用户重复访问时,无需再通过网络获取这些资源,而是直接读取本地磁盘缓存,所以页面几乎是瞬时完成加载。当然,为静态资源配置缓存HTTP头也可以达到这一目标,但服务工作线程为我们提供了额外的离线支持。

在服务工作线程中调用缓存API(Cache API,详情参见文章:JavaScript Start-up Performance)缓存Javascript,也能提早受益于V8引擎的代码缓存功能,重复访问页面时会节约一定的启动时间。

下一步,Treebo希望能减小其第三方依赖捆绑包的体积,减少JS执行时间,所以他们在生产环境中将React切换为Preact

由React切换为Preact

Preact是一个小到3KB但具有相同ES2015 API的React替代品。它着力于提供高性能渲染,同时提供一个可选的兼容层(preact-compat)用于整合React的原有生态,比如Redux。

Preact之所以能如此小巧,其原因之一是移除了合成事件(Synthetic Event)和属性类型(PropType)验证。除此之外它还提供了一系列特有功能:

  • 对比虚拟DOM和原生DOM;
  • 允许使用诸如class、for这样的通用属性;
  • 将(props, state)作为参数传入render方法;
  • 使用标准浏览器事件;
  • 支持完全的异步渲染;
  • 默认无效化子树。

多个渐进式网页应用的案例表明,切换至Preact可以有效减小JS捆绑包体积,减少JS启动时间。近期发布的渐进式网页应用如Lyft、Uber和Housing.com,都在其生产环境中使用了Preact。

注意事项:如果已经基于React开发的情况下如何使用Preact?理想情况下,你应该在开发、测试和生产环境中全面使用preact和preact-compat。这样可以帮助你尽早发现切换导致的bug。但如果你更倾向于只在生产环境的Webpack打包过程中,以别名方式引入preact和preact-compat(这样可以保证在开发环境或测试环境中,基于React和Enzyme的测试代码不受影响),那你在部署到生产服务器之前,需要对捆绑包进行全面测试,以确保切换后的代码正常工作。

Treebo经过这次切换,第三方依赖捆绑包体积从原来的140KB减小到100KB(前后均为gzip压缩后的体积),移动设备上的可交互时间从4.6秒减少至3.9秒。这是一次完胜。

你可以在Webpack配置中将react和react-dom别名分别设置成preact-compat,即可实现上述切换。

resolve: {
  alias: {
    react: 'preact-compat',
    'react-dom': 'preact-compat',
  },
},

这一方式的缺点:他们不得不探索各种变通方案(workarounds)来保证Preact能顺利与他们已使用的各种React生态整合。

在你使用React时,95%的场合Preact是一个优异的选项;但对于其他5%场合,你也许不得不去给Preact开bug以期能支持这些边缘的用例。

备注:WebPageTest工具尚未支持在印度以真实Moto G4设备测试,本文性能测试均以“Mumbai — EC2 — Chrome — Emulated Motorola G (gen 4) — 3GSlow — Mobile”配置进行。你可以在这里找到详细测试结果。

加载占位屏

“加载占位屏(Skeleton Screen)本质上就是一个页面的空白版本,信息会逐渐加载并显示在上面。”——Luke Wroblewski

Treebo利用具有预览能力的组件来实现其加载占位屏(也可以说是每个组件都具有加载占位屏)。这种方案基本上就是为各个原子级组件(如Text、Image等)提供一个带有预览能力的版本,对这样一个组件,如果必要的数据尚未到位,则显示为它的预览版本。

例如,上图中每个列表项中都包含宾馆名称、城市名、价格等,这些信息是由诸如<Text />这样的排印(Typography)组件实现的,这些组件会接受两个额外属性,preview和previewStyle:

<Text 
  preview={!hotel.name} 
  previewStyle={{width: 80%}}
>
  {hotel.name}
</Text>

当hotel.name不存在时,组件会将背景改为灰色,并根据传入的previewStyle设定宽度以及其他样式(如果不设置previewStyle,则宽度默认为100%)。

.text {
  font-size: 1.2rem;
  color: var(--color-secondary);

  &--preview {
    opacity: 0.1;
    height: 13px;
    width: 100%;
    background: var(--color-secondary);
  }

  @media (--medium-screen) {
    font-size: 1.4rem;

    &--preview {
      height: 16px;
    }
  }
}
import React, { PropTypes } from 'react';
import cn from 'classnames';

const Text = ({ 
  className, 
  tag, 
  preview, 
  previewStyle, 
  children, 
  ...props 
}) =>
  React.createElement(tag, {
    style: preview ? previewStyle : {},
    className: cn('text', {
      'text--preview': preview,
    }, className),
    ...props,
  }, children);

Text.propTypes = {
  className: PropTypes.string,
  tag: PropTypes.string.isRequired,
  preview: PropTypes.bool.isRequired,
  previewStyle: PropTypes.object,
  children: PropTypes.node,
};

Text.defaultProps = {
  tag: 'p',
  preview: false,
};

export default Text;

Treebo认为这种方式很灵活,因为是否切换至预览模式的逻辑并不依赖于其显示的数据,而是可以自由定制。比如上图中的“Incl. of all taxes”(包含所有税费)部分,它是静态文本,本来可以在页面加载的第一时间显示出来,但这时它上面的价格因为调用API尚未返回,仍显示为预览模式,如果提前显示这个提示就会令用户感到困惑。

所以他们利用价格的预览逻辑来判断是否将静态文本“Incl. of all taxes”显示为预览模式。

<Text preview={!price.sellingPrice}>
  Incl. of all taxes
</Text>

这样当读取价格时,你会看到一个美观的预览界面,一旦API成功返回,你会看到数据和文本同时被显示出来。

Webpack-bundle-analyzer

到这一步,Treebo决定做一些捆绑包分析,看看有没有其他可以立竿见影的优化空间。

注意事项:如果你在移动端网页中使用React这样的库,引入第三方依赖库时需要更加谨慎,否则会对性能带来一些负面影响。可以考虑将第三方依赖库分成多个组块,这样路由只需读取必要的依赖。

Treebo利用webpack-bundle-analyzer持续关注他们捆绑包的体积变化,并监控每个路由的组块中分别包含了哪些模块。他们也利用它来发现针对捆绑包体积有哪些优化空间,比如剥除moment.js的地区(locale)、复用深层依赖等。

利用Webpack优化moment.js

Treebo重度依赖moment.js处理日期。当你导入moment.js并用Webpack构建后,默认情况下包含了moment.js本身和它的所有地区信息,捆绑包会增大约61.95KB(gzip压缩后)。这对你最终的第三方依赖捆绑包的体积有较大影响。

两款Webpack插件可以用于优化moment.js体积:IgnorePluginContextReplacementPlugin

Treebo的产品并不需要地区支持,他们选择用IgnorePlugin移除所有地区源文件。

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

剥除这些地区后,moment.js的捆绑包体积减小到16.48KB左右(gzip压缩后)。

剥除moment.js地区信息最大的收获是,第三方依赖捆绑包的整体体积从179KB左右减小到119KB左右。60KB的瘦身对于在初始化时就必须加载的关键捆绑包是非常显著的。这也有效减少了可交互时间。你可以在这里了解更多关于优化moment.js的方案。

复用现有的深层依赖

Treebo在初期就使用了“qs”库来处理URL中的查询字符串。从webpack-bundle-analyzer的报告中,他们发现“react-router”包含的“history”库递阶(in-turn)依赖了“query-string”库。

这两个不同的库可以实现相同的操作,他们将源码中将“qs”替换成了上图所示版本的“query-string”(作为显式依赖安装),结果他们的捆绑包进一步减小了2.72KB(gzip压缩后,等于原“qs”库的体积)。

Treebo致力于融入开源社区。他们使用了大量开源软件。作为回报,他们开源了自己大部分的Webpack配置,以及包含了他们很多生产环境配置的样板项目。你在这里可以找到:https://github.com/lakshyaranganath/pwa

他们也许诺了会尽量保持更新这些配置。你可以把它们作为又一个渐进式网页应用的参考实现。

总结与展望

Treebo知道没有哪个应用会是完美的,他们积极地探索更多方式来持续提升用户体验。这包括但不限于:

图片懒加载

你可能已经从前文的网络瀑布图表中发现了,网站图片下载在跟JS下载争抢带宽。

由于浏览器在解析<img>标签时就会触发下载图片,它们在JS下载同时分享网络带宽。一个简单方案是,仅当图片进入用户可视区域(viewport)时才懒加载图片,这会有效改善我们的可交互时间。

Google的Lighthouse工具的屏幕外图片检查项目可以有效排查出这些问题:

双重导入

Treebo意识到虽然他们异步读取了非关键CSS(在内联了关键CSS之后),但随着他们应用功能的演进,这种方式对于他们的用户是不可行的。更多的功能和路由意味着更多CSS,全量下载会造成带宽占用和浪费。

Treebo结合了loadCSSbabel-plugin-dual-import两种方案,改为通过显式调用读取CSS:它们实现了一个定制的importCss(‘chunkname’)方法,用于在import(‘chunkpath’)下载各自JS组块同时下载CSS组块。

import assetsManifest from '../../build/client/assetsManifest.json';

lateChunk(app, head, initialState, route) {
    return `
          <style>${assets.main.styles}</style>
          // inline the current route's css and assign an id to it
          ${!assets[route.name] ? '' : `<style id="${route.name}.css">${assets[route.name].styles}</style>`}
        </head>
        <body>
          <div id="root">${app}</div>
          <script>window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}</script>
          <script>window.__ASSETS_MANIFEST__ = ${JSON.stringify(assetsManifest)}</script>
          <script src="${assets.webpackManifest.js}"></script>
          <script src="${assets.vendor.js}"></script>
          <script src="${assets.main.js}"></script>
        </body>
      </html>`;
  },
export default (chunkName) => {
  if (!__BROWSER__) {
    return Promise.resolve();
  } else if (!(chunkName in window.__ASSETS_MANIFEST__)) {
    return Promise.reject(`chunk not found: ${chunkName}`);
  } else if (!window.__ASSETS_MANIFEST__[chunkName].css) {
    return Promise.resolve(`chunk css does not exist: ${chunkName}`);
  } else if (document.getElementById(`${chunkName}.css`)) {
    return Promise.resolve(`css chunk already loaded: ${chunkName}`);
  }

  const head = document.getElementsByTagName('head')[0];
  const link = document.createElement('link');
  link.href = window.__ASSETS_MANIFEST__[chunkName].css;
  link.id = `${chunkName}.css`;
  link.rel = 'stylesheet';

  return new Promise((resolve, reject) => {
    let timeout;

    link.onload = () => {
      link.onload = null;
      link.onerror = null;
      clearTimeout(timeout);
      resolve(`css chunk loaded: ${chunkName}`);
    };

    link.onerror = () => {
      link.onload = null;
      link.onerror = null;
      clearTimeout(timeout);
      reject(new Error(`could not load css chunk: ${chunkName}`));
    };

    timeout = setTimeout(link.onerror, 30000);
    head.appendChild(link);
  });
};
<IndexRoute
  name="landing"
  getComponent={(_, cb) => {
    Promise.all([
      import('./views/LandingPage/LandingPage' /* webpackChunkName: 'landing' */),
      importCss('landing'),
    ]).then(([module]) => cb(null, module.default));
  }}
/>

<Route
  name="search"
  path="/search/"
  getComponent={(_, cb) => {
    Promise.all([
      import('./views/SearchResultsPage/SearchResultsPage' /* webpackChunkName: 'search' */),
      importCss('search'),
    ]).then(([module]) => cb(null, module.default));
  }}
/>

不像原来所有CSS都在DOMContentLoaded时下载,在这个新方案中,一次路由跳转会触发两个并发的异步请求,一个请求JS,另一个请求CSS。这是一个更加可行的方案,用户只需下载当前路由所必须的CSS。

A/B测试

Treebo正在基于服务器端渲染和代码分割实现一套A/B测试方案,以确保无论在服务器端还是浏览器端渲染,都只推送用户需要的变体。(Treebo会另外发表一篇博客介绍细节)

贪婪加载(Eager Loading)

理想状况下,Treebo并不希望在页面初始化时就读取所有组块,这是因为他们想避免与关键资源下载争抢带宽——如果没有利用服务工作线程缓存,这也会浪费移动端用户宝贵的带宽资源。如果看一下Treebo在诸如持续可交互时间(Consistently Interactive)这样的指标上的表现,他们还有很大进步空间:

在这个领域他们在做着各种各样的尝试。一个例子是,在按下按钮的水波动画(译者按:猜测是指Material Design的按钮动画)过程中,贪婪加载下一个路由的组块。Treebo调用Webpack的动态导入dynamic import()来读取下一个路由的组块,并用setTimeout延迟路由转换。他们也要确保下一个路由的组块足够小,在较慢的3G网络下400毫秒内可以完成下载。

结尾

这次写作中各方的协作很愉快。很明显我们还有更多工作要做,但我们希望你阅读这篇Treebo改进性能之旅时能体会到乐趣 :)你可以在Twitter上联系到我们@addyosmani@__lakshya(是的,两个下划线XD),我们很乐于听到你们的想法。

感谢@_zouhir@_developit@samcccone的评审和反馈。

如果你刚开始接触React,Wes Bos的React for Beginners是很全面的入门指南。

感谢Jason MillerLakshya Ranganath

译者后记

对撰写技术文章我并不陌生,在工作过程中曾有幸先后发表过英文文章和中文文章。这次的翻译纯粹出于个人兴趣,一是读过英文原文后被原作者的专业风格所打动,二是十一放假期间终于有些自己的时间。没想到这篇翻译比自己写文章更辛苦,很大的一个问题是,前端领域近年发展很快,很多新兴的英文术语国内尚无官方翻译,只得多方搜索查证,比如:

  • JS Bundle:JS捆绑包,这是Google中文文档的翻译,业界也有翻译成“JS束”的;
  • First Paint Time:白屏时间,这个在中文社区最流行的翻译并不是直译,但很准确的表达了含义;
  • Skeleton Screen:加载占位屏,百度能搜得到的名字是加载占位图,但文中是由多个组件组成的,所以我擅自将“图”改为“屏”字。

总体而言,这次翻译我自己收获颇丰,希望之后还有机会翻译这样精彩的文章。