Vue服务端渲染
前言: 现在浏览器对于spa页面的抓取能力还是比较差,本文从零开始搭建一个简单的Vue服务端渲染的案例。
本质
- 服务端和客户端共用代码和入口文件
- 通过webpack分开打包服务端和客户端代码 , 然后打包
- 通过webpack打包后服务端的bundle后,通过服务端模板
server-index.html和vue-server-renderer生成一个渲染bundler,返回返回给用户,其中服务端模板需要引入客户端打包的js来进行一下事件和交互。
项目构建
-
创建项目
mkdir vue-ssr && cd vue-ssr -
初始化项目
npm init -y -
添加对应的依赖
## 安装webpack依赖和插件 npm install -D webpack webpack-cli webpack-dev-server webpack-merge # 安装对应的babel处理js文件 npm install -D babel-loader @babel/core @babel/preset-env # 安装css依赖 npm install -D vue-style-loader css-loader # 安装对应的vue依赖 npm install vue vue-template-compiler -
创建目录结构
├── config # 配置文件 │ ├── webpack.base.js # webpack基础配置文件 │ ├── webpack.client.js # 客户端配置文件 ├── package-lock.json ├── package.json ├── public # 存放模板目录 │ ├── index.html ├── src # 源码目录 │ ├── App.vue # 入口文件vue │ ├── app.js # 入口打包工厂函数文件 │ ├── component # 组件目录 │ │ ├── Bar.vue │ │ └── Foo.vue │ ├── entry-client.js # webpack打包文件 -
编写webpack.base.js
const path = require('path'); const VueLoaderPlugin = require('vue-loader/lib/plugin'); module.exports = { mode: 'production', output: { filename: '[name].bundle.js', path: path.resolve(__dirname, '../dist') }, module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'], } }, exclude: /node_modules/, }, { test: /\.vue$/, use: ['vue-loader'] }, { test: /\.css$/, use: ['vue-style-loader', 'css-loader'] } ] }, plugins: [ new VueLoaderPlugin() ] }; -
编写客户端打包文件webpack.client.js
const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const merge = require('webpack-merge'); const base = require('./webpack.base.js'); module.exports = merge(base, { entry: { client: path.resolve(__dirname, '../src/entry-client.js'), }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../public/index.html'), }), ] }); -
public目录下存放index.html
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>Document</title> </head> <body> <div id="app"></div> </body> </html> -
简单的编写2个组件
Bar.vue Foo.vue# Bar.vue <template> <div style="background: red;" @click="show"> bar </div> </template> <script> export default { methods: { show() { alert(1); } } }; </script> # Foo.vue <template> <div style="background: #abcdef;"> Foo </div> </template> -
app.vue的编写
<template> <div id="app"> app的vue <Bar></Bar> <Foo></Foo> </div> </template> <script> import Bar.vue form './component/bar.vue' import Foo.vue form './component/foo.vue' export default { metaInfo: { title: 'My Awesome Webapp', }, components: { Bar, Foo }, } </script>
核心部分
入口文件
-
入口文件app.js
import Vue from 'vue'; import App from './App.vue'; export default () => { const app = new Vue({ render: (h) => h(App), }); return { app }; }解析:为什么这里要导出一个函数,而不是直接导出一个实例,如果不导出一个函数,在客户端,我们每一个用户都是新建了一个实例,这样不会有问题,但是在服务器,由于程序一直运行,所以的用户都会共享同一个实例,这样就不会出现混乱,所以要每次访问都要返回一个新的实例,这样就不会有冲突
客户端部分
-
客户端webpack打包文件
webpack.client.jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const merge = require('webpack-merge'); const base = require('./webpack.base.js'); module.exports = merge(base, { entry: { client: path.resolve(__dirname, '../src/entry-client.js'), }, plugins: [ new HtmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../public/index.html'), }), ] });到这一步我们已经初步搭建了客户端开发的流程,启动webpack-dev-server就可以看到页面了
-
配置脚步
package.json"scripts": { "client:dev": "webpack-dev-server --config ./config/webpack.client.js", "client:build": "webpack --config ./config/webpack.client.js", "server:build": "webpack --config ./config/webpack.server.js" }
服务端部分
-
通过
vue-server-renderer来进行服务端渲染npm install vue-server-renderer --save -
服务端入口
entry-server.jsimport createApp from './app'; // 服务器每次请求调用此函数,产生一个新的APP实例 export default () => { let { app, router } = createApp(); return app; } -
服务端的模板
server-index.html<!doctype html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> {{{ meta.inject().title.text() }}} </head> <body> <!--vue-ssr-outlet--> <script src="/client.bundle.js"></script> </body> </html> -
服务端webpack配置文件
webpack.server.jsconst path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const merge = require('webpack-merge'); const base = require('./webpack.base.js'); module.exports = merge(base, { target: 'node', entry: { server: path.resolve(__dirname, '../src/entry-server.js'), }, output: { libraryTarget: 'commonjs2' }, plugins: [ new HtmlWebpackPlugin({ filename: 'server-index.html', template: path.resolve(__dirname, '../public/server-index.html'), excludeChunks: ['server'] }), ] });解析: 由于打包后的bundler是给服务端使用的,而webpack默认的target是客户端,所以需要指明target是node,打包后,在内部会被
vue-server-renderer通过require(xxxx)使用,所以要指定webpack打包后通过commonjs2导出打包文件
# 运行脚本打包客户端和服务端代码 npm run client:build npm run server:build启动node服务
-
安装express依赖
npm i express -
编写
node-server.js// vue-server-renderer const express = require('express'); const VueServerRenderer = require('vue-server-renderer'); const fs = require('fs'); # 引入服务器打包后的文件和模板 const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf-8'); const template = fs.readFileSync('./dist/server-index.html', 'utf-8'); const app = express(); # 创建渲染函数 const render = VueServerRenderer.createBundleRenderer(serverBundle, { template }); app.get('/', (req, res) => { render.renderToString(function (err, html) { res.send(html); }); }); app.listen(4000);解析:
- 通过VueServerRenderer创建渲染函数,其中通过模板template把服务端打包的代码放入指定的位置中()
- 然后在调用render.renderToString 发送给用户
注意点
- 注意我们打包后并没有使用客户端的index.html, 而我们的客户端打包的js有需要根元素是#app,所以我们再app.vue中的根元素中添加了#app的div根标签,因为APP.vue也会被服务端引入到渲染函数中,返回给客户端
引入Vue-router
核心: 本质上当用户在浏览器上输入
localhost:4000/foo的时候,服务端只是访问了/, 然后通过router.push('/foo')跳转到指定页面。通过VueServerRenderer的render.renderToString传递访问路径参数给服务端的入口文件
entry-server.js-
安装依赖
npm i vue-router -
创建初始文件
router.js#router.js import Vue from 'vue'; import Router from 'vue-router'; import Bar from './component/Bar.vue'; import Foo from './component/Foo.vue'; Vue.use(Router); export default () => { const router = new Router({ mode: 'history', routes: [ { path: '/', component: Bar }, { path: '/foo', component: Foo } ] }); return router; }解析: 和 vue实例一样,我们需要创建一个函数,来让每一次访问都生成一个新的router实例
-
修改app.js
import Vue from 'vue'; import App from './App.vue'; import createRouter from './router.js'; export default () => { const store = createVuex(); const app = new Vue({ render: (h) => h(App), router, }); return { app, router }; } -
修改app.vue
<template> <div id="app"> app的vue <router-link to="/">Bar组件</router-link> <router-link to="/foo">foo组件</router-link> <router-view></router-view> </div> </template> <script> export default { } </script> -
修改entry-server.js
import createApp from './app'; // 服务器调用此函数,产生一个新的APP实例 export default (context) => { // 如果服务端访问/foo, 会首先访问首页,然后通过路由跳转到指定的路径到/foo return new Promise(((resolve, reject) => { let { app, router } = createApp(); console.log('entry-server'); router.push(context.url); // 跳转到指定路由 resolve(app); })); } -
修改node-server.js
........ // 如果访问的路径不存在 app.get('*', (req, res) => { const context = { url: req.url }; render.renderToString(context, (err, html) => { if (err) { if (err.code === 404) { res.status(404).end('Page not found') } else { res.status(500).end('Internal Server Error') } } else { res.end(html) } }); }); app.listen(4000); ........
引入Vuex
-
安装依赖
npm i vuex -
创建初始化文件
store.jsimport Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); export default () => { const store = new Vuex.Store({ state: { username: 'yx' }, mutations: { set_username(state) { state.username = 'hcc'; } }, actions: { set_username({ commit }) { return new Promise((resolve, reject) => { setTimeout(() => { commit('set_username'); resolve(); }, 1000); }); } } }); // 如果是浏览器环境,就直接用已经注入到全局环境的对象替换掉state if (typeof window === 'object' && window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } return store; } -
添加Vuex,为每一个实例创建一个store,不同的用户维护不同的store
# app.js import Vue from 'vue'; import App from './App.vue'; import createVuex from './store.js'; import createRouter from './router.js'; export default () => { const router = createRouter(); const store = createVuex(); const app = new Vue({ render: (h) => h(App), router, store }); return { app, router, store }; } -
修改服务端入口
entry-server.jsimport createApp from './app'; // 服务器调用此函数,产生一个新的APP实例 export default (context) => { // 如果服务端访问/foo, 会首先访问首页,然后通过路由跳转到指定的路径到/foo return new Promise(((resolve, reject) => { let { app, router, store } = createApp(); router.push(context.url); // 跳转到指定路由 console.log('entry-server的入口'); router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,执行 reject 函数,并返回 404 if (!matchedComponents.length) { return reject({ code: 404 }); } Promise.all(matchedComponents.map(component => { // 调用组件的特定的方法(只存在于服务端中) if (component.asyncData) { return component.asyncData({ store }); } })).then(() => { context.state = store.state; context.meta = app.$meta(); resolve(app); }).catch((e) => { console.log(e); return reject({ code: 404 }); }); // Promise 应该 resolve 应用程序实例,以便它可以渲染 }); })); }## Bar.vue <template> <div style="background: red;" @click="show"> bar vuex数据 {{$store.state.username}} </div> </template> <script> export default { # 服务端特有的方法 asyncData({ store }) { return store.dispatch('set_username'); }, # 客户端特有 mounted() { this.$store.dispatch('set_username'); }, methods: { show() { alert(1); } } }; </script>
-