初识Vue_ssr 基础的服务端渲染

资源

https://ssr.vuejs.org

当前开发版本

    "vue": "^2.6.11",
    "vue-router": "^3.3.4",
    "vue-server-renderer": "^2.6.11",
    "vuex": "^3.4.0"

概念

服务端渲染:将vue实例渲染为HTML字符串直接返回,在前端激活为交互程序,老得;SSH只能返回HTML字符串,并无法激活。

接下来 采用同构(同vue)的方式来进行开发,实现服务端渲染。

目前服务端渲染的方式

传统web开发:
传统web开发,网⻚内容直接在服务端渲染完成,一次性传输到浏览器。从数据库直接拿到html代码;

缺点是:服务器响应时间长;带宽消耗,负荷比较大。这种就是所见即所得。

单⻚应用 Single Page App:(spa)
单⻚应用优秀的用户体验,使其逐渐成为主流,⻚面内容由JS渲染出来,这种方式称为客户端渲染。
给前端返回的是html的结构,但是没有内容。内容由前端的库,vue or react渲染html;再发送ajax请求,请求数据获取data中的数据来渲染。

缺点:不利于SEO,不利于搜索引擎。如果数据没有返回,首屏加载速度会慢

服务端渲染 Server Side Render
SSR解决方案,后端渲染出完整的首屏的dom结构返回,还是用vue or react模板去开发的。前端拿到的内容包括首屏(html结构)及完整spa结构(路由....),在前端做路由的跳转;应用激活后依然按照spa方式运行,这种⻚面渲染方式被称为服务端渲染 (server side render)

使用SSR的优点

  • seo:搜索引擎优化
  • 首屏渲染内容到达时间快一次请求一次响应;
如果是一个spa 一个请求 一次响应,打开请求之后还需要ajax请求数据,速度就没有那么快。

express

npm i express -S
基础http服务 代码演示:
// nodejs 代码
const express = require('express')

// 这里是获取express的实例,
// 可以从源码中看到:
//源码路径:/node_modules/@types/express/index.d.ts
// declare function e(): core.Express; 最后导出了 export = e;  所有执行这个函数就可以得到实例
const server = express()

// 需要做路由处理,否则打开http://localhost:3000/ 端口会报错。
// 编写路由做不同的url处理
// req 请求
// res 响应
server.get('/', (req,res)=>{
  res.send('3000') //浏览器会认为返回的是 html
})
// 监听端口
server.listen(3000, ()=>{
  console.log('执行了')
})
进入到当前目录下,执行文件,如 node 1-express-start.js;
显示 执行了 就说明 代码没有问题
直接访问端口也是一样的,localhost:300

node 文件进行修改,每次都需要node运行一下,可以安装nodemon;就可以实时更新 npm install -g nodemon; 在启动node 的时候可以用 nodemon 1-express-start.js

基础的服务端渲染

使用服务器将vue实例成HTMLHTML字符串并返回

vue服务器渲染器: vue-server-renderer**

npm i vue-server-renderer -S 或者 同时安装vue npm i vue vue-server-renderer -S;确保版本相同
WX20200615-142832@2x.png

  • 安装好之后 ,测试下
首先创建文件
分为三个步骤
 1. 创建vue实例
 2. 获取渲染器实例
 3. 用渲染器来渲染vue实例
 // 创建vue实例
   const Vue = require ('vue')
   const app = new Vue({
      template:'<div>Hello</div>'
   })

 // 获取渲染器实例
   const {createRenderer} = require('vue-server-renderer') // 获取到工厂函数
   const renderer =createRenderer() // 就可以得到一个渲染器

 // 用渲染器来渲染vue实例
  // 返回的是promise,需要.then
 renderer.renderToString(app)
 .then((html)=>{
  console.log(html)
 })
 .catch((err)=>{
  console.log(err);
  
})
结果展示:
WX20200614-230858@2x.png

data-server-rendered 服务端渲染的

现在需要做的是如果用户刷新展示出来,结合express
在刚刚的express的get中,将上面vue编写的代码进行返回即可
// nodejs 代码
const express = require('express')

const server = express()
// 创建vue实例
const Vue = require ('vue')
// 获取渲染器实例
const {createRenderer} = require('vue-server-renderer') // 获取到工厂函数
// 用渲染器来渲染vue实例
const renderer =createRenderer() // 就可以得到一个渲染器
server.get('/', (req,res)=>{
  // 每次用户刷新 都渲染出一个全新的vue出来
  const app = new Vue({
    template:'<div>Hello~~~~哇哦~~~</div>'
  })
  // 返回的是promise,需要.then
  renderer.renderToString(app)
  .then((html)=>{
    // 直接把结果返回给浏览器
    res.send(html)
  })
  .catch(()=>{
    // 错误时 返回状态吗500
    res.status(500)
    res.send('Internal Server Error, 500')
  })
})
// 监听端口
server.listen(3000, ()=>{
  console.log('执行了')
})

WX20200614-232519@2x.png

WX20200614-232747@2x.png

修改一下,使用数据展示
  const app = new Vue({
    template:'<div>{{context}}</div>',
    data(){
      return {
        context:'vvvvvue'
      }
    }

WX20200614-233104@2x.png

如何实现交互呢?
如果直接在服务端写,是否能实现呢?
    template:'<div @click="onClick">{{context}}</div>',
    data(){
      return {
        context:'vue-ssr'
      }
    },
    methods: {
      onClick(){
        console.log('可以点击吗')
      }
    }
页面:
WX20200614-233747@2x.png
没有绑定成功,原因是:
已经转化成字符串再发送到前端,是不可能的。所以需要激活过程

解决路由、同构问题

路由

npm i vue-router -s
在没有SSR的情况下,是返回一个单例的Router实例,
WX20200615-150049@2x.png

在服务端渲染的情况下,为了避免Router污染的问题,每次请求都返回一个全新的Router。

//作为一个工厂函数,每次用户请求返回一个新的router实例
export default function createRouter(){
 return new VueRouter({
   mode: 'history',
   base: process.env.BASE_URL,
   routes
 })
}

如何让服务端渲染的路由,拿到前端来用?
需要先理解构建流程:
WX20200615-151509@2x.png

还是需要用webpack进行打包;
服务端的入口有两个:Server entryClient enrty
——————————会生成两个包——————————
生成文件: Server Bundle 「服务器 bundle」用于服务端首屏(不是首页,请求的是什么页什么就是首屏)渲染、Client Bundle「客户端bundle」用于客户端激活(生成的js代码附加到html,新建一个vue实例,比如上面测试的点击事件的实现)因为服务端传过来的是字符串。前端需要激活。

代码结构

src  
├── router  
├────── index.js # 路由
├── store  
├────── index.js # 全局状态  
├── main.js # 创建vue实例  
├── entry-client.js # 客户端入口,静态内容“激活” 
└── entry-server.js # 服务端入口,首屏内容渲染
  • main.js 的调整

src/main.js

import Vue from 'vue'
import App from './App.vue'
import createRouter from './router'

Vue.config.productionTip = false

// 每个请求获取一个单独的vue实例
// `调用者是entry-server(首屏渲染) 会传递参数是上下文对象`
export function createApp(context){
  const router = createRouter()
  const app =new Vue({
    router,
    context, // 利用context可以拿到一些参数
    render: h => h(App)
  }).$mount('#app')
  // 导出app实例以及router实例
  return {app, router}
}
  • entry-server.js 首屏渲染

src/entry-server.js

`// 首屏渲染`
`// 在服务端执行的代码`
import {createApp} from './main'

// 创建vue的实例
`// 调用者是renderer`
export default context =>{
  // 为了让renderer 可以等待处理最后的结果,return的应该是一个promiss
  return new Promise((resolve, reject)=>{
    // 创建vue实例和路由实例
    const {app, router} =createApp(context)

    // 需要渲染首屏 就要拿到当前的url 渲染器会拿到当前的url
    // 跳转首屏。
    // url的来源。是从请求中可以拿到。传递给renderer
    router.push(context.url) // 考虑到当前页面会存在ajax请求等异步任务处理。要等异步任务处理完在跳转页面

    // 监听路由器的ready,确异步任务都完成
    router.onReady(()=>{
      //该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。
      //这可以有效确保服务端渲染时服务端和客户端输出的一致。
      resolve(app)
    }, reject) // 作为onReady事件的失败函数处理
  })
}
  • entry-client.js 客户端激活

src/entry-client.js

`// 客户端激活 就是用户端的交互 比如click等
// 在浏览器执行的代码`
import {createApp}~~~~ from './main'
// 创建vue实例
const {app, router}  =createApp()

//等待router就绪
router.onReady(()=>{
  //挂载激活
  app.$mount('#app')
})
entry-server 和 entry-client都用到了 main.js中的createApp 渲染实例必须要得到vue的实例;
webpack打包
  • 安装依赖
npm install webpack-node-externals lodash.merge \-D
  • 创建一个vue.config.js在根目录下
// 两个插件分别负责打包客户端和服务端
const VueSSRServerPlugin = require("vue-server-renderer/server-plugin");
const VueSSRClientPlugin = require("vue-server-renderer/client-plugin");
const nodeExternals = require("webpack-node-externals");
const merge = require("lodash.merge");
// 根据传入环境变量决定入口文件和相应配置项
const TARGET_NODE = process.env.WEBPACK_TARGET === "node";
const target = TARGET_NODE ? "server" : "client";

module.exports = {
  css: {
    extract: false
  },
  outputDir: './dist/'+target, // 输出路径  target看上面判断
  configureWebpack: () => ({ // 输入路径
    // 将 entry 指向应用程序的 server / client 文件
    entry: `./src/entry-${target}.js`, // 入口
    // 对 bundle renderer 提供 source map 支持
    devtool: 'source-map',
    // target设置为node使webpack以Node适用的方式处理动态导入,
    // 并且还会在编译Vue组件时告知`vue-loader`输出面向服务器代码。
    target: TARGET_NODE ? "node" : "web",
    // 是否模拟node全局变量
    node: TARGET_NODE ? undefined : false,
    output: {
      // 此处使用Node风格导出模块
      libraryTarget: TARGET_NODE ? "commonjs2" : undefined
    },
    // https://webpack.js.org/configuration/externals/#function
    // https://github.com/liady/webpack-node-externals
    // 外置化应用程序依赖模块。可以使服务器构建速度更快,并生成较小的打包文件。
    externals: TARGET_NODE
      ? nodeExternals({
          // 不要外置化webpack需要处理的依赖模块。
          // 可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
          // 还应该将修改`global`(例如polyfill)的依赖模块列入白名单
          whitelist: [/\.css$/]
        })
      : undefined,
    optimization: {
      splitChunks: undefined
    },
    // 这是将服务器的整个输出构建为单个 JSON 文件的插件。
    // 服务端默认文件名为 `vue-ssr-server-bundle.json`
    // 客户端默认文件名为 `vue-ssr-client-manifest.json`。
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpack: config => {
    // cli4项目添加
    if (TARGET_NODE) {
        config.optimization.delete('splitChunks')
    }
      
    config.module
      .rule("vue")
      .use("vue-loader")
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        });
      });
  }
};
package.json 进行调整
  • 安装依赖:
npm i cross-env \-D
  "scripts": {
    "build": "npm run build:server & npm run build:client",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build"
  },
执行 npm run build 进行打包

WX20200615-173939@2x.png

  • dist文件

WX20200615-174131@2x.png

  • index.html 宿主文件

public/index.html

注释的格式是约定好的,不要加空格

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
  </head>
  <body>
      <!--vue-ssr-outlet-->
  </body>
</html>
  • 将server中的node文件进行调整

server/4-ssr.js

const express = require('express')
const app = express()

// 静态资源服务
// 把这个路径打开(../dist/client),让用户可以下载文件
const path = require('path')
const resolve = dir => path.resolve(__dirname, dir)
// 相对路径不可靠(../dist/client),需要用绝对路径
app.use(express.static(resolve('../dist/client'),{index: false}))//指定根目录,将根目录开发给用户看
//{index: false} 设置的目的是,因为在client 里面有index.html; 所以就不会走下面的代码,就会直接返回client里面的index.html

// 渲染器: bundleRenderer, 它可以获取前面生成的两个json文件
const { createBundleRenderer } = require('vue-server-renderer')
//指向绝对路径
const bundle = resolve('../dist/server/vue-ssr-server-bundle.json')
//得到渲染器可以直接渲染 vue实例
const renderer = createBundleRenderer(bundle, {
  // 选项
  runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext 文档地址
  // 宿主文件
  template: require('fs').readFileSync(resolve("../public/index.html"), "utf-8"), // 宿主文件  utf-8的方式转化成字符串
  clientManifest: require(resolve("../dist/client/vue-ssr-client-manifest.json")) // 客户端清单   优化内容
})

app.get('*',async(req,res)=>{
   const context = {
     url: req.url
    }
  try{
    // 渲染获取html
    // 创建vue实例 创建首屏 渲染出来 现在是个静态的不能交互
    const html = await renderer.renderToString(context)
    res.send(html)//发送到 前端交互  entry-client 一挂在就可以渲染出来
  }catch(error){
    res.status(500).send('Internal Server Error')
  }
})

app.listen(3001)
  • 代码测试结果

WX20200616-172617@2x.png
同构成功!!!!


(注意)到当前开发进度,:每一次的项目修改,都需要重新 npm run build 和 启动node文件

整合vuex

安装vuex
`npm install -S vuex`  
如果是用vue-cli创建的项目,可以用 
vue add vuex 安装,结果一样
  • 生成store/index.js

WX20200616-174902@2x.png

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)
// `需要独立出来`
export default function createStore(){
  return new Vuex.Store({
    state: {
      count:100
    },
    mutations: {
      add(state){
        state.count+=1
      }
    },
    actions: {
    },
    modules: {
    }
  })
}
  • 在main.js中挂在 sotre
import Vue from 'vue'
import App from './App.vue'
import createRouter from './router'
`import createStore from './store'`

Vue.config.productionTip = false

// 每个请求获取一个单独的vue实例
// 调用者是entry-server(首屏渲染) 会传递参数是上下文对象
export function createApp(context){
  const router = createRouter()
  const store = createStore()
  const app =new Vue({
    router,
     // 利用context可以拿到一些参数
    context,
    store, `// 挂载`
    render: h => h(App)
  }).$mount('#app')
  // 导出app实例以及router实例
  return {app, router, store}
}
  • 在Hello.vue 中添加

测试vuex的引入

<p @click="$store.commit('add')">{{$store.state.count}}</p>
数据预取

服务器端渲染的是应用程序的"快照",如果应用依赖于一些异步数据,那么在开始渲染之前,需要先预取和解析好这些数据。
在服务端把数据准备好,带着数据的把页面渲染完成。

  • 首先在store/index.js 中添加一个数据的的初始化
mutations: {
  // 加初始化数据
  init(state,count){
    state.count =count
  }
},
  • 之后在sotre/index.js 利用actions 发起一个异步请求,模仿接口的请求
    actions: {
      `// 一个异步请求数据 触发init 模仿一个接口`
      getCount({commit}){
        return new Promise((reslove)=>{
          setTimeout(() => {
            commit('init', Math.random()*100) // 生成随机数作为初始值
            reslove()
          }, 1000);
        })
      }
    },
  • 接下来在需要调用数据的路由对应页面来做数据预取逻辑 Hello.vue
export default {
  name: 'Hello',
  asyncData({store, router}) {
  console.log(router, 'asyncDtata-router')
    return store.dispatch('getCount')
  }
}

asyncData它使得你能够在渲染组件之前异步获取数据。 asyncData方法会在组件(限于页面组件)每次加载之前被调用。它可以在服务端或路由更新之前被调用。

  • 首屏处理数据在entry-server.js
// 首屏渲染
//在服务端执行的代码
import {createApp} from './main'

//创建vue的实例
// 调用者是renderer
export default context =>{
  // 为了让renderer 可以等待处理最后的结果,return的应该是一个promiss
  return new Promise((resolve, reject)=>{
    // 创建vue实例和路由实例
    `const {app, router,store} =createApp(context)`

    // 需要渲染首屏 就要拿到当前的url 渲染器会拿到当前的url
    // 跳转首屏。
    // url的来源。是从请求中可以拿到。传递给renderer
    router.push(context.url) // 考虑到当前页面会存在ajax请求等异步任务处理。要等异步任务处理完在跳转页面
    // 监听路由器的ready,确异步任务都完成
    router.onReady(()=>{
      //该方法把一个回调排队,在路由完成初始导航时调用,这意味着它可以解析所有的异步进入钩子和路由初始化相关联的异步组件。
      //这可以有效确保服务端渲染时服务端和客户端输出的一致。

      `// 首先处理异步的数据,之后在存放到渲染中
      // 所以需要匹配组建中是否存在asyncData选项
      const matchedComponents =router.getMatchedComponents() // 获取url匹配 到所有匹配的组建数组`

      //用户是瞎输入的地址,可能会导致matchedComponents获取错误 404
      if(!matchedComponents.length){
        return reject({code:404})
      }

      `//需要遍历一下数组 看组建是否有匹配asyncData`
      Promise.all(
        matchedComponents.map(comp =>{
          // 看组建是否有匹配到asyncData
          if(comp.asyncData){  
            //  传 store 为了找到dispatch 对应的 actions
            // 传 router 是为了如果url后面带参数 &wd=vue
            return comp.asyncData({store,route:router.currentRoute})// 异步调用 所以返回的是一个promise,每次都return,就会返回一个promise数组
          }
        })).then(()=>{
          // 数据放在store 前端不知道这一步,所以需要通知前端
          //接下来做一个约定
          // 所有的预取数据resolve之后
          //  store已进填充了当前数据状态
          //数据需要同步到前端
          // 序列化操作,转化成字符串 前端使用window.__INITIAL_STATE__获取
          // 赋值给 context.state; 是一个约定,
          context.state = store.state
          resolve(app)
        }).catch(reject) // 捕获异常
    }, reject) // 作为onReady事件的失败函数处理
  })
}

在entry-client.js
恢复store

const {app, router, store}  =createApp()

if(window.__INITIAL_STATE__){
  console.log(window.__INITIAL_STATE__, 'window.__INITIAL_STATE__');
  // 恢复state
  store.replaceState(window.__INITIAL_STATE__)
}

WX20200616-222307@2x.png
WX20200616-223053@2x.png
服务端渲染的时候直接生成的,反序列化之后直接生成一个字符串插入这里,在前端一执行就变成对象了。


  • About作为首页,刷新后返回home页面,asyncData没有执行

如果,路由切换到http://localhost:3001/about在About刷新再切回首页,就不会走服务器的数据,而是本地的state数据。
About刷新结果:
WX20200616-222622@2x.png

切回来会变成这样:
WX20200616-222721@2x.png

问题

只是解决首屏加载数据的问题,没有解决在客户端路由切换的问题。
如果在客户端的组建里面也发现asyncData这个配置项,也需要执行

思路

加入全局混入 mixin
在main.js 中 混合式mixin

// 添加全局混入 mixin
// 混入到 beforeMount钩子中
// 服务端中不会被触发beforeMount,因为对服务端来说直接渲染页面,不存在Dom挂在,所以不会触发这个钩子
// 所以只会在客户端执行 beforMount
Vue.mixin({
  beforeMount() { // 这个钩子执行的时候vue实例已经存在了,在前端已经挂载过了。所以可以从this中去获取sotre
    const {asyncData} =this.$options
    if(asyncData){
      //存在就调用
      asyncData(
        {
          store: this.$store, // this 指的是vue实例
          route: this.$route
        }
      )//这里需要传参数
    }
  },
})

OK~关于 初识vue 的ssr服务端渲染就到这里了。
可能会存在错别字,但是不影响知识的传递,哈哈哈哈哈哈!
有问题随时留言,我们一起探讨和进步,谢谢

一起加油把 Yes ok!

学习资源

原生:vue ssr https://ssr.vuejs.org/zh/
框架:nuxt.js https://nuxtjs.org/
github: https://github.com/speak44/ss...

阅读 366

推荐阅读