关键渲染路径

关键渲染路径(Critical Rendering Path)从定义上来讲就是浏览器从获取HTML资源开始构建界面到用户可以完整地看到你的界面这段时间内的一系列步骤。在整个过程中,我们通过手动干预,以便优化浏览器的渲染行为

文档对象模型

每个web界面都有一个文档对象模型(Document Object Model),我们简称为DOM。它在渲染阶段中代表了界面的基本对象。HTML一旦被解析,DOM对象就会被构建出来。DOM树包含了很多的对象。

以一段简单的代码为例。下面的界面分三个部分:headersection,以及footer。这是一个非常简单的界面结构,styel.css作为一个外部的样式脚本被引入HTML当中。

<html>
  <head>
  <link rel="stylesheet" href="style.css">
  <title>Critical Rendering Path</title>
  <body>
    <header>
      <h1>The Rendering Path</h1>
      <p>Every step during the rendering of a HTML page, forms the path.</p>
    </header>
    <main>
         <h1>You need a dom tree</h1>
         <p>Have you ever come across the concepts behind a DOM Tree?</p>
    </main>
    <footer>
         <small>Thank you for reading!</small>
    </footer>
  </body> 
  </head>
</html>

当以上的代码被浏览器解析,就会生成以下的dom结构树:
wp editor md 544e96550cdb1c91c1c8b7200659cd03 - 关键渲染路径

浏览器会一般会用一段时间去完成上面的构建过程,如果HTML结构很清晰,浏览器就会加快这个构建过程。

CSSOM Tree

下一个就是CSSOM Tree。它是样式对象模型(CSS Obect Model)的简称。CSSOM Tree 是一个基于对象的树。它包含了所有与DOM Tree相关联的样式。一般来说,CSS 样式可以被集成或者显示地申明。

header{
   background-color: white;
   color: black;
}
p{
   font-weight:400;
}
h1{
   font-size:72px;
}
small{
   text-align:left
}

运行以上代码,浏览器会生成下面的一个树状结构:
wp editor md 0ce5fb4d73249021d96c945c3bc8af9d - 关键渲染路径

通常,CSS资源是属于渲染阻断(render-blocking)的资源。所谓渲染阻断资源是指浏览器会在下载该资源后才会进行DOM Tree 的构建。而CSS资源之所以是渲染阻断资源是因为我们无法在CSS下载之前构建这颗对象树。在早期,CSS资源都存储在一个文件里面。现在,很多开发者用各种技术实现了将css文件分割,只有关键样式的文件才会在渲染阶段被引入进来。

Javascript的执行

Javascript是用来操作DOM的。例如:弹出窗口,轮播广告等用户和界面进行交互的场景。这些交互依赖的脚本运行和下载很耗费时间,并且会减少网页加载的时间。这就是我们也称javascript资源为解析阻断(Parser Blocking)资源文件的原因。

所谓的解析阻断,就是浏览器会在javascript文件在下载并且执行的过程中中断了DOM Tree构建的行为。DOM Tee会在Javascript代码执行之后才会继续构建。

这就是为什么说Javascript是一种昂贵的资源的原因

一些真实的例子

下面是一段简单的HTML代码,仅仅展示了一些文本和图片。你可以看到,整个界面用了40ms的时间展示出来。虽然上面包含了图片,但是界面显示的时间会比图片显示的时间要少。这是因为图片并不是一种关键资源,它不会阻碍DOM Tree的构建和解析。关键资源只包含三种文件资源:HTML, CSSJavacript。虽然图片不是一种关键资源,但我们仍然需要尽快地显示图片。
wp editor md 0c55cc09d8397bb91c22f62cf8f72b1a - 关键渲染路径

接下来,我们增加一些css代码片段进去。
可以看到,一个外部的资源请求被发送。html文件下载的时间虽然减少了,但是整个渲染和展示的过程却增加了10倍,这是为什么呢?
1. 基础的HTML代码不会花费太多的时间下载和构建。但是CSS文件代表的CSSOM需要构建时间。这就是整个过程花费的时间原因。
2. 同样,如果脚本看Javacript,domContentLoaded 事件并不会被触发。主要原因是js代码会去查询CSSOM。意味着,CSS文件必须要在JS文件之前下载并解析好。

提示:domContentLoaded事件会在HTML DOM 被完全下载并解析后触发。该事件的触发并不需要等图片,子框架或者样式脚本被下载完成。唯一的条件是文档被下载下来。给浏览器增加此事件,可以监听DOM的解析状态。下面是示例代码:

window.addEventListener('DOMContentLoaded', (event) => {
    console.log('DOM Content Loaded Event');
});

如果用内联脚本替换外部文件,性能不会有太多的改善。这是因为CSSOM需要被下载下来。如果考虑是用外部文件,建议你给script标签使用async属性。这样它就不会阻断解析器。下面会提到更多的细节。

基础概念

在我们解决这些问题之前,我们来学习一下描述关键渲染路径的一些相关概念:
1. 关键资源(Critical Resource):所有能够阻断界面渲染的资源。
2. 关键路径长度(Critical Path Length):构建界面所需要的关键资源所请求的次数。
3. 关键字节(Critical Bytes):完成构建界面所需要的的总共文件字节大小。

在上面的第一个示例中,纯HMLT文件会包含:
1. 1个关键资源
2. 1个请求过程
3. 192比特的数据

在第二个示例当中,纯HTML文件外加CSS脚本,会包含:
1. 2个关键资源
2. 2个请求过程
3. 400比特的数据

无论你是使用的是哪种框架,或者是基本的HTML + CSS + JS结构的界面,你都需要围绕着上面的指标来对界面进行优化处理并且改进他们:

  • 应该尽可能地减少关键资源,这可以让我们的CPU和浏览器减少工作量。
  • 资源的大小决定了其需要下载的时间。资源较大,关键路径则会相应的增加。请求的耗时也会随之增加。

  • 关键字节应该减少。我们可以忽略那些没有用到的资源,然后对采取压缩的方式减小体积。

如何减少CSS渲染阻断资源

在任何web界面都有一屏显示不完的内容,它们可以通过滚动条显示。对于那些在首屏无法显示的内容,我们需要好好处理一下。确保首屏的内容要样式都被加载出来,这些都是关键的样式。剩下的样式可以稍后再下载。这样做可以提升首屏的加载速度。同样,可以剔除一些不必要的渲染阻断样式。
我们用一个实际的案例,来说明渲染阻断资源。如何通过一个小小的改变,让是你的代码更加友好。

如何减少解析阻断资源

懒加载

加载的关键就是“懒加载”。淘宝和京东这些电商网站有非常多的内容要展示。他们是如何加载的呢?随着你滚动界面,内容开始加载,但是感觉不到迟缓。
这是因为它们使用了一个叫做懒加载的技术。任何媒体,样式,脚本,图片,甚至HTML文件都可以被懒加载出来。在有限的时间内加载完资源,可以改进我们的关键渲染路径得分。

  • 假设你有一个覆盖层盖在界面上。
  • 界面加载的过程中无需加载CSS,JS 和HTML。

  • 在底部增加一个按钮,只有当用户点击按钮时,再加载更多的内容。

  • 利用webpack来帮助你完成这个特性。

下面是一些用纯脚本来完成懒加载的技术示例:

首先从图片和iframe开始。我们要如何懒加载非关键的图片呢?或者换个说法:我们如何下载那些只有用户和界面进行交互之后才展示的图片呢?下面的示例,我们使用<img><iframe>标签提供给我们的默认加载的属性。浏览器识别这些属性,就会延迟加载iframe和图片元素。具体的实施方法如下:

<img src="image.png" loading="lazy">
<iframe src="tutorial.html" loading="lazy"></iframe>

注意:设置img标签的属性loading=lazy后,非首屏的图片不会正常加载,所以不要对首屏展示的图片添加此属性。
如果你的浏览器不支持loading=lazy属性,你可以换另外一个API:IntersectionObserver。这是浏览器为你提供的另外一种API。这个API定义了一种基准,然后会以这种基准配置一定的比例来显示元素。当一个元素处于可见的视口内时,就会执行加载它。下面是一段简单的示意代码,帮助你更好的认识这个API 的用法。

  1. 我们监听所有包含lazy类的元素。
  2. 当包含lazy类的元素在可视窗口之内,交互的比例就会在0之上。如果交互比例等于或者小于0,目标则未出现在视图当中。我们不需要做任何事情。
  3. 预先定义好监听事件,会给我们自动监听上面的元素。
var intersectionObserver = new IntersectionObserver(function(entries) {
  if (entries[0].intersectionRatio <= 0) return;

  //intersection ratio is above zero
  console.log('Loading Lazy Items');
});
// start observing
intersectionObserver.observe(document.querySelector('.lazy));

Async, Defer, Preload

提示:AsyncDefer是用来使用在外部脚本属性上的。

使用Async,告诉浏览器可以在js代码下载的时候做一些其他的事情。下载之后的js代码会被立即执行。

  • js代码是异步下载的。
  • 其他脚本会被暂时阻断。
  • DOM渲染是在同时也在进行中。
  • DOM渲染会被脚本的执行所阻断。
  • 渲染阻断脚本的问题通过这个属性可以得以解决。

如果资源不重要,不要使用async,忽略它即可

wp editor md b7259ba92cfbf4777a313908d790dbf5 - 关键渲染路径

示例:

<p>...content before scripts...</p>

<script>
  document.addEventListener('DOMContentLoaded', () => alert("DOM ready!"));
</script>

<script async src=""></script>

<!-- will be visible after the above script is completely executed –>
<p>...content after scripts...</p>

使用Defer属性,js代码资源在下载的同时HTML也在渲染。但是js并不会在下载之后立即执行。而是等到HTML完全渲染之后才会执行代码。

  • Defer走得比async属性更远。
  • js代码的执行永远在界面渲染之后。
  • Defer属性完全可以避免界面的渲染阻断。

wp editor md 1587d99beed195105013b33a66ed942e - 关键渲染路径

示例:

<p>...content before script...</p>

<script defer src=""></script>

<!-- this content will be visible immediately -->
<p>...content after script...</p>

使用Preload是在那些不在HTML文件中的,但是却可以渲染并且解析的js或者css文件。使用preload,浏览器会去下载这些资源,但是需要等到这些资源可用的时候才会去执行代码。

  • 需要合理地使用这个属性。浏览器会下载这些文件,即使这些文件对于你的界面来说是非必要的。
  • 过渡使用preload会拖慢你的界面速度。
  • 如果有过多的文件使用preload,那么固有的优先级将会受到影响。
  • 但预加载的文件是首屏展示的文件时,才会提高你的额Google PageSpeed Insight 的得分。
  • 预加载只能使用在link标签中。
  • 当预加载标签会在一个文件被解析后才会被发现。
Examples of Preload
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="main.js" as="script">

编写纯粹脚本,避免使用第三方脚本

纯粹的脚本能带来性能收益。例如,第三方的脚本可以帮你做很多事情,第三方脚本经常可以解决大部分事情。但一个简单的问题也使用一个巨大的库来解决的话,会降低你的网页性能。
一项WebAIM团队的调查发现,将近100万的网站使用了第三方脚本,并且带来了严重的可访问性问题。如果你关注你的用户体验,多写一点纯脚本的代码吧。

问题并不是要100%都自己写纯粹的代码,问题应该是在需要的时候使用辅助函数或者小一点体积的插件。

缓存以及过期的内容

如果一个资源会被经常使用,那么我们就不需要多次加载他们。缓存能够帮我做到这一点。给他们的header里面设置一个过期的日期。当它们过期后,才需要重新加载一次。为了在前端实现缓存,浏览器会查询HTTP返回头的属性:
1. ETag
2. Cache-Control
3. Last-Modified
4. Expires

ETag

ETag也被称为Entity Tag,表示的就是一段缓存标记的字符串。这段标记会告诉浏览器是否继续使用缓存。如果资源没有变化,服务器会返回一样的token。body内容是空的。这就是我们经常看到的304状态。如果资源已经过期了,body会返回最新的数据。

Cache Control

Cache Control 允许你的应用决定浏览器的缓存策略。可以在下面这些不同的选项中选择:no-cache, no-store, private 或者 public

Last Modified

Last Modified 和ETag类似,HTTP头中的这个属性记录了本资源最后被修改的时间,通过对比告知浏览器是否需要发起一个新的请求。

Expires

Expires经常用于验证数据是否过期。我们的网页数据总是需要不过期的数据。如果header中的过期字段标识过期了,就表明这个资源是无效的。

使用JS脚本文件,利用service worker可以自由的决定数据是否需要被下载。例如有两个文件:style.cssscript.js。利用service worker可以决定哪个需要重新下载,哪个则需要跑缓存。

/*Install gets executed when the user launches the single page application for *the first time
*/

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(cacheName).then(function(cache) {
      return cache.addAll(
        [
          'styles.css',
          'script.js'
        ]
      );
    })
  );
});

//When a user performs an operation
document.querySelector('.lazy').addEventListener('click', function(event) {
  event.preventDefault();
  caches.open('lazy_posts’).then(function(cache) {
    fetch('/get-article’).then(function(response) {
      return response;
    }).then(function(urls) {
      cache.addAll(urls);
    });
  });
});

//When there is a network response
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('lazy_posts').then(function(cache) {
      return cache.match(event.request).then(function (response) {
        return response 
      });
    })
  );
});

关于React

读到这里,应该知道了什么是关键渲染路径了,也应该知道代码在性能中提升中扮演多重要的位置。下面这节,我们将讲解一下如何处理性能,让关键渲染路径变得尽可能的少。我们选择了React框架作为例子。优化技术分为两个阶段。首先是web应用开始下载的阶段。其次是界面加载后的阶段。

阶段一

我们来写一个最简单的应用,大概包含下面的结构:

  1. Header
  2. Sidebar

  3. Footer

用户只有在登录的情况下,才能看到侧边栏。Webpack可以帮助我们分割代码,启用code splitting,使用APP.js中使用Route组件中的React Lazy loading
下面我就会将里面的逻辑进行讲解。逻辑代码只有在应用需要的时候才会被加载。好处就是总体代码就会被减少。
例如侧边栏并不是要一开始就需要加载,我们有很多种方法来改进性能。首先,我们需要向路由中注册懒加载的概念。如下代码所示,代码被分割成了三个基本片段。只有相关联的路由被加载的时候,他们才会被下载下来。这就意味着整个界面的“关键比特”不需要包含着侧边栏代码体积量。同样,我们也可以在App.js中实现懒加载。最终的方式由开发者决定,根据他们的开发情景。我们来看下如何才父组件中实现:

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
 |- index.js
 |- Header.js
 |- Sidebar.js
 |- Footer.js
 |- loader.js
 |- route.js
|- /node_modules
import { Switch, browserHistory, BrowserRouter as Router, Route} from 'react-router-dom';
const Header = React.lazy( () => import('Header'));
const Footer = React.lazy( () => import(Footer));
const Sidebar = React.lazy( () => import(Sidebar));

const Routes = (props) => {
return isServerAvailable ? (
<Router history={browserHistory}>
           <Switch>
             <Route path="/" exact><Redirect to='/Header’ /></Route>
             <Route path="/sidebar" exact component={props => <Sidebar {...props} />} />
             <Route path="/footer" exact component={props => <Footer {...props} />} />
          </Switch>
</Router>
}

接下来,我们有条件地渲染APP.js。下面的这段代码,如果props.user为空,不会加载侧边栏。渲染侧边栏是有条件的,当user.props改变了,React和Webpack才会通过变化,通知相应的脚本片段下载。如果侧边栏的代码量很多,那么我们这样做就可以显著地减少首屏加载的时间。条件渲染组件的使用可以贯穿整个应用。

const Header = React.lazy( () => import('Header'));
const Footer = React.lazy( () => import(Footer));
const Sidebar = React.lazy( () => import(Sidebar));

function App (props) {
return(
<React.Fragment>
   <Header user = {props.user} />
   {props.user ? <Sidebar user = {props.user /> : null}
   <Footer/>
</React.Fragment>
)
}

仔细思考条件渲染,React允许我们通过点击一个按钮来加载一个组件。例如,如果用户点击了登录按钮,右侧栏也被加载,我们的代码就需要改成下面的样子:

//Sidebar.js
export default () => {
  console.log('You can return the Sidebar component here!');
};
import _ from 'lodash';
function buildSidebar() {
   const element = document.createElement('div');
   const button = document.createElement('button');
   button.innerHTML = 'Login';
   element.innerHTML = _.join(['Loading Sidebar', 'webpack'], ' ');
   element.appendChild(button);
   button.onclick = e => import(/* webpackChunkName: "sidebar" */ './sidebar).then(module => {
     const sidebar = module.default;
     sidebar()   
   });

   return element;
 }

document.body.appendChild(buildSidebar());

将每个路由组件都保持懒加载很重要,还要一个叫做Suspense的关键组件。Suspense的作用在于,当组件在加载当中过程中提供一个内容回退。回退的内容可以是一张图片,或者一个提示告诉用户耐心等待。我们修改代码,用Suspene代替。

import React, { Suspense } from 'react';
import { Switch, browserHistory, BrowserRouter as Router, Route} from 'react-router-dom';
Import Loader from ‘./loader.js’
const Header = React.lazy( () => import('Header'));
const Footer = React.lazy( () => import(Footer));
const Sidebar = React.lazy( () => import(Sidebar));

const Routes = (props) => {
return isServerAvailable ? (
<Router history={browserHistory}>
    <Suspense fallback={<Loader trigger={true} />}>
           <Switch>
             <Route path="/" exact><Redirect to='/Header’ /></Route>
             <Route path="/sidebar" exact component={props => <Sidebar {...props} />} />
             <Route path="/footer" exact component={props => <Footer {...props} />} />
      </Switch>
     </Suspense>
</Router>
}

阶段二

现在,应用已经被下载下来了,你需要知道React是如何工作的,优化工作才能继续。React是一个很有趣的框架,包含一个宿主树(Host tree) 以及宿主实例(Host instance)。Host Tree本质上就是DOM,Host Instance表示的就是节点。React使用React DOM 来建立应用于宿主环境之间的联系。最小的React Dom是一个js对象。这些对象在对象被创建之后就会马上更新。React更新Host Tree 以完美地匹配React Dom的变化。这个过程叫做和解(reconciliation)。

使用正确的状态管理方法:

每次React DOM Tree 更新,他会强制浏览器回流。这对你的应用带来严重的影响。和解(reconciliation)就是用来减少这些影响的。同样,使用状态管理也可以有效的防止重新渲染的问题。例如使用useState钩子。

如果你写了一个类组件,使用shouldComponentUpdate生命周期函数。保证这个类永远是一个PureComponent。此外,如果在PurComponent中使用shouldComponentUpdate,重渲染的机会就会变得很小。

使用React.Memo

React.Memo使用组件和可以保存的props。当一个组件需要被重新渲染时,这个方法经常被用来提高性能。

function MyComponent(props) {
}
function areEqual(prevProps, nextProps) {
  /*
  return true if passing nextProps to render would return
  the same result as passing prevProps to render,
  otherwise return false
  */
}
export default React.memo(MyComponent, areEqual);

如果使用函数组件,要充分利用userCallbackuseMemo方法。

总结

现在你应该知道了什么是关键渲染路径,现在自己去试试分析自己的代码吧。关键渲染路径包含了你写的每行代码以及每个文件。同样你也需要关注那些非首屏的元素,如果你之前还没有用我上面提到过的那些技巧和提示去优化你的应用,那么从现在起,你可以就可以开始去做了。性能对于web应用都非常重要,尤其是当它变得越来越复杂,越来越大的时候,每一个毫秒都是非常重要的,但也别忘了,不成熟的优化方案会给你的网页带来灾难。永远记得检查并且尝试去优化。

发表回复 0