ssr2改造开始

340 阅读5分钟

2021年了,新的一年应该有一个新的开始,那就从博客更新开始吧!趁着早点的时候,早点更新免得落下延迟更新的毛病,一年到头了也应该有自己的目标和上一年的总结,这个来的有点迟,不过好饭不怕晚是吧?虽然晚但是得到,这是至理名言。nice!新的一年肯定啊努力挣钱努力攒钱 ,毕竟还是得有以后啊。第二个薪资达到自己期望的值,不行就换!第三个当然也是最重要的如果明年疫情过去,条件允许当然是出去玩啊。希望明年再来看这个的时候已经达到了自己满意的地步。好了也该言归正传了,上一篇讲了ssr的理解,当然也应该具体实现一下是吧?这一趴就带你去感受vue实际怎么用ssr,大神们 的react自行度娘吧。

  1. 同构项目

    我们构思怎么写ssr的过程中,我们想怎么在我们的项目中写一个ssr,也就是说怎么将服务端渲染在vue项目中进行启动渲染 ,前边一篇文章也说了,前端人员不安分,搞搞搞然后搞出了前端各种,然后又有了Nodejs,最后发现首屏渲染有问题哈,自己选的路跪着也要走完呀,前端用服务端渲染当然还是在前端搞啊,也就是说在原项目的基础上vue中 夹杂一个服务端,那我们就用node吧!不然还会什么?既然在vue项目中写服务端代码,那不能再用hbs模版或者ejs了吧?这样一套代码也不能同时在客户端跑又在服务端跑啊。写不一样的那就需要维护两边啦,那就提出了同构的概念,一份代码在服务端执行的时候负责渲染页面,然后代码在浏览器执行的时候负责交互。 至于 有人说异构渲染?那我不知道了,爱折腾的前端人员还没搞出来呢吧 哈哈哈

  2. node如何渲染Vue结构的代码

    官方提供了一个插件vue-server-renderer可以直接将vue实例渲染成Dom标记

    const Vue  =require('vue');
    const app = new Vue({
        template:`<div>Hello World</div>`
    })
    
    //第二步 :创建一个renderer
    
    const renderer = require('vue-server-renderer').createRenderer();
    
    
    // 第三步:将Vue实例渲染为HTML
    
    renderer.renderToString(app,(err,html)=>{
        if (err) throw err
        console.log(html);
    })
    
    // 在2.5.0+, 如果没有传入回调函数,则会返回Promise
    
    renderer.renderToString(app).then(html=>{
        console.log(html);
    }).catch(err=>{
        console.error(err);
    })
    
  3. Vue中服务端怎么写?

    const Vue = require('vue')
    const Koa = require('koa');
    
    const renderer = require('vue-server-renderer').createRenderer()
    
    const app = new Koa();
    const Router = require('koa-router')
    const router = new Router()
    
    // /home
    router.get('/(.*)', async (ctx, next) => {
        const app = new Vue({
        data: {
            url: ctx.request.url
        },
        template: `<div>访问的 URL 是: {{ url }}</div>`
        })
    
        renderer.renderToString(app, (err, html) => {
        if (err) {
            ctx.status(500).end('Internal Server Error')
            return
        }
        ctx.body = `
            <!DOCTYPE html>
            <html lang="en">
            <head><title>Hello</title></head>
            <body>${html}</body>
            </html>
        `
        })
    })
    app
      .use(router.routes())
      .use(router.allowedMethods());
    app.listen(8080, () => {
      console.log('listen 8080')
    })
    

    上边的例子中可以看出vue-server-renderer 返回的是一个html 片段,官方叫标记(markup),并不是完整的html 页面。我们必须像上边的例子那样用一个额外的HTML也米娜包裹容器来包裹生成的HTML标记

    我们可以提供一个模版页面 。 像 vue中的app页面的包裹元素。

    <!DOCTYPE html>
    <html lang="en">
     <head><title>Hello</title></head>
     <body>
       <!--vue-ssr-outlet-->  
     </body>
    </html>
    

    注意: 注视这里将是应用程序HTML标记注入的地方。这是插件提供的,不用问为什么这就是规定哈哈哈哈,如果不用当然也可以啦 ,那你就自己去处理一下吧。大神们我当然管不了

  4. 以后的思路注意点

    • 服务端渲染的vue.js应用程序也可以被认为是“同构”或者是 “通用”,因为应用程序的大部分代码都可以在服务器和客户端上运行。
    • 在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序案例。对于服务端渲染来,我们也希望如此:每个应用应该是全新的、独立的应用程序案例,以便不会造成交叉请求造成的状态污染。

    对于第一点来说

    • 我们之前一直在铺垫,服务端和客户端共用一套代码,既然在客户端和服务端都能运行,那应该有两个入口文件,一些DOM和BOM的操作在服务端肯定是不能够的。
    • 通常Vue的应用程序是由webpack和vue-loader 构建,并且许多的webpack特定功能不能在Node.js中运行

    对于第二点来说我们想一下原来对于客户端,我们每一个人的电脑上对应的是一个独立的客户端,也就是说你自己运行的应用程序是独立的和别人没有关系,对于服务端来说也是一样的,每个请求每个人发来的请求都应该是全新的独立的应用程序案例,以便不会有交叉造成状态污染

  5. 改造正式开始

    • 我们再来看一下 上边的服务端代码

      const Vue = require('vue')
      const Koa = require('koa');
      
      const renderer = require('vue-server-renderer').createRenderer()
      
      const app = new Koa();
      const Router = require('koa-router')
      const router = new Router()
      // /home
      router.get('/(.*)', async (ctx, next) => {
          const vm = new Vue({
          data: {
              url: ctx.request.url
          },
          template: `<div>访问的 URL 是: {{ url }}</div>`
          })
          // 当为其他的url的时候 ,我们获取到访问的url链接,然后再通过某种机制让当前的vm切换到这个url对应的内容区
          renderer.renderToString(vm, (err, html) => {
            if (err) {
                ctx.status(500).end('Internal Server Error')
                return
            }
            ctx.body = `
                <!DOCTYPE html>
                <html lang="en">
                <head><title>Hello</title></head>
                <body>${html}</body>
                </html>
            `
          })
      })
      
      app
        .use(router.routes())
        .use(router.allowedMethods());
      app.listen(8080, () => {
        console.log('listen 8080')
      })
      

      上边的例子写了一个template:但是我们不可能每个url都渲染某一个固定的模版吧?那不是就炸了。所以要先搞定路由

    • 改造路由实例 router/index.js

      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
        }
      ]
      // 导出工厂函数,他可以返回新的 Router 实例
      export function createRouter(){
        return new VueRouter({
          mode: 'history',
          base: process.env.BASE_URL,
          routes
        })
      }
      
      

      疑惑了吧?为什么和脚手架开始的router 不一样,export 一个工厂函数,这里要着重了解一下:

      原来的spa应用,在客户端每个人一个router 实例,不会产生污染,但在服务端大家都来访问服务器的router 不能只有一个router实例吧?这样会产生变量啊 一些东西的污染,举例子来说 客户端上你自己有一根雪糕,你自己吃,但是放到服务器上还是一根雪糕,别人来了舔一口,你还愿意吃么?哈哈哈很恶心是吧。那这就需要每个人发一根雪糕了。

      服务端的每个请求,都有一个单独的实例避免在请求过程中,产生状态污染,这就是用工厂函数的意义。

    • 构建流程

    • 改造创建vue实例的main.js,用于创建实例

      import Vue from "vue";
      import App from "./App.vue"; 
      import { createRouter } from "./router"; 
      export function createApp(context) { 
        const router = createRouter(); 
        const app = new Vue({ 
          router, 
          context,  // 这个context 是一个约定,就叫这个名字
          render: h => h(App) 
        });
        return { app, router }; 
      }
      

      router实例不是单一的,那当然createApp 也不应该是单一的,所以依旧用了工厂函数,但是中间有一个context参数,context上下文 是服务器传递给vue实例的参数对象 , 之后我们调用的时候会进行参数的传递

    • entry-client 客户端入口,用于静态的内容进行激活

      // 挂载创建的Vue的实例 。 将来在浏览器执行
      
      // 返回给浏览器的是静态页面需要激活
      import {createApp} from './main'
      
      const { app,router } = createApp() ;
      
      
      // App.vue 模版中跟元素具有`id= app`
      // 路由就绪,执行挂载(激活过程)
      router.onReady(()=>{
          app.$mount('#app') // 宿主问 
      })
      
    • Entry-server 服务端的入口用于首屏内容的渲染

      //给服务器提供一个方法,可以根据接受url 设置路由地址,然后返回创建vue实例
      
      // 在服务器执行
      import { createApp } from './main'
      
      export default context =>{   //koa的context,由服务给vue的
      
          return new Promise((resolve,reject)=>{
              // 获取vue实例和router实例
              const { app ,router} = createApp(context); 
                  
              // 跳转至首屏
              router.push(context.url) // 前端路由跳转
      
              // onReady 完成时,异步任务都会结束, 由于有异步任务,比如首屏有请求的情况下
              router.onReady(()=>{
                  resolve(app);
              },reject)
          })
      }
      
    • 分析一下流程上边的流程

      request => Koa => main.js => router/index.js

      请求 处理请求 生成vue实例 生成router实例

    • 进行我们的打包配置 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,
        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 => {
          config.module
            .rule("vue")
            .use("vue-loader")
            .tap(options => {
              merge(options, {
                optimizeSSR: false
              });
            });
        }
      };
      

      注意: 上边的根据环境变量决定入口文件和相应配置项 如果TARGET_NODE为node 那么target 为entry-server.js 入口,否则则是entry-client.js 入口

    • package.json 修改一下,也就是需要 再执行npm run build 的时候需要 同时打包服务端和客户端

      {
        // 主要修改 打包入口 npm run build 执行npm run build:server 和npm run build:client"  同时在打包
        //"build:server"时传递一个变量 WEBPACK_TARGET=node  这样入口文件就是entry-server.js了
        //--mode server设置成了服务端渲染的模式了
        // cross-env  用于跨平台设置环境变量
        "scripts": {
          "serve": "vue-cli-service serve",
          "build:client": "vue-cli-service build",
          "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server",
          "build": "npm run build:server && npm run build:client",
          "lint": "vue-cli-service lint"
        },
        
      }
      
    • 打包之后的目录结构

      -dist

      ​ -client

      ​ - js

      ​ - index.html

      ​ - vue-ssr-client-manifest.json

      ​ -server

      ​ -vue-ssr-server-bundle.json

    • 宿主文件 :服务端渲染还是需要一个宿主文件的 ./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">
          <title>服务端渲染</title>
        </head>
        <body>
          <!--vue-ssr-outlet-->    是服务端渲染的插口
        </body>
      </html>
      
      
    • 上边的服务端代码并不完整,只是引出 router的改造,所以最后我们需要写 服务端代码

      // 加载本地文件
      const fs = require("fs");
      // 处理url
      const path = require("path");
      const express = require('express')
      const app = express()
      
      // 获取绝对路径
      const resolve = dir => {
          return path.resolve(__dirname, dir)
      }
      
      // 第 1 步:开放dist/client目录,由于在改目录下有需要加载的js文件,需要开放改目录 关闭默认下载index页的选项,不然到不了后面路由
      // static 特性 只要url 中有/ 默认访问index.html ,恰好该目录下有一个index.html ,避免直接访问了index.html 
      app.use(express.static(resolve('../dist/client'), {index: false}))
      
      // 第 2 步:获得一个createBundleRenderer
      const { createBundleRenderer } = require("vue-server-renderer");
      
      // 第 3 步:导入服务端打包文件
      const bundle = require(resolve("../dist/server/vue-ssr-server-bundle.json"));
      
      // 第 4 步:创建渲染器
      const template = fs.readFileSync(resolve("../public/index.html"), "utf-8");
      const clientManifest = require(resolve("../dist/client/vue-ssr-client-manifest.json"));
      const renderer = createBundleRenderer(bundle, {
        runInNewContext: false, // https://ssr.vuejs.org/zh/api/#runinnewcontext
        template, // 宿主文件
        clientManifest // 客户端清单
      });
      
      // 路由是通配符,表示所有url都接受
      app.get('*', async (req,res)=>{
          console.log(req.url);
          
        // 设置url和title两个重要参数
        const context = {
          title:'ssr test',
          url:req.url // 首屏地址
        }
        const html = await renderer.renderToString(context);
        res.send(html)
      })
      
      const port = 3001;
      app.listen(port, function() {
        // eslint-disable-next-line no-console
        console.log(`server started at localhost:${port}`);
      });
      
  6. 总结流程

    request 发送请求之后 => 到达 服务端Koa ,服务端进行处理路由,在处理路由的过程中,createBundleRenderer会处理将context发送给main.js => main.js 拿到之后进行服务端的将context 传给vue实例 ,然后对 写的vue 文件 renderToString。

    总之总体来说就是 服务端用原来写的vue组件什么的 渲染出首屏,之后客户端进行激活,之后切换路由就是客户端的单页面应用,

    url不变的情况下。如果url产生变化那么再次渲染,这也是首屏的理解,首屏并不是首页。