BigPipe is a technology developed by Facebook to optimize web page loading speed. There are almost no articles implemented with node.js on the Internet. In fact, it is not just node.js. BigPipe implementations in other languages are rare on the Internet. So long after this technology appeared, I thought that after the entire web page framework was sent first, I used another or several ajax requests to request the modules in the page. It was not until not long ago that I learned that the core concept of BigPipe is to use only one HTTP request, but the page elements are sent in order.
It will be easy to understand this core concept. Thanks to the asynchronous feature of node.js, it is easy to implement BigPipe with node.js. This article will use examples step by step to illustrate the causes of BigPipe technology and a simple implementation based on node.js.
I will use express to demonstrate. For simplicity, we choose jade as the template engine, and we do not use the engine's sub-template (partial) feature, but instead use the child template to render HTML as the parent template data.
First create a nodejs-bigpipe folder and write a package.json file as follows:
The code copy is as follows:
{
"name": "bigpipe-experiment"
, "version": "0.1.0"
, "private": true
, "dependencies": {
"express": "3.xx"
, "consolidate": "latest"
, "jade": "latest"
}
}
Run npm install to install these three libraries. Consolidate is used to facilitate calling jade.
Let’s make the simplest attempt first, two files:
app.js:
The code copy is as follows:
var express = require('express')
, cons = require('consolidate')
, jade = require('jade')
, path = require('path')
var app = express()
app.engine('jade', cons.jade)
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'jade')
app.use(function (req, res) {
res.render('layout', {
s1: "Hello, I'm the first section."
, s2: "Hello, I'm the second section."
})
})
app.listen(3000)
views/layout.jade
The code copy is as follows:
doctype html
head
title Hello, World!
style
section {
margin: 20px auto;
border: 1px dotted gray;
width: 80%;
height: 150px;
}
section#s1!=s1
section#s2!=s2
The effects are as follows:
Next, we put two section templates into two different template files:
views/s1.jade:
The code copy is as follows:
h1 Partial 1
.content!=content
views/s2.jade:
The code copy is as follows:
h1 Partial 2
.content!=content
Add some styles to layout.jade style
The code copy is as follows:
section h1 {
font-size: 1.5;
padding: 10px 20px;
margin: 0;
border-bottom: 1px dotted gray;
}
section div {
margin: 10px;
}
Change the app.use() part of app.js to:
The code copy is as follows:
var temp = {
s1: jade.compile(fs.readFileSync(path.join(__dirname, 'views', 's1.jade')))
, s2: jade.compile(fs.readFileSync(path.join(__dirname, 'views', 's2.jade')))
}
app.use(function (req, res) {
res.render('layout', {
s1: temp.s1({ content: "Hello, I'm the first section." })
, s2: temp.s2({ content: "Hello, I'm the second section." })
})
})
We said before, "The HTML after rendering is completed with the child template as the data of the parent template", which means that the two methods temp.s1 and temp.s2 will generate HTML code for the two files s1.jade and s2.jade, and then use these two pieces of code as the values of the two variables s1 and s2 in layout.jade.
Now the page looks like this:
Generally speaking, the data of the two sections are obtained separately - whether it is by querying the database or RESTful request, we use two functions to simulate such asynchronous operations.
The code copy is as follows:
var getData = {
d1: function (fn) {
setTimeout(fn, 3000, null, { content: "Hello, I'm the first section." })
}
, d2: function (fn) {
setTimeout(fn, 5000, null, { content: "Hello, I'm the second section." })
}
}
In this way, the logic in app.use() will be more complicated, and the simplest way to deal with it is:
The code copy is as follows:
app.use(function (req, res) {
getData.d1(function (err, s1data) {
getData.d2(function (err, s2data) {
res.render('layout', {
s1: temp.s1(s1data)
, s2: temp.s2(s2data)
})
})
})
})
This will also get the results we want, but in this case, it will take a full 8 seconds to return.
In fact, the implementation logic shows that getData.d2 starts calling after the result of getData.d1 is returned, and they do not have such a dependency. We can use libraries such as async that handles JavaScript asynchronous calls to solve this problem, but let's simply write it here:
The code copy is as follows:
app.use(function (req, res) {
var n = 2
, result = {}
getData.d1(function (err, s1data) {
result.s1data = s1data
--n || writeResult()
})
getData.d2(function (err, s2data) {
result.s2data = s2data
--n || writeResult()
})
function writeResult() {
res.render('layout', {
s1: temp.s1(result.s1data)
, s2: temp.s2(result.s2data)
})
}
})
This takes only 5 seconds.
Before the next optimization, we add the jquery library and put the css style into external files. By the way, we will add the runtime.js file needed for using the jade template that we will use later on, and run it in the directory containing app.js:
The code copy is as follows:
mkdir static
cd static
curl http://code.jquery.com/jquery-1.8.3.min.js -o jquery.js
ln -s ../node_modules/jade/runtime.min.js jade.js
And take out the code in the style tag in layout.jade and put it in static/style.css, and change the head tag to:
The code copy is as follows:
head
title Hello, World!
link(href="/static/style.css", rel="stylesheet")
script(src="/static/jquery.js")
script(src="/static/jade.js")
In app.js, we simulate both download speeds to two seconds, and added before app.use(function (req, res) {:
The code copy is as follows:
var static = express.static(path.join(__dirname, 'static'))
app.use('/static', function (req, res, next) {
setTimeout(static, 2000, req, res, next)
})
Due to external static files, our page now has a loading time of about 7 seconds.
If we return the head part as soon as we receive an HTTP request, and then two sections wait until the asynchronous operation is completed before returning, this uses HTTP's blocked transmission encoding mechanism. In node.js, as long as you use the res.write() method, the Transfer-Encoding: chunked header will be automatically added. In this way, while the browser loads the static file, the node server is waiting for the result of the asynchronous call. Let's first delete the two lines of the section in layout.jade:
The code copy is as follows:
section#s1!=s1
section#s2!=s2
Therefore, we don't need to give this object in res.render() { s1: …, s2: … }, and because res.render() will call res.end() by default, we need to manually set the callback function after render is completed, and use the res.write() method in it. The content of layout.jade does not need to be in the writeResult() callback function. We can return it when we receive this request. Note that we manually added the content-type header:
The code copy is as follows:
app.use(function (req, res) {
res.render('layout', function (err, str) {
if (err) return res.req.next(err)
res.setHeader('content-type', 'text/html; charset=utf-8')
res.write(str)
})
var n = 2
getData.d1(function (err, s1data) {
res.write('<section id="s1">' + temp.s1(s1data) + '</section>')
--n || res.end()
})
getData.d2(function (err, s2data) {
res.write('<section id="s2">' + temp.s2(s2data) + '</section>')
--n || res.end()
})
})
Now the final loading speed is back to about 5 seconds. In actual operation, the browser first receives the head part code and loads three static files. This takes two seconds. Then in the third second, Partial 1 appears, Partial 2 appears in the fifth second, and the web page loading ends. I won't give a screenshot, the screenshot effect is the same as the screenshots in the previous 5 seconds.
However, it is important to note that this effect can be achieved because getData.d1 is faster than getData.d2. That is to say, which block in the web page is returned first depends on who returns the result of the asynchronous call of the interface behind it. If we change getData.d1 to return in 8 seconds, we will first return Partial 2. The order of s1 and s2 is reversed, and the final result of the web page is inconsistent with our expectations.
This problem ultimately leads us to BigPipe, which is a technology that can decouple the display order of each part of the web page from the transmission order of data.
The basic idea is to first transmit the general framework of the entire web page, and the parts that need to be transmitted later are represented by empty divs (or other tags):
The code copy is as follows:
res.render('layout', function (err, str) {
if (err) return res.req.next(err)
res.setHeader('content-type', 'text/html; charset=utf-8')
res.write(str)
res.write('<section id="s1"></section><section id="s2"></section>')
})
Then write the returned data into JavaScript
The code copy is as follows:
getData.d1(function (err, s1data) {
res.write('<script>$("#s1").html("' + temp.s1(s1data).replace(/"/g, '//"') + '")</script>')
--n || res.end()
})
The processing of s2 is similar to this. At this time, you will see that in the second second of requesting the web page, two blank dotted boxes appear, in the fifth second, Partial 2 appears, and in the eighth second, Partial 1 appears, and the web page request is completed.
At this point, we have completed the simplest web page implemented by BigPipe technology.
It should be noted that if the web page fragment to be written has script tags, such as changing s1.jade to:
The code copy is as follows:
h1 Partial 1
.content!=content
script
alert("alert from s1.jade")
Then refresh the web page and you will find that the alert sentence is not executed, and the web page will have errors. Check the source code and know that it is an error caused by the string in <script>. Just replace it with <//script>
The code copy is as follows:
res.write('<script>$("#s1").html("' + temp.s1(s1data).replace(/"/g, '//"').replace(/</script>/g, '<//script>') + '")</script>')
Above we explain the principles of BigPipe and the basic method of implementing BigPipe with node.js. And how should it be used in reality? Here is a simple method for throwing bricks and jade, the code is as follows:
The code copy is as follows:
var resProto = require('express/lib/response')
resProto.pipe = function (selector, html, replace) {
this.write('<script>' + '$("' + selector + '").' +
(replace === true ? 'replaceWith' : 'html') +
'("' + html.replace(/"/g, '//"').replace(/<//script>/g, '<//script>') +
'")</script>')
}
function PipeName (res, name) {
res.pipeCount = res.pipeCount || 0
res.pipeMap = res.pipeMap || {}
if (res.pipeMap[name]) return
res.pipeCount++
res.pipeMap[name] = this.id = ['pipe', Math.random().toString().substring(2), (new Date()).valueOf()].join('_')
this.res = res
this.name = name
}
resProto.pipeName = function (name) {
return new PipeName(this, name)
}
resProto.pipeLayout = function (view, options) {
var res = this
Object.keys(options).forEach(function (key) {
if (options[key] instanceof PipeName) options[key] = '<span id="' + options[key].id + '"></span>'
})
res.render(view, options, function (err, str) {
if (err) return res.req.next(err)
res.setHeader('content-type', 'text/html; charset=utf-8')
res.write(str)
if (!res.pipeCount) res.end()
})
}
resProto.pipePartial = function (name, view, options) {
var res = this
res.render(view, options, function (err, str) {
if (err) return res.req.next(err)
res.pipe('#'+res.pipeMap[name], str, true)
--res.pipeCount || res.end()
})
}
app.get('/', function (req, res) {
res.pipeLayout('layout', {
s1: res.pipeName('s1name')
, s2: res.pipeName('s2name')
})
getData.d1(function (err, s1data) {
res.pipePartial('s1name', 's1', s1data)
})
getData.d2(function (err, s2data) {
res.pipePartial('s2name', 's2', s2data)
})
})
Also add two sections in layout.jade:
The code copy is as follows:
section#s1!=s1
section#s2!=s2
The idea here is that the content of pipe needs to be placed with a span tag first, obtain the data asynchronously and render the corresponding HTML code before outputting it to the browser, and replace the placeholder span element with jQuery's replaceWith method.
The code of this article is at https://github.com/undozen/bigpipe-on-node. I have made each step into a commit. I hope you can actually run it locally and hack it. Because the next few steps involve the loading order, you really have to open the browser yourself to experience it and cannot see it from the screenshot (actually, it should be implemented with gif animation, but I was too lazy to do it).
There is still a lot of room for optimization about BigPipe practice. For example, it is best to set a triggered time value for the content of pipe. If the data called asynchronously returns quickly, you do not need to use BigPipe. You can directly generate a web page and send it out. You can wait until the data request has exceeded a certain period of time before using BigPipe. Compared with ajax, using BigPipe not only saves the number of requests from the browser to the node.js server, but also saves the number of requests from the node.js server to the data source. However, let’s share the specific optimization and practice methods after Snowball Network uses BigPipe.