VUE SSR 开发环境搭建

420 阅读5分钟

前言

前一篇文章,我大致描述了vue ssr的过程,并且构建出了vue ssr的生产环境。上篇文档主要还是讲述了vue ssr的整个思想,如果足够熟悉,会明显发现vue ssr的整个过程很零散,与vue3提供SSR函数有很大的不同,vue3对ssr做了补充。

下面还是讲述vue2的开发环境搭建,而不是vue3;虽然,微软已经宣布IE已经寿尽寝终,但是,我发现还有很多坑逼在用IE,就互联网行业而言,也还有人是用IE,或许在非专业人士眼中,所有的浏览器并没有什么差别。只要还有5%的目标用户在用IE,IE就不会消亡;其实5%在流量大头企业,已经是很恐怖的收入了,嗯,严谨点可以降到2%-1%。

这篇文章是对vue ssr的深入,对vue ssr概念还是不太熟,建议查看上一篇文章,有空的话,建议手撸一遍,两遍更好:VUE SSR基础功能搭建,以下,我们开始吧!

(文章尾部会附上代码链接)

概述

开发环境简单点来讲,就是修改一次代码,就按照上一篇文章那样build一下,但是,这个过程应该让程序自动完成。就这个自动过程,我们来分析一下:

  1. vueCLI提供了开发环境,即,如果我们在vueCli的基础上添加vue-server-renderer/client-plugin,vueCLI会生成我们需要的vue-ssr-client-manifest.json文件,但是,我们还缺少vue-ssr-server-bundle.json
  2. 如果我们开启两个vscode的控制台,执行两次serve命令,提供不同的参数,则会产生两个HMR,按道理应该是会生成我们需要的vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json文件,我们还是缺少renderToString的过程。
  3. 由此,上一篇文章单纯的vueCLI满足不了我们的需求了,所以,我们得自己搭建一个vueCLI开发环境,依然要用vueCLI,因为,完全自己弄,真的,真的,真的很恶心(感谢开源社区,感谢尤大)。

开发环境需要的东西

根据webpack的node api,需要两个东西来满足我们的需求:开发环境服务和HMR(庆幸的是loader帮我们实现了HMR,我们只需要简单使用)。下面提出两个webpack中间件和一个渲染服务,满足我们需求:

  1. webpack-hot-middleware:提供了HMR相关东西的定义,各种loader实现的接口
  2. webpack-dev-middleware:提供开发环境服务
  3. 自建node渲染服务,执行两次webpack([webpack配置])方法,获取到对应的bunldmanifest,通过renderToString结合html模板,生成最终的html(这个过程不清楚的,还是得看上一篇文章),将上面两个middlewareuse进该服务,端口问题也没有了。

注意,上一篇文章,渲染服务用的是koa,但是,这两个中间件是express的,所以,需要调整

webpack config的获取

因为不能直接使用npm run serve了,所以,webpack相关的,我们都要弄,上面说还是要用vueCLI,是因为除了服务,还是其他配套的东西,完全不需要自己再弄。(信我,真的恶心

  1. vueCLI提供了,webpack配置审查功能,意思是,我们可以直接获取webpack的配置,不用自己瞎搞:import cliConfig from '@vue/cli-service/webpack.config.js'
  2. 新建webpack-client.jswebpack-server.js自定义两个如果需要的配置,通过webpack-merge库合并就是我们的目标webpack配置。

webpack-hot-middleware的使用

注意事项:

  1. webpack配置entry需要添加:'webpack-hot-middleware/client',否者HMR不生效;
  2. webpack添加插件:webpack.HotModuleReplacementPluginwebpack.NoEmitOnErrorsPlugin

以上就是整体思路和注意事项,下面直接上代码帮助理解

整体流程

sequenceDiagram
用户 ->> 服务器: 访问页面
服务器 ->> node: 触发node服务
node ->> node: node:app.listen监听端口服务
node ->> node: 添加webpack-dev-middleware服务中间件(提供开发服务)
node ->> node: 添加webpack-hot-middleware服务中间件(提供热更新服务)
node ->> node: 通过require('@vue/cli-service/webpack.config')获取webpack配置
node -->> 服务器: 项目文件更改触发以上两个中间件,根据webpack配置打包代码
服务器 ->> 服务器: 生成vue-ssr-server-bundle.json和vue-ssr-client-manifest.json
服务器 ->> 服务器: 根据前面生成的文件,通过vue-server-renderer/renderToString方法生成目标代码
服务器 ->> 服务器: 将生成的html插入<!--vue-ssr-outlet-->,渲染页面
服务器 --) 用户: 将页面返回

详细代码

基础代码 (与上一篇文章一致,不做赘述)

  1. main.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'

Vue.config.productionTip = false

export function createApp () {
  const router = createRouter()
  const store = createStore()

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })

  return { app, router, store }
}

  1. router
import Vue from 'vue'
import VueRouter from 'vue-router'
import HomeView from '../views/HomeView.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomeView
  },
  {
    path: '/about',
    name: 'about',
    component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
  }
]

export function createRouter () {
  return new VueRouter({
    mode: 'history',
    routes
  })
}

  1. store
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
  return new Vuex.Store({
    state: {
    },
    getters: {
    },
    mutations: {
    },
    actions: {
    },
    modules: {
    }
  })
}

  1. entry-client.js
import { createApp } from './main.js'

const { app, router, store } = createApp()

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 })
      }
      return Promise.resolve()
    })).then(next).catch(next)
  })

  app.$mount('#app')
})

if (module.hot) {
  module.hot.acesst()
}

  1. entry-serve.js
import { createApp } from './main.js'

export default content => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    router.push(content.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()

      if (!matchedComponents.length) {
        return resolve(app)
      }

      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          return component.asyncData({ store, route: router.currentRoute })
        }
        return Promise.resolve()
      }))
        .then((arr) => {
          content.state = store.state
          resolve(app)
        })
        .catch(reject)
      resolve(app)
    }, reject)
  })
}

  1. package.json
"scripts": {
    "serve": "vue-cli-service serve",
    "build": "npm run build:client && npm run build:server && node ./server/copy_server.js",
    "build:client": "vue-cli-service build",
    "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --no-clean",
    "dev": "cross-env WEBPACK_TARGET=node NODE_ENV=development node ./server/dev.js",
    "test:unit": "vue-cli-service test:unit",
    "lint": "vue-cli-service lint"
  },

package.json入口分析

npm run dev

执行了命令:cross-env WEBPACK_TARGET=node NODE_ENV=development node ./server/dev.js,这里设置了两个环境变量,并且执行了node代码,作用是根据WEBPACK_TARGET环境变量配置相对应的webpack配置,node代码根据webpack配置,通过webpack中间件:webpack-dev-middleware服务中间件、webpack-hot-middleware服务中间件,提供对应的服务

node服务文件夹描述

其他文件不一一描述,就是cli生成的文件以及上面的基础文件

image.png

关键代码

  1. server/dev.js文件,该文件是webpack服务的所有代码集合
  2. server/webpack.client.config.js文件
  3. server/webpack.server.config.js文件

webpack相关的配置

相关的配置内容都比较简单,复杂的配置都在@vue/cli-service/webpack.config里面,我们这里做个简单的继承,就能让项目跑起来,自己配置的话,比较恶心

server/webapck.client.config.js

const { merge } = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const localIpAddr = require('ip').address()

const webpackConfig = require('@vue/cli-service/webpack.config')

const config = merge(webpackConfig, {
  entry: './src/entry-client.js',
  devtool: 'eval-source-map',
  target: 'web',
  node: false,
  devServer: {
    static: '/dist',
    hot: true
  },
  output: {
    libraryTarget: undefined,
    publicPath: `http://${localIpAddr}:8080/`
  },
  externals: undefined,
  optimization: {
    splitChunks: undefined
  },
  plugins: [new VueSSRClientPlugin()]
})

module.exports = config

server/webpack.server.config.js

const { merge } = require('webpack-merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const nodeExternals = require('webpack-node-externals')

const webpackConfig = require('@vue/cli-service/webpack.config')

const config = merge(webpackConfig, {
  entry: './src/entry-server.js',
  devtool: 'eval-source-map',
  target: 'node',
  node: undefined,
  output: {
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({ allowlist: [/\.css$/] }),
  optimization: {
    splitChunks: undefined
  },
  plugins: [new VueSSRServerPlugin()]
})

module.exports = config

server/dev.js代码如下:

const express = require('express')
const path = require('path')
const fs = require('fs')

const webpack = require('webpack')
const { createFsFromVolume, Volume } = require('memfs')

const app = express()

const resolve = file => path.resolve(__dirname, file)

const readFileByMemfs = (fs, file) => {
  try {
    return fs.readFileSync(path.join(wpClientConfig.output.path, file), 'utf-8')
  } catch (err) {
    console.error('func rreadFileByMemfs error:', err)
  }
}

let clientManifest
let bundle
let renderer

let ready
/** 添加promise,等待webpack构建完成 */
const readyPromise = new Promise(r => { ready = r })

/** 引入vue的服务端渲染函数 */
const { createBundleRenderer } = require('vue-server-renderer')

/** 引入模板文件,用于插入服务端渲染函数,渲染完成的html字符串 */
const templatePath = resolve('../public/index.template.html')
const template = fs.readFileSync(templatePath, 'utf-8')

/**
 * 每次热更新:webpack-hot-middleware, webpack服务:webpack-dev-middleware更新都会触发该函数,
 * 用于确定manifest.json和bundle.json是否已经处理好,处理好之后再转交给vue-server-renderer生产html
 */
const update = () => {
  if (bundle && clientManifest) { ready() }
  renderer = createBundleRenderer(bundle, {
    runInNewContext: false,
    template,
    clientManifest
  })
}

/** 引入client的webpack配置,并更改相关的webpack配置 */
const wpClientConfig = require('./webpack.client.config')

/**
 * 如果需要更改,提供热更新服务,意思是在执行webpack.entry之前执行:webpack-hot-middleware/client代码,
 * 注意:这里并不是热更新,热更新中间件需要app.use才是使用,这里仅仅是热更新需要的代码
 * */
wpClientConfig.entry = ['webpack-hot-middleware/client', wpClientConfig.entry]
wpClientConfig.output.filename = '[name].js'
/** 官方要求的相关插件 */
wpClientConfig.plugins.push(
  new webpack.HotModuleReplacementPlugin(),
  new webpack.NoEmitOnErrorsPlugin()
)

/** 上面已经准备好client的webpack配置,以下开始执行 */
const wpClienetCompiler = webpack(wpClientConfig)
const wpDevServer = require('webpack-dev-middleware')(wpClienetCompiler, {
  publicPath: wpClientConfig.output.publicPath,
  stats: 'errors-warnings'
})
app.use(wpDevServer)

/** webpack Compiler完成后的hooks */
wpClienetCompiler.hooks.done.tap('vue-server-renderer/client-plugin', stats => {
  const statsJson = stats.toJson() /** 转换格式 */

  /** 异常处理 */
  statsJson.errors.forEach(err => console.error(err))
  statsJson.warnings.forEach(err => console.warn(err))

  if (statsJson.errors.length) return

  /** 获取生成后的manifest.json文件 */
  clientManifest = JSON.parse(readFileByMemfs(wpClienetCompiler.outputFileSystem, 'vue-ssr-client-manifest.json'))
  update()
})

/** use热更新代码以及相关的配置,具体可查看官方配置 */
app.use(require('webpack-hot-middleware')(wpClienetCompiler, {
  heartbeat: 5000,
  log: false,
  path: '/__webpack_hmr',
  timeout: 2000,
  overlay: false,
  reload: true
}))

/** 引入server相关的webpack配置,并执行webpack配置 */
const wpServerConfig = require('./webpack.server.config')
const wpServerCompiler = webpack(wpServerConfig)

/** 将编译的内容存放在内存,提升速度 */
const serverFs = createFsFromVolume(new Volume())
wpServerCompiler.outputFileSystem = serverFs

/** watch代码更改 */
wpServerCompiler.watch({}, (err, stats) => {
  if (err || stats.hasErrors()) {
    console.error(err || 'webpack watch error!')
    return
  }

  const statsJson = stats.toJson() /** 格式转换 */

  /** 异常处理 */
  statsJson.errors.forEach(err => console.error(err))
  statsJson.warnings.forEach(err => console.warn(err))

  if (statsJson.errors.length) return

  /** 读取生产后的bundle.json文件,这时两个关键文件已经生成 */
  bundle = JSON.parse(readFileByMemfs(serverFs, 'vue-ssr-server-bundle.json'))
  update()
})

/** 通过上面webpack生成的manifest.json、bundle.json 通过renderToString生成html代码 */
/** 以下node静态资源服务代码 */
function renderToString (content) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(content, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

// const serve = (path) => express.static(resolve(path), { maxAge: 0 })

// app.use('/dist', serve('./dist'))
// app.use('/public', serve('./public'))
// app.use('/manifest.json', serve('./manifest.json'))

function render (req, res) {
  const s = Date.now()

  res.setHeader('Content-Type', 'text/html')

  const handleError = err => {
    if (err.url) {
      res.redirect(err.url)
    } else if (err.code === 404) {
      res.status(404).send('404 | url找不到')
    } else {
      // Render Error Page or Redirect
      res.status(500).send('500 | Internal Server Error')
      console.error(`ender 错误: ${req.url}`)
      console.error(err.stack)
    }
  }

  const context = {
    title: 'vue ssr',
    description: 'vue2 ssr服务端渲染',
    keyword: 'vue2,ssr',
    url: req.url
  }
  renderToString(context, (err, html) => {
    if (err) {
      return handleError(err)
    }
    res.send(html)
    console.log(`whole request: ${Date.now() - s}ms`)
  })
}

app.get('*', (req, res) => {
  readyPromise.then(() => render(req, res))
})

const port = 8080
app.listen(port, () => {
  console.log(`server is running in localhost:${port}`)
})

以上全部核心代码已经展示,关键逻辑已添加注释

server/server.js文件

该文件就是打包后的服务端执行代码,需要将里面引入的包再打包才能正常运行

const fs = require('fs')
const path = require('path')

const Koa = require('koa')
const send = require('koa-send')
const compress = require('koa-compress')

const app = new Koa()
const resolve = file => path.resolve(__dirname, file)

// 第 2 步:获得一个createBundleRenderer
const template = fs.readFileSync(resolve('./index.template.html'), 'utf-8')
const { createBundleRenderer } = require('vue-server-renderer')
const bundle = require(resolve('./vue-ssr-server-bundle.json'))
const clientManifest = require(resolve('./vue-ssr-client-manifest.json'))

const renderer = createBundleRenderer(bundle, {
  runInNewContext: false,
  template,
  clientManifest
})

function renderToString (content) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(content, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}

// 注入变量
app.use(async (ctx, next) => {
  ctx.ssrContext = {
    url: ctx.path + ctx.search,
    title: 'vue ssr',
    description: 'vue2 ssr服务端渲染',
    keyword: 'vue2,ssr'
  }

  await next()
})

// 第 3 步:添加一个中间件来处理所有请求
app.use(async (ctx, next) => {
  const url = ctx.path
  if (/[.](js|css|jpg|jpeg|png|gif|map|ico|cur|json|html|txt|svg|font|woff|ttf)$/.test(url)) {
    await send(ctx, url, { root: path.resolve(__dirname, './') })
    return
  }

  ctx.res.setHeader('Content-Type', 'text/html')
  try {
    const html = await renderToString(ctx.ssrContext)
    ctx.body = html
  } catch (res) {
    console.log(
      `服务器catch异常:${
        res instanceof Error ? res.stack : JSON.stringify(res)
      }`
    )
    ctx.response.redirect(`/error/${res.code ? res.code : 500}`)
  }
  next()
})

app.use(compress({ threshold: 2048 }))

const port = 8080
app.listen(port, function () {
  console.log(`server started at localhost:${port}`)
})

线上demo

github.com/goodrufu/ss…