vuejs 页面渲染_Vue SSR服务端渲染改造踩坑指南

本文将对专栏《从0到1 实战朋友圈移动Web App开发》涉及的实战项目进行Vue的SSR改造,在线体验地址,建议使用手机浏览器体验:

客户端渲染地址:https://app.nihaoshijie.com.cn/ SSR渲染地址:https://app.nihaoshijie.com.cn/index_ssr

本文是对改造中踩的坑进行总结和梳理,至于改造的步骤,强烈建议直接参考官方文档指南:https://ssr.vuejs.org/zh/ 如果在改造中遇到任何问题并难以找到解决办法,不妨试试在文中找到答案。

版本匹配
Vue的SSR渲染,可以当作一个全新的项目,需要安装依赖的模块(node_modules),可以将原先使用vue cli 3创建的项目的package.json拷贝过来,确保不缺少相关模块,然后在此基础上添加SSR需要的模块。

主要是vue-server-renderer:

npm install vue vue-server-renderer --save
vue-server-renderer是SSR渲染的核心,提供bundle renderer来调用renderToString()方法将Vue组件渲染成HTML字符串,需要注意的是vue-server-renderer 和 vue 必须匹配版本,例如@2.6.11版本的vue必须对应@2.6.11版本的vue-server-renderer 。

路由模式history
采用了vue-router的Vue的SSR渲染,必须使用history作为路由模式,因为hash模式的路由提交不到服务器上,如果之前使用的是hash模式,需要进行修改:

const router = new Router({
mode: 'history',
...
})
两个入口
Vue的SSR渲染,一般都会是同构的,也就是业务代码是一套,通过不同的构建配置,来分别构建客户端client和服务端server,对webpack构建而言,这就需要有两个入口,修改vue.config.js来支持,代码如下:

const TARGET_NODE = process.env.WEBPACK_TARGET === 'node'

const target = TARGET_NODE ? 'server' : 'client'
...
configureWebpack: {
// 将 entry 指向应用程序的 server / client 文件
entry: ./src/entry-${target}.js,
// 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
// 并且还会在编译 Vue 组件时,
// 告知 vue-loader 输送面向服务器代码(server-oriented code)。
target: TARGET_NODE ? 'node' : 'web',
// node: TARGET_NODE? undefined : false,
// 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
output: {
libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
},
// devtool: 'source-map',
// https://webpack.js.org/configuration/externals/#function
// https://github.com/liady/webpack-node-externals
// 外置化应用程序依赖模块。可以使服务器构建速度更快,
// 并生成较小的 bundle 文件。
externals: TARGET_NODE ? nodeExternals({
// 不要外置化 webpack 需要处理的依赖模块。
// 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
// 你还应该将修改 global(例如 polyfill)的依赖模块列入白名单
whitelist: /.css$/
}) : undefined,
optimization: {
splitChunks: false
},
},
chainWebpack: config => {
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
return merge(options, {
optimizeSSR: false //https://vue-loader-v14.vuejs.org/zh-cn/options.html#optimizeSSR
})
})
},
...
其中,分别使用entry-client.js和entry-server.js作为entry入口即可,如果需要一次命令同时执行两个构建,可以修改package.json如下:

"scripts": {
"build": "vue-cli-service build",
"build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build && vue-cli-service build"
},
对于开发模式的SSR构建,由于使用不多,这里只提供生产模式的SSR构建,执行如下命令:

npm run build:server
bundle文件
Vue的SSR渲染服务端启动时,bundle renderer主要解析两个bundle文件,分别是:vue-ssr-server-bundle.json和vue-ssr-client-manifest.json文件,这两个文件分别由webpack构建生成,可以修改之前的vue.config.js配置文件:

configureWebpack: {
...
plugins: [
TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()
],
...
},
其中vue-ssr-server-bundle.json主要存放的是资源的映射信息,是bundle renderer必须的,而vue-ssr-client-manifest.json是对象clientManifest配置项,此对象包含了 webpack 整个构建过程的信息,从而可以让 bundle renderer自动推导需要在HTML模板中注入的内容,从而实现最佳的预加载(preload)和预取(prefetch)资源,如下图所示:

模拟window对象
Vue的SSR渲然Vue组件(包括根组件App.vue和其若干个子组件)时,由于没有动态更新,所有的组件生命周期钩子函数中,只有 beforeCreate 和 created 会在SSR渲染过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMount 或 mounted),只会在客户端执行。 此外还需要注意的是,应该避免在 beforeCreate 和 created 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端 (client-side only) 的代码中,我们可以设置一个 timer,然后在 beforeDestroy 或 destroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。为了避免这种情况,请将副作用代码移动到 beforeMount 或 mounted 生命周期中。 对于SSR渲染,由于采用的Node.js环境,所以需要对于window对象做兼容处理,这里推荐使用jsdom:

npm install jsdom --save
然后,在SSR的入口文件service.js中(非entry-server.js),添加如下代码:

const jsdom = require('jsdom')
const { JSDOM } = jsdom

/* 模拟window对象逻辑 */
const resourceLoader = new jsdom.ResourceLoader({
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
});// 设置UA
const dom = new JSDOM('', {
url:'https://app.nihaoshijie.com.cn/index.html',
resources: resourceLoader
});

global.window = dom.window
global.document = window.document
global.navigator = window.navigator
window.nodeis = true //给window标识出node环境的标志位
/* 模拟window对象逻辑 */
normalizeFile get undefined file错误
当运营SSR渲染,遇到如下错误时:

该错误通常会导致vue-ssr-client-manifest.json文件中的映射信息异常,导致无法正确找到对应的资源文件,可能属于VueSSRClientPlugin()的bug,但是可以通过webpack配置来进行规避,修改vue.config.js,添加代码如下:

css: {
sourceMap: true
}
相关的issue地址。

在服务端请求SSR首屏数据
对于一些首屏非静态页面的场景,这些页面的渲染依赖后端数据,所以在SSR端进行数据的拉取,并且在SSR渲染完成之后,将数据直接带给客户端进行二次渲染,减少请求的次数,所以对于数据共享的方案,最合适的方式是通过Vuex的Store完成,所以这里推荐项目使用Vuex,首先修改entry-server.js代码如下:

// 对所有匹配的路由组件调用 asyncData()
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {

      return Component.asyncData({
        store,
        route: router.currentRoute
      })
    }
  })).then(() => {
    // 在所有预取钩子(preFetch hook) resolve 后,
    // 我们的 store 现在已经填充入渲染应用程序所需的状态。
    // 当我们将状态附加到上下文,
    // 并且 `template` 选项用于 renderer 时,
    // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
    context.state = store.state

    resolve(app)
  }).catch((err)=>{
    console.error(err)
    reject(err)
  })

同时给首屏的第一个路由组件添加asyncData方法来请求数据,注意是组件的静态方法,而非在methods中定义的方法,代码如下:

export default {
name: 'wecircle',
...
asyncData ({ store }) {
// 触发 action 后,会返回 Promise
return store.dispatch('setWecircleDataListSSR')
},
...
}
后面的action和mutation按照正常逻辑写即可,最后,当SSR数据渲染完成后,会在生成的HTML中添加一个window.INITIAL_STATE对象,修改entry-client.js可以将数据直接赋值给客户端渲染,代码如下:

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

if (window.INITIAL_STATE) {
store.replaceState(window.INITIAL_STATE)
}
最后一点需要注意是由于客户端和服务端代码是同构的,但是服务端请求数据的地址和客户端是完全不一样的,所以针对这种场景,需要利用之前设置的window.nodejs标志位来判断在不同场景下的接口地址,从而区分对待。

cookie透传
当在SSR端请求数据时,可能会需要带上浏览器的cookie,在客户端到SSR服务器的请求中,客户端是携带有cookie数据的。但是在SSR服务器请求后端接口的过程中,却是没有相应的cookie数据的。因此在SSR服务器进行接口请求的时候,我们需要手动拿到客户端的cookie传给后端服务器。这里如果使用是axios,就可以手动设置axios请求的headers字段,代码如下: 在server.js中获取浏览器cookie,并利用window对象存储:

app.use('*', (req, res) => {
...
window.ssr_cookie = req.cookie
...
})
在axios中,添加header将cookie塞进去:

axios.create({
...
headers: window.ssr_cookie || {}
...
})
这样就可以将浏览器的cookie带给SSR服务器了。

No stacktrace on NavigationDuplicated error错误
该错误本身是由于重复的点击相同的导航组件,会报错NavigationDuplicated,由于SSR渲染会使用hostory模式,所以在第一次进入路由时,会经过多次导航(可以通过给router添加beforeEach钩子可以看到),所以也会报NavigationDuplicated错误,本身这个错误不影响使用,但是如果需要规避,可以采用如下代码,在router.js中添加:

const originalPush = Router.prototype.push
Router.prototype.push = function push(location, onResolve, onReject) {
if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject)
return originalPush.call(this, location).catch(err => err)
}
相关的issue地址。

同时支持客户端渲染和服务端渲染
既然使用了Vue的SSR渲染,那么首先需要考虑的就是SSR服务的稳定性,所以为了最大程度的保证服务可用,当服务端渲染挂掉时,需要有容错逻辑保证页面可用,所以,原先的客户端渲染相关的构建要保留,即通过直接访问index.html的方式能够正常使用页面,这里直接通过nginx配置路径转发,代码如下:

location /index.html {
return 301 https://$server_name/;
}
即将原先的通过http://www.abc.com/index.html访问的地址转发到http://www.abc.com/,这样就能够触发采用history模式的vue-router的path="/"的路由,对于客户端访问和服务的访问,分别配置不同的转发,如下:

客户端渲染服务

location / {
# 给静态文件添加缓存
location ~ ..(js|css|png|jpeg)(.) {
valid_referers *.nihaoshijie.com.cn;
if ($invalid_referer) {
return 404;
}
proxy_pass http://localhost:8080;
expires 3d;# 3天
}
proxy_pass http://localhost:8080; # 静态资源走8080端口
}

ssr服务

location = /index_ssr {
proxy_pass http://localhost:8888; # ssr服务使用8888端口
}
只保留/index_ssr作为SSR渲染的入口,然后在server.js中,将/index_ssr处理成首页的路径,并添加对SSR渲染的容错逻辑,代码如下:

if (req.originalUrl === '/index_ssr' || req.originalUrl === '/index_ssr/') {
context.url = '/'
}
...
renderer(bundle, manifest).renderToString(context, (err, html) => {
...
if (err) {
// 发现报错,直接走客户端渲染
res.redirect('/')
// 记录错误信息 这部分内容可以上传到日志平台 便于统计
console.error(error during render : ${req.url})
console.error(err)
}
...
})
针对服务端渲染的容错机制,不限于使用上面介绍的方案,也可以自行根据实际场景来解决。

PWA和SSR的集成
由于本项目使用到了PWA技术,这样在集成时需要注意PWA相关的代码和插件只需要在entry-client.js入口的逻辑中添加即可,SSR服务端是不需要配置PWA相关的逻辑,例如之前的OfflinePlugin插件,在vue.config.js做如下配置:

if (TARGET_NODE) {
plugins.push(new VueSSRServerPlugin())
} else {
plugins.push(new VueSSRClientPlugin())

plugins.push(new OfflinePlugin({
// 要求触发ServiceWorker事件回调
ServiceWorker: {
events: true,
// push事件逻辑写在另外一个文件里面
entry: './public/sw-push.js'
},
// 更更新策略选择全部更新
updateStrategy: 'all',
// 除去一些不需要缓存的文件
excludes: ['/.map', '/.svg', '/.png', '/.jpg', '/sw-push.js', '/sw-my.js','*/.json'],

  // 添加index.html的更新
  rewrites (asset) {
    if (asset.indexOf('index.html') > -1) {
      return './index.html'
    }

    return asset
  }
}))

}
总结
对于Vue的SSR渲染,本身逻辑是相对复杂的,需要同时了解客户端和Node端的技术,并且使用起来对后端服务也是有一定要求的,所以需要具体的使用场景来判断是否使用,作为初学者,笔者建议跟着官方文档来一步一步进行改造,这样能够更加理解其中的含义,如果感兴趣的话也可以了解一下Vue的SSR框架:nuxt.js

上述改造完整项目Github地址。
————————————————
版权声明:本文为CSDN博主「Li小飞」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_42498206/article/details/112357085

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,175评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,674评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,151评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,597评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,505评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,969评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,455评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,118评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,227评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,213评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,214评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,928评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,512评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,616评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,848评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,228评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,772评论 2 339

推荐阅读更多精彩内容