如何在Koa集成Bigpipe首屏渲染服务

2,083 阅读6分钟

本文章使用的代码

一、服务端渲染

为什么前后端分离的时代还需要服务端渲染?

就是为了快啊!还能做SEO啊!下面我们来简单分析下这两种方式的渲染过程

前后端分离页面渲染模式:

1、浏览器发起页面请求

2、解析html

3、发起请求获取页面对应的js、css

4、解析css、js

5、发起ajax请求获取数据后将数据渲染到DOM中

服务端渲染:

1、浏览器发起请求

2、服务端发起请求获取对应的页面数据后将数据拼接到读取的html中

3、返回拼接后的html给浏览器

4、浏览器解析html

5、获取资源、解析资源

通过上面的对比,可以看出为什么服务端渲染更快?因为前端通过ajax渲染,需要等到获取js后,再发起http请求获取到数据后才完成渲染,而服务端免去了多次http请求的过程(http请求耗时),直接让服务端返回渲染好的html页面。

那类似首屏这种对速度有要求的就可以使用服务端渲染了。

这里提出一个问题,如果一个页面,在服务端渲染中,数据源比较多的情况下,我们需要等待所有的请求都返回数据才进行html拼接并返回,这样我们页面最终渲染的速度就限制在最迟返回数据的请求上了。

那针对上述数据源较多的情况,还有优化的方案吗?答案就是Bigpipe。

二、Bigpipe流式渲染

Bigpipe是一种采用流的方式对页面进行渲染的机制,在浏览器请求页面时,打开管道后持续对页面的块进行输出。

如下图,块A、B、C拼装好块之后直接通过开始建立的管道输出到页面中,这样页面的最终输出就不需要依赖最后一个块的拼装时间了。

三、在Koa中以中间件的方式集成Bigpipe

下面来抽象一个简单的bigpipe中间件。

以中间件的形式加载bigpipe服务,并指定模板与静态资源的跟目录

// app.js

app.use(createBigpipeMiddleware(
  templatePath = resolve(__dirname, './template'),  // 模板文件夹
  publicPath = resolve(__dirname, './view')  // 静态资源目录
));
使用bigpipe,我们一般需要读取一个html-layout,接下来就是定义每一个块的模板路径和数据源,执行一个render方法后,开始返回html并持续输出我们定义的块。
// app.js

app.use((ctx) => {
  let bigpipe = ctx.body = ctx.createBigpipe();

  // 定义输出的html layout
  bigpipe.defineLayout('/bigpipe.html');

  // 定义片段,这里我们使用promise的方式模拟http请求
  bigpipe.definePagelets([
    {
      id: 'A',
      tpl: '/article.handlebars',    
      getData: () => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve(a)
          }, 3000)
        })
      }
    },
    {
      id: 'B',
      tpl: '/article.handlebars',
      getData: () => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve(b)
          }, 2000)
        })
      }
    },
    {
      id: 'C',
      tpl: '/article.handlebars',
      getData: () => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve(c)
          }, 0)
        })
      }
    }
  ]);

  bigpipe.render();
})

bigpipe.definePagelets传入的对象数组中,每一个对象中的id为每一个块对应需要插入的DOM节点的id属性值,tpl为该模板在模板根目录下的路径,getData只是一个模拟http请求的函数,可以设定在x秒后返回输出数据并进行拼接返回。html-layout如下。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>test bigpipe</title>
<body>
  <div id="A"></div>

  <div id="B"></div>

  <div id="C"></div>

  <script>
    // 渲染模板字符串到对应节点
    var renderFlush = function (selector, html) {
      var dom = document.querySelector(selector);
      dom.innerHTML = html
    };
  </script>

下面的createBigpipeMiddleware中间件的实现

const { resolve } = require('path')
const Bigpipe = require('./lib/bigpipe')

module.exports = createBigPipeReadable

function createBigPipeReadable (
  templatePath = resolve(__dirname, '../../template'),  // 模板根目录(默认)
  publicPath = resolve(__dirname, '../../../../public') // html根目录(默认)
) {
  
  // 返回一个带有ctx与next参数的async函数
  return async function initBigPipe(ctx, next) {
    if (ctx.createBigpipe) return next()
    
    // 在上下文中挂载createBigpipe方法供我们在业务中使用
    ctx.createBigpipe = function () {
      ctx.type = 'html';

      return new Bigpipe({
        appContext: ctx,
        templatePath: templatePath,
        publicPath: publicPath
      })
    }

    return next()
  }
}

上面是koa中间件的写法,不太理解的可以google查一查。这个中间件会在ctx中挂载方法createBigpipe用于初始化bigpipe服务,那在后续的业务文件中就可以直接通过调用ctx.createBigpipe来调用bigpipe服务了

下面就是具体bigpipe对象的类实现了。

首先,我们先让Bigpipe对象继承Readable(因为Koa不支持直接调用底层res进行响应处理)

const Readable = require('stream').Readable;

class Bigpipe extends Readable {
  constructor(props) {
    super(props);
    this.appContext = props.appContext;  // koa上下文
    this.templatePath = props.templatePath; 
    this.publicPath = props.publicPath;
    this.layout = '';  // html-layout
    this.pagelets = [];  // 用于存放块
    this.pageletsNum = 0;
  }

  _read() {}

  ...
}

接下来实现一个defineLayout函数,把layout转成字符串(也就是上文贴出来的html)

const { join } = require('path');

class Bigpipe extends Readable {
  ...

  defineLayout(realPath) {
    let layoutPath = join(this.publicPath, realPath)
    
    this.layout = fs.readFileSync(layoutPath).toString();
  }

  ...
}

下面的definePagelets用于传入块的配置,可传入一个对象多次调用或者直接传入一个数组

class Bigpipe extends Readable {
  ...

  definePagelets(pagelets) {
    if (Array.isArray(pagelets)) {
      this.pagelets = this.pagelets.concat(pagelets);
    } else {
      if (typeof pagelets === 'object') {
        this.pagelets.push(pagelets)
      }
    }

    this.pageletsNum = this.pagelets.length;
  }

  ...
}

接下来是就是render函数了,调用后直接开始输出layout还有对块进行拼接传输

class Bigpipe extends Readable {
  ...

  // 配置好后渲染主逻辑
  async render() {
    // 首先输出html骨架
    this.push(this.layout);

    // 所有块完成后,关闭流
    await Promise.all(this.wrap(this.pagelets))

    // 结束传输
    this.done();
  }

  ...
}

上面,因为Bigpipe继承了Readable,所以可以用push的方式推入数据,接着await后则是一个Promise.all方法,等到所有的块输出完成后,才执行done方法闭合html结束数据传输。

下面是最重要的方法,wrap方法,用于将传入的块数组包装成promise(这里我们使用handlebars作为模板引擎,当然还有很多其他选择)

const Handlebars = require('handlebars');

class Bigpipe extends Readable {
  ...

  //将proxy,包装成Promise
  wrap(pagelets) {
    return pagelets.map((pagelet, idx) => {
      // 返回一个promise,模板拼接好输出到页面中即resolve
      return new Promise((resolve, reject) => {
        (async () => {
          let data = null,
              tpl = function() {},
              tplHtml = '';

          // 调用个个块的getData方法,等待数据获取
          data = await pagelet.getData()
          
          // 获取hbs模板
          tpl = this.getHtmlTemplate(pagelet.tpl);

          // 将数据拼接好后返回模板字符串,并清除换行符
          tplHtml = this.clearEnter(tpl(data));

          // 以script输出到页面中
          this.push(`
            <script>
              renderFlush("#${pagelet.id}","${tplHtml}")
            </script>
          `)

          this.pageletsNum--;

          resolve()
        })()
      })
    })
  }

  // 获取骨架并转成字符串
  getHtmlTemplate(realPath) {
    let tplPath = join(this.templatePath, realPath);
    let tplSource = fs.readFileSync(tplPath).toString();
    
    // 编译模板
    return Handlebars.compile(tplSource);
  }

  // 清除模板字符串的换行符
  clearEnter(html) {
    return html.replace(/[\r\n]/g,"")
  }

  ...
}

每一个promise中,在data返回后,都会调用this.push方法推入一串脚本,执行的就是如下的在html-layout中的函数,传入的是id属性值与拼接好的html块,执行renderFlush就会将块输出到html中。

var renderFlush = function (selector, html) {
  var dom = document.querySelector(selector);
  dom.innerHTML = html  
};

上面我们传入了getData方法,相应的你也可以使用request等模块去封装一个函数去获取对应数据,这里只是作为演示,直接使用一个promise返回数据。

执行node app.js后,访问localhost:9000,结果如下

1、先输出html与块C

2、2秒后,输出块B

3、3秒后,输出完毕,管道关闭(注意,浏览器刷新按钮从叉变成了箭头)

四、总结

bigpipe渲染确实更快,具体是否需要还是得看业务场景,比如像facebook和新浪等就用了这种方式渲染页面,可惜的是没有开源出来。有错误欢迎大家指正啊。轻喷、轻喷就好。