vue:服务端渲染技术

Waxiangyu
服务端渲染:

简单说:比如说一个模板,数据是从后台获取的,如果用客户端渲染那么浏览器会先渲染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就可以不断地读取到组件的数据,然后输出,这样就实现了流式服务端渲染技术。

阅读 4.8k
628 声望
28 粉丝
0 条评论
628 声望
28 粉丝
文章目录
宣传栏