实现ssr服务端渲染demo
最近在研究ssr服务器端渲染,自己写了的小demo。
项目布局
├── build // 配置文件 │ │── webpack.base // 公共配置 │ │── webpack.client // 生成client bundle的配置 │ │── webpack.server // 生成server bundle的配置 ├── dist // 项目打包路径 ├── public // 模板文件 │ │── index.html // client模板html文件 │ │── index.ssr.html // server模板html文件 ├── src // 源码目录 │ ├── assets // 图片目录 │ ├── components // 组件 │ │ ├── bar.vue // bar测试组件 │ │ ├── foo.vue // foo测试组件 │ │── app.vue // vue应用的根组件 │ │── main.js // 入口基础文件 │ ├── client-entry.js // 浏览器环境入口 │ ├── server-entry.js // 服务器环境入口 │ │ ├── router.js // 路由配置 │ │ ├── store.js // vuex的状态管理 ├── favicon.ico // 图标
注:以防版本不对应产生的问题。package.json我也把放出来了,不过在文章的最后面
上图是vue官方的ssr原理介绍图片。从这张图片,我们可以知道:我们需要通过webpack打包生成两份bundle文件:
client bundle,给浏览器用。和纯vue前端项目bundle类似
server bundle,供服务端ssr使用,一个json文件
技术栈
vue + vuex + vue-router + webpack +es6/7 + less + koa
拆分 webpack 打包配置
构建文件目录
webpack.base.js 是公共配置,配置如下:
// 基础的webpack配置 // webpack专用配置 const path = require('path') const vueloader = require('vue-loader/lib/plugin') const resolve = dir => { return path.resolve(__dirname, dir) } module.exports = { 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$/, use: ['vue-style-loader', 'css-loader'] }, { test: /\.vue$/, use: 'vue-loader' }, { test: /\.less$/, loader: 'vue-style-loader!css-loader!less-loader' }, { test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, use: { loader: 'url-loader', options: { limit: 300000, name: '[name].[ext]?[hash]' } } } ] }, plugins: [ new vueloader() ] }
webpack.client.js 是生成client bundle的配置,配置如下:
const merge = require('webpack-merge') const base = require('./webpack.base') const path = require('path') const resolve = dir => { return path.resolve(__dirname, dir) } const clientrenderplugin = require('vue-server-renderer/client-plugin') const htmlwebpackplugin = require('html-webpack-plugin') module.exports = merge(base, { entry: { client: resolve('../src/client-entry.js') }, plugins: [ new clientrenderplugin(), new htmlwebpackplugin({ filename: 'index.html', template: resolve('../public/index.html') }) ] })
webpack.server.js是生成server bundle的配置,配置如下:
const merge = require('webpack-merge') const base = require('./webpack.base') const path = require('path') const resolve = dir => { return path.resolve(__dirname, dir) } const vuessrserverplugin = require('vue-server-renderer/server-plugin') const htmlwebpackplugin = require('html-webpack-plugin') module.exports = merge(base, { entry: { server: resolve('../src/server-entry.js') }, target: 'node', // 用给node来使用 // devtool: 'source-map', output: { librarytarget: 'commonjs2' }, plugins: [ new vuessrserverplugin(), new htmlwebpackplugin({ filename: 'index.ssr.html', template: resolve('../public/index.ssr.html'), excludechunks: ['server'] // 排查某个模块 }), ] })
下图是我的项目文件目录
components 目录下是组件
app.vue vue应用的根组件
client-entry.js 浏览器环境入口
server-entry.js 服务器环境入口
main.js 入口基础文件
router.js 路由配置文件
store.js vuex状态管理文件
前端渲染 demo
前端渲染demo部分比较简单,就包含两个组件:foo 和 ba
foo.vue
<template> <div > <p @click="handleclick">foo--{{num}}-点击测试js是否正常</p> <p>{{this.$store.state.name}}</p> <p>-----图片分割线----</p> <img :src="logo" alt=""> <img src="../assets/images/kfbg.png" alt=""> </div> </template> <script> export default { data(){ return { num:0, logo: require('../assets/images/kfbg.png') } }, asyncdata(store) { // asyncdata 方法只在服务端执行,并且只在页面组件中执行 return store.dispatch('changename') }, mounted: function() { this.$store.dispatch('changename') }, methods: { handleclick() { this.num ++; } } } // vue 优化 pwa+ ssr 实现预缓存效果 //vue多页面一般都用ssr写 //学而思、掘金、新闻类网站用的的ssr </script>
bar.vue
<template> <div> bar <p>vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 vue 组件,进行生成 dom 和操作 dom。然而,也可以将同一个组件渲染为服务器端的 html 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。 </div> </template>
app.vue
<template> <div id="app"> <p class="nav"> <router-link :class="{'currentclass':path=='/'}" to="/">foo</router-link> <router-link :class="{'currentclass':path=='/bar'}" to="/bar">bar</router-link> </p> <router-view></router-view> </div> </template> <script> export default { // data(){ // return { // path: this.$store.state.route.path // } // }, computed: { path(){ return this.$store.state.route.path } } } </script> <style lang="less" scope> .nav{ text-align: center; display: flex; align-items: center; a{ flex: 2; background: #f5f5f5; text-decoration:none; color: #333; &.currentclass{ background:#f43553; color: #fff; } } } </style>
router.js
import vue from 'vue' import foo from './components/foo.vue' import vuerouter from 'vue-router' vue.use(vuerouter) export default () => { const router = new vuerouter({ mode: 'history', routes: [ { path: '/', component: foo }, { path: '/bar', component: () => import('./components/bar.vue') }, ] }) return router }
store.js
import vue from 'vue' import vuex from 'vuex' vue.use(vuex) export default () => { const store = new vuex.store({ state: { name: '' }, mutations: { changename(state) { state.name = 'yxf' } }, actions: { changename({ commit }) { return new promise((resolve, reject) => { settimeout(() => { commit('changename') resolve() }) }) } } }) if(typeof window !== 'undefined' && window.__initial_state__) { store.replacestate(window.__initial_state__) } return store }
拆分 js 入口
在前端渲染的时候,只需要一个入口 main.js。现在要做后端渲染,就得有两个 js 文件:client-entry.js 和 server-entry.js 分别作为浏览器和服务器的入口。
main.js基础文件
//入口文件 import vue from 'vue' import createrouter from './router' import app from './app.vue' import createstore from './store' import { sync } from 'vuex-router-sync' // 把当前vuerouter状态同步到vuex中 export default () => { const router = createrouter() const store = createstore() sync(store, router) const app = new vue({ router, store, render: h => h(app) }) return { app, router, store } }
client-entry.js 浏览器入口
import createapp from './main' const { app, router } = createapp() router.onready(() => { app.$mount('#app') })
server-entry.js 服务器入口
import createapp from './main' // 服务器需要调用当前这个文件产生一个vue实例 export default context => { // 涉及到异步组件的问题 return new promise((resolve, reject) => { const { app, router, store } = createapp() // 设置路由 router.push(context.url) // 返回的实例应跳转到 / 如/bar router.onready(() => { const matchs = router.getmatchedcomponents() console.log(matchs.length) if(matchs.length === 0) { reject({ code: 404 }) } // matchs匹配到所有的组件,整个都在服务端执行的 promise.all( matchs.map(component => { if(component.asyncdata) { // asyncdata 是在服务端调用的 return component.asyncdata(store) } }) ).then(() => { // 以上all中的方法,会改变store中的state context.state = store.state;// 把vuex的状态挂载到上下文中,会将状态挂到window上 resolve(app) }).catch(reject) },reject) }) } // 服务器端配置好后,需要导出给node使用
模板文件
index.html client模板html文件
<!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>document</title> </head> <body> <div id="app"></div> <!--vue-ssr-outlet--> </body> </html>
index.ssr.html server模板html文件
<!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>document</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
编写服务端渲染主体逻辑
vue ssr 依赖于包 vue-server-render,它的调用支持两种入口格式:createrenderer 和 createbundlerenderer,前者以 vue 组件为入口,后者以打包后的 js 文件为入口,本文采取后者。
server.js
const koa = require('koa') const router = require('koa-router') const server = new koa() const router = new router() const path = require('path') const static = require('koa-static') const fs = require('fs') const { createbundlerenderer } = require('vue-server-renderer') const serverbundle = require('./dist/vue-ssr-server-bundle.json') //渲染打包后的结果 const template = fs.readfilesync(path.resolve(__dirname, './dist/index.ssr.html'), 'utf8') //客户端manifest.json const clientmanifest = require('./dist/vue-ssr-client-manifest.json') const render = createbundlerenderer(serverbundle, { template, // 模板里必须要有 vue-ssr-outlet clientmanifest }) router.get('/',async ctx => { ctx.body = await new promise((resolve, reject) => { render.rendertostring({url: '/'}, (err, data) => { if(err) reject(err); resolve(data); }) }) }) server.use(router.routes()) // koa 静态服务中间件 server.use(static(path.resolve(__dirname,'./dist'))) server.use( async ctx => { try{ ctx.body = await new promise((resolve, reject) => { render.rendertostring({ url: ctx.url }, (err, data) => { if(err) reject(err) resolve(data) }) }) }catch (e) { ctx.body = '404' } }) server.listen(3002, () => { console.log('服务器已启动!') })
项目地址:https://github.com/xiaonizi66/vue-ssr-demo
package.json
{ "name": "ssr", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"error: no test specified\" && exit 1", "client:dev": "webpack-dev-server --config ./build/webpack.client.js --mode development", "client:build": "webpack --config ./build/webpack.client.js --mode production", "server:build": "webpack --config ./build/webpack.server.js --mode production" }, "author": "", "license": "isc", "dependencies": { "koa": "^2.7.0", "koa-router": "^7.4.0", "koa-static": "^5.0.0", "vue": "^2.6.10", "vue-loader": "^15.7.1", "vue-router": "^3.1.2", "vue-server-renderer": "^2.6.10", "vuex": "^3.1.1", "vuex-router-sync": "^5.0.0" }, "devdependencies": { "@babel/core": "^7.5.5", "@babel/preset-env": "^7.5.5", "babel-loader": "^8.0.6", "css-loader": "^3.2.0", "html-webpack-plugin": "^3.2.0", "less": "^3.9.0", "less-loader": "^5.0.0", "url-loader": "^2.1.0", "vue-style-loader": "^4.1.2", "vue-template-compiler": "^2.6.10", "webpack": "^4.39.1", "webpack-cli": "^3.3.6", "webpack-dev-server": "^3.8.0", "webpack-merge": "^4.2.1" } }
最终渲染效果:
项目运行
git clone https://github.com/xiaonizi66/vue-ssr-demo
npm install
npm run server:build
npm run cilent:build
nodemon server.js
也可在build后面加上 -- --watch 如:npm run server:build -- --watch 用来监听
上一篇: AngularJS 中的数据源的循环输出
下一篇: Pornhub Web 开发者访谈