vue:服務端渲染技術

Waxiangyu發表於2018-03-02

服務端渲染:

簡單說:比如說一個模板,資料是從後臺獲取的,如果用客戶端渲染那麼瀏覽器會先渲染htmlcss,然後再通過jsajax去向後臺請求資料再更改渲染。就是在前端再用Node建個後臺,把首屏資料載入成一個完整的頁面在node建的後臺渲染好,瀏覽器拿到的就是一個完整的dom樹。根據專案開啟地址,路由指到哪個頁面就跳到哪。

服務端比起客戶端渲染頁面的優點:

  • 首屏渲染速度更快

客戶端渲染的一個缺點是,使用者第一次訪問頁面,此時瀏覽器沒有快取,需要先從服務端下載js,然後再通過js操作動態新增dom並渲染頁面,時間較長。而服務端渲染的規則是,使用者第一次訪問瀏覽器可以直接解析html文件並渲染頁面,並屏渲染速度比客戶端渲染更快。

  • SEO

服務端渲染可以讓搜尋引擎更容易讀取頁面的meta資訊,以及其它SEO相關資訊,大大增加了網站在搜尋引擎中的速度。

  • 減少HTTP請求

服務端渲染可以把一些動態資料在首次渲染時同步輸出到頁面,而客戶端渲染需要通過AJAX等手段非同步獲取這些資料,這樣就相當於多了一次HTTP請求。

普通服務端渲染

vue提供了renderToString介面,可以在服務端把vue元件渲染成模板字串,我們先看下用法:

benchmarks/ssr/renderToString.js

const Vue = require(`../../dist/vue.runtime.common.js`)
const createRenderer = require(`../../packages/vue-server-renderer`).createRenderer
const renderToString = createRenderer().renderToString
const gridComponent = require(`./common.js`) // vue支行時的程式碼,不包括編譯部分

console.log(`--- renderToString --- `)
const self = (global || root)
self.s = self.performance.now()

renderToString(new Vue(gridComponent), (err, res) => {
  if (err) throw err
  // console.log(res)
  console.log(`Complete time: ` + (self.performance.now() - self.s).toFixed(2) + `ms`)
  console.log()
})

這段程式碼是支行在node.js環境中的,主要依賴vue.common.js,vue-server-render.其中vue.common.jsvue執行時程式碼,不包括編譯部分:vue-server-render對外提供createRenderer方法,renderToStringcreateRenderer方法返回值的一個屬性,它支援傳入vue例項和渲染完成後的回撥函式,這裡要注意,由於引用的是隻包括執行時的vue程式碼,不包括編譯部分,所以其中err表示是否出錯,result表示dom字串。在實際應用中,我們可以將回撥函式拿到的result拼接到模板中,下面看下renderToString的實現:

src/server/create-renderer.js

const render = createRenderFunction(modules, directives, isUnaryTag, cache)
return {
    renderToString (
      component: Component,
      context: any,
      cb: any
    ): ?Promise<string> {
      if (typeof context === `function`) {
        cb = context
        context = {}
      }
      if (context) {
        templateRenderer.bindRenderFns(context)
      }

      // no callback, return Promise
      let promise
      if (!cb) {
        ({ promise, cb } = createPromiseCallback())
      }

      let result = ``
      const write = createWriteFunction(text => {
        result += text
        return false
      }, cb)
      try {
        //  render:把component轉換模板字串str ,write方法不斷拼接模板字串,用result做儲存,然後呼叫next,當component通過render完畢,執行done傳入resut,
        render(component, write, context, () => {
          if (template) {
            result = templateRenderer.renderSync(result, context)
          }
          cb(null, result)
        })
      } catch (e) {
        cb(e)
      }

      return promise
    }
}

renderToString方法支援傳入vue例項component和渲染完成後的回撥函式done。它定義了result變數,同時定義了write方法,最後執行render方法。整個過程比較核心的就是render方法:

src/server/render.js

return function render (
    component: Component,
    write: (text: string, next: Function) => void,
    userContext: ?Object,
    done: Function
  ) {
    warned = Object.create(null)
    const context = new RenderContext({
      activeInstance: component,
      userContext,
      write, done, renderNode,
      isUnaryTag, modules, directives,
      cache
    })
    installSSRHelpers(component)
    normalizeRender(component)
    renderNode(component._render(), true, context)
  }
/**
 * // render實際上是執行了renderNode方法,並把component._render()方法生成的vnode物件作為引數傳入。
 * @param node 先判斷node型別,如果是component Vnode,則根據這個Node建立一個元件的例項並呼叫_render方法作為當前node的childVnode,然後遞迴呼叫renderNode
 * @param isRoot 如果是一個普通dom Vnode物件,則呼叫renderElement渲染元素,否則就是一個文字節點,直接用write方法。
 * @param context
 */
function renderNode (node, isRoot, context) {
  if (node.isString) {
    renderStringNode(node, context)
  } else if (isDef(node.componentOptions)) {
    renderComponent(node, isRoot, context)
  } else if (isDef(node.tag)) {
    renderElement(node, isRoot, context)
  } else if (isTrue(node.isComment)) {
    if (isDef(node.asyncFactory)) {
      // async component
      renderAsyncComponent(node, isRoot, context)
    } else {
      context.write(`<!--${node.text}-->`, context.next)
    }
  } else {
    context.write(
      node.raw ? node.text : escape(String(node.text)),
      context.next
    )
  }
}
/**主要功能是把VNode物件渲染成dom元素。
 * 先判斷是不是根元素,然後渲染開始開始標籤,如果是自閉合標籤<img/>直接寫入write,再執行next方法 
 * 如果沒有子元素,又不是閉合標籤,通過write寫入開始-閉合標籤。再執行next.dom渲染完畢
 * 否則就通過write寫入開始標籤,接著渲染所有的子節點,再通過write寫入閉合標籤,最後執行next
 * @param context
 */
function renderElement (el, isRoot, context) {
  const { write, next } = context

  if (isTrue(isRoot)) {
    if (!el.data) el.data = {}
    if (!el.data.attrs) el.data.attrs = {}
    el.data.attrs[SSR_ATTR] = `true`
  }

  if (el.functionalOptions) {
    registerComponentForCache(el.functionalOptions, write)
  }

  const startTag = renderStartingTag(el, context)
  const endTag = `</${el.tag}>`
  if (context.isUnaryTag(el.tag)) {
    write(startTag, next)
  } else if (isUndef(el.children) || el.children.length === 0) {
    write(startTag + endTag, next)
  } else {
    const children: Array<VNode> = el.children
    context.renderStates.push({
      type: `Element`,
      rendered: 0,
      total: children.length,
      endTag, children
    })
    write(startTag, next)
  }
}

流式服務端渲染

普通伺服器有一個痛點——由於渲染是同步過程,所以如果這個app很複雜的話,可能會阻塞伺服器的event loop,同步伺服器在優化不當時甚至會給客戶端獲得內容的速度帶來負面影響。vue提供了renderToStream介面,在渲染元件時返回一個可讀的stream,可以直接pipeHTTP Response中,流式渲染能確保在服務端響應度,也能讓使用者更快地獲得渲染內容。renderToStream原始碼:

benchmarks/ssr/renderToStream.js

const Vue = require(`../../dist/vue.runtime.common.js`)
const createRenderer = require(`../../packages/vue-server-renderer`).createRenderer
const renderToStream = createRenderer().renderToStream
const gridComponent = require(`./common.js`)

console.log(`--- renderToStream --- `)
const self = (global || root)
const s = self.performance.now()

const stream = renderToStream(new Vue(gridComponent))
let str = ``
let first
let complete
stream.once(`data`, () => {
  first = self.performance.now() - s
})
stream.on(`data`, chunk => {
  str += chunk
})
stream.on(`end`, () => {
  complete = self.performance.now() - s
  console.log(`first chunk: ${first.toFixed(2)}ms`)
  console.log(`complete: ${complete.toFixed(2)}ms`)
  console.log()
})

這段程式碼也是同樣執行在node環境中的,與rendetToString不同,它會把vue例項渲染成一個可讀的stream。原始碼演示的是監聽資料的讀取,並記錄讀取資料的時間
,而在實際應用中,我們也可以這樣寫:

const Vue = require(`../../dist/vue.runtime.common.js`)
const createRenderer = require(`../../packages/vue-server-renderer`).createRenderer
const renderToStream = createRenderer().renderToStream
const gridComponent = require(`./common.js`)

const stream = renderToStream(new Vue(gridComponent))
app.use((req,res)=>{
    stream.pipe(res)
})

如果程式碼執行在Express框架中,則可以通過app.use方法建立middleware,然後直接把stream piperes中,這樣客戶端就能很快地獲得渲染內容了,下面看下renderToStream的實現:

src/server/create-renderer.js

  const render = createRenderFunction(modules, directives, isUnaryTag, cache)
  
  return {
    ...

    renderToStream (component: Component,context?: Object): stream$Readable {
        if (context) {
            templateRenderer.bindRenderFns(context)
        }
        const renderStream = new RenderStream((write, done) => {
            render(component, write, context, done)
        })
        if (!template) {
            return renderStream
        } else {
            const templateStream = templateRenderer.createStream(context)
            renderStream.on(`error`, err => {
                templateStream.emit(`error`, err)
            })
            renderStream.pipe(templateStream)
            return templateStream
        }
   }

renderToStream傳入一個Vue物件例項,返回的是一個RenderStream物件的例項,我們來看下RenderStream物件的實現:

src/server/create-stream.js

// 繼承了node的可讀流stream.Readable;必須提供一個_read方法從底層資源抓取資料。通過Push(chunk)呼叫_read。向佇列插入資料,push(null)結束
export default class RenderStream extends stream.Readable {
  buffer: string; // 緩衝區字串
  render: (write: Function, done: Function) => void; // 儲存傳入的render方法,最後分別定義了write和end方法
  expectedSize: number;  // 讀取佇列中插入內容的大小
  write: Function;
  next: Function;
  end: Function;
  done: boolean;

  constructor (render: Function) {
    super() // super呼叫父類的建構函式
    this.buffer = ``
    this.render = render
    this.expectedSize = 0
    
    // 首先把text拼接到buffer緩衝區,然後判斷buffer.length,如果大於expecteSize,用this.text儲存                
    //text,同時呼叫this.pushBySize把緩衝區內容推入讀取佇列中。
    this.write = createWriteFunction((text, next) => {
      const n = this.expectedSize
      this.buffer += text
      if (this.buffer.length >= n) {
        this.next = next
        this.pushBySize(n)
        return true // we will decide when to call next
      }
      return false
    }, err => {
      this.emit(`error`, err)
    })

     // 渲染完成後;我們應該把最後一個緩衝區推掉.
    this.end = () => {
      this.done = true // 標誌元件的渲染已經完畢,然後呼叫push將緩衝區剩餘內容推入讀取佇列中
      this.push(this.buffer) //把緩衝區剩餘內容推入讀取佇列中
    }
  }

  //擷取buffer緩衝區前n個長度的資料,推入到讀取佇列中,同時更新buffer緩衝區,刪除前n條資料
  pushBySize (n: number) {
    const bufferToPush = this.buffer.substring(0, n)
    this.buffer = this.buffer.substring(n)
    this.push(bufferToPush)
  }

  tryRender () {
    try {
      this.render(this.write, this.end) // 開始渲染元件,在初始化RenderStream方法時傳入。
    } catch (e) {
      this.emit(`error`, e)
    }
  }

  tryNext () {
    try {
      this.next() // 繼續渲染元件
    } catch (e) {
      this.emit(`error`, e)
    }
  }

  _read (n: number) {
    this.expectedSize = n
    // 可能最後一個塊增加了緩衝區到大於2 n,這意味著我們需要通過多次讀取呼叫來消耗它
    // down to < n.
    if (isTrue(this.done)) { // 如果為true,則表示渲染完畢;
      this.push(null) //觸發結束訊號
      return
    }
    if (this.buffer.length >= n) { // 緩衝區字串長度足夠,把緩衝區內容推入讀取佇列。
      this.pushBySize(n)
      return
    }
    if (isUndef(this.next)) {
         this.tryRender() //false,開始渲染元件
    } else {
         this.tryNext() //繼續渲染元件
    }
  }
}

回顧一下,首先呼叫renderToStream(new Vue(option))建立好stream物件後,通過stream.pipe()方法把資料傳送到一個WritableStream中,會觸發RenderToStream內部_read方法的呼叫,不斷把渲染的元件推入讀取佇列中,這個WritableStream就可以不斷地讀取到元件的資料,然後輸出,這樣就實現了流式服務端渲染技術。

相關文章