vue服务端渲染之webpack配置

上一节用很简单的代码粗略的模拟了一下服务端渲染,这节来吧webpack加入进来。

首先安装包,

  • webpack(核心打包应用), webpack-cli(解析命令行参数), webpack-dev-server(在开发环境下提供一个运行环境,支持热更新),html-webpack-plugin(将打包后的结果插入到html中)
  • babel-loader(babel和webpack的桥梁), @babel/core(babel的核心模块),@babel/preset-env(把高级语法转化为es5(这是一个插件的集合))
  • vue-style-loader(是style-loader的升级版,style-loader不支持服务端渲染), css-loader
  • vue-loader(vue-loader和webpack的桥梁), vue-template-compiler(将template转化为render)

yarn add webpack webpack-cli ... vue-template-compiler --save-dev

新建webpack.config.js

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"),
  mode: "development",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "./dist"),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
      {
        test: /\.vue$/,
        use: "vue-loader",
      },
      {
        test: /\.css$/,
        use: ["vue-style-loader", "css-loader"],
      },
    ],
  },
  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, "./public/index.html"),
    }),
  ],
};

package.json文件中scripts字段添加命令devbuild
运行 yarn dev

image.png

以上就和我们平时做项目一样,只是这个配置要比vue-cli的简单很多。当前目录结构:
image.png

接下来进入正题,服务端渲染(工程化),先来看张图


image.png

现在的目录结构:


image.png

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

webpack.client.jswebpack.server.js均“继承了” webpack.base.js,这里要用到包webpack-merge
因为现在打包有不同的入口,因此entry字段就不要放在webpack.base里了,而要放到各自的配置文件中,模板也要改变,webpack各文件如下:

//--------webpack.base.js
const path = require("path");
const VueLoaderPlugin = require("vue-loader/lib/plugin");
module.exports = {
  mode: "development",
  output: {
    filename: "[name].bundle.js",
    path: path.resolve(__dirname, "./dist"),
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
          },
        },
      },
      {
        test: /\.vue$/,
        use: "vue-loader",
      },
      {
        test: /\.css$/,
        use: ["vue-style-loader", "css-loader"],
      },
    ],
  },
  plugins: [new VueLoaderPlugin()],
};
//---------webpack.base.js结束----

//-------------webpack.client.js开始------------
const base = require("./webpack.base.js");
const { merge } = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = merge(base, {  //合并配置
  entry: {
    client: path.resolve(__dirname, "../src/client-entry.js"),
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: "client.html",
      template: path.resolve(__dirname, "../public/client.html"),
    }),
  ],
});

之前做纯客户端渲染时,main.js一般是这样的:

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

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

但是上节说到过,服务端没有dom的概念,而我们想把main.js做成一个公用入口配置文件,因此,去掉el选项,幸好,vue给我们提供了$mount方法来手动挂载,可以随后在client-entry.js中手动挂载。
还有一点,在服务端渲染时,不能直接 let app = new Vue(...,这样,多个客户端访问时,接受到的都是同一个vue实例,这显然是不合适的。因此考虑生成vue实例的那块做成一个工厂函数,每次都导出一个新的实例。

改造后:

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

export default function() {
  let app = new Vue({
    render: (h) => h(App),
  });
  return { app };
}

先来完成入口js---client-entry

import createApp from "./main";

let { app } = createApp();

app.$mount("#app");  //记得要在public/client.html中增加id为app的元素哦

运行yarn client:dev

image.png

可以看到,文件,样式,js都正常运行

然后,后台入口server-entry.js, 后台webpack配置

//-------server-entry.js
import createApp from "./main.js";
export default () => {   //这里可以接收由render.renderToString传递的参数 
  let { app } = createApp();
  return app;
};
//-------server-entry.js结束------

//-------webpack.server.js开始------
const base = require("./webpack.base.js");
const { merge } = require("webpack-merge");
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const VueServerRenderer = require("vue-server-renderer/server-plugin");   // 给webpack.client.js中添加插件 const VueServerRenderer = require("vue-server-renderer/client-plugin"); 
module.exports = merge(base, {
  entry: {
    server: path.resolve(__dirname, "../src/server-entry.js"),
  },
  target: "node", //意思是输出的文件是给node使用的,因此当碰到fs,path等node的模块,不会打包
  output: {
    libraryTarget: "commonjs2", // 打包出的js是commonjs规范的写法,即module.exports = ...
  },
  plugins: [
    new VueServerRenderer(), // 用来与客户端关联 (webpack.client.js中也要加,用来与服务端关联)
    new HtmlWebpackPlugin({
      filename: "server.html",
      template: path.resolve(__dirname, "../public/server.html"),
      excludeChunks: ["server"], // 服务端的代码,不是以script标签引入的,而是经过vue-server-renderer解析后,插入到<!--vue-ssr-outlet-->处,因此,去掉引入的操作
      minify: false,
    }),
  ],
});

// 服务端打包出来的结果, 要给koa用,通过koa渲染成一个字符串插入到server.html中
// 需要将客户端打包的js插入到server.html中(因为服务端渲染出来的只是字符串,而js操作打包在了客户端的js中)

然后,启动一个服务,server.js

const Koa = require("koa");
const Router = require("koa-router");
// const Vue = require("vue");
const fs = require("fs");
const path = require("path");
const static = require("koa-static"); //
const VueServerRenderer = require("vue-server-renderer");

const router = new Router();

let template = fs.readFileSync(
  path.resolve(__dirname, "dist/server.html"),
  "utf8"
);
// 以下两个文件分别是各自的webpack配置中的vue-server-renderer生成的(yarn client:build/server:build)
let ServerBundle = require("./dist/vue-ssr-server-bundle.json");  //  配置了服务端入口
let clientManifest = require("./dist/vue-ssr-client-manifest.json");  // 配置了客户端入口

let render = VueServerRenderer.createBundleRenderer(ServerBundle, {
  template, //模板
  clientManifest,  // 相应的客户端映射
});

router.get("/", async (ctx) => {
  ctx.body = await new Promise((resolve, reject) => {
    render.renderToString(
      /*这里还可以接收参数, 将会传递到server-entry中的函数中*/ (err, res) => {
        if (err) {
          console.log(err);
          reject(err);
        } else {
          resolve(res);
        }
      }
    );
  });
});

let app = new Koa();

app.use(static(path.resolve(__dirname, "dist"))); //告诉静态页以哪个目录来显示
app.use(router.routes());

app.listen(3000);

到这里,还有一个问题,客户端有app.$mount('#app')操作,让vue组件可以挂在到ID为app的dom上,但是server.html中只有<!--vue-ssr-outlet-->, 因此前端代码中的js操作还是不能挂载到相应的dom上。解决办法是,在App.vue中根元素加id:

// ----------App.vue
<template>
  <div id="app">
    <Bar></Bar>
    <Foo></Foo>
  </div>
</template>
...

现在,我们来改造一下上边的代码,引入vue-router
新建router.js

//-----------router.js开始-------------
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);

export default () => {
  //同理,导出一个工厂函数,每次都生成新的vueRouter实例
  let router = new VueRouter({
    mode: "history",
    routes: [
      {
        path: "/",
        component: () => import("./components/foo.vue"),
      },
      {
        path: "/bar",
        component: () => import("./components/bar.vue"),
      },
    ],
  });
  return router;
};
//----------router.js结束-----------
//-----------main.js中增加router------
import Vue from "vue";
import App from "./App.vue";
import createRouter from "./router";
export default function() {
  let router = createRouter();
  let app = new Vue({
    router, // 客户端的router直接渲染
    render: (h) => h(App),
  });
  return { app, router };
}
//---------------main.js结束-------------

//------------App.vue改为路由形式----
<template>
  <div id="app">
    <router-link to="/">foo</router-link>
    <router-link to="/bar">bar</router-link>
    <router-view></router-view>
  </div>
</template>
<script>
import Bar from "./components/bar.vue";
import Foo from "./components/foo.vue";
export default {
  name: "App",
  components: {
    Bar,
    Foo
  }
};
</script>

重新打包客户端,服务端代码, 打开localhost:3000

image.png

点击bar
router.gif

看样子已经完成了。但是刷新时...
reload.gif

第一次正常是因为,走的都是前端路由,而第二次刷新出故障,是因为页面是ssr(由服务端返回的),而我们代码中只有对/路径的处理,因此在后台代码中要多加一些路由映射

router.get("(.*)", async (ctx) => {  // 此处注意,有大坑,不能使用通配符,之前的版本可以,要以(.*)代替。
  try {
    ctx.body = await new Promise((resolve, reject) => {
      render.renderToString({ url: ctx.path }/*这里便将用户访问的路径传入到server-entry中了*/, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data);
        }
      });
    });
  } catch (e) {
    console.log(e);
  }
});

//----------------server-entry.js
import createApp from "./app.js";

// 这里服务端渲染要求打包后的结果需要返回一个函数
// 服务端稍后会调用函数 传递一些参数到这个函数中
export default (context) => {  // 这里的context就是刚才上边传递来的对象
  let { app, router } = createApp();
  router.push(context.url); // 渲染时 先让路由跳转到当前客户请求的路径
  // router路由对象
  return app; // 已经渲染完成了 把当前路径对应的内容渲染好了
};

大功告成!!!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容