vue-ssr的实现原理连载(二)

 

在上一篇文章中,通过一个koa服务和vue-server-renderer创建了一个简单vue服务端渲染。这一片文章让我们来进一步填充,并在vue开发中使用ssr。

这一节我们来继续实现vue的ssr,本节所有源码可以查看我的github: https://github.com/Jasonwang911/vue-ssr/tree/master/step2 ,欢迎star

首先在上一篇代码中继续搭建一个简单的vue项目: 

  我们建立src目录和public目录,public目录存放一个index.html,包含一个id为app的div元素;src目录中有一个main.js入口文件和一个App.vue入口文件并且包含一个components文件夹用于放置我们创建的组件。

  main.js用于创建vue实例: 

import Vue from "vue";
import App from "./App";

const vm = new Vue({
  el: "#app",
  render: h => h(App)
});

  App.vue作为vue的组件的入口,分别引入了Bar组件和Foo组件,这两个组件都放置在src下的components文件夹中

<template>
 <div>
   <div>我是App.vue, {{msg}}</div>
   <bar />
   <foo />
 </div>
</template>

<script>
import Bar from "./components/Bar";
import Foo from "./components/Foo";

export default {
  data() {
    return {
      msg: "hello vue-ssr"
    };
  },
  components: {
    Bar,
    Foo
  }
};
</script>

  

接下来我们要使用webpack来进行文件的处理和组织等一系列的工程化操作,首先需要安装一些后面需要的依赖: 

yarn add webpack webpack-cli webpack-dev-server babel-loader @babel/preset-env @babel/core vue-style-loader css-loader vue-loader vue-template-compiler html-webpack-plugin webpack-merge

 简单的分别介绍一下每个包的作用: 

webpack-cli webpack 的命令行解析工具
webpack-dev-server 用来创建一个开发环境,webpack 开发服务
babel-loader 解析 js 语法,主要是进行转化操作
@babel/preset-env 解析 es6 语法并转化为 es5
@babel/core babel 的核心模块
vue-style-loader vue 中为 ssr 提供的解析 css 的工具,不适用 ssr 的话可以使用 style-loader
css-loader 处理 css 文件
vue-loader 处理 vue 文件
vue-template-compiler 处理 vue 模板编译的
html-webpack-plugin 一个处理 html 的插件
webpack-merge 合并 webpack 配置文件的
 
然后我们在根目录创建一个webpack的配置文件 webpack.config.js 并进行一些常规的配置: 
 
const path = require("path");
const VueLoader = require("vue-loader/lib/plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = {
  mode: "development",
  // webpack的入口文件
  entry: resolve("./src/main.js"),
  // webpack的出口
  output: {
    // 打包完成的文件名
    filename: "bundle.js",
    // 打包完成的输出路径
    path: resolve("./dist")
  },
  resolve: {
    // 引用文件的时候扩展名的寻找顺序
    extensions: [".js", ".vue"]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"]
          }
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        // 注意: ssr 中使用的 vue-style-loader, vue项目中使用的 style-loader,效果相同,只是前者支持服务端渲染
        use: ["vue-style-loader", "css-loader"]
      },
      {
        test: /\.vue$/,
        // 对vue文件使用vue-loader需要使用vue-loader的一个插件 vue-loader/lib/plugin
        use: "vue-loader"
      }
    ]
  },
  plugins: [
    new VueLoader(),
    new HtmlWebpackPlugin({
      // 模板的名称
      filename: "index.html",
      // 模板的路径
      template: resolve("./public/index.html")
    })
  ]
};

  

webpack 启动: npx webpack-dev-server 会启动 node-modules/.bin/webpack-dev-server,然后会自动根据根目录下 webpack.config.js 的配置去启动 webpack   
 
启动后页面会正常显示vue的项目的内容。
 
然后我们来分析一下vue-ssr, 下面的图片是官方提供的架构图: 
 上图表明了,vue-ssr是同构框架,即我们开发的同一份代码会被运行在服务端和客户端两个环境中。实现是依赖于webpack的编译的,并且有两个入口,server-entry 和 client-entry , 通过webpack把你的代码编译成两个bundle: server-bundle 和 client-bundle ;
 
然后我们来进一步拆分我们上面的代码,首先从入口文件main.js入手:main.js是项目的入口文件,作用是提供vue的实例。作为入口,有可能是在服务端,有可能是在客户端,在客户端只调用一次,负责将创建vue实例,并且挂载元素,渲染根组件;在服务端呢,不能挂载元素,并且每次调用都要产生一个新的实例,给每个访问的用户的使用。于是,我们将 main.js 改造为一个函数,在调用这个函数的时候返回一个对象,这个对象包含了一个vue的实例,返回一个对象的作用是为了后续的扩展,改造为函数的原因是解决上述的问题: 
  1.根据客户端或者服务端来添加或不添加el;
  2.每次调用都产生一个新的实例,服务端的根本要求
import Vue from "vue";
import App from "./App";

// const vm = new Vue({
//   el: "#app",
//   render: h => h(App)
// });

// main.js是项目的入口文件,作用是提供vue的实例
// 将入口文件改造为一个函数,每次调用都返回一个vue的实例,这样做可以 1.根据客户端或者服务端来添加或不添加el; 2.每次调用都产生一个新的实例,服务端的根本要求
export default () => {
  const app = new Vue({
    render: h => h(App)
  });
  return { app };
};

  然后我们在src下先创建客户端的入口文件 client-entry.js。在这个文件中我们引入入口文件main.js,执行mian.js导出的函数获取vue实例,并通过$mount的方法进行挂载元素: 

// 客户端
import createApp from "./main";

const { app } = createApp();
// 挂载到 #app 的元素上
app.$mount("#app");

 入口文件改造完成后,可以修改一下webpack的配置文件,查看是否还运行正常: 修改 webpack.config.js 文件的 入口为 ./src/client-entry.js  

entry: resolve("./src/client-entry.js")

 使用 npx webpack-dev-server 重启项目,在浏览器查看一切正常,说明我们的配置是有效的。

再回来看webpack的配置,现在我们只是配置了客户端的webpakc配置,后续肯定会分别打包客户端和服务端,所以一般情况下,webpack的配置文件不会写到项目的根目录下面。我们创建build文件夹来独立放置构建相关的配置文件,并将webpack.config.js文件移入build文件夹并改名为webpack.base.js。同时,我们新建一个文件 webpack.client.js 将客户端独有的配置拆分到webpack.client.js,在webpack.client.js中使用webpack-merge来合并webpack的配置:

webpack.base.js: 

const path = require("path");
const VueLoader = require("vue-loader/lib/plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = {
  // webpack的出口
  output: {
    // 打包完成的文件名
    filename: "[name].bundle.js",
    // 打包完成的输出路径
    path: resolve("../dist")
  },
  resolve: {
    // 引用文件的时候扩展名的寻找顺序
    extensions: [".js", ".vue"]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"]
          }
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        // 注意: ssr 中使用的 vue-style-loader, vue项目中使用的 style-loader,效果相同,只是前者支持服务端渲染
        use: ["vue-style-loader", "css-loader"]
      },
      {
        test: /\.vue$/,
        // 对vue文件使用vue-loader需要使用vue-loader的一个插件 vue-loader/lib/plugin
        use: "vue-loader"
      }
    ]
  },
  plugins: [new VueLoader()]
};

  webpack.client.js: 

// 客户端的webpack配置
const path = require("path");
const merge = require("webpack-merge");
const base = require("./webpack.base");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = merge(base, {
  // webpack的入口文件
  entry: {
    // 给每个模块起名,方便在bundle进行标识
    client: resolve("../src/client-entry.js")
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 模板的名称
      filename: "index.html",
      // 模板的路径
      template: resolve("../public/index.html")
    })
  ]
});

  配置完成后,我们来启动webpack对结果进行查看: 这时我们不能通过之前的 npx webpack-dev-server 来启动了,我们需要给webpack指定配置文件,为了方便,可以在package.json中进行配置: 

"scripts": {
    "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development",
    "client:build": "webpack --config ./build/webpack.client.js --mode production"
  }

  这样我们就可以通过 npm run client:dev 来启动了,运行命令并查看浏览器对应的端口,结果应该和之前是相同的。npm run client:build 可以来进行打包客户端的操作;

截止到此,客户端的配置和打包就完成了,接下来我们来对服务端进行配置

首先,在src下简历一个服务端的入口文件 server-entry.js ,通样的引用main.js中的函数来创建vue实例,不同的是服务端的入口创建的vue实例不能挂载元素,并且也需要创建一个函数,方便在调用的时候创建一个新的实例返回给node服务端来使用,以保证每个浏览器端请求的都是一个新的实例。

// 服务端入口
import createApp from "./main";

const { app } = createApp();
// 调用当前这个文件产生一个vue的实例,并且需要导出给node服务端使用
export default () => {
  const { app } = createApp();
  return app;
};

  接下来我们在build文件夹下创建打包服务端的webpack的配置文件,相对于客户端,服务端配置不同的是,target 配置为node,并且配置 output 的 libraryTarget 为 commonjs2 用来表明打包出来的文件需要给node使用,给node使用的代码要遵守commonjs规范,也就是将export default 转变为 module.exports, 配置 libraryTarget: 'commonjs2' 会将文件最终导出的结果,放到 module.exports上,根据vue架构图的显示,服务端只是打包出静态的字符串视图,并不需要解将打包后的结果通过 html-webpack-plugin插件挂载到html上面,所以在服务端的html-webpack-plugin插件中配置了 excludeChunks: ["server"] 来去除服务端打包出来的bundle: 

// 客户端的webpack配置
const path = require("path");
const merge = require("webpack-merge");
const base = require("./webpack.base");
const HtmlWebpackPlugin = require("html-webpack-plugin");

const resolve = dir => {
  return path.resolve(__dirname, dir);
};

module.exports = merge(base, {
  // webpack的入口文件
  entry: {
    // 给每个模块起名,方便在bundle进行标识
    server: resolve("../src/server-entry.js")
  },
  // 确定是给node提供
  target: "node",
  // 给node使用的代码要遵守commonjs规范,也就是将export default 转变为 module.exports, 配置 libraryTarget: 'commonjs2' 会将文件最终导出的结果,放到 module.exports上
  output: {
    libraryTarget: "commonjs2"
  },
  plugins: [
    new HtmlWebpackPlugin({
      // 模板的名称
      filename: "index.ssr.html",
      // 模板的路径
      template: resolve("../public/index.ssr.html"),
      // 排除服务端打包后的结果
      excludeChunks: ["server"]
    })
  ]
});

 根据配置,我们继续在public文件夹下新建  index.ssr.html , 并在此模板中添加 vue-server-renderer 的魔法注释 vue-ssr-outlet

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0"> -->
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>服务端ssr</title>
  </head>
  <body>
    <!-- vue-ssr-outlet -->
  </body>
</html>

  然后我们在package.json文件中添加服务端的打包命令

"server:build": "webpack --config ./build/webpack.server.js --mode production"

  我们分别运行客户端和服务端打包命令: npm run client:build  和 npm run server:build 并查看打包结果

 

这样我们分别打包出了客户端和服务端的代码;

目前我们得到的结果是有一个客户端的正常打包,还有一个服务端的打包,服务端打包出来的 index.ssr.html 其实什么都没有做,只是一个直接拷贝,服务端的 server.bundle.js 打包呢结果如下: 

 

  请注意 module.exports = {}  这个正是 libraryTarget: "commonjs2" 设置的结果,返回去看vue-ssr官网的架构图, 服务端打包出来的静态字符串,然后引入客户端打包出来的动态方法就是最后的结果, 现在来回想一下教程一,通过 vue-server-renderer 提供的的渲染函数的createRenderer() 方法实现了服务端的渲染,同样的vue-server-renderer 提供的的渲染函数也提供了对bundle的渲染,下面继续来改造一下koa服务端, 将我们服务端打包的结果渲染成字符串: 

const Koa = require("koa");
const Router = require("koa-router");
const fs = require("fs");
const VueServerRender = require("vue-server-renderer");

const app = new Koa();
const router = new Router();

// 读取服务端打包完成的结果字符串
let ServerBundle = fs.readFileSync("./dist/server.bundle.js", "utf8");
// 读取模板
let template = fs.readFileSync("./dist/index.ssr.html", "utf8");
// 创建bundle渲染器, 进行渲染并插入模板
let render = VueServerRender.createBundleRenderer(ServerBundle, {
  template
});

// render.renderToString() 接收一个vue的实例并返回一个promise的字符串,将返回的字符串直接渲染到页面上,注意这个方法是个异步操作
router.get("/", async ctx => {
  // css样式只能通过回调,同步的话会有问题
  ctx.body = await new Promise((resolve, reject) => {
    render.renderToString((err, data) => {
      if (err) reject(err);
      resolve(data);
    });
  });
});

app.use(router.routes());

app.listen(3000, () => {
  console.log(`node serve run at port 3000`);
});

  

我们只做了两件事,1.分别读取服务端打包后的模板和bundle  2.使用vue-server-renderer提供的createBundleRenderer(bundle:string, {template}) 来渲染打包后的结果,并转化为字符串发送给浏览器。

使用 nodemon serve.js 启动koa服务,并在浏览器查看,页面顺利的渲染了, 查看网页源代码: 

 

的确把服务端打包后的结果插入了 <!--vue-ssr-outlet-->占位符的位置。但是点击事件是没有作用的,原因是renderToString()返回的是字符串,并没有动态功能。这时候我们在dist目录下的index.ssr.html中引入客户端打包好的js文件: 

<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0"> -->
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>服务端ssr</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
    <script src="client.bundle.js"></script>
  </body>
</html>

  查看浏览器,出现404,script标签的src会向服务器发送一个请求,但是服务器并没有这个静态文件,我们继续添加koa的静态文件功能: 

const path = require("path");
const static = require("koa-static");

// koa 静态服务中间件
app.use(static(path.resolve(__dirname, "dist")));

  然后我们重新打包客户端和服务端的代码,分别执行

  npm run client:build -- --watch       

  npm run server:build -- --watch 

  然后还要手动的在打包出来的服务端index.ssr.html中引入客户端的js: client.bundle.js 

  然后启动node服务: nodemon server.js

刷新浏览器,事件成功了,到此,新一版vue 服务端渲染就完成,也算是整个走完了一遍vue-ssr官网的架构图。

 

 

 
posted @ 2019-08-19 13:22  Jason齐齐  阅读(283)  评论(0编辑  收藏  举报