如何构建 vue-ssr 项目__Vue.js
发布于 4 年前 作者 banyungong 1304 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

如何通过 web 服务器去渲染一个 vue 实例

构建一个极简的服务端渲染需要什么

  • web 服务器
  • vue-server-renderer
  • vue
const Vue = require('vue')
const Koa = require('koa')
const app = new Koa()
const Router = require('koa-router')
const router = new Router()
const renderer = require('vue-server-renderer').createRenderer()
router.get(/./, (ctx)=>{
  const app = new Vue({
    data: {
      url: ctx.request.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  renderer.renderToString(app, (err, html) => {
    if (err) {
      ctx.status = 500 
      ctx.body = err.toString()
    }
    ctx.body = `
    <!DOCTYPE html>
    <html lang="en">
      <head><title>Hello</title></head>
      <body>${html}</body>
    </html>
  `
  })
})
app.use(router.routes())
app.listen(4000,()=>{
  console.log('listen 4000')
})
  1. 首先通过 koa、koa-router 快速起了一个 web 服务器,这个服务器接受任何路径
  2. 创建了一个renderer对象,创建一个 vue 实例
  3. renderer.renderToString 将 vue 实例解析为 html 字符串
  4. 通过 ctx.body ,拼接成一个完整的 html 字符串模版返回。

相信经过上面的代码实例可得知,即使你没有使用过 vue-ssr 的经历,但是你简单地使用过 vue 和 koa 的同学都可以看出来这个代码非常明了。
唯一要注意的地方就是,我们是通过 require(‘vue-server-renderer’).createRenderer() 来创建一个 renderer 对象. 这个renderer 对象有一个 renderToString 的方法

renderer.renderToString(app,(err,html)=>{})

  • app 就是创建的 vue 实例
  • callback, 解析 app 后执行的回调,回调的第二个参数就是解析完实例得到的 html 字符串,这个的 html 字符串是挂载到 #app 那部分,是不包含 head、body 的,所以我们需要将它拼接成完整的 html 字符串返回给客户端。

使用 template 用法

上面方法中 ctx.body 的部分需要手动去拼接模版,vue-ssr 支持使用模版的方式。

来看下模版长啥样,发现出来多一行*<!–vue-ssr-outlet–>*注释,和普通的html文件没有差别

<!–vue-ssr-outlet–> 注释 – 这里将是应用程序 HTML 标记注入的地方。也就是 renderToString 回调中的 html 会被注入到这里。

<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

有了模版该如何使用它呢?

只需要在创建 renderer 之前给 createRenderer 函数传递 template 参数即可。

看下使用模版和自定义模版的区别,可以看到通过其他部分都相同,只是我们指定了 template 后,ctx.body 返回的地方我们不需要手动去拼接一个完整的 html 结构了。

const renderer = require('vue-server-renderer').createRenderer({
  template: fs.readFileSync('./index.template.html','utf-8')
})
router.get(/./, (ctx)=>{
  const app = new Vue({
    data: {
      url: ctx.request.url
    },
    template:"<div>访问路径{{url}}</div>"
  })
  renderer.renderToString(app, (err, html) => {
    if (err) {
      ctx.status = 500 
      ctx.body = err.toString()
    }
    ctx.body = html
  })
})

项目级

上面的实例是 demo 的展示,在实际项目中开发的话我们会根据客户端和服务端将它们分别划分在不同的区块中。

项目结构

// 一个基本项目可能像是这样:
build                                    -- webpack配置
|——- client.config.js 
|——- server.config.js
|——- webpack.base.config.js
src
├── components
│   ├── Foo.vue
│   ├── Bar.vue
│   └── Baz.vue
├── App.vue
├── app.js # 通用 entry(universal entry)  -- 生成 vue 的工厂函数
├── entry-client.js # 仅运行于浏览器        -- 将 vue 实例挂载,作为 webpack 的入口
|── entry-server.js # 仅运行于服务器        -- 数据预处理逻辑,作为 webpack 的入口
|-- server.js                            -- web 服务器启动入口 
|-- store.js                             -- 服务端数据预处理存储容器
|-- router.js                            -- vue 路由表

加载一个vue-ssr应用整体流程

首先根据上面的项目结构我们可以大概知道,我们的服务端和客户端分别以 entry-client.js 和 entry-server.js 为入口,通过 webpack 打包出对应的 bundle.js 文件。

首先不考虑 entry-client.js 和 entry-server.js 做了什么(后续会补充),我们需要知道,它们经过 webpack 打包后生成了我们需要的创建 ssr 的依赖 .js 文件。 可以看下图打包出来的文件,.json 文件是用来关联 .js 文件的,就是一个辅助文件,真正起作用的还是两个 .js 文件。 假设我们以及打包好了这两份文件,我们来看 server.js 中做了什么。

server.js

// ... 省略不重要步骤
const renderer = require('vue-server-renderer').createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'),{
  runInNewContext:false,
  template: fs.readFileSync('./index.template.html','utf-8'),
  // 客户端构建
  clientManifest:require('./dist/vue-ssr-client-manifest.json')
})
router.get('/home', async (ctx)=>{
  ctx.res.setHeader('Content-Type', 'text/html')
  const html = await renderer.renderToString()
  ctx.body = html
})
app.listen(4000,()=>{
})

省略了一些不重要的步骤,来看 server.js,其实它和我们上面创建一个简单的服务端渲染步骤基本相同

  • 创建一个 renderer 对象,不同点在于创建这个对象是根据已经打包好的 .json 文件去找到真正起作用.js 文件去生成的。
  • 由于在 createBunldeRenderer 创建 renderer 对象的时候同时传入了 server.json 和 client-mainfest.json 两个部分,所以我们在使用 renderer.renderToString() 的时候也不需要去传入 vue实例了。
  • 最终得到 html 字符串和上面相同,返回客户端就完成了服务端渲染的部分。接下来就是客户端解析渲染 dom 的过程。

流程梳理

有了对项目结构的了解,和 server.js 的基本了解后来梳理下 vue-ssr 整个工作流程是怎么样的?

  • 首先我们会启动一个 web 服务,也就上面的 server.js ,来查看一个服务端路径
router.get('/home', async (ctx)=>{
  const context = {
    title:'template render',
    url:ctx.request.url
  }
  ctx.res.setHeader('Content-Type', 'text/html')
  const html = await renderer.renderToString(context)
  ctx.body = html
})
app.listen(4000,()=>{
  console.log('listen 4000')
})
  • 当我们访问 http://localhost:4000/home 就会命中该路由,执行 renderer.renderToString(context) ,renderer 是根据我们已经打包好的 bundle 文件生成的 renderer对象。相当于去执行 entry-server.js 服务端数据处理和存储的操作

  • 根据模版文件,得到 html 文件后返回给客户端,Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。相当于去执行 entry-client.js 客户端的逻辑

    由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。 如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:

	<div id="app" data-server-rendered="true">

entry-client.js 和 entry-server.js

经过上面的流程梳理我们知道了当访问一个 vue-ssr 的整个流程: 访问 web 服务器地址 > 执行 renderer.renderToString(context) 解析已经打包的 bunlde 返回 html 字符串 > 在客户端激活这些静态的 html,使它们成为动态的。

接下来我们需要看看 entry-client.js 和 entry-server.js 做了什么。

entry-server.js

  • 这里的 context 就是 renderer.renderToString(context) 传递的值,至于你想传递什么是你在 web 服务器中自定义的,可以传递任何你想给客户端的值。
  • 这里我们可以通过 context 来获取到客户端返回 web 服务器的地址,通过 context.url (需要你在服务端传递该值)获取到该路径,并且通过 router.push(context.url) 实例来访问相同的路径。
  • context.url 对应的组件中会定义一个 asyncData 的静态方法,并且将服务端存储在 store 的值传递给该方法。
  • 将 store 中的值存储给 context.state ,context.state 将作为 window.INITIAL_STATE 状态,自动嵌入到最终的 HTML 中。就是一个全局变量。
import { createApp } from './app'

export default context => {
   // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
    // 以便服务器能够等待所有的内容在渲染前,
    // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router,store } = createApp()

    // 设置服务器端 router 的位置
    router.push(context.url)
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      // 对所有匹配的路由组件调用 asyncData
      // Promise.all([p1,p2,p3])
      const allSyncData = matchedComponents.map(Component => {
        if(Component.asyncData) {
          return Component.asyncData({
            store,route:router.currentRoute
          })
        }
      })
      Promise.all(allSyncData).then(() => {
        // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中。
        context.state = store.state
        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

entry-client.js

  • 执行匹配到的组件中定义的 asyncData 静态方法,将 store 中的值取出来作为客户端的数据。
import { createApp } from './app'
// 你仍然需要在挂载 app 之前调用 router.onReady,因为路由器必须要提前解析路由配置中的异步组件,才能正确地调用组件中可能存在的路由钩子。
const { app,router,store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  // 使用 `router.beforeResolve()`,以便确保所有异步组件都 resolve。
  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')
})

bundle 文件

  • server.config 打包后会生成 vue-ssr-server-bundle.json 文件,这个文件是给 createBundleRenderer 用的,用于服务端渲染出 html 文件
{
  "entry": "main.b991138d1115e77cdb8f.js",
  // 服务端解析对于的js文件去生成 html 文件
  "files": {
    "1.b61d4c4bc28567ea69b2.js":"xxx.map",
    "main.b991138d1115e77cdb8f.js":"xxx.map"
  },
  "maps": {
    "1.b61d4c4bc28567ea69b2.js": {},
    "main.b991138d1115e77cdb8f.js": {}
  }
}
  • vue-ssr-client-manifest.json 文件,这个文件是客户端构建清单,服务端拿到这份构建清单找到一下用于初始化的js脚步或css注入到 html 一起发给浏览器。
// all 字段中对于的是客户端打包后 dist 中的文件
{
  "publicPath": "/dist/",
  "all": [
    "0.js",
    "0.js.map",
    "app.js",
    "app.js.map"
  ],
  "initial": [
    "app.js"
  ],
  "async": [
    "0.js"
  ],
  "modules": {
    // ...
  }
}

最后客户端中会通过下面方式来加载对于的js文件

<link rel="preload" href="/dist/app.js" as="script"><link rel="preload" href="/dist/0.js" as="script"></head>

构建配置

webpack.base.config.js

服务端和客户端相同的配置一些通用配置,和我们平时使用的 webpack 配置相同,截取部分展示

module.exports = {
  mode:isProd ? 'production' : 'development',
  devtool: isProd
    ? false
    : '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    filename: '[name].[chunkhash].js'
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          compilerOptions: {
            preserveWhitespace: false
          }
        }
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: '[name].[ext]?[hash]'
        }
      },
      {
        test: /\.styl(us)?$/,
        use: isProd
          ? ExtractTextPlugin.extract({
              use: [
                {
                  loader: 'css-loader',
                  options: { minimize: true }
                },
                'stylus-loader'
              ],
              fallback: 'vue-style-loader'
            })
          : ['vue-style-loader', 'css-loader', 'stylus-loader']
      },
    ]
  },
  plugins:  [
        new VueLoaderPlugin()
      ]
}

client.config.js

const webpack = require('webpack')
const {merge} = require('webpack-merge')
const baseConfig = require('./webpack.base.config')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const path = require('path')
module.exports = merge(baseConfig,{
  entry:path.resolve('__dirname','../entry-client.js'),
  plugins:[
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})

server.config.js

const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.config.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const path = require('path')
module.exports = merge(baseConfig,{
  entry:path.resolve('__dirname','../entry-server.js'),
  target:'node',
  devtool:'source-map',
  // 告知 server bundle 使用 node 风格导出模块
  output:{
    libraryTarget:'commonjs2'
  },
  externals: nodeExternals({
    allowlist:/\.css$/
  }),
  plugins:[
    new VueSSRServerPlugin()
  ]
})

开发环境配置

webpack 提供 node api可以在 node 运行时使用。

修改 server.js

server.js 作为 web 服务器的入口文件,我们需要判断当前运行的环境是开发环境还是生产环境。

const isProd = process.env.NODE_ENV === 'production'
async function prdServer(ctx) {
	// ...生产环境去读取 dist/ 下的 bundle 文件
}
async function devServer(ctx){
	// 开发环境
}
router.get('/home',isProd ? prdServer : devServer)
app.use(router.routes())
app.listen(4000,()=>{
  console.log('listen 4000')
})

dev-server.js

生产环境中是通过读取内存中 dist/ 文件夹下的 bundle 来解析生成 html 字符串的。在开发环境中我们该怎么拿到 bundle 文件呢?

  • webpack function 读取 webpack 配置来获取编译后的文件
  • memory-fs 来读取内存中的文件
  • koa-webpack-dev-middleware 将 bundle 写入内存中,当客户端文件发生变化可以支持热更新

webpack 函数使用

导入的 webpack 函数会将 配置对象 传给 webpack,如果同时传入回调函数会在 webpack compiler 运行时被执行:

• 方式一:添加回调函数

const webpackConfig = {
    // ...配置项
}
const callback = (err,stats) => {}
webpack(webpackConfig, callback)

err对象 不包含 编译错误,必须使用 stats.hasErrors() 单独处理,文档的 错误处理 将对这部分将对此进行详细介绍。err 对象只包含 webpack 相关的问题,例如配置错误等。

  • 方式二:得到一个 compiler 实例

你可以通过手动执行它或者为它的构建时添加一个监听器,compiler 提供以下方法

  • compiler.run(callback)
  • compiler.watch(watchOptions,handler) 启动所有编译工作
const webpackConfig = {
    // ...配置项
}
const compiler = webpack(webpackConfig)

客户端配置

      const clientCompiler = webpack(clientConfig)
      const devMiddleware = require('koa-webpack-dev-middleware')(clientCompiler,{
        publicPath:clientConfig.output.publicPath,
        noInfo:true,
        stats:{
          colors:true
        }
      })

      app.use(devMiddleware)
      // 编译完成时触发
      clientCompiler.hooks.done.tap('koa-webpack-dev-middleware', stats => {
        stats = stats.toJson()
        stats.errors.forEach(err => console.error(err))
        stats.warnings.forEach(err => console.warn(err))
        if (stats.errors.length) return
        clientManifest = JSON.parse(readFile(
          devMiddleware.fileSystem,
          'vue-ssr-client-manifest.json'
        ))
        update()
      })

默认情况下,webpack 使用普通文件系统来读取文件并将文件写入磁盘。但是,还可以使用不同类型的文件系统(内存(memory), webDAV 等)来更改输入或输出行为。为了实现这一点,可以改变 inputFileSystem 或 outputFileSystem。例如,可以使用 memory-fs 替换默认的 outputFileSystem,以将文件写入到内存中。

koa-webpack-dev-middleware 内部就是用 memory-fs 来替换 webpack 默认的 outputFileSystem 将文件写入内存中的。

  • 读取内存中的 vue-ssr-client-mainfest.json
  • 调用 update 封装好的更新方法

服务端配置

  • 读取内存中的vue-ssr-server-bundle.json文件
  • 调用 update 封装好的更新方法
     // hot middleware
      app.use(require('koa-webpack-hot-middleware')(clientCompiler, { heartbeat: 5000 }))
      // watch and update server renderer
      const serverCompiler = webpack(serverConfig)
      serverCompiler.outputFileSystem = mfs
      serverCompiler.watch({}, (err, stats) => {
        if (err) throw err
        stats = stats.toJson()
        if (stats.errors.length) return

        // read bundle generated by vue-ssr-webpack-plugin
        bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
        update()
      })

update 方法

const update = async () => {
        if(bundle && clientManifest) {
          const renderer =  createRenderer(bundle,{
            template:require('fs').readFileSync(templatePath,'utf-8'),
            clientManifest
          })
          // 自定义上下文
          html = await renderer.renderToString({url:ctx.url,title:'这里是标题'})
          ready()
        }
      }

总结

本文将自己理解的 vue-ssr 构建过程做了梳理,有遗漏或者不对的地方欢迎在留言区留言。感谢👏

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 三只萌新 原文链接:https://juejin.im/post/6856739548778725389

回到顶部