原创:2020开年之作-高质量 SSR 解析(无任何配置代码,无基础性问题分析)

2,186 阅读15分钟

前言,不一样的SSR解析

2020即将过半,还仅是今年首文,惭愧惭愧。既然是开年大作,同时又是如此老生常谈的话题,本文当然不会随波逐流啦。笔者承诺,本文绝不贴一行关于SSR配置的代码,不分析关于SSR的基础性问题,如果你是来学习如何配置SSR的,那不好意思,Vue SSR指南奉上。

本文延续vue + webpack展开,不同的是加入了SSR。借助之前搭建的项目脚手架,成功升级为Vue SSR 脚手架。本文旨在记录在脚手架搭建过程及项目中遇到的问题及一些思考,希望能帮助到各位。

先来张图,也许看完这张图,能让你对SSR有不一样的理解

关于SSR,你都有什么问题?

  1. 为什么要Node?
  2. Node在SSR中充当了什么角色?有什么作用?
  3. 官方教程中的客户端entry和服务端entry都起了什么作用?
  4. SSR后,所有的接口都必须经过 Node 转发吗?
  5. Node服务起来后,初始的页面及经过vue-router路由跳转后的页面是如何渲染的?

直奔主题。列出的这几个问题,是笔者在开发前后思考过的问题,也是新手在涉及SSR开发时较容易混淆不理解的几个地方。现将思路分享给大家(如果你看懂了上图,基本也就理解了这些问题)。

为什么是Node?

其实任何一种后端语言都可以,前提是使用vue-server-renderer 2.5+。

但是,MVVM框架的出现实现了前后端分离,后端从前端业务层解放出来,如果做 Vue SSR是在非Node环境中,那岂不后端同学也得参与其中的构建工作,前后端又紧密结合在一起。所以还是建议使用 Node.js 来做SSR。

Node在SSR中充当了什么角色?有什么作用?

简要的来讲,Node层面主要是请求获取数据,生成页面"快照"(什么是快照,下面会提及)吐出页面,可以理解它为中间件的角色 主要有两个作用:

  1. 编译webpack(服务端配置文件),生成服务端bundle
  2. 根据前端路由进行匹配,预获取数据,渲染对应的html

server-entry 和 client-entry

如果加上生成vue实例的工厂函数 create-entry,整个项目应该有三个入口文件,为什么有三个这里不解释了,详见Vue SSR 指南有描述。

至于为什么要有服务端和客户端两个entry并不难理解,用最简单的话来讲,SSR终极奥义就是让服务端来渲染Vue页面

但是Vue应用程序是由 webpack 和 vue-loader 构建编译的,Node没办法运行并渲染导出页面,所以就需要一个只用于服务端的入口文件和专用于打包服务端bundle的webpack配置文件

webpack内使用 vue-server-renderer/server-plugin插件,打包出可传递到 createBundleRenderer (vue-server-renderer 提供的API) 的 JSON 文件,这个JSON文件内,有我们在 vue-router 中注册的所有路由对应的页面及静态文件资源路径,因为每一个路由表注册的页面,都是需要SSR来渲染的。

故,总结起来,server-entry 就是用于打包 server bundle,server bundle 是用来服务端渲染,即SSR

那么客户端entry呢?同理,用于客户端webpack打包 client bundle,结合 vue-server-renderer/client-plugin,在编译打包前端代码之余,会输出一份名为 vue-ssr-client-manifest.json 的 JSON 文件,官方称之为 客户端构建清单,将其作为 ops 传入到上面提到的 createBundleRenderer 中,进行资源混入,即将css,js等插入到html中,统一返回给浏览器,完成服务端渲染。

关于Node接口转发和前端页面渲染

这两个问题可以一并解释。

首先,如果是需要服务端渲染的页面,并且该页面中有请求后端的操作,同时该页面是初始页面,就需要由 Node 进行转发,否则可直接异步发送请求即可。

怎么定义初始页面呢?即在浏览器刷新页面后首次渲染的页面即为初始页面,在此之后,页面中所有的操作,请求,交互,跳转(前提是你的页面跳转链接为 router-link 的形式,而非纯粹的 a 标签 )都已经和 Node 无关,是由客户端 Vue 控制,这也就解释了 SSR 只是生成页面的“快照”的说法。

不知在看完上面对问题的分析,你是否有些启发。当然,如果没有涉及你所不明白的点,直接在评论区留言提出让你疑惑的问题,大家一起讨论。

关于SSR,你是否遇到了如下坑点?

下面进入下一个话题,即项目踩坑汇总。其实大部分的坑点都有正确的解答,比如SSR最经典的问题:

cookie穿透?element-ui table组件不支持SSR?echarts服务端渲染?window对象?等等...

对于这些经典问题,很多大牛也对齐进行了解释,本文就不再照本宣科,徒增文章字数了。这里我只汇总下我遇到的最为突出的问题吧,如果不幸你也遇到,那么恭喜你。如果你有更好的处理方法,请不要吝啬你的评论。

element-ui中table组件不支持SSR

"咦,这不是上面你说的经典问题吗,要打脸咯"。别急别急,先看我们如何解决。

和若干文章中提出的解决方案一样,在github中找到解决此问题的 issue,传送门

注意:使用此解决方案,需要将element-ui改为按需加载,否则,之前的版本与该 issue版本不兼容

就这样?所有论坛,博客都这么解释的,很独特么?你以为完了吗?当然不:

大坑来了!!! issue 提供的解决方案中,table.js有如下代码

if (array.includes(column)) {
  return;
}

有什么问题吗?当然高版本浏览器下是没问题的,但是IE就不行了,includes是什么鬼?

这还不简单, babel/preset-env 中配置{ "useBuiltIns": "usage","corejs": 3 },使用corejs来转换高版本API,也可以使用 @babel/plugin-transform-runtime同时配置 corejs: 3

有那么好解决还叫大坑吗?先让我们想想webpack怎么配置babel-loader的,如下:

{
    test: /\.js$/,
    loader: 'babel-loader',
    exclude: /node_modules/
}

重点: 忽略了node_modules下的所有js文件, 那babel肯定是不会去处理上面说的table.js了.

怎么办呢? 方法来咯:

  1. 同时配置include, 你忽略完我, 我在include进来
  2. babelrc文件中仅仅配置@babel/preset-env对语法进行转换, 不要使用 core-js 来编译高版本API, 在create-app.js,即入口文件中, 引入'core-js/stable'(前提是 babel7的版本,之前的@babel/polyfill会导入所有垫片并引发全局污染的问题,故已不推荐使用,而由core-js/stableregenerator-runtime/runtime代替,下文进行详细解释)。这样在入口文件编译时,就把所有高版本API进行了编译,但是与前两种方式不同的是,它会把所有的api全部转换,而不是按需引入。

vue编译后报错 document is not defined

原因是用于webpack服务器配置文件(webpack.server.config.js) 中使用了 mini-css-extract-plugin 对css进行处理,提取css插入到html中,此时服务端并没有 document。

如何处理:不在服务端配置文件中处理提取css操作,这也是Vue SSR官方推荐的。

asyncData内不能使用 this 关键字

其实我一开始没觉得这是个问题,直到有一天有人在博客中问我,他想在asyncData方法内获取当前实例对象的某个data值,有没有什么方法...

首先,无论是客户端数据预取还是服务器端数据预取,asyncData方法只在组件加载之前或路由更新时被调用,当然这取决于你的客户端entry中设置 asyncData 函数在何处调用。

一般情况下,客户端entry通过vue-router的导航守卫beforeResolve进行匹配路由及调用组件内asyncData的操作,此时 asyncData方法内是没有办法通过 this 来引用组件的实例对象。

没必要纠结于在 asyncData 中如何获取当前实例的data,asyncData方法只是用来获取数据,根据什么获取呢?比如文章详情,得有ID啊,难道不从当前实例的 data 中获取吗?像这样:axios.get('/api/detail/${this.id}'),错了。

在服务端entry和客户端entry中,已经把 store,router作为当前页面的上下文对象传入,需要的参数直接从 router 或 store 获取就好了。

如何处理404 500

这里主要是服务端如何处理404 500 等错误,因为在客户端渲染时,数据获取出错不会单独跳转到404或500的页面,一般情况下只会弹框提示或展示默认数据等友好操作。

但服务端渲染就不一样了,浏览器输入router中不存在的路由或当前路由ID不存在时,一般会响应404该页面不存在,当服务器错误时响应500,如何响应呢?

注意,处理错误页面,一定要从客户端思维转移到服务端思维上。因为项目中的页面,都是在views目录下以 .vue 结尾的文件,他们都是通过调用 renderer.renderToString处理后生成的html,如:

浏览器访问 www.xxx.com/join-us,服务器entry中会匹配/join-us路由,匹配到调用该组件内的 asyncData 方法,然后返回该路由对于的vue实例,但如果没匹配到,直接将 reject 返回(服务器entry返回promise实例供renderer.renderToString做后续处理)

也就是说你不能将错误页面以 .vue 的形式放在views目录下,即404.vue或500.vue,因为用户不会自己输入 www.xxx.com/404 进行访问。如何处理呢?其实如果你写过 Node 项目,就很简单了,将错误页面直接从服务端的返回即可。两种做法:

  1. 将错误页面的html字符串直接通过 ctx.body = '我是400.html的内容' 返回
  2. 借用 koa-viewsejs 模板引擎,通过 ctx.render('400') 进行返回

哪里返回呢?就是上面提到的,在服务端entry中,当前端路由没有匹配到返回 reject 时,在renderer.renderToString进行返回。伪代码:

// server-entry.js
router.onReady(() => {
  if(没有匹配到该路由) {
    return reject({ code: 404 })
  }
  // 对所有匹配的路由组件调用 asyncData
  Promise.all(遍历匹配到的组件,调用内部asyncData(context)).then(() => {
    resolve(app);// 返回对于的组件实例
  }).catch(reject)
}, reject);

// ssr.js
renderer.renderToString(context).then(app => {
    ctx.body = app // 匹配到的组件html
}).catch((err) => {
    ctx.body = '我是400.html的内容'
})

如何注入title标签

关于 Head 的注入,SSR官方也给出了较为详细的解释,为什么还要总结呢?因为实践中发现,按照官方的配置,title 标签是注入了,但是每一个vue文件都必须设置title选项,即使在renderToString之前,为上下文对象设置了默认的title属性。下面详细分析下,这个问题,也是笔者遇到的较为麻烦的问题,也暴漏了我对 vue 全局混入某个知识点的缺失。

问题是这样的(官方 Head 注入的方式):

获取title时,初次进入某个详情页,需要将title设置为当前详情页的title,进入首页,应为全局默认title。

如:该详情页为 阿里巴巴-详情页,其中 title “阿里巴巴” 是在 asyncData 中触发Action 后获取的, 此时title展示正常,但是当使用 router-link 点击进入首页时,此时的title依然展示为 '阿里巴巴-详情页'。 下面分析原因,先看下我们是如何在客户端混入的(可查看Vue SSR 指南官方教程)

// 注意:Vue SSR 官方教程中,是在组件内进行的混入,也存在上述提到的问题,看了尤大大的 HackerNews Demo, 混入步骤是在创建新的应用程序实例,即 app.js 中注入的。按照 HackerNews Demo 中,在本项目 create-app.js 中定义如下混入代码:
function getTitle(vm) {
  const { title } = vm.$options
  if (title) {
    return typeof title === 'function'
      ? title.call(vm)
      : title
  }
}
const serverTitleMixin = {
  created() {
    const title = getTitle(this)
    if (title) {
      this.$ssrContext.title = title
    }
  }
}
const clientTitleMixin = {
  mounted() {
    const title = getTitle(this)
    if (title) {
      document.title = title
    }
  }
}
const titleMixin = process.env.VUE_ENV === 'server'
? serverTitleMixin
: clientTitleMixin

Vue.mixin(titleMixin) // 进行混入
......

简单解释下代码:

serverTitleMixin:服务端渲染下,全局混入created,通过 this.$ssrContext 直接访问组件中的服务器端渲染上下文,从而设置当前页面title

clientTitleMixin:客户端渲染下,全局混入mounted,通过 document 设置title

上面说了,问题是在点击 router-link 标签后出现的,此时为客户端渲染,从详情页进入首页后,会触发混入的mounted,通过 getTitle() 方法获取首页的 title,此时,我们首页没有设置title,故为undefined,所以 document.title 依然为详情页的title,因为只有一个document。

“不对不对,我已经在server.js中,在调用 renderer.renderToString 之前,对ctx上下文设置了默认title:

const context = {
  title: 'Vue HN 2.0', // default title
  url: req.url
}
return renderer.renderToString(context).then(html => {
  ...
}).catch(() => {
  ...
})

你看,我写的和 HackerNews Demo 中一模一样,难道是尤大大错了?

尤大大错没错我不知道,我也在 vue-hackernews Demo 的github中针对这一问题提出相应的 issue,期待回复。

再说回上面的问题,context设置了默认title,可你别忘了,服务端渲染会走这一步,客户端渲染是不会的!所以在服务端设置默认title,对于客户端渲染来说是无效的。

既然这个行不通,你可能想到了,在全局混入时加个 else 判断不就ok了,vue页面没有设置 title 时,设置为默认title。行,咱们试试

// 客户端全局混入
const clientTitleMixin = {
    mounted() {
      const title = getTitle(this)
      document.title = title? title: '默认title'
    }
}

乍一看很合理,进入首页获取首页对应的vue实例的title对象,发现没设置,那么 document.title显然就应该是 '默认title'。

对吗?明显不对,这么设置,不管首页还是详情页,title始终会是 “默认title”,为什么?因为我们的混入 是在全局下注入的。下面copy下vue官网对于全局混入特别提醒:

请谨慎使用全局混入,因为它会影响每个单独创建的 Vue 实例 (包括第三方组件)。大多数情况下,只应当应用于自定义选项。推荐将其作为插件发布,以避免重复应用混入。

明白了吗?说实话,在做项目时,我完全忽略到了这一点:混入会影响所有单独创建的 Vue 实例,我的详情页由N个vue实例,包括element-ui的组件实例,没办法保证它们的调用顺序,有的还是异步组件。它们是没有设置title的,进入全局混入的 mounted,返回undefined,之前设置好的title,瞬间会变为 '默认title'

解决方法:

  1. 在首页设置title选项。但是项目页面太多,首页,列表页,搜索页...都是相同的title,所以这么设置较为麻烦
  2. client-entry 中,在数据预取阶段,即 router.beforeResolve 内,设置document.title = '默认title'。只在需要显示不同title的组件内进行单独设置即可。但这样会有一个问题:不同路由在客户端切换时,有一瞬间闪烁后才进行赋值。很明显,是因为 router.beforeResolve先于mounted执行,document.title先为'默认title', 后为赋值的新的title。

上面两个方法尝试后发现都不理想,有点棘手了。

想想最初为什么要在 router.beforeResolve 对 document.title 清空呢?因为在创建app工厂函数内(通用entry),全局混入带来的负面影响。

那如果不在此处(app.js)进行全局混入呢?

看看我们的客户端entry,即client-entry.js内,我们在此处通过调用app.js导出的工厂函数,创建应用程序,并全局混入了 beforeRouteUpdate,以便能在路由更新时预取数据。那么是否能在此处对客户端渲染下的 document.title 赋值呢?

答案是可以的,如何选择全局混入哪个组件选项呢?

首先,必须要能在组件切换时,获取组件内的title选

其次,必须能获取到组件实例

看看router导航守卫,我们选择拿beforeRouteEnter开刀,但 beforeRouteEnter 守卫必须在 next 回调中才能获取当前vue实例。详细代码如下:

beforeRouteEnter(to, from, next) {
  next(vm => {
    // 获取组件内的 title 选项
    const { title } = vm.$options
    // 如果存在 title 则进行调用或赋值,反之则使用默认title
    document.title = title
    ? (typeof title === 'function'? title.call(vm): title)
    : '我是默认title'
  })
}

先解释下为什么此处的全局混入,在所有vue实例或第三方组件中不会触发,因为它是导航守卫啊,每个路由都映射一个组件,重点:一个组件。所以不会出现之前全局混入带来的负面影响。

解决了吗?并没有,客户端渲染情况下问题解决了,服务端渲染此时又出现问题了,因为我们把在app.js的全局混入取消掉了。

此时刷新页面,如果当前的.vue中没有设置title,document.title为服务器context中设置的默认title,这样是没问题的,但如果当前的.vue设置了title,document.title会出现闪烁赋值的情况:

第一次的title为默认title,这个title是服务端返回的,第二次的title,才是详情页内设置的title,这个title是客户端设置的。这样当然不行了,前端设置title,SEO就有问题了,这还是SSR吗?

怎么解决呢?很简单,两种方式进行结合就好了,下面修改下之前全局混入的代码:

// title.js
function getTitle(vm) {
  const { title } = vm.$options
  if (title) {
    return typeof title === 'function'
      ? title.call(vm)
      : title
  }
}
const serverTitleMixin = {
  created() {
    const title = getTitle(this)
    if (title) {
      this.$ssrContext.title = title
    }
  }
}

export default serverTitleMixin

然后在app.js中进行判断,如果为服务端渲染,则进行混入:

// app.js
import titleMixin from './utils/title'
if(process.env.VUE_ENV === 'server') 
Vue.mixin(titleMixin)

完美解决之前的问题。主要思路是在通用entry中,单独对服务端进行title混入,客户端title在client-entry中全局混入beforeRouteEnter,进行title设置。

html2canvas 转图片时,遇到资源重复加载的问题,包括JS css font字体等

原因:html2canvas转图片时,会将整个html文档进行解析,遇到JS css font引用时,会再次加载整个页面的静态资源,造成重复请求,带宽浪费。

解决方法:html2canvas内配置ignoreElements,忽略相关文件,如下:

ignoreElements: ( element ) => {
    if(element.tagName === 'LINK' || element.tagName === 'STYLE' || element.tagName === 'IMG' )
    return true
}

关于babelrc,你是否真的清楚如何配置

在项目搭建起初,始终对babel的配置模棱两可,该使用什么插件,该使用哪个插件转换高版本API?其中需要哪些依赖?下面进行统一汇总(以下分析均建立在babel7版本)。

语法转换(以下方式二选一即可)

语法转换之一 @babel/preset-env

不管你的项目是何种架构,一旦使用了ES2015+语法,如 let const () => {}等,就需要babel将其转换为浏览器识别的语法。

此时就需要 @babel/preset-env 这一预设了

babel会将上述提到的语法(不止以上)进行编译转换。注意:此时的 @babel/preset-env只是语法转换,对新的内置对象(Promise...)、实例方法(includes...)、静态方法(Object.assign, Array.from...)是无法转换的。

语法转换之二 plugins

@babel/preset-env 是一系列插件的集合。也就是说,你可以不配置该预设,前提是你知道项目中需要针对哪些语法使用哪种插件进行转换,如使用 @babel/transform-arrow-functions对箭头函数进行转换,使用@babel/plugin-transform-block-scopinglet const进行转换。

API转换

这里所说的API,指的是ES6中新增的Javascript API,诸如SetMapsProxyReflectSymbolPromise等等。而API的转换,就需要垫片了,也就是常说的 polyfill。垫片的配置方式有下面两种:

垫片之一 @babel/preset-env

上面提到,如果不对该预设进行其他配置,只会对语法进行转换,但是这个预设没那么简单,还支持对API进行转换,具体设置如下:

"presets": [
    [
      "@babel/preset-env", { 
        "useBuiltIns": "usage", // or entry
        "corejs": 3
      }
    ]
  ],

@babel/preset-env 通过 useBuiltIns 参数,提供了多种polyfill实现,

当设置为 entry时,垫片的覆盖面比较全,不需要担心有遗漏的垫片,缺点是会污染全局,但如果不发布到npm最为第三方库供人使用,这点大可不必考虑;

当设置为 useage 可以按需引入 polyfill, 打包体积小, 但如果打包忽略node_modules 时,如果第三方包未转译则会出现兼容问题,这一点,在本文记录的踩坑第一点就遇到了。

垫片之二 @babel/runtime + @babel/plugin-transform-runtime + @babel/runtime-corejs3

@babel/runtime使用 helper function 来实现 API 的兼容,而 @babel/plugin-transform-runtime以沙箱垫片的方式防止污染全局,并抽离公共的 helper function。

也就是说,二者缺一不可。@babel/runtime提供了一系列 helper function。

那什么是 helper function?大白话讲,就是如果你要处理某个API,@babel/runtime内正好有处理该API的函数,这个函数就是 helper function

@babel/plugin-transform-runtime是做什么的?

转换新 API,通过设置 corejs: 3,前提是install @babel/runtime-corejs3

沙箱垫片的方式,防止全局污染

当你要多次不同模块内处理某个高版本API时,@babel/plugin-transform-runtime便会将这个公用的 helper function 进行提取,重复使用。如:

var _promise = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/promise"));
var _assign = _interopRequireDefault(require("@babel/runtime-corejs3/core-js-stable/object/assign"));

总结下吧,如果要转换高版本API,使用 @babel/preset-env @babel/plugin-transform-runtime,二选一即可。

注意:@babel/plugin-transform-runtime,依赖于@babel/runtime-corejs3@babel/runtime,且 @babel/runtime 必须作为生成环境依赖进行install

备注:babel7+@babel/polyfill 已经被废弃,由 core-jsregenerator-runtime 模块代替,在入口文件中引入这两个模块也可做到API转换,但是不推荐,仍存在全局污染问题和导入所有垫片的问题。

在使用 @babel/plugin-transform-runtime 编译高版本API时,遇到Cannot assign to read only property 'exports' of object '#<Object>'报错

首先报错的原因是importmodule.exports的混用,module.exportscommonJS语法,import是ES6语法,如果混用,webpack就会报错。

检查了下项目,发现并没有混用啊,运行在Node端的代码都严格的采用 commonJS 语法,何来混用呢?既然是编译出的错,那看看编译后的 vue-ssr-server-bundle.json,发现问题了。

具体原因:在 Node 端,使用了ES6的API,在配置 @babel/plugin-transform-runtime 时,并没有告知 babel 严格区分commonJS文件和ES6文件,那么 babel 会默认这个文件是ES6的文件,然后就使用 import 导入了某个转换API的 helper function,从而产生二者混用的错误。

解决方法:是在babel配置sourceType: "unambiguous",让babel根据ES6的 import 或者 export 声明来进行推测区分是commonJS文件还是ES6文件。

补充:sourceType,告知 babel 应该以何种模式编译代码。有三个选项:scriptmoduleunambiguous。默认为script

上面就是我在项目中遇到的一些问题和对 Vue SSR 的一些总结。文章略长,希望看过本文后,能让你有些许的收获。当然如果你对上面的问题有更好的解决方案,或是关于SSR你还有别的问题或坑点,也希望你能在评论中留言,大家一起探讨。