注意:这是笔者用于记录自己学习SSR的一篇文章,用于梳理将一个项目进行服务器渲染的过程,本文仅是读者根据demo得出的理解,如果您想通过本文来学习如何部署ssr,笔者建议您查阅其他更权威的资料进行学习;当然,如果您发现了本文中有任何描述不恰当的地方,还恳请您指出更正。
1. 什么是服务器渲染?
我们现在有同一个项目,当访问8000端口时,我们是客户端渲染,当访问3333端口时,我们是服务器渲染。
http://localhost:8000/footer

http://localhost:3333/footer

从页面的显示来看,其实并没有什么区别,但是如果我们查看源码,我们就会发现很大的不同:
view-source:http://localhost:8000/header,这是我们访问未使用服务器渲染的页面源码:

view-source:http://localhost:3333/footer,这是我们访问服务器渲染的页面源码:

1.1 服务端渲染 vs 客户端渲染
1.1.1 服务端渲染(SSR)的优势
从这两份源码我们可以知道,服务器渲染后返回到浏览器的源码中,已经包含了我们页面中的节点信息,也就是说网络爬虫可以抓取到完整的页面信息,所以,服务器渲染更利于SEO;
首屏的渲染是通过node发送过来的html字符串,而并不依赖js文件,更利于首屏渲染,这会使用户更快的看到网页内容,尤其是针对大型的单页面应用,打包后的文件体积比较大,破铜客户端渲染加载所有所需文件时间比较长,首页会有一个很长的白屏时间。
1.1.2 服务端渲染的局限性
- 服务器负荷更高:
传统模式下通过客户端完成渲染,现在统一到了服务端node去完成。尤其是高并发访问的情况,会大量占用服务器CPU资源 - 开发环境受限:
在服务端渲染中,只会执行到componentDidMount之前的生命周期钩子,因此项目引用的第三方的库也不可用其它生命周期钩子,这对引用库的选择产生了很大的限制
注意,这并不意味者我们不能使用其他生命周期钩子函数,这里的意思是只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。 - 学习成本较高
除了对webpack、Vue要熟悉,还需要掌握node、Koa等相关技术。相对于客户端渲染,项目构建、部署过程更加复杂。这也意味着维护成本也会相应地增加。
1.2 服务器渲染和客户端渲染的行为比较
2. 服务器渲染的简单Demo
- 我们需要将app.js按照不同方式进行打包,Server Entry用于打包服务器渲染需要的代码Server Bundle,Client Entry 用于打包客户端渲染需要的代码Client Bundle。
- Server Bundle用于构建Bundle Renderer,Bundle Renderer根据用户的请求生成首屏Html代码;
- Client Bundle用于支持浏览器上的单页面需求。这里可能有点难以理解,Hydrate在Vue官网上面被译作水合,我们思考,如果没有Client Bundle 被Hydrate 到客户端的html中,此时的客户端上html中还不存在接管单页面应用的逻辑js,若点击了某一个路由,由于没有处理路由的js函数,客户端将重新发起请求到服务端,服务端根据用户请求再次渲染出相应的html返回。所以,我们还需要将Client Bundle加载到html中用于单页面应用的逻辑处理(注意:此处是笔者的个人理解,如果有误,希望指出修正。)。
接下来,我们按照上图,一步一步将项目应用到服务器渲染。
2.1 createApp.js
这里的createApp.js就是上图中的app.js
如果我们不进行服务端渲染,那么我们的vue项目打包的入口文件一般情况下是main.js,这个文件会实例化一个Vue,然后被挂载到浏览器端。
app.js和main.js的功能类似,但是,我们在这里还需要返回Vue实例所使用的router,store等实例。
import Vue from 'vue' import VueRouter from 'vue-router' import App from './app.vue' import createRouter from './router' Vue.use(VueRouter) export default () => {
const router = createRouter() const app = new Vue({
router, render: h => h(App) }) return {
app, router } }
注意:现在这个网页应用仅仅包括了最基本的单页面路由,没有使用Vuex,axios等
2.2 clientEntry.js
这个文件用于打包客户端渲染的文件,当我们不使用服务器渲染时,这个文件就是一般情况下的main.js,对应上图中的Client entry:
import createApp from './createApp' const {
app } = createApp() app.$mount('#root')
我们可以看到,这个clientEntry.js只做了一件事,那就是从createApp中拿到Vue实例,然后徐将Vue实例挂载到浏览器的root组件上。
2.3 serverEntry.js
serverEntry.js抛出了一个函数,我们接收到一个context对象,这个函数返回一个promise对象,在这个promise对象中,我们我们为context添加了一些属性。
import createApp from './createApp' export default context => {
return new Promise((resolve, reject) => {
const {
app, router } = createApp() router.push(context.url) router.onReady(() => {
const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) {
return reject(new Error('no component matched')) } context.router = router resolve(app) }) }) }
看到这里的时候,大家可能并不知道serverEntry.js做了什么,但是我们需要知道:打包后生成的文件存在一个函数可以被我们调用,这个函数为context添加了一些属性,并且函数中包含了完整的的app应用。
2.4 webpack的配置文件
webpack.client.js是webpack打包clientEntry.js的配置文件,webpack.server.js是webpack打包serverEntry.js的配置文件,官方还推荐我们使用一个webpack.base.js抽离出前两个配置文件的公共部分。
2.4.1 webpack.base.js
webpack.base.js中是一些公共配置:
const createVueLoaderOptions = require('./vue-loader.config') const isDev = process.env.NODE_ENV === 'development' const config = {
resolve: {
extensions: ['.js', '.vue'] }, module: {
rules: [ {
test: /\.(vue|js|jsx)$/, loader: 'eslint-loader', exclude: /node_modules/, enforce: 'pre' }, {
test: /\.vue$/, loader: 'vue-loader', options: createVueLoaderOptions(isDev) }, {
test: /\.jsx$/, loader: 'babel-loader' }, {
test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, {
test: /\.(gif|jpg|jpeg|png|svg)$/, use: [ {
loader: 'url-loader', options: {
limit: 1024, name: 'resources/[path][name].[hash:8].[ext]' } } ] } ] } } module.exports = config
2.4.2 webpack.client.js
const path = require('path') const HTMLPlugin = require('html-webpack-plugin') const webpack = require('webpack') const merge = require('webpack-merge') const baseConfig = require('./webpack.base') const VueClientPlugin = require('vue-server-renderer/client-plugin') const isDev = process.env.NODE_ENV === 'development' const defaultPluins = [ new webpack.DefinePlugin({
'process.env': {
NODE_ENV: isDev ? '"development"' : '"production"' } }), new HTMLPlugin({
template: path.join(__dirname, 'template.html') }), new VueClientPlugin() ] const devServer = {
port: 7999, host: '0.0.0.0', overlay: {
errors: true }, headers: {
'Access-Control-Allow-Origin': '*' }, historyApiFallback: {
index: '/public/index.html' }, proxy: {
'/api': 'http://127.0.0.1:3332', '/user': 'http://127.0.0.1:3332' }, hot: true } let config if (isDev) {
config = merge(baseConfig, {
target: 'web', entry: path.join(__dirname, '../src/clientEntry.js'), output: {
filename: 'bundle.[hash:8].js', path: path.join(__dirname, '../public'), publicPath: 'http://127.0.0.1:7999/public/' }, devtool: '#cheap-module-eval-source-map', module: {
rules: [ {
test: /\.(sc|sa|c)ss/, use: [ 'vue-style-loader', 'css-loader', 'sass-loader', {
loader: 'postcss-loader', options: {
sourceMap: true } } ] } ] }, devServer, plugins: defaultPluins.concat([ new webpack.HotModuleReplacementPlugin(), new webpack.NoEmitOnErrorsPlugin() ]) }) } module.exports = config
webpack.client.js和我们不使用服务器渲染时唯一(注意:这里的【唯一】仅仅是对于这个简单demo来说)的不同就是我们还使用了vue-server-renderer/client-plugin,这个plugin的作用是生成一个名为vue-ssr-client-manifest.json的文件。这个文件将在我们做服务端渲染的时候用到。
2.4.3 webpack.server.js
const path = require('path') const ExtractPlugin = require('extract-text-webpack-plugin') const webpack = require('webpack') const merge = require('webpack-merge') const baseConfig = require('./webpack.base') const VueServerPlugin = require('vue-server-renderer/server-plugin') let config const isDev = process.env.NODE_ENV === 'development' const plugins = [ new ExtractPlugin('styles.[contentHash:8].css'), new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 'process.env.VUE_ENV': '"server"' }) ] if (isDev) {
plugins.push(new VueServerPlugin()) } config = merge(baseConfig, {
target: 'node', entry: path.join(__dirname, '../src/serverEntry.js'), devtool: 'source-map', output: {
libraryTarget: 'commonjs2', filename: 'server-entry.js', path: path.join(__dirname, '../server-build') }, externals: Object.keys(require('../package.json').dependencies), module: {
rules: [ {
test: /\.(sc|sa|c)ss/, use: ExtractPlugin.extract({
fallback: 'vue-style-loader', use: [ 'css-loader', 'sass-loader', {
loader: 'postcss-loader', options: {
sourceMap: true } } ] }) } ] }, plugins }) module.exports = config
为了方便读者联系上下文,这里直接贴上了全部的webpack.server.js,但是,最值得注意的是这一段配置:
... target: 'node', entry: path.join(__dirname, '../src/serverEntry.js'), devtool: 'source-map', output: {
libraryTarget: 'commonjs2', filename: 'server-entry.js', path: path.join(__dirname, '../server-build') }, ...
因为这个打包后的文件需要在node端运行,所以我们需要更改target和libraryTarget。
同样的,在打包serverEntry也使用了一个和vue-server-renderer/client-plugin类似的vue-server-renderer/server-plugin,这个插件用于生成一个名为vue-ssr-server-bundle.json的文件。
3. vue-ssr-client-manifest.json和vue-ssr-server-bundle.json
这两个文件在我们之后的服务器渲染时都会使用到。为了之后我们能更加理解ssr,我们先来看看这两个文件是什么:
3.1 vue-ssr-client-manifest.json
vue-ssr-client-manifest.json是我们打包客户端渲染时使用vue-server-renderer/client-plugin 生成的文件。
其文件内容:

从这个json文件我们可以明显看出,借助client-plugin,将应用使用的文件进行了分类,publicPath是公共路径,all 是所有的文件,initial是入口文件依赖的js和css,async是首屏不需要的异步的js。所以,我们能够通过vue-ssr-client-manifest.json做什么呢?其最重要的作用就是我们能根据initial拿到客户端渲染的js代码。
3.2 vue-ssr-server-bundle.json
vue-ssr-server-bundle.json是我们打包serverEntry.js通过vue-server-renderer/server-plugin生成的。
其文件内容(vue-ssr-server-bundle.json文件很大,为了方便观察,我将每个键对应的值都进行了删减):

entry是服务款入口的文件,files是服务端依赖的文件列表,maps是sourcemaps文件列表。
这里,我们主要观察files的内容,如果我们将files展开,我们会看到一堆文件名:value,我们看一下下图中的value值:

是的,你没有看错,这里面全部都是js代码。而这些js代码,就是我们在服务端根据用户请求来生成完整html需要使用到的代码。
3. node服务端
3.1 ssr-router.js
既然需要我们根据用户请求来生成对应的html文件,我们继续要构建一个和前端路由功能类似的ssr-router.js用于服务器渲染时的路由匹配,其实说成匹配并不恰当,但关键是,根据用户请求来生成一个完整的html。
const Router = require('koa-router') const axios = require('axios') const path = require('path') const fs = require('fs') const MemoryFS = require('memory-fs') const webpack = require('webpack') const VueServerRenderer = require('vue-server-renderer') const serverRender = require('./server-render') const serverConfig = require('../../build/webpack.server') const serverCompiler = webpack(serverConfig) const mfs = new MemoryFS() serverCompiler.outputFileSystem = mfs let bundle //使用配置webpack.server.js来调用了webpack进行打包 //其实在这里我们也可以像打包客户端一样在外部打包,但这样更方便我们开发。 serverCompiler.watch({
}, (err, stats) => {
//当监听到文件变化时,我们重新打包 if (err) throw err stats = stats.toJson() stats.errors.forEach(err => console.log(err)) stats.warnings.forEach(warn => console.warn(err)) const bundlePath = path.join( serverConfig.output.path, 'vue-ssr-server-bundle.json' ) bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8')) console.log('new bundle generated') }) // ctx 包含了用户的请求路径信息 const handleSSR = async (ctx) => {
//当服务端打包还未完成时,如果这时候用户发起请求,直接return if (!bundle) {
ctx.body = '你等一会,别着急......' return } //获取到clientEntry打包生成的vue-ssr-client-manifest.json const clientManifestResp = await axios.get( 'http://127.0.0.1:7999/public/vue-ssr-client-manifest.json' ) const clientManifest = clientManifestResp.data //获取到模板,用于填充网页内容 const template = fs.readFileSync( path.join(__dirname, '../server.template.ejs'), 'utf-8' ) //构建一个渲染器,这个渲染器如何工作,后文有较为详细的叙述 const renderer = VueServerRenderer .createBundleRenderer(bundle, {
inject: false, clientManifest }) //调用渲染方法,在这一步中,会向ctx中添加一个完整的html信息 await serverRender(ctx, renderer, template) } const router = new Router() //koa-router的get方法,调用handleSSR时向其中传入ctx,并向用户返回执行完handleSSR之后的ctx router.get('*', handleSSR) module.exports = router
3.2 server-render.js
这个函数其实可以写在ssr-router.js内部,因为它其实是完成ssr-router.js的主要步骤。
但我们这里将它抽离成单独的js文件。
const ejs = require('ejs') module.exports = async (ctx, renderer, template) => {
ctx.headers['Content-Type'] = 'text/html' const context = {
url: ctx.path } try {
const appString = await renderer.renderToString(context) if (context.router.currentRoute.fullPath !== ctx.path) {
return ctx.redirect(context.router.currentRoute.fullPath) } const html = ejs.render(template, {
appString, style: context.renderStyles(), scripts: context.renderScripts() }) ctx.body = html //将完整的html赋值给ctx } catch (err) {
console.log('render error', err) throw err } }
我们现在结合3.1和3.2来说明这个完整的html是如何生成的。
在ssr-router.js中我们这样创建了VueServerRenderer:
const renderer = VueServerRenderer .createBundleRenderer(bundle, {
inject: false, clientManifest }) await serverRender(ctx, renderer, template)
在server-render.js中我们调用了renderer.renderToString(context):
const appString = await renderer.renderToString(context)
如果我们能去阅读vue-server-renderer的源码createBundleRenderer部分,我们就能知道这里传入的bundle是如何根据ctx来生成html的了,这是将bundle的处理过程当中的关键步骤流程图:

在renderToString()阶段,会执行runner(context):
我们之前分析了bundle(即vue-ssr-server-bundle.json)的内容,bundle中存在entry。当执行createBundleRunner()时,在内部会执行compileModule(),生成一个处理编译后源码的函数evaluate。evaluate函数会将编译后文件源码包装成module对象,而后返回module.exports.defualt,它就是封装了文件源码的函数,执行这个函数就相当于执行文件源码。当这个文件是入口文件时,返回的就是entry入口文件源码的封装函数,也就是runner,那么执行runner(context)至关于执行entry-server.js导出的函数。我们可以再次返回到2.2 serverEntry.js,加深我们对客户端渲染入口文件返回一个函数的理解。
run = context => {
return new Promise((resolve, reject) => {
const {
app, router } = createApp() router.push(context.url) router.onReady(() => {
const matchedComponents = router.getMatchedComponents() if (!matchedComponents.length) {
return reject(new Error('no component matched')) } context.router = router resolve(app) }) }) }
在执行runner(context)的时候,因为 const context = { url: ctx.path },所以我们就可以根据用户的请求路径,通过router.push(context.url)获取到相应的路由实例,然后router.onReady()意味着我们将此路由下的所有同步/异步组件都已经加载完毕,向其中添加了一个回调函数,在这个函数中,我们把这个加载完成的路由实例添加到context对象上:context.router = router,此时,context就已经拿到了渲染一个完整html的所有数据
然后,我们在将context的数据引入到模板上,得到一个html:
const html = ejs.render(template, {
appString, style: context.renderStyles(), scripts: context.renderScripts() })
又因为我们最后返回给浏览器的是ctx,所以:
ctx.body = html //将完整的html赋值给ctx
在renderToString()阶段,执行玩runner(context)后,还会执行render(app),这里的app其实就是我们执行了runner(context)之后拿到的vue实例。这时候,就是clientManifest发挥作用的时候了:
clientManifest中记录着资源加载信息,经过运行app获得context对象中_registedComponents拿到moduleIds,而后获得usedAsyncFiles(组件依赖的文件)。其与preloadFiles(clientManifest中的initial文件数组)的并集就是初始渲染的预加载的资源列表,与prefetchFiles(clientManifest中的async文件数组)的差集就是预取的资源列表。 也就是在这个时候,context的scripts中增加了接管单页面应用所需要的js文件。
4. 创建服务端:
server.js:用于启动服务
const Koa = require('koa') const send = require('koa-send') const path = require('path') const staticRouter = require('./routers/static') const app = new Koa() const isDev = process.env.NODE_ENV === 'development' app.use(async (ctx, next) => {
try {
console.log(`request with path ${
ctx.path}`) await next() } catch (err) {
console.log(err) ctx.status = 500 if (isDev) {
ctx.body = err.message } else {
ctx.bosy = 'please try again later' } } }) app.use(async (ctx, next) => {
if (ctx.path === '/favicon.ico') {
await send(ctx, '/favicon.ico', {
root: path.join(__dirname, '../') }) } else {
await next() } }) app.use(staticRouter.routes()).use(staticRouter.allowedMethods()) let pageRouter if (isDev) {
pageRouter = require('./routers/dev-ssr') } app.use(pageRouter.routes()).use(pageRouter.allowedMethods()) const HOST = process.env.HOST || '0.0.0.0' const PORT = process.env.PORT || 3332 app.listen(PORT, HOST, () => {
console.log(`server is listening on ${
HOST}:${
PORT}`) })
server.js用于启动服务端,如果有需要,也可以在其中设置一下路由拦截
5. package.json
添加运行脚本:
"scripts": {
"dev:client": "cross-env NODE_ENV=development webpack-dev-server --config build/webpack.client.js", "dev:server": "nodemon server/server.js", "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"" }
然后我们在命令台:
npm run dev
最后,访问localhost:3332即可访问服务端渲染的网页。
6. 结语:
ssr需要花一定时间才能更好地理解,这里是笔者的demo地址,如有需要,可以自行下载。
发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/213861.html原文链接:https://javaforall.net
