Vue项目使用SSR服务器渲染

10 篇文章 0 订阅

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

首先在此感谢以下文章及其作者,为笔者在学习SSR时提供了必不可少的帮助:
浅谈服务端渲染(SSR)
浅谈Vue SSR中的Bundle
【VueSSR系列二】clientManifest与bundle的处理流程解读

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 服务端渲染的局限性

  1. 服务器负荷更高:
    传统模式下通过客户端完成渲染,现在统一到了服务端node去完成。尤其是高并发访问的情况,会大量占用服务器CPU资源
  2. 开发环境受限:
    在服务端渲染中,只会执行到componentDidMount之前的生命周期钩子,因此项目引用的第三方的库也不可用其它生命周期钩子,这对引用库的选择产生了很大的限制
    注意,这并不意味者我们不能使用其他生命周期钩子函数,这里的意思是只有 beforeCreate 和 created 会在服务器端渲染(SSR)过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。
  3. 学习成本较高
    除了对webpack、Vue要熟悉,还需要掌握node、Koa等相关技术。相对于客户端渲染,项目构建、部署过程更加复杂。这也意味着维护成本也会相应地增加。

1.2 服务器渲染和客户端渲染的行为比较

以下两张图来源于浅谈服务端渲染(SSR)
此图来源https://www.jianshu.com/p/10b6074d772c
在这里插入图片描述

服务端渲染是先向后端服务器请求数据,然后生成完整首屏html返回给浏览器;而客户端渲染是等js代码下载、加载、解析完成后再请求数据渲染,等待的过程页面是什么都没有的,就是用户看到的白屏。就是服务端渲染不需要等待js代码下载完成并请求数据,就可以返回一个已有完整数据的首屏页面。
作者:coder_Lucky
链接:https://www.jianshu.com/p/10b6074d772c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2. 服务器渲染的简单Demo

服务端渲染需要生成完整的首屏html返回给浏览器,并且当用户加载首屏完成之后,客户端仍然是一个单页面应用。接下来,我们尝试理解SSR的构建流程:
在这里插入图片描述

  1. 我们需要将app.js按照不同方式进行打包,Server Entry用于打包服务器渲染需要的代码Server BundleClient Entry 用于打包客户端渲染需要的代码Client Bundle
  2. Server Bundle用于构建Bundle RendererBundle Renderer根据用户的请求生成首屏Html代码;
  3. Client Bundle用于支持浏览器上的单页面需求。这里可能有点难以理解,Hydrate在Vue官网上面被译作水合,我们思考,如果没有Client BundleHydrate 到客户端的html中,此时的客户端上html中还不存在接管单页面应用的逻辑js,若点击了某一个路由,由于没有处理路由的js函数,客户端将重新发起请求到服务端,服务端根据用户请求再次渲染出相应的html返回。所以,我们还需要将Client Bundle加载到html中用于单页面应用的逻辑处理(注意:此处是笔者的个人理解,如果有误,希望指出修正。)。
    接下来,我们按照上图,一步一步将项目应用到服务器渲染。

2.1 createApp.js

这里的createApp.js就是上图中的app.js
如果我们不进行服务端渲染,那么我们的vue项目打包的入口文件一般情况下是main.js,这个文件会实例化一个Vue,然后被挂载到浏览器端。
app.jsmain.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端运行,所以我们需要更改targetlibraryTarget
同样的,在打包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服务端

我们需要在node端做以下行为:
创建服务端,接收用户请求,根据用户请求来生成一个完整的Html界面,并将客户端渲染需要的js文件Hydrate到该html文件中。
我们使用koa来处理服务端的工作。

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.13.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地址,如有需要,可以自行下载。

  • 2
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值