如何构建 vue-ssr 项目的方法步骤


Posted in Javascript onAugust 04, 2020

如何通过 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')
})
  • 首先通过 koa、koa-router 快速起了一个 web 服务器,这个服务器接受任何路径
  • 创建了一个renderer对象,创建一个 vue 实例
  • renderer.renderToString 将 vue 实例解析为 html 字符串
  • 通过 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 文件。

如何构建 vue-ssr 项目的方法步骤

假设我们以及打包好了这两份文件,我们来看 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')
})

构建配置

 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 构建过程做了梳理,到此这篇关于如何构建 vue-ssr 项目的文章就介绍到这了,更多相关如何构建 vue-ssr 项目内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!

Javascript 相关文章推荐
IE6下JS动态设置图片src地址问题
Jan 08 Javascript
javascript组合使用构造函数模式和原型模式实例
Jun 04 Javascript
jQuery实现分隔条左右拖动功能
Nov 21 Javascript
javascript自动恢复文本框点击清除后的默认文本
Jan 12 Javascript
jquery easyui datagrid实现增加,修改,删除方法总结
May 25 Javascript
浅谈js数据类型判断与数组判断
Aug 29 Javascript
js实现百度地图定位于地址逆解析,显示自己当前的地理位置
Dec 08 Javascript
layer弹出层中H5播放器全屏出错的解决方法
Feb 21 Javascript
vue组件watch属性实例讲解
Nov 07 Javascript
jQuery实现监听下拉框选中内容发生改变操作示例
Jul 13 jQuery
微信小程序实现发送模板消息功能示例【通过openid推送消息给用户】
May 05 Javascript
vux-scroller实现移动端上拉加载功能过程解析
Oct 08 Javascript
vue-quill-editor的使用及个性化定制操作
Aug 04 #Javascript
vue 添加和编辑用同一个表单,el-form表单提交后清空表单数据操作
Aug 03 #Javascript
浅谈vue中get请求解决传输数据是数组格式的问题
Aug 03 #Javascript
VUE使用axios调用后台API接口的方法
Aug 03 #Javascript
vue cli3.0打包上线静态资源找不到路径的解决操作
Aug 03 #Javascript
js数组中去除重复值的几种方法
Aug 03 #Javascript
Vue打包部署到Nginx时,css样式不生效的解决方式
Aug 03 #Javascript
You might like
探讨PHP删除文件夹的三种方法
2013/06/09 PHP
PHP的cURL库简介及使用示例
2015/02/06 PHP
thinkphp微信开发(消息加密解密)
2015/12/02 PHP
ThinkPHP中html:list标签用法分析
2016/01/09 PHP
PHP时间处理类操作示例
2018/09/05 PHP
JQUERY THICKBOX弹出层插件
2008/08/30 Javascript
利用jQuery操作对象数组的实现代码
2011/04/27 Javascript
JQuery触发事件例如click
2013/09/11 Javascript
javascript:FF/Chrome与IE动态加载元素的区别说明
2014/01/26 Javascript
jquery通过select列表选择框对表格数据进行过滤示例
2014/05/07 Javascript
JavaScript中的逻辑判断符&amp;&amp;、||与!介绍
2014/12/31 Javascript
Jquery修改image的src属性,图片不加载问题的解决方法
2016/05/17 Javascript
JavaScript的this关键字的理解
2016/06/18 Javascript
JS字符串统计操作示例【遍历,截取,输出,计算】
2017/03/27 Javascript
jQuery插件FusionCharts绘制的2D帕累托图效果示例【附demo源码】
2017/03/28 jQuery
Nodejs调用Dll模块的方法
2018/09/17 NodeJs
详解jQuery设置内容和属性
2019/04/11 jQuery
JavaScript中this的全面解析及常见实例
2019/05/14 Javascript
Vue如何实现验证码输入交互
2020/12/07 Vue.js
[03:17]史诗级大片应援2018DOTA2国际邀请赛 致敬每一位坚守遗迹的勇士
2018/07/20 DOTA
[54:06]OG vs TNC 2018国际邀请赛小组赛BO2 第二场 8.19
2018/08/21 DOTA
Python的爬虫程序编写框架Scrapy入门学习教程
2016/07/02 Python
python-docx修改已存在的Word文档的表格的字体格式方法
2018/05/08 Python
python基于http下载视频或音频
2018/06/20 Python
Pandas 按索引合并数据集的方法
2018/11/15 Python
Python补齐字符串长度的实例
2018/11/15 Python
python随机在一张图像上截取任意大小图片的方法
2019/01/24 Python
python实现贪吃蛇游戏
2020/03/21 Python
简单了解Pandas缺失值处理方法
2019/11/16 Python
python实现将一维列表转换为多维列表(numpy+reshape)
2019/11/29 Python
keras中的backend.clip用法
2020/05/22 Python
行政管理毕业生自荐信
2014/02/24 职场文书
2014五年级班主任工作总结
2014/12/05 职场文书
个人总结与自我评价
2015/02/14 职场文书
源码解读Spring-Integration执行过程
2021/06/11 Java/Android
Elasticsearch Recovery 详细介绍
2022/04/19 Java/Android