使用 React.js 的渐进式 Web 应用程序:第 2 部分 - 页面加载性能

系列第二篇,来看看基于 React 路由分块的页面加载优化。css

使用 React.js 的渐进式 Web 应用程序:第 2 部分 - 页面加载性能

这是新系列的第二部分,新系列介绍的是使用 Lighthouse 优化移动 web 应用传输的技巧。本期,咱们关注的是页面加载性能。

保证页面加载性能是快的

移动 Web 的速度很关键。平均来讲,更快的体验会 延长 70% 的会话 以及两倍以上更多的移动广告收益。基于 React 的 Web 性能投资中,Flipkart Lite 使访问时间提高了三倍, GQ 在流量上获得了 80% 增加,Trainline 在 年收益上增加了 11M 而且 Instagram 的 好感度上升了 33%html

在你的 web app 加载时有一些 关键的用户时刻node

测量并优化一直很重要。Lighthouse 的页面加载检测会关注:react

关于 PWA 值得关注的有趣指标),Paul Irish 作了很棒的总结。webpack

良好性能的目标:git

  • 遵循 RAIL 性能模型 的 L 部分。A+ 的性能是咱们全部人都必须力求达到的,即使有的浏览器不支持 Service Worker。咱们仍然能够快速地在屏幕上得到一些有意义的内容,而且仅加载咱们所须要的
  • 在典型网络(3G)和硬件条件下
  • 首次访问在 5 秒内可交互,重复访问(Service Worker 可用)则在 2 秒内。
  • 首次加载(网络限制下),速度指数在 3000 或者更少。
  • 第二次加载(磁盘限制,由于 Service Worker 可用):速度指数 1000 或者更少。

让咱们再说说,关于经过 TTI 关注交互性。es6

关注抵达可交互时间(TTI)

为交互性优化,也就是使得 app 尽快能对用户可用(好比让他们能够四处点击,app 能够响应)。这对试图在移动设备上提供一流用户体验的现代 web 体验很关键。github

Lighthouse 目前将 TTI 做为布局是否达到稳定的衡量,web 字型是否可见而且主线程是否有足够的能力处理用户输入。有不少方法来手动跟踪 TTI,重要的是根据指标进行优化会提高你用户的体验。web

对于像 React 这样的库,你应该关心的是在移动设备上 启用库的代价 由于这会让人们有感知。在 ReactHN,咱们达到了 1700毫秒 内就完成了交互,尽管有多个视图,但咱们仍是保持整个 app 的大小和执行消耗相对很小:app 压缩包只有 11KB,vendor/React/libraries 压缩包只有 107KB。实际上,它们是这样的:chrome

以后,对于有小功能的 app 来讲,咱们会使用 PRPL 这样的性能模式,这种模式能够充分利用 HTTP/2 的服务器推送 功能,利用颗粒状的 “基于路由的分块” 来获得快速的可交互时间。(能够试试 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

若是你第一次接触模块打包工具,好比 Webpack,看看 JS 模块化打包器(视频) 可能会有帮助。

现在一些的 JavaScript 工具可以方便地将全部脚本打包成一个全部页面都引入的 bundle.js 文件。这意味着不少时候,你可能要加载不少对当前路由来讲并不须要的代码。为何一次路由须要加载 500KB 的 JS,而事实上 50KB 就够了呢?咱们应该丢开那些无助于得到更快体验的脚本,来加速得到可交互的路由。

当仅提供用户一次 route 所须要的最小功能的可用代码就能够的时候,避免提供庞大整块的 bundles(像上图)。

代码分割是解决整块的 bundles 的一个方法。想法大体是在你的代码中定义分割点,而后分割成不一样的文件进行按需懒加载。这会改善启动时间,帮助更迅速地达到可交互状态。

想象使用一个公寓列表 app。若是咱们登录的路由是列出咱们所在区域的地产(route-1)—— 咱们不须要所有地产详情(route-2)或者预定看房(route-3)的代码,因此咱们能够只提供列表路由所须要的 JavaScript 代码,而后动态加载其他部分。

这些年来,不少 app 已经使用了代码分割的概念,然而如今用 “基于路由的分块” 来称呼它。咱们能够经过 Webpack 模块打包器为 React 启用这个设置。

实践基于路由的代码分块

当 Webpack 在 app 代码中发现 require.ensure()(在 Webpack 2 中是 System.import)时,支持分割代码。这些方法出现的地方被称为“分割点”,Webpack 会对它们的每个都生成一个分开的 bundle,按需解决依赖。

// 定义一个 "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 的自动代码分割,它能够像 import 语句同样处理 System.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 运行期。

Brian Holt 在 React 的完整介绍 中对异步路由加载介绍得很好。。

Brian Holt 在 React 的完整介绍 对异步路由加载阐述地很全面。经过异步路由的代码分割在 React Router 的最新版本和 新的 React Router V4 上均可以使用。

使用异步的 getComponent + require.ensure() 的声明式路由 chunk

有一个能够更快设置代码分割的小技巧。在 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 文件中的一段开始 —— 它为每一个路由定义了引入调用和 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')" notfound="require('./NotFound')" 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') }} /> 

 复制代码

OMG,太棒了。这..就搞定了。不骗你。咱们能够如法炮制剩下的路由,而后运行 webpack。它会正确地找到 require.ensure() 调用,而且如咱们所愿地分割代码。

将声明式代码分割应用到咱们的大部分路由后,咱们能够看到路由分块生效了,只在须要的时候对一个路由(咱们可以预缓存在 Service Worker 里)加载所需代码:

提醒:有许多可用于 Service Worker 的简单 Webpack 插件:

CommonsChunkPlugin

为了识别出在不一样路由使用的通用模块并把它们放在一个通用的分块,须要使用 CommonsChunkPlugin。它须要在每一个页面引入两个 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 的小贴士

  • 你的代码库中 require.ensure() 调用的数目一般会关联到生成的 bundles 的数目。在代码库中大量使用 ensure 的时候意识到这点颇有用。
  • Webpack2 的 Tree-shaking 会帮助删除没用的 exports,这可让你的 bundle 尺寸变小。
  • 另外,避免在 通用/共享的 bundles 里面调用 require.ensure()。你会发现这建立了入口点引用,而咱们假定这些引用的依赖已经完成加载了。
  • 在 Webpack 2,System.import 目前不支持服务端渲染,但我已经在 StackOverflow 分享了怎么去处理这个问题。
  • 若是须要优化编译速度,能够看看 Dll pluginparallel-webpack 以及目标的编译。
  • 若是你但愿经过 Webpack 异步 或者 延迟 脚本,看看 script-ext-html-webpack-plugin

在 Webpack 编译中检测臃肿

Webpack 社区有不少创建在 Web 上的编译分析器包括 webpack.github.io/analyse/chrisbateman.github.io/webpack-vis…,和 alexkuz.github.io/stellar-web…,这些能方便地明确你项目中最大的模块。

source-map-explorer (来自 Paul Irish) 经过 source maps 来理解代码臃肿,也超级棒的。看看这个对 ReactHN Webpack bundle 的 tree-map 可视化,带有每一个文件的代码行数,以及百分比的统计分析:

你可能也会对来自 Sam Saccone 的 coverage-ext 感兴趣,它能够生成任何 webapp 的代码覆盖率。这个对于理解你的代码中有多少实际会被执行到颇有用。

代码分割(code-splitting)之上:PRPL 模式

Polymer 发现了一个有趣的 web 性能模式,用于精细服务的 apps,称为 PRPL(看看 Kevin 的 I/O 演讲)。这个模式尝试优化交互,各个字母表明:

  • (P)ush,对于初始路由推送关键资源。
  • (R)ender,渲染初始路由,并使它尽快变得可交互。
  • (P)re-cache,经过 Service Worker 预缓存剩下的路由。
  • (L)azy-load,根据用户在应用中的移动懒加载并懒初始化 apps 中对应的部分。

在这里,咱们必须给予 Polymer Shop demo 大大的赞扬,由于它展现给咱们移动设备上的实现方法。使用 PRPL(在这种状况下经过 HTML Imports,从而利用浏览器的后台 HTML parser 的好处)。屏幕上的像素你均可以使用。这里额外的工做在于分块和保持可交互。在一台真实移动设备上,咱们能够在 1.75 秒内达到可交互。其中 1.3 秒用于 JavaScript,但它都被打散了。在那之后全部功能均可以用了。

你到如今应该已经成功享受到将应用打碎到更精细的分块的好处了。当用户第一次访问咱们的 PWA,假设说他们访问一个特定的路由。服务器(使用 H/2 推送)可以推送下来仅仅那次路由须要的分块 —— 这些是用来启动应用的必要资源,并会进入网络缓存中。

一旦它们被推送下来了,咱们就能高效地准备好将来会被加载的页面分块到缓存中。当应用启动后,检查路由并发现咱们想要的已经在缓存中了,因此咱们就能使得应用的首次加载很是快 —— 不只仅是闪屏 —— 而是用户请求的可交互内容。

下一步是尽快渲染这个视图的内容。第三步是,当用户在看当前的视图的时候,使用 Service Worker 来开始预缓存全部其余用户尚未请求的分块和路由,将它们安装到 Service Worker 的缓存中。

此时,整个应用(或者大部分)都已经能够离线使用了。当用户跳转到应用的不一样部分,咱们能够从 Service Worker 的缓存中懒加载下面的部分。不须要网络加载 —— 由于它们已经被预缓存了。瞬间加载碉堡了!❤

PRPL 能够被应用到任何 app,正如 Flipkart 最近在他们的 React 栈上所展现的。彻底使用 PRPL 的 Apps 能够利用 HTTP/2 服务器推送的快速加载,经过产生两种编译版本,并根据浏览器的支持提供不一样版本:

  • 一个 bundled 编译,为没有 HTTP/2 推送支持的服务器/浏览器优化以最小化往返。对大多数人而言,这是如今默认的访问内容。

  • 一个没有 bundled 编译,用于支持 HTTP/2 推送的服务器/浏览器,使得首次绘制更快。

这个部分基于咱们在以前讨论的路由分块的概念。经过 PRPL,服务器和咱们的 Service Worker 协做来为非活动路由预缓存资源。当一个用户在你的 app 中浏览并改变路由,咱们对还没有缓存的路由进行懒加载,并建立请求的视图。

实现 PRPL

篇幅过长,没有阅读: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 也值得一读。

  • 渲染初始路由:这实际上取决于你使用的框架或者库。
  • 预缓存剩下的路由。对于缓存,咱们依赖于 Service Worker。sw-precache 能很好地生成一个 Service Worker 用于静态资源预缓存。对于 Webpack 咱们可使用 SWPrecacheWebpackPlugin
  • 按需懒加载并建立剩下的路由 —— 在 Webpack 领域,可使用 require.ensure() 和 System.import()。

经过 Webpack 的缓存失效和长期缓存

为何关心静态资源版本?

静态资源指的是咱们页面中像是脚本,stylesheets 和图片这样的资源。当用户第一次访问咱们页面的时候,他们须要其须要的全部资源。好比说当咱们加载一个路由的时候,JavaScript 块和上次访问之际并无改变 —— 咱们没必要从新抓取这些脚本由于他们已经在浏览器缓存中存在了。更少的网络请求是咱们在 web 性能优化中的胜利。

一般地,咱们使用对每一个文件设置 expires 头 来达到目的。一个 expires 头只意味着咱们能够告诉浏览器,避免在指定时间内(好比说1年)发起另外一个对该文件的请求到服务器。随着代码演变和从新部署,咱们想要确保用户能够得到最新的文件,若是没有改变的话则不须要从新下载资源。

Cache-busting 经过在文件名后面附加字符串来完成这个 —— 他能够是一个编译版本(好比 src=”chunk.js?v=1.2.0”),一个 timestamp 或者别的什么。我倾向于添加一个文件内容的 hash 到文件名(好比 chunk.d9834554decb6a8j.js)由于这个在文件内容发生改变的时候老是会改变。在 Webpack 社区经常使用 MD5 哈希生成的 16 字节长的“概要”来实现这个目的。

经过 Webpack 的静态资源长期缓存 是关于这个主题的优秀读物,你应该去看一看。我试图在下面涵盖其涉及到的主要内容。

在 Webpack 中经过 content-hashing 来作资源版本控制

在 Webpack 设置中加上以下内容来启用基于内容哈希的资源版本 [chunkhash]

filename: ‘[name].[chunkhash].js’,
chunkFilename: ‘[name].[chunkhash].js’复制代码

咱们也想要保证常规的 [name].js 和 内容哈希 ([name].[chunkhash].js) 文件名在咱们的 HTML 文件被正确引用。不一样之处在于引用

相关文章
相关标签/搜索