10

前言

  首先欢迎大家关注我的Github博客,也算是对我的一点鼓励,毕竟写东西没法获得变现,能坚持下去也是靠的是自己的热情和大家的鼓励。
  

同构(服务器渲染)

  Vue同构也就是我们常说的服务器渲染(Server Side Render),服务器渲染放在今天已经算不上是一个新鲜的东西了,从React到Vue都有各自的服务器渲染方案,很多小伙伴可能都有所接触,首先我们要了解一下为什么需要服务器渲染呢?Vue和React这类框架有一个特点,都属于浏览器渲染,比如一个最简单的例子:
  

<div id="app">
  {{ message }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

  我们可以看到,我们收到服务器的模板中其实并没有我们所期待界面对应的html结构,而仅有一个用于挂载应用的根元素,在客户端浏览器执行加载的JavaScript代码时,才会创建对应的DOM结构。然而浏览器渲染其实存在两个明显的缺点:

  • 对搜索引擎优化(SEO:Search Engine Optimization)不友好,各个搜索引擎实际上都是对网页的html结构和同步Javascript代码进行索引,因而客户端渲染可能会造成你的网页无法被搜索引擎正确索引。
  • TTC(内容到达时间:Time-To-Conten)过长,试想如果设备的网络较差或者设备的代码执行速度较慢,用户需要等待较长的时间才能看到页面的内容,等待期间看到的都是网页的白屏或者其他的加载状态,这绝对是糟糕的用户体验。

  幸运的是,Node的到来为这一切带来了曙光,JavaScript不仅仅可以在浏览器中执行,而且也可能在后端环境中执行。因此我们可以将用户的界面在服务器中渲染成HTML 字符串,然后再传给浏览器,这样用户获得的就是可预览的界面,最后将静态标记"混合"为客户端上完全交互的应用程序,整个渲染的过程就结束了。

最简单的例子

  Vue服务器渲染使用官方提供的库vue-server-renderer,由于Express比较直观,我们采用Express作为后端服务器,我们首先给出一个最简单的例子:

// server.js
const Vue = require('vue')
const server = require('express')()
// 创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()

server.get('*', (req, res) => {
  // 创建一个 Vue 实例
  const app = new Vue({
    data: {
      url: req.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    // html就是Vue实例app渲染的html
    res.end(`
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `)
  })
})

server.listen(8080)

  然后启动node server.js,并且浏览器中访问比如http://localhost:8080/app,浏览器界面中则会显示出:

访问的 URL 是:/app

  这时候观察该请求的返回值是:

  我们发现返回的html中已经渲染好DOM元素。因此我们无需等待立即可以看见页面的内容。而上面的代码逻辑也非常简单,http服务器接收到get请求的时候,都会创建一个Vue实例,vue-server-renderer中的createRenderer用来创建一个Renderer实例,Renderer中的renderToString用来将Vue实例转化对应的HTML字符串,需要注意的是,我们需要将创建好的字符串包裹在html一并返回。当然你可以采用页面模板的形式,将两者相分离:

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
    <!--这里将是应用程序 HTML 标记注入的地方>
  </body>
</html>
// renderer中包含了模板
const renderer = createRenderer({
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

renderer.renderToString(app, (err, html) => {
  res.end(html)
})

  当然这只是最简单的一个例子,浏览器收到的仅仅是对应Vue实例的html代码,并没有将其激活,因此是不可交互的。

浏览器渲染的流程

  对于浏览器渲染中,我们首选Webpack对代码进行打包,整体流程可以通过下面图来释义:

  对于一个Vue应用,源码层面其实主要包括三个方面: 组件、路由、状态管理。这部分代码我们认为是通用代码,可以同时在服务器端和浏览器端执行,Webpack有两个入口: server entryclient entry,分别用来打包在服务器端执行的代码与在浏览器端执行的代码。Server Bundle作为打包在服务器端执行的代码,负责的生成对应的HTML,而Clinet Bundle作为执行在浏览器端的代码,主要负责的就是激活应用。

下面我们给出对应的webpack配置,为了方便上手我们就仅仅只列出最简单的配置,让我们能将代码跑起来,配置包括三个部分: baseclientserver,其中base是二者间能通用的部分,client则是对应浏览器的打包配置,server是服务器端的打包配置,通过webpack-merge(可以简单理解成 Object.assign)将其连接:

// webpack.base.config.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
    output: {
        path: path.resolve(__dirname, '../dist'),
        publicPath: '/dist/',
        filename: '[name].[chunkhash].js'
    },
    module: {
        rules: [
            {
                test: /\.vue$/,
                loader: 'vue-loader'
            },
            {
                test: /\.js$/,
                loader: 'babel-loader',
                exclude: /node_modules/
            }
        ]
    },
    plugins: [
        new VueLoaderPlugin()
    ]
}

  上面是一个最简单的webpack中通用的配置,规定了三部分:

  • output: 打包文件怎样存储输出结果以及存储到哪里
  • module: 我们对js文件和vue文件执行相应的loader
  • plugins: VueLoaderPlugin插件是必须的,作用是将你定义过的其它规则复制并应用到 .vue 文件里相应语言的块。比如vue文件中script标签对应的JavaScript代码和stype标签对应的css代码。
// webpack.server.config.js
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(base, {
    target: 'node',
    entry: './src/entry-server.js',
    output: {
        libraryTarget: 'commonjs2'
    },
    plugins: [
        new VueSSRServerPlugin()
    ]
})

  上面的配置用来打包服务器架bundle:

  • target: 用来指示构建目标,node表示webpack会编译为用于类 Node.js环境
  • entry: 服务器打包入口文件
  • libraryTarget: 因为是用于Node环境,因此我们选择commonjs2
  • VueSSRServerPlugin: 用来打包生成的服务器端的bundle,最终可以将所有文件打包成一个json文件,最终传给服务器renderer使用。
// webpack.client.config.js
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(base, {
    entry: {
        app: './src/entry-client.js'
    },
    plugins: [
        // extract vendor chunks for better caching
        new webpack.optimize.CommonsChunkPlugin({
            name: 'vendor',
            minChunks: function (module) {
                // a module is extracted into the vendor chunk if...
                return (
                    // it's inside node_modules
                    /node_modules/.test(module.context)
                )
            }
        }),
        // extract webpack runtime & manifest to avoid vendor chunk hash changing
        // on every build.
        new webpack.optimize.CommonsChunkPlugin({
            name: 'manifest'
        }),
        new VueSSRClientPlugin()
    ]
})
  • entry: 浏览器打包入口文件。
  • VueSSRClientPlugin:类似于VueSSRServerPlugin插件,主要的作用就是将前端的代码打包成bundle.json,然后传值给renderer,可以自动推断和注入preload/prefetch指令和script标签到渲染的HTML中。

  关于CommonsChunkPlugin插件,其实对于一个最简单的应用而言是可以没有的,但是因为其有助于性能提升还是加了进来。在最开始学习Webpack的时候,每次打包的时候都会将所有的代码打包到同一个文件,比如app.[hash].js中,其实在app.[hash].js中包含两部分代码,一部分是每次都在变化的业务逻辑代码,另一部分是几乎不会变化的类库代码(例如Vue的源码)。现在这种情况其实很不利于浏览器的缓存,因为每次业务代码改变后,app.[hash].js一定会发生改变,因此浏览器不得不重新请求,而app.[hash].js的代码量可能都是数以兆计的。因此我们可以将业务代码和类库代码相分离,在上面的例子中:

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: function (module) {
    // a module is extracted into the vendor chunk if...
        return (
        // it's inside node_modules
        /node_modules/.test(module.context)
        )
    }
}),

  我们将引用的node_modules中的代码打包成vendor.[hash].js,其中就包含了引用的类库,这是代码中相对不变的部分。但是如果仅仅只有上面的部分的话,你会发现每次逻辑代码改变后,vendor.[hash].jshash值也会发生改变,这是为什么呢?因为Webpack每次打包运行的时候,仍然是会产生一些和Webpack当前运行相关的代码,会影响到运行的打包值,因此vendor.[hash].js每次打包仍然是会发生改变,这时候其实浏览器并不能正确的缓存。因此我们使用:

new webpack.optimize.CommonsChunkPlugin({
    name: 'manifest'
})

我们需要将运行环境提取到一个单独的manifest文件中,这样vendorhash就不会变了,浏览器就可以将vendor正确缓存,mainfesthash虽然每次在变,但很小,比起vendor变化带来的影响可以忽略不计。

我们之前讲过,Vue的应用其实可以划分成三个部分: 组件、路由、状态管理,作为SSR系列的第一篇上手文章,我们仅介绍如何在服务端渲染一个简单组件并在客户端激活该组件,使得其可交互。路由和状态管理等其他部分会在后序部分介绍。

组件

  首先我们用Vue写一个最简单的可计数的组件,点击"+"可以增加计数,点击"-"可以减少计数。

// App.vue
<template>
    <div id="app">
        <span>times: {{times}}</span>
        <button @click="add">+</button>
        <button @click="sub">-</button>
    </div>
</template>

<script>
    export default {
        name: "app",
        data: function () {
            return {
                times: 0
            }
        },
        methods: {
            add: function () {
                this.times = this.times + 1;
            },
            sub: function () {
                this.times = this.times - 1;
            }
        }
    }
</script>
<style scoped>
</style>

  上面的部分是一个非常简单的Vue组件,也是服务端和客户端渲染的通用代码。在单纯的客户端渲染的程序中,会存在一个app.js用来创建一个Vue实例并将其挂载到对应的dom上,例如:

// 客户端渲染 app.js
import App from './App.vue'

new Vue({
  el: '#app',
  components: { App },
  template: '<App/>',
})

  在服务器渲染中,app.js仅会对外暴露一个工厂函数,用来每次都调用的都会返回一个新的组件实例用于渲染。具体的其他逻辑都被各自转移到客户端和浏览器端的入口文件中。

import Vue from 'vue'
import App from './components/App.vue'

export function createApp() {
    return new Vue({
        render: h => h(App)
    })
}

  不同于客户端渲染,值得注意的是我们需要为每一次请求都创建一个新的Vue实例,而不能共享同一个实例,因为如果我们在多个请求之间使用一个共享的实例,可能会在各自的请求中造成状态的污染,所以我们为每一次请求都创建独立的的组件实例。

  接下来看浏览器端打包入口文件:

// entry-server.js
import { createApp } from './app'

export default context => {
    const app = createApp()
    return app
}

  entry-server.js对外提供一个函数,用于创建当前的组件实例。接着看客户端打包入口文件:

// client-server.js
import { createApp } from './app'

var app = createApp();

app.$mount('#app')

  逻辑也是非常的简单,我们创建一个Vue实例,用将其挂载到idapp的DOM结构中。

  这时候我们运行命令分别打包客户端和服务器端的代码,我们发现dist,目录下分别出现以下文件:

  我们可以看到app.[hash].js是打包的业务代码,vendor.[hash].js则是相应的库的代码(比如Vue源码),manifest.[hash].js则是CommonsChunkPlugin生成manifest文件。而vue-ssr-client-manifest.json则是VueSSRClientPlugin生成的对应客户端的bundle,而vue-ssr-server-bundle.json则是VueSSRServerPlugin插件生成的服务器端的bundle。有了上面的打包文件,我们就可以处理请求:

//server.js
const fs = require("fs")
const express = require("express")
const { createBundleRenderer } = require('vue-server-renderer')

const template = fs.readFileSync("./src/index.template.html", "utf-8")
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')


const app = express();
app.use("/dist", express.static("dist"))

const renderer = createBundleRenderer(bundle, {
    template,
    clientManifest
})


app.get('*', (req, res) => {
    renderer.renderToString({}, function (err, html) {
        res.end(html);
    });
})

app.listen(8080, function () {
    console.log("server start and listen port 8080")
})

  这次我们并没有使用一开始介绍的vue-server-renderer中的createRenderer函数,而是使用的createBundleRenderer函数,我们在server.js中分别引入了server-bundle.jsonclient-manifest.json与模板template.html,然后将其传给createBundleRenderer函数生成renderer,然后在每一次请求中,调用的rendererrenderToString方法,生成对应的html,然后返回客户端。renderToString的第一个参数实质是上下文context对象,一方面context用于处理模板文件,比如模板文件中存在

<title>{{title}}</title>

  而context中存在title: 'SSR',模板中的文件则会被插值。另一部分,客户端的入口文件server-entry.js的中函数也会收到该context,可用于传递相关的参数。

  我们之所以会使用express.staticdist文件夹下面的文件提供静态的资源服务的原因是客户端的代码中会注入相应的JavaScript文件(比如app.[hash].js),这样才能保证对应的资源可以被请求到。

  然后我们运行命令:

node server.js

  并在浏览器中访问http://localhost:8080。你就会发现一个简单的计数器的程序已经运行,并且是可运行,点击按钮会触发相应的事件。

  这是的对应接受的html结构为:

  我们发现返回的html代码中就有我们Vue实例对应的DOM结构,与普通的客户端结构不同的,根元素中存在data-server-rendered属性,表示该结构是由服务端对应渲染的节点,在开发模式中,Vue将渲染的虚拟DOM与当前的DOM结构相比较,如果相等的时候,则会复用当前结构,否则会放弃已经渲染好的结构,转而重新在客户端渲染。在生产模式下,则会略过检测的步骤,直接复用,避免浪费性能。

  在服务器渲染中,一个组件仅仅会经历beforeCreatecreated两个生命周期,而其余的例如beforeMount等生命周期并不会在服务器端执行,因此应该注意的是避免在beforeCreatecreated 生命周期时产生全局副作用的代码,例如在beforeCreatecreated中使用setInterval设置timer,而在beforeDestroydestroyed生命周期时将其销毁,这会造成timer永远不会被取消。

  至此我们介绍了一个最简单的Vue服务器渲染示例并在客户端对应将其激活,服务器渲染的其他部分比如路由、状态管理等部分我们将在接下来的文章一一介绍,有兴趣的同学记得在我的Github博客中点个Star,如果文章中有不正确的地方,欢迎指出,愿一同进步。


请叫我王磊同学
1.1k 声望169 粉丝

编程新人