vue-ssr实践

597 阅读7分钟

服务端渲染,预渲染,浏览器渲染

用户请求前的服务器渲染即为「预渲染」。

用户请求后的服务器渲染即为「服务端渲染」。
'预渲染': 
    + 预渲染不像服务器渲染那样即时编译HTML,它只在构建时为了特定的路由生成特定的几个静态页面,等于我们可以通过 Webpack 插件将一些特定页面组件 build时就编译为html文件,直接以静态资源的形式输出给搜索引擎。预渲染不执行js的,只适应于纯静态页面
服务端渲染: 解析js + 构建html文件 + 将页面返回浏览器
预渲染: 直接将页面返回给浏览器
    

服务端渲染

服务端渲染: 
    + '优点':
        + 实质时直接返回一个html字符串给浏览器,优化首屏加载时间
        + 有利于seo优化,可以被爬虫爬取到
    + '缺点':
        + 占用更多的CPU和内存
        + 一些常用的浏览器API不可以使用
        + 只在vue的beforeCreate和create两个生命周期生效

服务端渲染初体验

// server.js
const server  = require('express')
const vueServerRender = require('vue-server-renderer')
const app = server()
let Vue = require('vue')
let vm = new Vue({
  template:  `<div>hello</div>`
})
let render = vueServerRender.createRenderer()
app.get('/', (req, res) => {
  render.renderToString(vm, (err, html) => {
    res.end(`
    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Document</title>
    </head>
    <body>
      ${html}
    </body>
    </html>
    `)
  })
})
app.listen(3001)
// node server.js

读取模版

  • 一般我们可以将服务端渲染的数据放在模版中,然后读取模版
// index.html
!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

// server.js
...
const template = fs.readFileSync(path.resolve(__dirname, './index.html'), 'utf-8')
const render = vueServerRender.createRenderer({
  template
})
app.get('/', (req, res) => {
  render.renderToString(vm, (err, html) => {
    res.send(html)
  })
})
app.listen(3001)

依然是ok的

构建客户端

  • 目录结构
// public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ssr</title>
</head>
<body>
    <div id="root"></div>
</body>
</html>

// src/main.js
import Vue from 'vue'
import App from './App.vue'


const vm = new Vue({
  el: '#root',
  render: h => { 
    return h(App)
  }
})

// webpack.config.js配置基础的webpack配置
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
  entry: path.resolve(__dirname, './src/main.js'),
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  // resolve: {
  //   alias: {
  //     'vue$': 'vue/dist/vue.common.js'
  //   }
  // },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          },
        },
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader','css-loader']
      },
      {
        test: /\.vue$/,
        use: ['vue-loader']
      }
    ]
  },
  devServer: {
    open: true
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, 'public/index.html'),
    })
  ]
}

// package.json
...
"scripts": {
"build": "webpack",
"client:dev": "webpack-dev-server"
}

执行npm run client:dev,然后打开localhost:8080

至此,一个简单的客户端构建完毕

拆分功能,构建server.bundle.js和client.bundle.js

  • 为了兼容服务端,要将main.js编程函数形式,每次实例化,得到一个新的配置
// 'main.js'
import Vue from 'vue'
import App from './App.vue'
返回一个工厂函数,专门生产vue的实例
export default () => {
  const app = new Vue({ // 创建一个实例函数
    render: h => { 
      return h(App)
    }
  })
  return { app }
}

// 'client-entry.js'
// 客户端入口
import createApp from './main'
const { app } = createApp()
// 客户端需要挂载
console.log(app, 'app')
app.$mount('#root')

// 'server-entry.js'
// 服务器端入口
import createApp from './main'
// 服务端没有挂载,因此返回一个函数,服务端每次调用都产生一个新的app实例
export default () => {
  const { app } = createApp()
  return app
}


根据server.entry以及client.entry配合webpack打包出两种bundle,
// 'webpack配置'
// webpack.base.config.js

const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, '../dist')
  },
  // resolve: {
  //   alias: {
  //     'vue$': 'vue/dist/vue.common.js'
  //   }
  // },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          },
        },
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader','css-loader']
      },
      {
        test: /\.vue$/,
        use: ['vue-loader']
      }
    ]
  },
  devServer: {
    open: true
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}


// webpack.client.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpackMerge = require('webpack-merge')
const base = require('./webpack.base.config')
module.exports = webpackMerge(base, {
  mode: 'development',
  entry: {client: path.resolve(__dirname, '../src/client-entry.js')},
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, '../public/index.html'),
    })
  ]
})

// webpack.server.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpackMerge = require('webpack-merge')
const base = require('./webpack.base.config')
module.exports = webpackMerge(base, {
  mode: 'development',
  entry: {
    server: path.resolve(__dirname, '../src/server-entry.js'),
  },
  target: 'node', //打包给node用
  output: {
    libraryTarget: 'commonjs2' // 使用commonjs导出,变成module.exports,而不是一个闭包函数
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 只做一个拷贝
      filename: 'index.ssr.html',
      template: path.resolve(__dirname, '../public/index.ssr.html'),
      excludeChunks: ['server'] // 不需要引入server.bundle.js
    })
  ]
})

// package.json
  "scripts": {
    "client:build": "webpack --config ./config/webpack.client.config.js",
    "client:dev": "webpack-dev-server --config ./config/webpack.client.config.js",
    "server:build": "webpack --config ./config/webpack.server.config.js",
    "server:dev": "webpack-dev-server --config ./config/webpack.server.config.js"
  },

分别运行npm run client:build以及npm run server:build进行打包

启动node服务

至此,我们已经进行bundle阶段,接下来我们需要启动一个node服务。将server.bundle使用bundleRenderer方法显然成字符串插入到html模版中去

// node-server.js
const server  = require('express')
const vueServerRender = require('vue-server-renderer')
const fs = require('fs')
const path = require('path')
const app = server()
// 拿到打包后的server.bundle结合index.ssr.html,使用bundleRender生成渲成html字符串,返回给浏览器
const serverBundle = fs.readFileSync(path.resolve(__dirname, './dist/server.bundle.js'), 'utf8') // 拿到server.bundle打包后的结果
const template = fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'),'utf8')
const render = vueServerRender.createBundleRenderer(serverBundle, {
  template
})// 生成渲染函数
app.get('/', (req, res) => {
  // 将html字符串返回给浏览器
  render.renderToString((err, html) => {
    res.send(html)
  })
})
app.listen(3001)

nodemon node-server.js,打开localhost:3001,页面基本渲染出来,但是无交互因为我们反会给客户端的仅仅是一个字符串,因此我们需要客户端激活

客户端激活

打包出的client.bundle.js会给app注入一些功能

  • 将打包的client.bundle.js在index.ssr.html引入,并激活客户端
// index.ssr.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!--vue-ssr-outlet-->
  <script src="client.bundle.js"></script>
</body>
</html>

// node-sever.js
使用静态中间件将dist目录设置为静态目录,默认去这个文件下去找
...省略
    app.use(server.static(path.resolve(__dirname, 'dist')))
...

// App.vue//并激活客户端,由于服务端渲染的html字符串没有root这个id,因此我们在App.vue中强制添加一个root
<template>
  <div id="root"> // 在App.vue的根添加root
    <Bar />
    <Foo />
  </div>
</template>

此时服务端返回的html页面即可以渲染又赋予了行为,完成了一个初步的服务端渲染,总结,通过服务端和客户端的入口文件打包出对应的bundle,服务端利用serverRender将server的bundle与对应的模版组成字符串返回给浏览器,在模版中注入client的bundle,为模版赋予行为,并且激活客户端。

添加router

// router.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Bar from './components/Bar.vue'
Vue.use(VueRouter)

export default () => {
  const router = new VueRouter({ // 创建一个实例函数
    mode: 'history',
    routes: [{
      path: '/',
      component: Bar
    }, {
      path: '/foo',
      component: () => import('./components/Foo.vue')
    }]
  })
  return router
}

// main.js
import Vue from 'vue'
import App from './App.vue'
import createRouter from './router.js'
export default () => {
  let router = createRouter()
  const app = new Vue({ // 创建一个实例函数
    router,
    render: h => { 
      return h(App)
    }
  })
  return { app, router }
}

// 客户端绑定在vue实例上,可以直接使用,但服务端不可以
//  在render.renderToString方法中可以传一个上下文,在server.entry.js返回的函数中可以获得这个上下文,从而获得路径
// 'server.entry.js'
// 服务器端入口
import createApp from './main'
// 服务端没有挂载,因此返回一个函数,服务端每次调用都产生一个新的app实例
export default (context) => {
  // 服务器端,返回的永远都是index.html,但路由需要跳转到指定路径上,匹配到对应的组件
  
  return new Promise((reslove, reject) => {
    const { app, router } = createApp()
    router.push(context.url)
    router.onReady(() => {
      // 有异步加载组件,因此需要等组件加载完成再返回,否则返回一个空页面。
      reslove(app)
    })
  })
}

// node-server.js
... 省略
app.get('/', (req, res) => {
  // 将html字符串返回给浏览器
  let context = {
    url: req.url
  }
  render.renderToString(context, (err, html) => {
    res.send(html) // 这个context就可以在server.entry.js中取到
  })
})
...
+ 总结: 添加vuerouter,给从服务端传入url,然后在server-entry.js通过router实例push当前的url

添加vuex

// 'main.js'
...
import createStore from './store.js'


export default () => {
    ...
  const store = createStore()
  const app = new Vue({ // 创建一个实例函数
    router,
    store,
    render: h => { 
      return h(App)
    }
  })
  return { app, router, store }
}




// 'store.js'
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default () => {
  const store = new Vuex.Store({ // 创建一个实例函数
    state: {
      name: 'xx'
    },
    mutations: {
      set_name(state) {
        state.name = 'l'
      }
    },
    actions: {
      set_name({commit}) {
        return new Promise((reslove, reject) => {
          setTimeout(() => {
            commit('set_name')
            reslove()
          }, 1000)
        })
      }
    }
  })
  // store对象在服务端和浏览器端都会被访问,因此我们需要当改变服务端的时候,同步浏览器端
  if(typeof window !== 'undefined' && window.__INITIAL_STAT) {
    store.state = window.__INITIAL_STAT
  }
  return store
}


// server-entry.js
...
export default (context) => {
  
  return new Promise((reslove, reject) => {
    const { app, router, store } = createApp()
    ...

    router.onReady(() => {
      // 有异步加载组件,因此需要等组件加载完成再返回,否则返回一个空页面。
      const components = router.getMatchedComponents()
      Promise.all(components.map(component => {
        // 拿到他的asyncData属性,只在服务器端执行
        if (component.asyncData) {
          return component.asyncData({store}) // 返回一个promise
        }
      })).then(() => {
        context.state = store.state // 会自动在window上挂载一个属性
        reslove(app)
      })
    })
  })
}

// 'Foo.vue'
<template>
  <div>
    组件A
    {{$store.state.name}}
  </div>
</template>

<script>
  export default {
    asyncData({store}) {
    // 服务端执行
      return store.dispatch('set_name')
    },
    created() {
      // 服务端和客户端都会执行
      this.$store.dispatch('set_name')
    }
  }
</script>
  • 匹配到所有的components,让组件的asyncData依次执行,asyncData执行后的结果返回的是一个promise
  • context.state = store.state,会自动在window上挂在一个window.INITIAL_STATE={"name":"xx"}
  • 总结: 组件的asyncData只会在页面级,并且只在服务端渲染,因此在server-entry.js匹配到对应的组件,执行asyncData函数,将store传入,这样在页面的asyncData就可以取到store,并且将store挂在到全局的context中,这样就会在window生成window.INITIAL_STATE,在服务端改变的时候,同时将客户端的store.state改变

vue-meta插件

vue-meta

// router.js
...
    import VueMeta from 'vue-meta'
    Vue.use(VueMeta, {
      // optional pluginOptions
      refreshOnceOnNavigation: true
    })
...

// App.vue
...
    <script>
      import Bar from './components/Bar.vue'
      import Foo from './components/Foo.vue'
      export default {
        metaInfo: {
          title: 'myApp',
        },
        components: {
          Bar,
          Foo
        },
        data() {
          return {
    
          }
        }
      }
...

// server-entry.js
import createApp from './main'
export default (context) => {
  
  return new Promise((reslove, reject) => {
    const { app, router, store } = createApp()
    router.push(context.url)

    router.onReady(() => {
        ...
      })).then(() => {
        ...
        context.meta = app.$meta() 
        reslove(app)
      })
    })
  })
}

</script>

采取json方式

由于打包出的js经常变动,因此我们希望打包成json,这个json是不会变的

// webpack-server-config.js
const ServerPlugin = require('vue-server-renderer/server-plugin') // 会打包出一个json
module.exports = webpackMerge(base, {
...
  plugins: [
    new ServerPlugin(),
    ...
  ]
})

// webpack-client-config.js
const ClientPlugin = require('vue-server-renderer/client-plugin') // 会打包出一个json
module.exports = webpackMerge(base, {
...
  plugins: [
    new ClientPlugin(),
    ...
  ]
})


// node-server.js
...
    const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 拿到server.bundle打包后的结果
    const template = fs.readFileSync(path.resolve(__dirname, './dist/index.ssr.html'),'utf8')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    const render = vueServerRender.createBundleRenderer(serverBundle, {
      template,
      clientManifest
    })
...

这样修改文件,只需要重新打包,不需要重启node服务

代码