从一次 vue ssr 渲染客户端报错, 来看 ssr 客户端激活过程

4,554 阅读5分钟

原文链接: github.com/yinxin630/b…
技术交流: fiora.suisuijiang.com/

问题回顾

首先回顾下问题 github.com/vuejs/vue/i…

问题发生在这一块

function updateClass (oldVnode, vnode) {
    var el = vnode.elm;
    var cls = genClassForVnode(vnode);
    ...
    if (cls !== el._prevClass) {
        el.setAttribute('class', cls);
        el._prevClass = cls;
    }
}

vue 判断 vnode 的 class, 然后去和 dom 的 class 对比, 如果不一致就去更新.

但是由于 app.js 中的 v-if 在服务端和客户端结果不一致, 服务端时为 false, 渲染出了 comment(注释), 而客户端时为 true, 渲染出了 div.

updateClass() 中, vnode 的 tag 是 div, 而 vnode 的 elm 却是 comment. 因为 comment 节点是没有 setAttribute 方法的, 所以就报错了.

为什么会这样

我们向上查找调用栈, 到 hydrate() 方法, hydrate 是在浏览器中运行的, 是根据 vnode 更新 dom 的 patch 过程

我们来看这块

// Note: this is a browser-only function so we can assume elms are DOM nodes.
function hydrate (elm, vnode, insertedVnodeQueue, inVPre) {
    ...
    var children = vnode.children;
    var childNode = elm.firstChild;
    for (var i$1 = 0; i$1 < children.length; i$1++) {
        if (!childNode || !hydrate(childNode, children[i$1], insertedVnodeQueue, inVPre)) {
        childrenMatch = false;
        break
        }
        childNode = childNode.nextSibling;
    }
    ...
}

这里是一个递归调用, vue 逐个去对比 elm.childNodes 和 vnode.children, 并对子节点 重复 patch 过程.

我们能收获一个信息, vue 更新子节点时, 是按节点顺序去匹配的. elm.childNodes 分别是 [comment, h2], 而 vnode.children 分别是 [div, h2], 于是当 vue 对第一个子节点做 patch 时(hydrate(comment node, div vnode)), 发生了错误

也就是说, vue 并没有检查 dom 不匹配的情况

来个有趣的实验

将 app.js 修改为

<template>
    <div id="app">
        <h1 class="ssr" v-if="ssr" :style="{color: 'red'}">111</h1>
        <h2 class="csr" v-else  :style="{color: 'blue'}">222</h2>
    </div>
</template>

<script>
export default {
    data() {
        return {
            ssr: typeof window === 'undefined' ? true : false,
        };
    },
};
</script>

ssr 的渲染结果是 <h1 class="ssr" style="color:red;">111</h1>

而客户端激活后的结果是 <h1 class="csr" style="color: blue;">222</h1>

class / style / innerText 都更新了, 但是 tag 没变!

如果你去掉 style 话, 就只更新 innerText, class 也不变了

接下来分析下具体的 patch 过程

vue patch 过程

这块是组件触发 patch 的地方

if (isDef(data)) {
    var fullInvoke = false;
    for (var key in data) {
        if (!isRenderedModule(key)) {
            fullInvoke = true;
            invokeCreateHooks(vnode, insertedVnodeQueue);
            break
        }
    }
    if (!fullInvoke && data['class']) {
        // ensure collecting deps for deep class bindings for future updates
        traverse(data['class']);
    }
}

遍历 data 上的属性, 调用 isRenderedModule(key) 判断是否需要调用 invokeCreateHooks, invokeCreateHooks 就是一系列 dom 更新操作

// list of modules that can skip create hook during hydration because they
// are already rendered on the client or has no need for initialization
// Note: style is excluded because it relies on initial clone for future
// deep updates (#7063).
var isRenderedModule = makeMap('attrs,class,staticClass,staticStyle,key');

isRenderedModule 的实现, 就可以解释为什么去掉 style 后, 就不更新 class 了

function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
        cbs.create[i$1](emptyNode, vnode);
    }
    i = vnode.data.hook; // Reuse variable
    if (isDef(i)) {
        if (isDef(i.create)) { i.create(emptyNode, vnode); }
        if (isDef(i.insert)) { insertedVnodeQueue.push(vnode); }
    }
}

invokeCreateHooks 的逻辑很简单, 就是以 vnode 作为参数, 执行预置的 cbs.create hook

function createPatchFunction (backend) {
    var modules = backend.modules;
    var nodeOps = backend.nodeOps;

    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = [];
        for (j = 0; j < modules.length; ++j) {
            if (isDef(modules[j][hooks[i]])) {
                cbs[hooks[i]].push(modules[j][hooks[i]]);
            }
        }
    }
}

cbs.create hook 是在 createPatchFunction 方法一开始初始化的

其中 backend.modules 是这么来的

var platformModules = [
  attrs,
  klass,
  events,
  domProps,
  style,
  transition
];

/*  */

// the directive module should be applied last, after all
// built-in modules have been applied.
var modules = platformModules.concat(baseModules);

var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });

每个 module 都是一些 hook 定义, 当组件执行到该生命周期时, 就会逐个执行 module 中定义的该生命周期 hook

platformModules 就是浏览器相关的操作, 我们以 style 为例

var style = {
  create: updateStyle,
  update: updateStyle
};

style 包含 createupdate 两个 hooks, 分别对应组件创建时和更新时, 执行的方法都是 updateStyle

function updateStyle (oldVnode, vnode) {
  var data = vnode.data;
  var oldData = oldVnode.data;

  if (isUndef(data.staticStyle) && isUndef(data.style) &&
    isUndef(oldData.staticStyle) && isUndef(oldData.style)
  ) {
    return
  }

  var cur, name;
  var el = vnode.elm;
  var oldStaticStyle = oldData.staticStyle;
  var oldStyleBinding = oldData.normalizedStyle || oldData.style || {};

  // if static style exists, stylebinding already merged into it when doing normalizeStyleData
  var oldStyle = oldStaticStyle || oldStyleBinding;

  var style = normalizeStyleBinding(vnode.data.style) || {};

  // store normalized style under a different key for next diff
  // make sure to clone it if it's reactive, since the user likely wants
  // to mutate it.
  vnode.data.normalizedStyle = isDef(style.__ob__)
    ? extend({}, style)
    : style;

  var newStyle = getStyle(vnode, true);

  for (name in oldStyle) {
    if (isUndef(newStyle[name])) {
      setProp(el, name, '');
    }
  }
  for (name in newStyle) {
    cur = newStyle[name];
    if (cur !== oldStyle[name]) {
      // ie9 setting to null has no effect, must use empty string
      setProp(el, name, cur == null ? '' : cur);
    }
  }
}

updateStyle 方法就是更新 dom style 的操作

var klass = {
  create: updateClass,
  update: updateClass
};

再看 klass module, 因为 class 是关键字, 所以这里命名为了 klass, 执行的是更新 dom class 的操作

回过头来看问题

结合上述分析, 我们可以发现问题所在, 在 platformModules 中包含很多 dom 更新操作, 但是不包括 dom 的匹配和重建, 而是直接在已有的 dom 节点上更新

在 app.js 中, 我们使用了 style 属性, 于是触发了 create 阶段的 dom 更新, 但是因为实际的 dom 是 comment, 并不支持更新 class 和 style 的操作, 所以报错

实际上, vue ssr 是要求在服务端和客户端渲染结果一致的, 官网中有这么一段

在开发模式下,Vue 将推断客户端生成的虚拟 DOM 树 (virtual DOM tree),是否与从服务器渲染的 DOM 结构 (DOM structure) 匹配。如果无法匹配,它将退出混合模式,丢弃现有的 DOM 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。

怎么解决问题

有两种解决方案

  1. 保证服务端和客户端初次渲染时内容一致, 客户端与服务端不同的内容放到 mounted 事件中更新, mounted 仅在客户端执行
  2. 使用 v-show 替代 v-if, v-if 会渲染为注释, 而 v-show 会渲染为 dom + display:none, dom 可以在客户端激活时正确更新

未解之谜

vue 为什么不选择在 patch 的时候检查一下 dom 是否匹配呢? 不匹配时直接抛弃旧 dom 创建新的