SSR 原理
手动搭建 SSR 项目
安装依赖
webpack
:
- webpack
- webpack-cli
- webpack-dev-server
- webpack-merge
vue
:
- vue
- vue-loader
- vue-template-compiler
- vue-server-renderer
js
:
- babel-loader
- @babel/core
- @babel/preset-env
css
:
- vue-style-loader
- css-loader
other
:
- express
- html-webpack-plugin
搭建 vue 开发环境
-
/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>Document</title> </head> <body> <div id="app"></div> </body> </html>
-
/src/app.vue
客户端根组件<template> <div id="app"> <h1>我是一个vue组件</h1> <demo /> </div> </template> <script> import demo from './components/demo.vue'; export default { components: { demo } } </script> <style> </style>
-
/src/components/demo.vue
自定义组件<template> <div> <input type="text" v-model="val"> <p>输入框中的内容为:{{val}}</p> <div class="button" @click="show">click me</div> </div> </template> <script> export default { data() { return { val: 'SSR' } }, methods: { show() { alert(this.val); } } } </script> <style scoped> .button { width: 100px; height: 30px; background: #fac; text-align: center; line-height: 30px; border: 1px solid; border-radius: 5px; } </style>
-
/src/main.js
客户端入口文件import Vue from 'vue'; import App from './app.vue'; new Vue({ el: '#app', render: h => h(App), });
-
/webpack.config.js
客户端配置文件const HtmlWebpackPlugin = require('html-webpack-plugin'); const { resolve } = require('path'); const { VueLoaderPlugin } = require('vue-loader'); module.exports = { entry: './src/main.js', output: { filename: 'bundle.js', path: resolve('./dist') }, module: { rules: [ { test: /.vue$/, use: 'vue-loader', }, { test: /.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } }, exclude: /node_modules/ }, { test: /.css$/, use: ['vue-style-loader', 'css-loader'] } ] }, plugins: [ new VueLoaderPlugin(), new HtmlWebpackPlugin({ filename: 'index.html', template: resolve('./public/index.html') }) ] }
-
运行
npx webpack-dev-server
启动服务
搭建服务端环境
-
/src/main.js
入口文件import Vue from 'vue'; import App from './app.vue'; // 由于服务端渲染不需要 el 这个属性,则将这个封装成工厂函数,每次访问服务都是一个新的实例 export default function () { const app = new Vue({ el: '#app', render: h => h(App), }); return { app } }
-
/src/entry/client.entry.js
创建客户端入口文件import createApp from '../main.js'; const { app } = createApp(); app.$mount('#app');
-
/src/entry/server.entry.js
创建服务端入口文件import createApp from '../main.js'; export default function () { const { app } = createApp(); return app; }
-
配置文件
- 根据
client.entry.js、server.entry.js
两个入口重新配置配置文件; - 将之前的配置文件重构;
JavaScriptJavaScriptJavaScript// /webpack/webpack.base.js const { resolve } = require('path'); const { VueLoaderPlugin } = require('vue-loader'); module.exports = { output: { filename: '[name].bundle.js', path: resolve('./dist'), publicPath: '/' }, module: { rules: [ { test: /.vue$/, use: 'vue-loader', }, { test: /.js$/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env'] } }, exclude: /node_modules/ }, { test: /.css$/, use: ['vue-style-loader', 'css-loader'] } ] }, plugins: [ new VueLoaderPlugin(), ] }
// /webpack/webpack.client.js const HtmlWebpackPlugin = require("html-webpack-plugin"); const { default: merge } = require("webpack-merge"); const base = require('./webpack.base.js'); const { resolve } = require('path'); const VueSSRClientPlugin = require('vue-server-renderer/client-plugin'); module.exports = merge(base, { entry: { 'client': './src/entry/client.entry.js' }, plugins: [ new VueSSRClientPlugin(), new HtmlWebpackPlugin({ filename: 'index.html', template: resolve('./public/index.html') }) ] });
// /webpack/webpack.server.js const { merge } = require('webpack-merge'); const base = require('./webpack.base.js'); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin'); module.exports = merge(base, { entry: { server: './src/entry/server.entry.js' }, output: { libraryTarget: 'commonjs2', // 设置了该选项后 // module.exports = (() => { // return { xxx }; // })()); }, target: 'node', // 运行环境 plugins: [ new VueSSRServerPlugin() ] });
- 根据
-
配置 package.json 脚本命令
"scripts": { "dev": "webpack-dev-server --config ./webpack/webpack.client.js --mode development", "build:client": "webpack --config ./webpack/webpack.client.js --mode production", "build:server": "webpack --config ./webpack/webpack.server.js --mode production" },
-
/server/index.js
服务端JavaScriptHTMLconst express = require('express'); const server = express(); const fs = require('fs'); const { resolve } = require('path'); const { createBundleRenderer } = require('vue-server-renderer'); // 可以帮我们自动实现客户端激活(添加 id="app" 和自动引入 client.bundle.js) const serverBundle = require('../dist/vue-ssr-server-bundle.json'); // 服务端打包内容 const clientManifest = require('../dist/vue-ssr-client-manifest.json'); // manifest 可以预加载 // 开启静态资源服务器,用于客户端激活的时候,获取 客户端的打包内容 client.bundle.js server.use(express.static(resolve('../dist'), { index: false })); server.get('*', async (req, res) => { try { // 2. 创建渲染器 const render = createBundleRenderer(serverBundle, { template: fs.readFileSync('./index.ssr.html', 'utf-8'), clientManifest }); const html = await render.renderToString(); res.send(html) // 3. 利用渲染器将vue实例转化成html字符串 } catch (error) { console.log(error); res.status(500).send('服务器错误'); } // res.send('hello'); }); server.listen(12306, () => console.log('server is run at 12306'));
<!-- /server/index.ssr.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>Document</title> </head> <body> <!--vue-ssr-outlet--> <!-- <script src="/client.bundle.js"></script> --> </body> </html>
-
nodemon index.js
进入 server 文件夹 启动服务端服务
路由处理
-
安装 vue-router
npm i vue-router
-
/src/router.js
路由表import Vue from 'vue'; import VueRouter from 'vue-router'; Vue.use(VueRouter); export default function () { const router = new VueRouter({ mode: 'history', routes: [ { path: '/', component: () => import('./components/home.vue') }, { path: '/demo', component: () => import('./components/demo.vue') } ] }); return router; }
-
/src/main.js
客户端引入 vue routerimport Vue from 'vue'; import App from './app.vue'; import createRouter from './router'; export default function () { const router = createRouter(); const app = new Vue({ render: h => h(App), router, }); return { app, router } }
-
客户端相关组件
HTMLHTML<!-- app.vue --> <template> <div id="app"> <h1>我是一个vue组件</h1> <router-link to="/">首页</router-link> <router-link to="/demo">Demo</router-link> <router-view></router-view> </div> </template> <script> export default {}; </script> <style></style>
<!-- /src/components/home.vue --> <template> <div> <h1>我是Home页面</h1> </div> </template> <script> export default { }; </script> <style></style>
-
/server/index.js
服务端修改const express = require('express'); const server = express(); const fs = require('fs'); const { resolve } = require('path'); const { createBundleRenderer } = require('vue-server-renderer'); const serverBundle = require('../dist/vue-ssr-server-bundle.json'); const clientManifest = require('../dist/vue-ssr-client-manifest.json'); server.use(express.static(resolve('../dist'), { index: false })); server.get('*', async (req, res) => { // /demo, req.url /demo try { const url = req.url; // 2. 创建渲染器 const render = createBundleRenderer(serverBundle, { template: fs.readFileSync('./index.ssr.html', 'utf-8'), clientManifest }); const html = await render.renderToString({url}); res.send(html) // 3. 利用渲染器将vue实例转化成html字符串 } catch (error) { console.log(error); if (error.code == 404) { res.status(404).send('页面去火星了,找不到了,404啦'); return; } res.status(500).send('服务器错误'); } // res.send('hello'); }); server.listen(12306, () => console.log('server is run at 12306'));
-
/src/entry/server.entry.js
服务端入口引入 vue routerimport createApp from '../main.js'; export default function (ctx) { // 有可能是异步路由,则使用 promise return new Promise((resolve, reject) => { const { app, router, store } = createApp(); router.push(ctx.url); router.onReady(() => { // 判断当前路由下是否存在组件 const matchedComponents = router.getMatchedComponents(); if (matchedComponents.length == 0) { return reject({code: 404}); } resolve(app); }, reject); }); }
-
nodemon index.js
进入 server 文件夹 启动服务端服务
通过 vuex 数据预取
-
安装 vuex
npm i vuex
-
/src/store.js
创建 store.js// 数据预取 import Vue from 'vue'; import Vuex from 'vuex'; Vue.use(Vuex); export default function () { const store = new Vuex.Store({ state: { name: '', }, mutations: { setName(state, val) { state.name = val; } }, actions: { getName({ commit }) { return new Promise(resolve => { setTimeout(function () { resolve('SSR'); }, 300); }).then(val => { commit('setName', val); }) } } }); return store; }
-
/src/main.js
客户端引入 vueximport Vue from 'vue'; import App from './app.vue'; import createRouter from './router'; import createStore from './store'; export default function () { const router = createRouter(); const store = createStore(); const app = new Vue({ render: h => h(App), router, store }); return { app, router, store } }
-
home.vue
使用 vuex<template> <div> <h1>我是Home页面</h1> <div>欢迎,{{ name }}</div> </div> </template> <script> import { mapState } from 'vuex'; export default { computed: { ...mapState(['name']), }, created() { this.$store.dispatch('getName'); }, // 服务端调用,需要在服务端入口文件中处理 asyncData(store) { return store.dispatch('getName'); }, }; </script> <style></style>
-
/src/entry/server.entry.js
服务端入口中通过 asyncData 预取数据存储在 window.INITIAL_STATE 中import createApp from '../main.js'; export default function (ctx) { return new Promise((resolve, reject) => { const { app, router, store } = createApp(); router.push(ctx.url); router.onReady(() => { // 判断当前路由下是否存在组件 const matchedComponents = router.getMatchedComponents(); if (matchedComponents.length == 0) { return reject({code: 404}); } // 判断组件中是否存在 asyncData 方法,有则执行 asyncData Promise.all(matchedComponents.map(c => { if (c.asyncData) { return c.asyncData(store) } })).then(() => { // 注入到 window.__INITIAL_STATE__ 该全局属性上 ctx.state = store.state; resolve(app); }).catch(reject); // resolve(app); }, reject) }); }
-
/src/entry/client.entry.js
客户端更新 storeimport createApp from '../main.js'; const { app, store } = createApp(); app.$mount('#app'); // 如果存在 window.__INITIAL_STATE__ 说明服务端通过 vuex 预取数据了,则需要客户端更新 store if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); }
-
nodemon index.js
进入 server 文件夹 启动服务端服务
SSR🤜 同构渲染基础
上一篇