前言 手动搭建 vue ssr 一直是一些前端开发者的噩梦,因为其中牵扯到很多依赖包之间的配置以及webpack在node中的使用。就拿webpack配置来说,很多前端开发者还是喜欢用webpack-cli脚手架搭建项目。导致这样的原因之一无外乎学习成本高,软件复杂等。这也是有些前端开发者直接拥抱nuxt.js的部分原因。这篇博客使用vue2,以步骤为主,来展示如何创建完整ssr开发环境。
主要参考文章
vue2 ssr 中文官网
webpack 中文官网
webpack-dev-middleware
webpack-hot-middleware
webpack tapable
memory-fs
express
vue2 ssr 官方参考案例
构建ssr所需依赖包 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 { "devDependencies" : { "chokidar" : "^3.5.3" , "css-loader" : "^6.7.3" , "memory-fs" : "^0.5.0" , "vue-loader" : "^15.9.8" , "vue-style-loader" : "^4.1.3" , "webpack" : "^5.54.0" , "webpack-dev-middleware" : "^5.2.1" , "webpack-hot-middleware" : "^2.25.1" , "webpack-node-externals" : "^3.0.0" } , "scripts" : { "dev" : "node ./server/index.cjs" } , "dependencies" : { "express" : "^4.18.2" , "vue" : "^2.6.14" , "vue-router" : "^3.5.2" , "vue-server-renderer" : "^2.6.14" , "vue-template-compiler" : "^2.6.14" , "vuex" : "^3.6.2" , "vuex-router-sync" : "^5.0.0" } }
目录结构 需要创建如下图的目录结构,方能进行后面的代码编写
ssr是如何生成的? 了解完目录架构之后,首先需要知道ssr是如何生成的是至关重要的,只有这样我们才了解后续通过什么样的操作来构建ssr。首先看一张官方给出的构建图。
从图中可以看出,想要实现ssr,必选通过webpack构建生成的服务端bundle文件和客户端bundle文件,服务端bundle在bundle renderer的作用下生成html字符串,并发送给浏览器端并且和客户端bundle一起作用下激活,最终实现ssr。代码中创建html字符串和发送到浏览器 的实现如下所示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let devServerPromise = devServer ((serverBundle, options ) => { renderer = createBundleRenderer (serverBundle, Object .assign (options, { runInNewContext : false , })) }); ROUTER .get ('*' , (req, res ) => { const context = { url : req.url } devServerPromise.then ((random ) => { renderer.renderToString (context).then (html => { res.send (html) }).catch (err => { console .log ('err' ,req.url ,err) }) }) })
其实在构建图中还少一个关键点,如下所示
webpack在编译客户端时会生成客户端构建清单(clientManifest),清单里面的内容其实是当html字符串在浏览器中解析时要获取的资源内容(也可简单理解为client bundle),当解析html时根据内容去请求client bundle。
好!到目前为止,实现vue ssr思路已经很清晰了。在这里简单梳理一下。
flowchart LR
1[生成html字符串]
1-1[clientBundle]
1-2[renderToString函数]
1--需要-->1-1
1--需要-->1-2
1-2-1[serverBundle]
1-2-2[clientManifest]
1-2--依赖-->1-2-1
1-2--依赖-->1-2-2
2[webapck]
2--生成-->1-1
2--生成-->1-2-1
2--生成-->1-2-2
根据上面的图可知,想要实现ssr,就是需要serverBundle、clientBundle、clientManifest文件。而这三种文件又是通过webpack生成的。所以现在的要面临的问题就是配置webpack,生成这三种文件,然后通过renderToString函数实现ssr 。话不多说开始配置。
配置 webpack 在配置webpack之前,需要先创建要打包的代码、app.js、entry-client.js和entry-server.js。创建这些内容的作用是为后续webpack打包做准备。
1. 创建UAC(Universal Application Code)相关文件
在ssr-demo文件夹下的client文件夹中,分别创建router、store和views文件夹和相关内容。
1.1 router文件夹和相关内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import Vue from 'vue' import Router from 'vue-router' Vue .use (Router );export function createRouter ( ){ return new Router ({ mode :'history' , routes :[ {path :'/' ,component : () => import ('../views/index.vue' )}, ] }) }
1.2 store文件夹和相关内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import Vue from 'vue' import Vuex from 'vuex' Vue .use (Vuex );export function createStore ( ){ return new Vuex .Store ({ state :{}, actions :{}, mutations :{} }) }
1.3 view文件夹和相关内容 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <template > <div > kinghiee ssr test </div > </template > <script > export default {} </script > <style > </style >
2. 创建app.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import Vue from 'vue' ;import App from './App.vue' ;import { createRouter } from './router/index' ;import { createStore } from './store' ;import { sync } from 'vuex-router-sync' ;export function createApp ( ) { const router = createRouter (); const store = createStore (); sync (store, router); const app = new Vue ({ router, store, render : h => h (App ), }) return { app, router, store } };
app.js文件的作用: 使用简单工厂模式,创建vue实例,为每个请求创建新的应用程序实例,避免状态单例。更加详细解释请查看官网 ;
3. 创建entry-client.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 import { createApp } from './app' ;import Vue from 'vue' ;const { app, router, store } = createApp ();Vue .mixin ({ beforeRouteUpdate (to, from , next ) { const { asyncData } = this .$options if (asyncData) { asyncData ({ store : this .$store , route : to }).then (next).catch (next) } else { next () } } }); if (window .__INITIAL_STATE__ ) { store.replaceState (window .__INITIAL_STATE__ ); } router.onReady (() => { router.beforeResolve ((to, from , next ) => { const matched = router.getMatchedComponents (to); const prevMatched = router.getMatchedComponents (from ); let diffed = false ; const activated = matched.filter ((c, i ) => { return diffed || (diffed = (prevMatched[i] !== c)) }) if (!activated.length ) { return next (); } Promise .all (activated.map (c => { if (c.asyncData ) { return c.asyncData ({ store, route : to }) } })).then (() => { next (); }).catch (next) }) app.$mount('#app' ) })
4. 创建entry-server.js文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import { createApp } from './app' export default context => { return new Promise ((resolve, reject ) => { const { app, router, store } = createApp () router.push (context.url ) router.onReady (() => { const matchedComponents = router.getMatchedComponents () if (!matchedComponents.length ) { return reject ({ code : 404 }) } Promise .all (matchedComponents.map (Component => { if (Component .asyncData ) { return Component .asyncData ({ store, route : router.currentRoute }) } })).then (() => { context.state = store.state resolve (app) }).catch (reject) }, reject) }) }
注:entry-client.js和entry-server.js文件以及文件中为什么这么写在官网中都能找的到,这里只说明搭建步骤 官网
相关代码创建完毕之后,client文件夹内容大致如下
到此为止,webpack打包前期的准备工作已经结束。接下来开始配置webpack打包
5. webpack配置
在ssr webpack打包配置中分为客户端和服务端配置。客户端打包配置主要生成后面用到的客户端bundle和clientManifest,而服务端打包配置主要生成后面用到的服务端bundle,这三种文件正是ssr所需的关键文件。
5.1 客户端打包配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 const { resolve : RESOLVE } = require ('path' );const WEBPACK = require ('webpack' );const { VueLoaderPlugin : VUELOADERPLUGIN } = require ('vue-loader' );const VUESSRCLIENTPLUGIN = require ('vue-server-renderer/client-plugin' )module .exports = { mode : 'development' , entry : { app : RESOLVE (__dirname, '../client/entry-client.js' ) }, output : { path : RESOLVE (__dirname, '../dist' ), filename : 'src/[name].[contenthash:6].js' , publicPath : '/dist/' }, module : { rules : [ { test : /\.vue$/ , loader : 'vue-loader' }, { test : /\.css$/i , use : ["vue-style-loader" , "css-loader" ], }, ] }, resolve : { extensions : ['.js' , '.ts' , '.vue' , '.json' ], alias : { '@client' :RESOLVE (__dirname,'../client' ) } }, plugins : [ new VUELOADERPLUGIN (), new VUESSRCLIENTPLUGIN ({ filename : 'src/vue-ssr-client-manifest.json' }) ] }
5.2 服务端打包配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 const { resolve : RESOLVE } = require ('path' );const { VueLoaderPlugin : VUELOADERPLUGIN } = require ('vue-loader' );const VUESERVERPlUGINSSR = require ('vue-server-renderer/server-plugin' )const NODEEETERNALS = require ('webpack-node-externals' );module .exports = { target : 'node' , devtool : 'eval-cheap-source-map' , entry : RESOLVE (__dirname, '../client/entry-server.js' ), output : { path : RESOLVE (__dirname, '../dist' ), filename : 'server-bundle.js' , libraryTarget : 'commonjs2' }, module : { rules : [ { test : /\.vue$/ , loader : 'vue-loader' }, { test : /\.css$/i , use : ["vue-style-loader" , "css-loader" ], }, ] }, externalsPresets : { node : true }, externals : [NODEEETERNALS ()], plugins : [ new VUELOADERPLUGIN (), new VUESERVERPlUGINSSR ({ filename : 'src/vue-ssr-server-bundle.json' }) ] }
webpack 客户端和服务端都已配置好了,那如何生成相应的三种文件呐?其实生成这三种文件需要在webpack编译阶段,而对于配置开发环境来说,一般还用到热更新和webpack dev中间件,所以webpack编译和热更新常在一起出现。目前为止该准备的都已经到位了,现在就可以开始webpack编译和配置热更新操作了。
webpack编译和配置热更新 在server文件夹内创建如下文件夹和文件
1. webpack编译 本博客给出的代码示例和官方的示例组织上有不同的地方,但功能上一样。本博客按照功能的不同对代码进行了合理的拆分和封装,而不是把全部功能写到一个函数下面。这样做的目的是关注点单一、功能单一、便于开发和维护。
1.1 编译客户端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 let webpack = require ('webpack' );let path = require ('path' );module .exports = function clientCompile (clientConfig, clientManifestCb ) { clientConfig.entry .app = [ 'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true' , clientConfig.entry .app ]; clientConfig.output .filename = '[name].[contenthash:6].js' ; clientConfig.plugins .push ( new webpack.HotModuleReplacementPlugin (), new webpack.NoEmitOnErrorsPlugin (), ); let clientCompiler = webpack (clientConfig); let devMiddleware = require ('webpack-dev-middleware' )(clientCompiler, { publicPath : clientConfig.output .publicPath , serverSideRender : true , stats : { colors : true , modules : true , }, }); clientCompiler.hooks .done .tap ('done' , stats => { stats = stats.toJson ({stats :'errors-warnings' }); stats.errors .forEach (err => console .error (err)); stats.warnings .forEach (err => console .warn (err)); if (stats.errors .length ) return ; console .log ('\n客户端更新...\n' ); let manifestContent = devMiddleware.context .outputFileSystem .readFileSync ( path.resolve (clientConfig.output .path , 'src/vue-ssr-client-manifest.json' ), 'utf-8' ); clientManifestCb (JSON .parse (manifestContent)); }); let hotMiddleware = require ('webpack-hot-middleware' )(clientCompiler); return { devMiddleware, hotMiddleware } }
如上代码所示,把编译客户端的代码写到一个文件内,并导处客户端编译函数,在其他地方用。
1.2 编译服务端 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 let webpack = require ('webpack' );let path = require ('path' );const MFS = require ('memory-fs' );module .exports = function serverCompile ( serverConfig, serverBundleCb ) { let serverCompiler = webpack (serverConfig); let mfs = new MFS (); serverCompiler.outputFileSystem = mfs; serverCompiler.watch ({ ignored : /node_modules/ , }, (err, stats ) => { if (err) throw err; stats = stats.toJson (); if (stats.errors .length ) return ; console .log ('\n服务端更新...\n' ); let bundlePath = path.resolve ( serverConfig.output .path , 'src/vue-ssr-server-bundle.json' ); serverBundleCb (JSON .parse (mfs.readFileSync (bundlePath, 'utf-8' ))) }); }
写完客户端和服务端编译后,需要把函数导出来在后面的地方使用。代码如下
2. webpack编译和热更新配置 dev.cjs文件如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 const SERVER = require ('express' );const ROUTER = SERVER .Router ();const FS = require ('fs' );const PATH = require ('path' );let clientConfig = require ('../../build/webpack.client.dev' );let serverConfig = require ('../../build/webpack.server.dev' ); let templatePath = PATH .resolve (__dirname, '../server.template.html' );let { createBundleRenderer } = require ('vue-server-renderer' )let serverCompile = require ('../dev/serverCompile.cjs' );let clientCompile = require ('../dev/clientCompile.cjs' );let tempWatch = require ('../dev/tempWatch.cjs' );let renderer;const devServer = (cb ) => { let clientManifest, serverBundle, readyResolve, templateContent; templateContent = FS .readFileSync (templatePath, 'utf-8' ); let readyPromise = new Promise (resolve => readyResolve = resolve ); let updateClientAndServer = ( ) => { if (clientManifest && serverBundle) { readyResolve (); cb (serverBundle, { template : templateContent, clientManifest }) } }; tempWatch (templatePath, () => { updateClientAndServer (); }); let { devMiddleware, hotMiddleware} = clientCompile ( clientConfig, (clientManifestContent ) => { clientManifest = clientManifestContent; updateClientAndServer (); }) ROUTER .use (devMiddleware); ROUTER .use (hotMiddleware); serverCompile (serverConfig, (serverBundleContent ) => { serverBundle = serverBundleContent; updateClientAndServer (); }) return readyPromise; } let devServerPromise = devServer ((serverBundle, options ) => { renderer = createBundleRenderer (serverBundle, Object .assign (options, { runInNewContext : false , })) });
在devServer函数中,分别使用clientCompile函数,编译客户端。在回调函数把生成的clientManifestContent内容赋值给clientManifest,然后通知updateClientAndServer函数完成后续内容。同时clientCompile函数也返回了两个中间件并放入use函数中,完成后续的热更新和webpack dev server. 和clientCompile函数类似,serverCompile函数,它也是在回调函数中把生成的serverBundleContent赋值给serverBundle,并通知updateClientAndServer函数完成其他内容。在updateClientAndServer函数中,当clientManifest和serverBundle内容都有时,就可以把promise resolve掉,进而可以调用renderToString函数生成html字符串,发送给浏览器,最后实现ssr。
在devServer函数中还是用了tempWatch,该函数的作用是当模板文件发生变化时,更新相关内容。代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 let fs = require ('fs' );let chokidar = require ('chokidar' );module .exports = function tempWatch (templatePath, watchCb ) { chokidar.watch (templatePath).on ('change' , () => { console .log ('模板更新中...' ); templateContent = fs.readFileSync (templatePath, 'utf-8' ); console .log ('模板更新成功!' ); watchCb (); }); }
到此为止,vue ssr的配置和生成基本结束。但是现在通过浏览器还是访问不了,还需要最后一步配置服务器
配置服务器提供访问 在dev.cjs中添加路由配置,然后导处路由,在程序入口处使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 const SERVER = require ('express' );const ROUTER = SERVER .Router ();const FS = require ('fs' );const PATH = require ('path' );let clientConfig = require ('../../build/webpack.client.dev' );let serverConfig = require ('../../build/webpack.server.dev' ); let templatePath = PATH .resolve (__dirname, '../server.template.html' );let { createBundleRenderer } = require ('vue-server-renderer' )let serverCompile = require ('../dev/serverCompile.cjs' );let clientCompile = require ('../dev/clientCompile.cjs' );let tempWatch = require ('../dev/tempWatch.cjs' );let renderer;const devServer = (cb ) => { let clientManifest, serverBundle, readyResolve, templateContent; templateContent = FS .readFileSync (templatePath, 'utf-8' ); let readyPromise = new Promise (resolve => readyResolve = resolve ); let updateClientAndServer = ( ) => { if (clientManifest && serverBundle) { readyResolve (); cb (serverBundle, { template : templateContent, clientManifest }) } }; tempWatch (templatePath, () => { updateClientAndServer (); }); let { devMiddleware, hotMiddleware} = clientCompile ( clientConfig, (clientManifestContent ) => { clientManifest = clientManifestContent; updateClientAndServer (); }) ROUTER .use (devMiddleware); ROUTER .use (hotMiddleware); serverCompile (serverConfig, (serverBundleContent ) => { serverBundle = serverBundleContent; updateClientAndServer (); }) return readyPromise; } let devServerPromise = devServer ((serverBundle, options ) => { renderer = createBundleRenderer (serverBundle, Object .assign (options, { runInNewContext : false , })) }); ROUTER .get ('*' , (req, res ) => { const context = { url : req.url } devServerPromise.then (() => { renderer.renderToString (context).then (html => { res.send (html) }).catch (err => { console .log ('err' ,req.url ,err) }) }) }) module .exports = ROUTER ;
在程序入口处使用该路由
1 2 3 4 5 6 7 8 9 10 11 const SERVER = require ('express' )();const SSRROUTER = require ('./router/dev.cjs' );const PORT = 8000 ;SERVER .use (SSRROUTER );SERVER .listen (PORT ,() => { console .log (`app listening at port ${PORT} ` ); });
最后输入npm run dev启动项目,结果如下
注: 配置ssr的过程有点繁琐,如果途中有配置错的地方可以查看我的github ssr demo