如何在Koa整合Bigpipe首屏渲染服務

weixin_34357887發表於2018-06-25

本文章使用的程式碼

一、服務端渲染

為什麼前後端分離的時代還需要服務端渲染?

就是為了快啊!還能做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和新浪等就用了這種方式渲染頁面,可惜的是沒有開源出來。有錯誤歡迎大家指正啊。輕噴、輕噴就好。

相關文章