使用Express Server和Handlebars优化Critical-Path性能

最近,我在一个React同构网站工作。这个网站建立在React上,运行于Express服务器上。一切都进行得十分顺利,但是我仍对CSS包的加载阻塞不满意。因此,我开始考虑如何在Express服务器上进行关键路径的优化。

这篇文章主要是如何使用Express和Handlebars进行安装以及配置一个关键路径的性能优化的笔记。

先决条件

本文中,我会使用Node.js以及Express。熟悉这两种技术对于理解文中的示例是十分有帮助的。

criticalcss-article-png

TL;DR

我准备了一个简单容易上手的资源库

基础

关键路径(Critical-path)优化是关于如何消除CSS渲染阻塞的一种技术。它可以明显的提高网站负载的速度。该技术的目的是为了帮助用户摆脱CSS包加载的等待时间。一旦包加载后,浏览器进行缓存,之后的任何重载都会从缓存中读取。在此基础上,我们的目标如下:

  • 在第一和第二(以及第n)次加载间进行区分。
  • 第一次加载,异步加载CSS包,添加一个事件侦听以便知道加载何时完成。
  • 当包被加载时,添加一些CSS,让用户尽可能的拥有良好的体验。
  • 当侦听事件提示CSS包加载完成,移除内联CSS样式并为包服务。
  • 确保其他来源(Javascript包等等)不会阻塞渲染。

检测第一次加载

使用cookie检测是否为第一次加载。如果cookie没有被设置则表明是第一次加载。否则是第二次或者第n次加载。

异步加载CSS包

启动异步下载CSS包,使用一种包含一个无效media属性值的简单技术。将media属性值设置为无效可以使CSS包异步下载,但是在media属性值没有被设置为有效之前,是不会应用任何样式修饰的。换句话说就是,为了使CSS包应用样式,我们需要在包下载后将media属性值设置为有效。

关键CSS与CSS包

下载CSS包时,保持标记中关键的内联样式。一旦包下载,关键CSS就会被移除。为了做到这一点,我们需要创建一些关键的JavaScript进行相关的处理。

生命周期

综上所述,这里有一个生命周期的简单模式:

criticalcss-graph-preview-opt

同构

关于这种技术,你现在已经了解了一些,想象其与同构JavaScript应用相结合。同构JavaScript也被称为通用JavaScript,简单来说就是使用Javascript编写的应用程序可以在服务器运行,并生成HTML标记。如果你很好奇,阅读ReactDOM.renderToString以及ReactDOM.renderToStaticMarkup了解更多关于React的知识。

你可能十分奇怪,为什么我们需要在服务器上生成HTML。回想第一次加载,当我们使用客户端专用代码时,用户需要等待加载JavaScript包。当JavaScript包加载完之后,用户将会看到一个空白页或者是一个预加载。我相信,前端开发人员的目标是应该尽量减少这样的场景。使用同构代码则是不同的,用户看到的不是一个空白页或者说是一个预加载,而是会看到生成的标记,即使没有JavaScript包。当然,加载CSS包也需要一些时间,没有它用户看到的将是无样式的标记。幸运的是,采用关键路径的性能优化,就可以很容易的进行解决。

criticalcss-schema-opt

配置环境

EXPRESS

Express是一个最小的灵活的Node.js web应用框架。

首先,安装所有的软件包: express,express-handlebars以及cookie-parserexpress-handlebars是Handlebars用于Express的一个引擎,cookie-parser主要用于之后的cookie。

npm install express express-handlebars cookie-parser --save-dev

为导入这些包创建一个server.js文件。之后会使用这些path包,也是Node.js的一部分。

import express from 'express';
import expressHandlebars from 'express-handlebars';
import cookieParser from 'cookie-parser';
import path from 'path';

创建Express应用程序:

var app = express();

安装cookie-parser:

app.use(cookieParser());

CSS包将在/assets/css/bundle.css中,为了在Express中快速获取静态文件,我们需要设置静态文件所在的路径名称。可以通过使用内置的中间函数express.static来完成。我们的文件将在指定的目录build中;所以/build/assets/css/bundle.css中的本地文件将通过服务器被送达/assets/css/bundle.css中。

app.use(express.static('build'));

对于这个演示的目的,设置一个单一的HTTP GET路线(/)就够了:

// Register simple HTTP GET route for /
app.get('/', function(req, res){
  // Send status 200 and render content. Content, in this case, is a non-existent template. For me, rendering the layout is important.
  res.status(200).render('content');
});

将Express绑定到端口3000进行监听:

// Set the server port to 3000, and log the message when the server is ready.
app.listen(3000, function(){
  console.log('Local server is listening…');
});

Babel以及ES2016

鉴于ECMAScript 2016(或者说 ES2016)语法,我们需要安装Babel以及相关预置(presets)。Babel是一个JavaScript编译器,允许使用下一代JavaScript语法。Babel presets是一个特定的Babel转换逻辑,提取插件(或者presets)群组。我们的demo需要Reat以及ES2015 presets。

npm install babel-core babel-preset-es2015 babel-preset-react --save-dev

现在使用下面的代码创建.babelrc文件。这也就是我们在本质上说的,“Hey Babel,使用这些presets”:

{
  "presets": [
    "es2015",
    "react"
  ]
}

正如Babel的文档所说的,处理ES2016语法,Babel需要一个babel-core/register作为应用入口的挂钩。否则,就会抛出一个错误。创建entry.js:

require("babel-core/register");
require('./server.js');

现在,对配置进行检测:

$ node entry.js

终端应该显示如下信息:

Local server is listening…

然而,如果你在浏览器http://localhost:3000/进行浏览,将得到如下错误:

Error: No default engine was specified and no extension was provided.

这只是意味着Express不知道渲染什么以及如何进行渲染。在下一节中我们将摆脱这个错误。

Handlebars

Handlebars被视为是“steroids最小的模板”。对它进行设置,打开server.js

// register new template engine
// first parameter = file extension
// second parameter = callback = expressHandlebars
// defaultLayout is the name of default layout located in layoutsDir.
app.engine('handlebars', expressHandlebars(
{
  defaultLayout: 'main',
  layoutsDir:    path.join(__dirname, 'views/layouts'),
  partialsDir: path.join(__dirname, 'views/partials')
}
));
// register new view engine
app.set('view engine', 'handlebars');

创建目录views/layouts以及views/partials。在views/layouts中创建一个名为main.handlebars的文件,如下所示进行相关嵌入。这是我们主要的布局。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Critical-Path Performance Optimization</title>
    <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
  </head>
  <body>
  </body>
</html>

另外在views目录下创建名为content.handlebars的文件,嵌入到如下的代码中。

<div id="app">magic here</div>

现在启动服务器:

$ node entry.js

开启http://localhost:3000,错误消失,布局的标记也已经准备就绪。

关键路径(Critical Path)

环境已经配置好,现在开始实现关键路径的优化。

确定第一次加载

你会记得,我们的首要目标是决定第一次加载。在此基础上,可以决定是否需要关键样式或者从浏览器缓存中获取CSS包。在这一点我们使用cookie。如果cookie被设置,意味着这不是第一次加载;否则,是。cookie在关键JavaScript文件中被创建,携带着一些关键样式,文件将会被内嵌在模板中。Express可以通过检查cookie进行判断并处理。

将关键JavaScript文件命名为fastjs。如果cookie不存在,我们必须在布局文件中嵌入fastjs中的内容。这里我发现Handlebars partials十分好用。当你需要在多处进行标记复用时,partials就会变得十分有用。它们也可以被其它的模板进行调用,通常是用于页眉,页脚,导航等等。

在Handlebars部分,我在/views/partials中定义了一个partials目录。创建一个/views/partials/fastjs.handlebars文件。文件中,使用script标签,添加一个ID进行fastjs的引用。之后使用这个ID在DOM中进行移除。

<script id='fastjs'>
</script>

现在打开/views/layouts/main.handlebars。通过{{> partialName }}语法对partial进行调用。这段代码将被我们目标partial的内容所取代。我们的partial被命名为fastjs,所以在head标签结束处添加如下代码:

<head>
…
{{> fastjs}}
</head>

现在http://localhost:3000内包含fastjs partial的内容。cookie将会被这个简单的JavaScript函数创建。

<script id='fastjs'>
// Let's create a cookie named 'fastweb', setting its value to 'cache' and its expiration to one day
createCookie('fastweb', 'cache', 1);

// function to create cookie
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

现在你可以在http://localhost:3000中发现一个名为fastweb的cookie。只有当cookie不存在的时候,fastjs的内容才会被嵌入。为了确定这一点,我们需要在Express进行检查。使用cookie-parser npm包可以很容易做到这一点。在server.js添加如下代码:

app.get('/', function(req, res){
  res.status(200).render('content');
});

render函数的第二个参数是一个可选对象,包含该视图的局部变量。我们可以像如下代码传入一个变量:

app.get('/', function(req, res){
  res.status(200).render('content', {needToRenderFast: true});
});

现在,在我们看来可以对变量needToRenderFast进行打印,其值将为真。当名为fastweb的cookie不存在时,我们想要这个变量的值为true。否则,变量值应该为false。使用cookie-parser检查cookie是否存在是十分简单的,代码如下:

//Check whether cookie named fastweb is set to a value of 'cache'
req.cookies.fastweb === 'cache'

这里改写了我们的需求:

app.get('/', function(req, res){
  res.status(200).render('content', {
    needToRenderFast: !(req.cookies.fastweb === 'cache')
  });
});

基于这个变量的值,视图知道是否应该对关键文件进行渲染。感谢Handlebars内置的小帮手 - 名为if block帮手 - 这是十分容易实现的,打开布局文件添加if小帮手:

<head>
…
{{#if needToRenderFast}}
{{> fastjs}}
{{/if}}
</head>

瞧!当cookie不存在的时候,fastjs内容才会被嵌入。

注入关键CSS

关键CSS文件需要和关键JavaScript文件同时嵌入。首先,创建另一个名为/views/partials/fastcss.handlebars的partial。fastcss文件内的内容十分简单:

<style id="fastcss">
  body{background:#E91E63;}
</style>

fastjs partial,我们仅仅需要对其进行导入。打开布局文件:

<head>
…
{{#if needToRenderFast}}
{{> fastcss}}
{{> fastjs}}
{{/if}}
</head>

处理CSS包的加载

现在的问题是,即使CSS包已经加载完成,关键partial仍旧保留在DOM中。幸运的是,这是很容易就可以解决的。我们的布局标记如下所示:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Critical-Path Performance Optimization</title>
    {{#if needToRenderFast}}
    <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
    {{> fastcss}}
    {{> fastjs}}
    {{/if}}
  </head>
  <body>
  </body>
</html>

fastjs,fastcss以及CSS包都有自己的ID。可以利用这一点,打开fastjspartial并找到这些元素的引用。

var cssBundle = document.getElementById('cssbundle'),
fastCss = document.getElementById('fastcss'),
fastJs = document.getElementById('fastjs');

当CSS包加载的时候,我们希望可以得到相关的通知,这里使用了一个事件侦听器:

cssBundle.addEventListener('load', handleFastcss);

当CSS包被加载的时候,handleFastcss函数就会立即被调用。那一刻,我们想要从CSS包获取样式,移除#fastjs以及#fastcss元素并创建cookie。正如文章开头提到的,CSS包获取的样式将改变CSS包的media属性为一个有效的值进行修饰(在我们的示例中)。

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
}

现在,仅仅移除#fastjs以及fastcss元素:

function handleFastcss() {
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

handleFastcss函数内调用createCookie函数。

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}

最后的fastjs脚本代码如下:

<script id='fastjs'>
var cssBundle = document.getElementById('cssbundle'),
fastCss =  document.getElementById('fastcss'),
fastJs =  document.getElementById('fastjs');

cssBundle.addEventListener('load', handleFastcss);

function handleFastcss() {
  createCookie('fastweb', 'cache', 1);
  cssBundle.setAttribute('media', 'all');
  fastCss.parentNode.removeChild(fastCss);
  fastJs.parentNode.removeChild(fastJs);
}
function createCookie(name,value,days) {
  var expires = "";
  if (days) {
    var date = new Date();
    date.setTime(date.getTime()+(days*24*60*60*1000));
    var expires = "; expires="+date.toGMTString();
  }
  document.cookie = name+"="+value+expires+"; path=/";
}
</script>

请注意这个CSS加载处理程序仅适用于客户端。如果客户端JavaScript代码被禁用,将会继续使用fastcss样式。

第二次以及第n次加载处理

第一次加载符合行为预期,但是当在浏览器重新对其进行加载时,丢失了样式渲染。原因可能在于我们仅仅处理了cookie不存在的情况。如果cookie存在,CSS包必须以标准的方式进行链接。

编辑布局文件:

<head>
  …
  {{#if needToRenderFast}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="none"/>
  {{> fastcss}}
  {{> fastjs}}
  {{else}}
  <link rel="stylesheet" href="assets/css/bundle.css" id="cssbundle" media="all"/>
  {{/if}}
</head>

进行保存并查看结果。

结果

下面的GIF呈现了第一次加载。正如你看到的,CSS包正在被下载,网页拥有一个不同的背景。这是由fastcsspartial中的样式所导致的,cookie被创建并且bundle.css请求的状态为“200 OK”。

关键路径的性能优化: 第一次加载

第二个GIF展现了重载场景。cookie已经存在,关键文件被忽略,bundle.css请求的状态为“304 Not modified”。

关键路径的性能优化: 第二次以及第n次加载

结论

通过上面的架构,我们已经了解了整个生命周期。下一个步骤,检查脚本,图片,文字等所有的请求都是异步的,并不会阻止渲染。另外不要忘记在服务器启用GZIP压缩;好的Express的中间件就在于此。

criticalcss-graph-preview-opt

扩展阅读

本文根据@Filip Bartos的《Optimizing Critical-Path Performance With Express Server And Handlebars》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://www.smashingmagazine.com/2016/08/optimizing-critical-path-performance-with-express-server-and-handlebars/

静子

在校学生,本科计算机专业。一个积极进取、爱笑的女生,热爱前端,喜欢与人交流分享。想要通过自己的努力做到心中的那个自己。微博:@静-如秋叶

如需转载,烦请注明出处:http://www.w3cplus.com/css/optimizing-critical-path-performance-with-express-server-and-handlebars.html

返回顶部