Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
yinxin630 opened this issue Dec 19, 2019 · 5 comments
Labels

Comments

@yinxin630
Copy link
Owner

首先回顾下问题 vuejs/vue#10937

问题发生在这一块

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 创建新的

@yinxin630 yinxin630 added the vue label Dec 19, 2019
@LiuFang830
Copy link

LiuFang830 commented Dec 23, 2019

你好,我尝试了第二种做法,但是并没有起作用,请问第一种方法,如何保证客户端跟服务端初次渲染时内容一致呢?

@yinxin630
Copy link
Owner Author

image

@redteaLiufang 就像 issue 里 posva 给出的例子一样, data 中固定值, 并且不在 breforeCreatecreated 里修改该值, 而是放到 mounted 中修改

@yinxin630 yinxin630 reopened this Dec 23, 2019
@LiuFang830
Copy link

LiuFang830 commented Dec 26, 2019

我做了n种测试,比如v-if都改成v-show,data里数据都删掉,created和mounted中不做任何修改,都不行,经测试,只要template里出现三层及以上嵌套就会报错。但我不知道为什么。加<client-only>也不起作用。此外,这个问题只出现在生产环境,开发环境没有任何问题。

@LiuFang830
Copy link

最终我解决了,是在其他地方看到的别人的做法,这里引用一下。nuxt/nuxt#5800 (comment)

@yinxin630
Copy link
Owner Author

@redteaLiufang 能不能发一下有问题的代码, 我调试看看

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants