Vue+Node实现服务端渲染

2022-09-08 16:54:50 浏览数 (1)

引入

为什么要进行服务端渲染? 先来看下面两个例子

如图某音乐网站

我们可以看到有很多分类音乐榜单,我们看看他到底是怎样写的右击查看源码 body内容就下面几行代码,其他的数据都是通过js加载的

代码语言:javascript复制
<body>
<main id="app">
    <router-view></router-view>
    <qr-code ref="qr"></qr-code>
    <pay-card></pay-card>
    <mini-player></mini-player>
    <im-service ref="imservice"></im-service>
    <tocode-modal></tocode-modal>
    <router-nav></router-nav>
</main>

这就是一个典型的前端渲染的例子 页面内容什么都没有spider来了抓取的也是如上代码,这非常不利于seo。

当然服务端渲染与前端渲染各有优缺点

服务器端渲染:

优点:

  1. 端耗时少
  2. 有利于SEO
  3. 客户端的资源占用少
  4. 后端生成静态化文件。即生成缓存片段,减少数据库查询的浪费时间。高效。

缺点:

  1. 不利于前后端分离,开发效率低。
  2. 占用服务器资源。

客户端渲染

优点:

  1. 前后端分离。前端专注UI,后端专注api开发。前端有更多选择性,不需要遵循后端特定的模板
  2. 体验更好。
  3. 缺点:
  4. 前端响应较慢,不如服务器渲染的速度快。
  5. 不利于SEO

所以在开发时,我们要根据不同的业务场景选择不同的渲染方式,这就需要我们对客户端渲染和服务端渲染非常熟练才行。

实现服务端渲染

那么怎样使vue实现服务端渲染? 这里vue官方也有介绍 Vue SSR 指南

接下来我们写一个小项目实现vue服务端渲染 我们用到的技术有vue、node、webpack、ejs 在进行服务端渲染前我们先通过下图将服务端渲染的整个逻辑理清

开发阶段

1.在开发阶段我们会启动一个webpack-dev-server进行前端业务逻辑的高效开发,这个阶段前端还是和以往一样代码该咋写,这个阶段只负责前端的一些逻辑

2.当前端构建完毕后,我们会进行服务端渲染,但是由于webpack-dev-server是一个单独的服务,我们没有办法在webpack-dev-server上面添加服务端渲染的逻辑,所以我们要单独启动一个server服务,这里我们使用node构建Vue官方也推荐node。

3.在node中我们会用到 vue-server-renderer 帮我们在node环境里面渲染出vue代码生成的html代码,这部分代码会直接返回给用户浏览器直接显示 在开发阶段我们两个服务 如果直接访问webpack-dev-server就是前端渲染 如果访问node就是服务端渲染

上面提到 vue-server-renderer要渲染vue代码生成的html代码以便返回,那么我们服务端怎么拿到前端的vue代码

在node端我们要做服务端渲染,我们也要打包一个vue应用的代码逻辑,并且打包出来不是运行在客户端端而是服务端,所以 客户端服务端至少有一个webpack配置(webpack.client.config.jswebpack.server.config.js

webpack.server.config.js是留给服务端用的,所以我们在 NodeServer 也要运行一个webpack 这里我们就使用 webpack server compiler 启动webpack完成打包. 我们通过 webpack server compiler 生成一个serverbundle.js文件 这个文件相当于前端的app.js 因为在 webpack dev sever 里面也会打包一个app.js,这两个大部分内容是一样的但有些逻辑不一样。

而NodeServer可以直接访问到webpack打包后的文件( webpack.server.config.js),node获取到serverbundle.js后,利用 vue-server-renderer 执行服务端渲染(渲染出html代码)直接返回给用户,但是我们只是返回的html代码,如果要在页面上执行js逻辑,css样式等,我们还是得依赖于webpack-dev-server 给我们打包出来的客户端的app.js以及其他的css文件等静态资源。

这里我们可以通过axios请求 webpack-dev-server 获取资源然后在插入到html中在返回给用户,这样用户才能看到正常页面,用户才能进行各种操作,路由跳转等。 开发阶段的逻辑大概是这样

接下来我们进行开发阶段服务端渲染的构建 本例是在一个小项目上构建的,源码已上传至GitHub这里就不一一介绍文件夹结构了 首先构建用于服务端的webpack配置

webpack配置

代码语言:javascript复制
/build/webpack.config.server.js

const path = require('path')
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
const webpack = require('webpack')
let { merge }= require('webpack-merge')
let baseConfig = require('./webpack.config.base')
//获取package.json cross 环境变量
const VueServerPlugin  = require('vue-server-renderer/server-plugin')

let config
const isDev = process.env.NODE_ENV === 'development'
config = merge(baseConfig,{
   mode:'production',
   target:'node',
   entry:path.join(__dirname,'../client/server-entry.js'),
   devtool:'source-map',
   output:{
     //通过module.expots导出 在nodehjs中就可以直接引用
       libraryTarget:"commonjs2",
       filename:'server-entry.js',
       path:path.join(__dirname,'../server-build')
   },
   externals: Object.keys(require('../package.json').dependencies),//排除第三方依赖打包
   module:{
    rules:[{
        test:/.styl/,
        use:[
        {
            loader: MiniCssExtractPlugin.loader,
            options: {
              publicPath: path.join(__dirname,'../dist')
            },
        },
        'css-loader',
        {
            loader:'postcss-loader',
            options:{
                sourceMap:true
            }//使用前面的sourcemap
        },
        'stylus-loader',
        ]
    },
]
   },
   plugins:[
       new MiniCssExtractPlugin({
        filename:'main.[chunkhash:8].css'//生成的样式文件名称
       }),
       //配置环境变量
       new webpack.DefinePlugin({
           'process.env':{
               NODE_ENV:JSON.stringify(process.NODE_ENV || 'development'),
               VUE_ENV:'server'
           }
       }),
       new VueServerPlugin()//该配置打包不会输出js文件而是一个json文件,通过这个json文件做一些服务端渲染的操作
   ]

})


module.exports = config

在server端

  1. 设置了打包文件的输出方式,方便nodejs引用
  2. 打包时排除第三方依赖包,因为我们可以直接require node_modules里面的模块,不像浏览器的要打包所有类库
  3. 单独将css打包成一个单独的文件
  4. 通过vue-server-renderer/server-plugin将打包后的结果输出为一个json文件,这个json文件用于createBundleRenderer详情参照文档

接下来构建NodeServer 这里我们使用koa框架 构建NodeServer

代码语言:javascript复制
npm i koa  koa-router -s
代码语言:javascript复制
/server/server.js

const Koa  = require('koa')
const path = require('path')
const Router = require('koa-router');
const app = new Koa()
const isDev = process.env.NODE_ENV === 'development'
let router = new Router()
//全局异常处理
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.body = 'please try again later'
        }

    }

})

//不同环境的渲染方式
let pageRouter
if(isDev){
    pageRouter = require('./routers/dev-ssr')
}else{
    pageRouter = require('./routers/ssr')
}

app.use(pageRouter.routes()).use(pageRouter.allowedMethods())

const HOST = process.env.HOST || '0.0.0.0'
const PORT = process.env.PORT || '4444'
app.listen(PORT,HOST,()=>{
    console.log(`server is run in ${HOST}:${PORT}`)
})

服务端渲染

创建 /server/routers/dev-ssr.js 和 /server/rouuters.ssr.js分别用于开发环境的服务端渲染和生产环境的服务端渲染

创建模板文件 方便渲染html /server/server.template.ejs

代码语言:javascript复制
 /server/routers/dev-ssr.js
 
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 serverConfig = require('../../build/webpack.config.server')//引入配置文件

const serverCompiler = webpack(serverConfig)//编译webpack
const mfs = new MemoryFS()//将文件写到内存对象
serverCompiler.outputFileSystem = mfs//将文件输出到内存

let bundle //记录webpack每次打包生成的文件
serverCompiler.watch({}, (err, stats) => {
  //watch时每次在client目录修改时都会重新打包
    
  if (err) throw err
    
  //其他错误捕获
  stats = stats.toJson()
  stats.errors.forEach(err => console.log(err))
  stats.warnings.forEach(warn => console.warn(warn))

  //读取bundle文件
  const bundlePath = path.join(
    serverConfig.output.path,
    'vue-ssr-server-bundle.json'  //vue-server-renderer/server-plugin生成的json文件
  )
  bundle = JSON.parse(mfs.readFileSync(bundlePath, 'utf-8'))//二进制转换字符串读取
  console.log('new bundle generated')
})

//服务端渲染 返回html
const handleSSR = async (ctx) => {
  if (!bundle) {
    ctx.body = '你等一会,别着急......'
    return
  }

  //发送axios获取客户端的js
  const clientManifestResp = await axios.get(
        'http://127.0.0.1:3000/public/vue-ssr-client-manifest.json'
  )
  const clientManifest = clientManifestResp.data
    
  //获取模板文件
  const template = fs.readFileSync(
      path.join(__dirname, '../server.template.ejs'),
      'utf-8'
    )

  //生成调用renderer的function
  const renderer =  VueServerRenderer
  .createBundleRenderer(bundle, {
    inject: false,//默认vuer-server-renderer指定template必须按照vue-server-renderer指定的模板形式构建但是限制较大我们设置false
    clientManifest//获取 vue-ssr-client-manifest.json 客户端打包成的json文件 包含各种js文件,,不然我们看到的只是html
        
  })

}

在dev-ssr.js里面我们进行开发环境的服务端渲染逻辑

  1. 启动webpack打包生成vue-ssr-server-bundle.json 该文件用于createBundleRenderer
  2. 获取客户端打包的生成的vue-ssr-client-manifest.json 这个文件也用于createBundleRenderer 通过这个文件我们可以获取前端打包好的js文件 ,这个js文件相当于平时打包的bundle.js包含了页面交互,样式等 在此之前我们要在/build/webpack.config.client.js进行配置,使其打包一份vue-ssr-client-manifest.json文件
代码语言:javascript复制
...
const VueClientPlugin = require('vue-server-renderer/client-plugin')
//开发模式生效
if(isDev){
    ...
         plugins:[
           ...
            new VueClientPlugin()//该插件会自动生成vue-ssr-client-manifest.json 方便服务端渲染引用
        ]
        
}else{
      ...
         plugins:[
           ...
            new VueClientPlugin()//该插件会自动生成vue-ssr-client-manifest.json 方便服务端渲染引用
        ]
    
}
...

3.获取模板文件,生成调用renderer对象

代码语言:javascript复制
/server/server.template.ejs

<!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">

  <%- style %>
</head>
<body>
  <div id="root"><%- appString %></div>


  <%- scripts %>
</body>
</html>

到此我们将服务端渲染要用到的东西都准备完毕,接下来我们编写服务端渲染的逻辑 我们将其封装到一个单独的js

服务端渲染逻辑

代码语言:javascript复制
/server/routers/server-render.js
let ejs = require('ejs')

module.exports = async (ctx, renderer, template) => {
  ctx.headers['Content-Type'] = 'text/html'

  const context = { url:ctx.path}
  /**
   * context 用在服务端渲染的时候传入到vue-server-renderer vue-server-renderer拿到context之后渲染完成之后会在在context插入各种属性
   * 方面我们渲染html 包含客户端js路径,css路径,将vue组件的样式写入到当前路由style标签,以及title等。。。
   *
   * */

  try {
    const appString = await renderer.renderToString(context)
    //渲染模板
    const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),//生成<link ...>标签
      scripts: context.renderScripts(),//生成<script ...>标签
    })

    ctx.body = html
  } catch (err) {
    console.log('render error', err)
    throw err
  }
}

在dev-ssr.js引用

代码语言:javascript复制
...
const VueServerRenderer = require('vue-server-renderer')//服务端渲染

const serverRender = require('../routers/server.render')
  await serverRender(ctx, renderer, template)
}

const router = new Router()
router.get(/.*/, handleSSR)//每个请求都经过handSSR处理

module.exports = router

到此服务端渲染的基本配置已经完成

接下来我们配置用于服务端的webpack打包入口文件

入口文件

/client/server-enrty.js

/client/create-app.js 我们将app对象单独抽离出来,这么做的原因主要是,我们每一次服务端渲染都要生成一个新的app,我们不能使用上次渲染过的app对象再次进行下一次渲染,因为这个app对象以及包含了上一次渲染的状态这会影响我们下一次渲染的内容 因此我们前端的router、store加载于之前是有所不同的以store为例

代码语言:javascript复制
//传统写法
// export default store
/**服务端渲染的写法 */
export default ()=>{
    const store = new Vuex.Store({
        state:defalutState,
        mutations,
        getters,
        plugins:[
            //每一个方法都是一个插件接收store对象
            (store)=>{


            }
        ]
    })
        return store
}
//在index.js
代码语言:javascript复制
/client/create-app.js
//每次渲染都渲染一个新的app,不能用上一次渲染过的

import Vue from 'vue'
import Vuex from 'vuex'
import VueRouter from 'vue-router'
import App from './APP.vue'
import Meta from 'vue-meta'
import createStore from './store/store'
import createRouter from './config/router.js'

import './assets/styles/global.styl'

//
Vue.use(VueRouter)
Vue.use(Vuex)
Vue.use(Meta)//处理页面元信息
//每一次创建新的属性
export default ()=>{
    const router =  createRouter()
    const store = createStore()
    const app = new Vue({
        router,
        store,
        render: (h) => h(App)
    })
    return {app,router,store}
}
代码语言:javascript复制
/client/server-entry.js
import createApp from './create-app'
//该函数接收的context 来自 server-render.js里面的 renderer.renderToString(context)
export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    router.push(context.url)//在服务端渲染使url与组件匹配

    //路由被推进去后,所有的异步操作都完成后执行的回调
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()//获取url匹配的组件
      //如果请求的url没有相应的组件
      if (!matchedComponents.length) {
        return reject(new Error('no component matched'))
      }
      resolve(app)
    })
  })
}

1.在server-entry.js 每次进行服务端渲染都会创建一个app对象

2.执行路由匹配请参照编程式导航

3.返回vue对象

此时服务端渲染基本完成

静态资源处理

接下来我们完成静态资源的处理,如果不处理静态资源会抛出如下异常 no component matched

代码语言:javascript复制
/server/routers/static.js

const static = require('koa-static');
module.exports = function(router,options){
    options = options||{};
    options.image = options.image||30;
    options.script = options.script||1;
    options.styles = options.styles||30;
    options.html = options.html||30;
    options.other = options.othre||7;
    router.all(/((.jpg)|(.png)|(.gif))$/i,static('./',{
        maxAge:options.image*86400*1000
    }));
    router.all(/((.js)|(.jsx))$/i,static('./',{
        maxAge:options.script*86400*1000
    }));
    router.all(/((.css))$/i,static('./',{
        maxAge:options.styles*86400*1000
    }));
    router.all(/((.html)|(.htm))$/i,static('./',{
        maxAge:options.html*86400*1000
    }));
}

我们引入了koa-static该模块主要用于静态资源的管理,我们对每种静态资源都进行了缓存 server.js引用

代码语言:javascript复制
...
const staticRouter = require('./routers/static')
...
// favicon.ico 处理
app.use(async (ctx,next)=>{
    if(ctx.path==='/favicon.ico'){
      await  send(ctx,'/favicon.ico',{root:path.join(__dirname,'../')})
    }else{
        await next()
    }
})
//静态资源
staticRouter(router,{
    html:365
});
app.use(router.routes());
//页面路由
...

** 服务端渲染处理头信息** 我们要对每个页面进行头信息的处理,如title、desc、meta等,这些每个页面可能都是不相同的。 通过vue-meta处理 以title为例 1.在create-app.js引入

代码语言:javascript复制
...
import Meta from 'vue-meta'

Vue.use(Meta)//处理页面元信息
...

2.在组件里面键入meta信息

在APP.vue声明默认信息,当其他页面没有相关信息时会使用APP.vue的信息

代码语言:javascript复制
...
export default {
  //声明元信息
 metaInfo:{
     title:'这时首页'
 }
...

在其他也页面键入如上信息这里不做展示

接下来我们还要修改webpack-dev-server 的入口文件index.js因为 入口文件并不依赖于create-app.js也就没有办法展示元信息

新键文件client-entry.js

代码语言:javascript复制
//client-entry.js
import createApp from './create-app'

const {app,router} = createApp()

router.onReady(()=>{
    app.$mount('#app')
})

入口文件修改

代码语言:javascript复制
/build/webpack.cofig.base.js
...
  entry:path.join(__dirname,'../client/client-entry.js'),
    
...
代码语言:javascript复制
/build/webpack.config.client.js
...
entry:{
   app:path.join(__dirname,'../client/client-entry.js')
}
...

到此客户端的元信息配置完成

如果是服务端渲染我们还要自己配置title在传入到模板引擎 在server-entry.js

代码语言:javascript复制
...
context.meta = app.$meta
resolve(app)
...

在server-render.js

代码语言:javascript复制
...
 const appString = await renderer.renderToString(context)
    const {title} = context.meta.inject()//查找meta信息

    //渲染模板
    const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),//生成<link ...>
      scripts: context.renderScripts(),//生成<script ...>
      title: '123',//带标签的title信息
      initalState: context.renderState()
    })
...

更新模板文件

代码语言:javascript复制
<!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  %>
...

到此开发环境的的服务端渲染完成 在package.json键入配置

代码语言:javascript复制
 "dev:client": "cross-env NODE=development webpack-dev-server --config build/webpack.config.client.js",
 "dev:server": "nodemon server/server.js",
 "dev": "concurrently "npm run dev:client" "npm run dev:server"",
正式环境

接下来我们进行正式环境的服务端渲染配置,相较于开发阶段正式环境简单太多了 在package.json键入如下配置

代码语言:javascript复制
"build:client": "cross-env NODE=production webpack --config build/webpack.config.client.js",
"build:server": "cross-env NODE=production webpack --config build/webpack.config.server.js",
"build": "npm run clean && npm run build:client && npm run build:server",
 "clean": "rimraf public && rimraf server-build",

运行命令 npm run build

成功打包文件

填充 ssr.js /server/routers/ssr.js

代码语言:javascript复制
const Router = require('koa-router')
const path = require('path')
const VueServerRender = require('vue-server-renderer')
const fs = require('fs')
const serverRender = require('./server.render')

const clientManifest = require('../../public/vue-ssr-client-manifest.json')

const renderer = VueServerRender.createBundleRenderer(
    path.join(__dirname,'../../server-build/vue-ssr-server-bundle.json'),
    {
        inject:false,
        clientManifest
    }
)
const template = fs.readFileSync(
    path.join(__dirname,'../server.template.ejs'),
    'utf-8'

)

const pageRouter = new Router()

pageRouter.get(/.*/,async (ctx)=>{
    await serverRender(ctx,renderer,template)
})

module.exports = pageRouter

在package.json添加配置

代码语言:javascript复制
 "start": "cross-env NODE=production node server/server.js",
代码语言:javascript复制
npm run start

成功执行服务端渲染

东西挺多的,在来理一下主要的思路

开发阶段

首先要实现服务端渲染,我们要通过vue-server-renderer的createBundleRenderer方法该方法接收两个参数 第一个参数是bundle,第二个参数是一些选项配置 第一个参数bundle其实可以简单的理解为对server-enter.js的打包,不过打包后不会生成js文件,而是一个json文件vue-ssr-server-bundle.json 我们要拿到这个json文件创建一个renderer对象,进行服务端渲染的一些操作 在server-entry.js入口文件 我们接收客户端的url,并push进router,使客户端的url匹配相应的组件,这样就实现了不同url跳转不同的页面,这就是服务端渲染的路由操作

代码语言:javascript复制
...
router.push(context.url)
...

但是我们如何进行路由跳转?因为路由是js写的,所以我们会获取到前端(webpack-dev-server)打包的一些js、css文件并插入到html中这样就有了交互操作,界面美化 createBundleRenderer的第二个参数就派上用场了,该参数用于设置一些选项配置

代码语言:javascript复制
const clientManifestResp = await axios.get(
        'http://127.0.0.1:3000/public/vue-ssr-client-manifest.json'
  )
  // return console
const clientManifest = clientManifestResp.data//静态文件地址
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false, // 推荐
  template, // (可选)页面模板
  clientManifest // (可选)客户端构建 manifest
})

clientManifest 就是前端打包后的一些静态资源文件的地址,为什么会生成这个地址?

因为我们在webpack.config.client.js进行了配置

代码语言:javascript复制
```
...
plugins:[
    new VueClientPlugin()//该插件会自动生成资源静态地址方便服务端渲染引用
]
```

拿到这些地址后vue-server-renderer会自动帮我们处理这些地址的依赖关系 在renderTostring()完成之后我们就可以通过context.renderStyles()获取样式context.renderScripts()获取js 获取后通过ejs渲染模板传入到html,这样即完成了服务端渲染。

代码语言:javascript复制
...
  const appString = await renderer.renderToString(context)
    const {title} = context.meta.inject()//查找meta信息

   const html = ejs.render(template, {
      appString,
      style: context.renderStyles(),
      scripts: context.renderScripts(),
      title: title.text(),//带标签的title信息
    })
    ctx.body = html

正式环境

在正式环境就没有那么麻烦, 因为bundle文件与manifest文件都已经被打包好,我们直接获取这两个文件,进行服务端渲染就可以了

代码语言:javascript复制
//ssr.js
const Router = require('koa-router')
const path = require('path')
const VueServerRender = require('vue-server-renderer')
const fs = require('fs')
const serverRender = require('./server.render')

const clientManifest = require('../../public/vue-ssr-client-manifest.json')

const renderer = VueServerRender.createBundleRenderer(
    path.join(__dirname,'../../server-build/vue-ssr-server-bundle.json'),
    {
        inject:false,
        clientManifest
    }
)
const template = fs.readFileSync(
    path.join(__dirname,'../server.template.ejs'),
    'utf-8'

)

const pageRouter = new Router()

pageRouter.get(/.*/,async (ctx)=>{
    await serverRender(ctx,renderer,template)
})

module.exports = pageRouter

0 人点赞