原文链接: 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 包含 create
和 update
两个 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 并从头开始渲染。在生产模式下,此检测会被跳过,以避免性能损耗。
怎么解决问题
有两种解决方案
- 保证服务端和客户端初次渲染时内容一致, 客户端与服务端不同的内容放到
mounted
事件中更新,mounted
仅在客户端执行 - 使用
v-show
替代v-if
, v-if 会渲染为注释, 而 v-show 会渲染为 dom + display:none, dom 可以在客户端激活时正确更新
未解之谜
vue 为什么不选择在 patch 的时候检查一下 dom 是否匹配呢? 不匹配时直接抛弃旧 dom 创建新的