上一节用很简单的代码粗略的模拟了一下服务端渲染,这节来吧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
字段添加命令dev
和build
。
运行 yarn dev
以上就和我们平时做项目一样,只是这个配置要比vue-cli的简单很多。当前目录结构:
接下来进入正题,服务端渲染(工程化),先来看张图
现在的目录结构:
现在的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.js
和webpack.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
可以看到,文件,样式,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
点击bar
看样子已经完成了。但是刷新时...
第一次正常是因为,走的都是前端路由,而第二次刷新出故障,是因为页面是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; // 已经渲染完成了 把当前路径对应的内容渲染好了
};
大功告成!!!