vue SSR(开发)

2 篇文章 0 订阅

一、传统服务端渲染、客户端渲染、服务端渲染(SSR)

1.传统服务端渲染:访问url,网页内容在服务器端渲染完成,返回HTML字符串,一次性传输到浏览器,浏览器渲染HTML。

 

2.客户端渲染:访问URL,服务端返回不包含DOM结构的HTML,浏览器渲染HTML,渲染js,请求数据,服务器返回json数据。

  • 客户端渲染至少会进行两次http请求,首屏到达时间会相对慢些,
  • 返回不包含DOM结构的HTML,SEO不友好

 

 3.服务端渲染(SSR):访问url,服务端读取vue模板,解析成DOM节点,返回首屏HTML及完整spa结构,客户端显示首屏,并激活后可使用spa方式运行。

  • 解决首屏速度慢和SEO问题

 

二、服务端渲染(SSR)

1.基本用法

使用渲染器将vue实例渲染成html字符串

const express = require('express');
const app = express()
const {createRenderer} = require('vue-server-renderer');
// 获取渲染器
const renderer = createRenderer();
const Vue = require('vue')
// 路由
app.get('/', async (req, res) => {
  // 创建一个vue实例
  const vm = new Vue({
    template:'<p>{{msg}}</p>',
    data(){
      return {
        msg: 'hello ssr'
      }
    },
  })
  try {
    // 将vm转换为html字符串
    const html = await renderer.renderToString(vm)
    res.send(html);
  } catch(error) {
    res.status(500).send('服务器内部错误')
  }
})

// 监听
app.listen(3000)

缺点:

  • 不能实现交互,像click事件等都不能实现,需要客户端激活才能实现;
  • 前后端代码混合在一起。

所以需要改造一下。

 

2.同构开发SSR应用

1)构建流程

对于客户端应用程序和服务器应用程序,我们都要使用 webpack 打包。

  • 服务器需要「服务器 bundle」然后用于服务器端首屏渲染(SSR)
  • 「客户端 bundle」会发送给浏览器,用于混合静态标记(即激活)。客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

 

(2)代码结构

src

├── router

├────── index.js # 路由声明

├── store

├────── index.js # 全局状态

├── main.js # ⽤于创建vue实例

├── entry-client.js # 客户端⼊⼝,⽤于静态内容“激活”

└── entry-server.js # 服务端⼊⼝,⽤于⾸屏内容渲染

 

(3)基础用法

【1】main.js

main.js是我们应用程序的「通用 entry」。

  • 在纯客户端应用程序中,我们将在此文件中创建根 Vue 实例,并直接挂载到 DOM。
  • 对于服务器端渲染(SSR),不需要挂载,只需要返回创建vue实例的工厂方法。(避免状态单例:Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。如果我们在多个请求之间使用一个共享的实例,很容易导致交叉请求状态污染)
import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

// 返回一个应用程序工厂
export default function createApp() {
  const app =  new Vue({
    render: h => h(App),
  })
  return {app};
}

【2】entry-client.js

entry-client.js 是客户端入口,通过webpack打包生成client-bundle用于客户端激活,激活后能使用spa方式运行。

entry-client.js 将vue实例挂载到#app上

import {createApp} from './main'
// 客户端将vue实例挂载到#app上
const {app} = createApp();
app.$mount('#app')

【3】entry-server.js

entry-client.js 是服务器端入口,通过webpack打包生成server-bundle用于服务器渲染。

entry-client.js 直接返回vue实例。

import {createApp} from './main'

// 服务端返回vue实例
export default (context) => {
  const {app} = createApp();
  return app;
}

 

(4)添加路由,首屏渲染

【1】添加路由

vue add router

【2】router/index.js

返回创建router的工厂函数

import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
  }
]

// 暴露创建路由工厂
export default function createRouter() {
  const router = new VueRouter({
    // mode只影响客户端,不影响服务端
    mode: 'history',
    // base: process.env.BASE_URL,
    routes
  })
  return router;
}

【3】main.js

main.js在之前的基础上添加创建路由来处理首屏渲染

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

Vue.config.productionTip = false

// 返回一个应用程序工厂:返回vue实例和Router实例
export function createApp(context) {
  // 处理首屏,就需要先处理路由跳转
  const router = createRouter()
  const app =  new Vue({
    router,
    context,
    render: h => h(App)
  })
  return { app, router };
}

【4】entry-client.js

在之前的基础上等待路由准备好后再挂载

import {createApp} from './main'
const { app, router } = createApp();
router.onReady(() => {
  // 客户端将vue实例挂载到#app上
  app.$mount('#app')
})

【5】entry-server.js

在之前基础上加上路由跳转,解决首屏渲染问题

import {createApp} from './main'

// 服务端返回vue实例
export default context => {
  // 因为其中可能有异步处理,所以使用promise等待异步处理完,路由准备好后再返回app
  return new Promise((resolve, reject) => {
    const { app, router } = createApp(context);
    // 跳转到首屏地址
    router.push(context.url);
    // 路由准备就绪
    router.onReady(() => {
      resolve(app);
    },reject);
  })
}

【6】webpack配置

安装依赖

npm i -D webpack-node-externals lodash.merge

根目录创建vue.config.js:

// 两个插件分别负责打包客户端和服务端
const VueSSRServerPlugin = require('vue-server-render/server-plugin');
const VueSSRClientPlugin = require('vue-server-render/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,
  configureWebpack: () => ({
    // 入口指向应用程序的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则使⽤node⻛格导出模块
      libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
    },
    // 外置化应⽤程序依赖模块。可以使服务器构建速度更快,并⽣成较⼩的打包⽂件。
    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
      });
    });
  }
}

【7】脚本配置

安装依赖:

 cnpm i cross-env -D

定义创建脚本package.json:

"scripts": {
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build",
    "build": "npm run build:server && npm run build:client",
    "serve": "vue-cli-service serve",
    "lint": "vue-cli-service lint"
},

【8】修改宿主文件./public/index.html

添加出口设置

<!DOCTYPE html>
<html lang="">
  <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">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

【9】打包

npm run build

生成dist文件夹,包含client和server文件夹

【10】服务器启动⽂件./server/ssr.js

const express = require('express');
const app = express()
const { createBundleRenderer } = require('vue-server-renderer');

// 获取文件路径
const resolve = dir => require('path').resolve(__dirname, dir);
// 开放dist/client目录,关闭默认下载index页的选项,不然到不了后面路由
app.use(express.static(resolve('../dist/client'), {index:false}))
// 服务端打包文件地址
const bundle = resolve('../dist/server/vue-ssr-server-bundle.json');
// 创建渲染器
const renderer = createBundleRenderer(bundle, {
  runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
  template: require('fs').readFileSync(resolve("../public/index.html"), "utf8"), // 宿主⽂件
  clientManifest: require(resolve("../dist/client/vue-ssr-client-manifest.json")) // 客户端清单
})

app.get('*', async (req,res)=>{
  // 设置url和title两个重要参数
  const context = {
    title:'ssr test',
    url:req.url
  }
  const html = await renderer.renderToString(context);
  res.send(html)
})

 // 监听
app.listen(3000)

进入server文件夹,执行该文件启动服务器

node ssr.js

打开 http://localhost:3000/ ,出现以下页面则为服务器渲染成功

查看网页源码:

  • data-server-rendered 特殊属性,让客户端 Vue 知道这部分 HTML 是由 Vue 在服务端渲染的,并且应该以激活模式进行挂载。在没有 data-server-rendered 属性的元素上,还可以向 $mount 函数的 hydrating 参数位置传入 true,来强制使用激活模式(hydration):
// 强制使用应用程序的激活模式
app.$mount('#app', true)
  • 在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。
  • defer属性是指没有影响首屏性能,延后自动下载。

 

(5)添加vuex

【1】安装vuex

vue add vuex

【2】修改./store/index.js:返回创建store的工厂函数

import Vue from 'vue'
import Vuex from 'vuex'
import { createApp } from '../main'

Vue.use(Vuex)

//返回创建store的工厂函数
export function createStore() {
  return new Vuex.Store({
    state: {
      count: 1
    },
    mutations: {
      add(state) {
        state.count += 1;
      }
    }
  })
}

【3】修改main.js:创建store并添加到vue实例中

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

// import router from './router'
import createRouter from './router/index'
import createStore from './store/index'

Vue.config.productionTip = false

// 返回一个应用程序工厂:返回vue实例和Router实例
export function createApp(context) {
  // 处理首屏,就需要先处理路由跳转
  const router = createRouter()
  // 创建store实例
  const store = createStore();
  const app =  new Vue({
    router,
    context,
    store,
    render: h => h(App)
  })
  return { app, router, store };
}

【4】在./views/Home.vue中添加store操作:

<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
    <h2 @click="$store.commit('add')">{{$store.state.count}}</h2>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'Home',
  components: {
    HelloWorld
  }
}
</script>

由于代码发生改变,所以我们需要重新打包:

根目录下:

npm run build

server目录下:

node ssr.js

打开 http://localhost:3000/, 显示以下页面,当最底下的1可以点击增加事便是成功

 

 

(6)vuex处理异步数据

服务器端渲染的是应用程序的“快照”,当应用依赖于一些异步数据时,在开始渲染之前,还需要预先获取和解析好异步数据

【1】添加异步数据

./store/index.js中添加actions:

import { resolve } from 'core-js/fn/promise';
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

//返回创建store的工厂函数
export function createStore() {
  return new Vuex.Store({
    state: {
      count: 1
    },
    mutations: {
      add(state) {
        state.count += 1;
      },
      init(state, count) {
        state.count = count;
      }
    },
    // 加一个异步请求count的action
    actions: {
      getCount({commit}) {
        return new Promise(resolve => {
          setTimeout(() => {
            commit("init", Math.random() * 100);
            resolve();
          },1000)
        })
      }
    }
  })
}

【2】添加约定函数asyncData

  • 等待被调用
  • 当被调用时返回actions中getCount的执行结果
<template>
  <div class="home">
    <img alt="Vue logo" src="../assets/logo.png">
    <HelloWorld msg="Welcome to Your Vue.js App"/>
    <h2 @click="$store.commit('add')">{{$store.state.count}}</h2>
  </div>
</template>

<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'Home',
  components: {
    HelloWorld
  },
  // 约定一个asyncData,当被调用时返回actions中getCount的执行结果
  asyncData({store, route}) {
    return store.dispatch('getCount')
  }
}
</script>

【3】执行asyncData,将数据状态放到约定好的context.state中(会被渲染器转成字符串)

  • 在哪执行:由于要在渲染之前预先获取和解析好异步数据,所以在获取到router之后,返回app之前,执行当前路由所有组件中的asyncData
  • 怎么获取组件中的asyncData: 获取到当前路由对应的组件,并判断每个组件是否有asyncData方法
  • 执行asyncData获取到数据后改怎么传递给服务器端:约定将app的数据状态存放到context.state中,渲染器会将state序列化成字符串, 未来在前端激活之前可以再恢复它。
import {createApp} from './main'

// 服务端返回vue实例
export default context => {
  // 因为其中可能有异步处理,所以使用promise等待异步处理完,路由准备好后再返回app
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp(context);
    // 跳转到首屏地址
    router.push(context.url);
    // 路由准备就绪
    router.onReady(() => {
      // 在获取到router之后,返回app之前,执行当前路由所有组件中的asyncData
      // 获取当前路由的所有组件
      const matched = router.getMatchedComponents();

      // 当获取不到对应组件时,404,返回错误码
      if(!matched.length) {
        return reject({code: 404})
      }

      // 统一处理返回的结果
      Promise.all(
      // 遍历当前路由的所有组件
        matched.map(component => {
          // 判断组件中是否有asyncData
          if(component.asyncData) {
            // 若有则执行asyncData,并返回执行结果
            return component.asyncData({
              store, 
              route: router.currentRoute
            })
          }
        })
      ).then(() => {
        // 约定将app的数据状态存放到context.state中
        // 渲染器会将state序列化成字符串,反序列化后放到window.__InITIAL_STATE
        // 未来在前端激活之前可以再恢复它
        context.state = store.state;
        resolve(app);
      }).catch(reject)
    },reject);
  })
}

【4】客户端将数据状态从字符串还原回来

服务器端将state存放在window. __INITIAL_STATE__中,所以只需将store中的数据替换成window. __INITIAL_STATE__即可。

import {createApp} from './main'
const { app, router, store } = createApp();

// 还原state
if(window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__);
}

router.onReady(() => {
  // 客户端将vue实例挂载到#app上
  app.$mount('#app')
})

【5】重新打包,并启动服务器

在根目录下:

npm run build

在server目录下:

node ssr.js

当页面正常显示异步数据时成功:

控制台中可发现等待了一秒:

 

源码中携带了特殊的script标签:

服务端中得到的结果反序列化后的结果存放到前端,让前端调用。

【6】处理客户端asyncData调用

当要获取非首屏的异步数据时,需要在客户端执行对应组件中的asyncData方法,可以在main.js文件中使用全局混入的方法:

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

// import router from './router'
import createRouter from './router/index'
import {createStore} from './store/index'

Vue.config.productionTip = false

// 当要获取非首屏的异步数据时,需要在客户端执行对应组件中的asyncData方法
// 加入全局混入,在beforeMount钩子中执行asyncData方法
Vue.mixin({
// 当执行到这个钩子时,前面已经在客户端创建好了vue实例,已经有了$store和$route
  beforeMount() {
    const { asyncData } = this.$options;
    if(asyncData) {
      asyncData({
        store: this.$store, 
        route: this.$route
      })
    }
  }
})

// 返回一个应用程序工厂:返回vue实例和Router实例
export function createApp(context) {
  // 处理首屏,就需要先处理路由跳转
  const router = createRouter()
  const store = createStore();
  console.log(store);
  const app =  new Vue({
    router,
    context,
    store,
    render: h => h(App)
  })
  return { app, router, store };
}

【7】重新打包和启动服务器,查看结果

打开http://localhost:3000/about , 再点击路由进入Home页,若异步数据开始显示为1,一秒后显示随机数据,则为成功。

因为首屏访问的是about页面,home组件的asyncData并没有被服务器执行,所以home组件中的count仍为初始值1。

当由about组件切换到home组件时,客户端执行了home组件的asyncData方法,所以count值由1变为随机值97.556...

 

 

 

 

 

 

 

 

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值