虚拟 DOM 前世今生
虚拟 DOM 也叫 VDOM,虚拟 DOM 其实并不是什么新鲜事物,早在多年前网上就已经有很多介绍虚拟 DOM 的文章。
但把虚拟 DOM 发扬光大的是 React,并且 vue2.0 也引入虚拟 DOM,可以看出虚拟 DOM 在前端举足轻重的地位。
简单来说虚拟 DOM 就是用数据格式表示 DOM 结构,并没有真实的 append 到 DOM 上,因此称为虚拟 DOM。
虚拟 DOM 有何作用
使用虚拟 DOM 带来的好处是显而易见:和浏览器交互去操作 DOM 的效率远不及去操作数据结构。操作数据结构是指改变“虚拟 DOM 对象”,这个过程比修改真实 DOM 快很多。
不过使用虚拟 DOM 并不能使得操作 DOM 的数量减少,因为虚拟 DOM 也最终是要挂载到浏览器上成为真实 DOM 节点,但能够精确的获取最小的、最必要的操作 DOM 的集合。
这样一来,我们抽象表示 DOM,每次通过虚拟 DOM 的 diff 算法计算出视图前后更新的最小差异,再根据这个最小差异去渲染/更新真实的 DOM,无疑更为可靠,性能更高。
创建虚拟 DOM
说了这么多,到底该如何创建虚拟 DOM 呢?
我们仿造一些主流的虚拟 DOM 库的思想去实现一个简易版的虚拟 DOM。
如现有如下 DOM 结构:
<ul id="ul1">
<li class="li-stl1">item1</li>
<li class="li-stl1">item2</li>
<li class="li-stl1">item3</li>
</ul>
现在如果要用 js 来表示,我们构建这样一个对象结构:
const myVirtualDom = {
tagName: "ul",
attributes: {
id: "ul1",
},
children: [
{ tagName: "li", attributes: { class: "li-stl1" }, children: ["item1"] },
{ tagName: "li", attributes: { class: "li-stl1" }, children: ["item2"] },
{ tagName: "li", attributes: { class: "li-stl1" }, children: ["item3"] },
],
};
- tagName 表示真实 DOM 标签类型;
- attributes 是一个对象,表示真实 DOM 节点上所有的属性;
- children 对应真实 DOM 的 childNodes,其中 childNodes 每一项又是类似的结构。
定义好数据结构后,现在需要一个可以生成如此结构虚拟 DOM 的方法(类)。
用于生产虚拟 DOM:
class VirtualDom {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = attributes;
this.children = children;
}
}
// 封装 createVirtualDom 方法,内部调用 Element 构造函数
function createVirtualDom(tagName, attributes, children) {
return new VirtualDom(tagName, attributes, children);
}
上述虚拟 DOM 就可以这样生成:
const myVirtualDom = createVirtualDom("ul", { id: "ul1" }, [
createVirtualDom("li", { class: "li-stl1" }, ["item1"]),
createVirtualDom("li", { class: "li-stl1" }, ["item2"]),
createVirtualDom("li", { class: "li-stl1" }, ["item3"]),
]);
在这里插入图片描述
生成的虚拟 DOM 对象的数据格式更如我们定义的那样。
是不是很简单?生成了虚拟 DOM 对象后,我们继续完成虚拟 DOM 转换为真实 DOM 节点的过程。
虚拟 DOM 变 真实 DOM
首先创建一个 setAttribute 方法,setAttribute 方法的作用是对 DOM 节点进行属性设置。
参数1:DOM 节点 参数2:属性名 参数3:属性值
const setAttribute = (node, key, value) => {
switch (key) {
case "style":
node.style.cssText = value;
break;
case "value":
let tagName = node.tagName || "";
tagName = tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
node.value = value;
} else {
// 非 input && textarea 则使用 setAttribute 去设置 value 属性
node.setAttribute(key, value);
}
break;
default:
node.setAttribute(key, value);
break;
}
};
虚拟 DOM 类中加入 render 实例方法,该方法的作用是根据虚拟 DOM 生成真实 DOM 片段:
class VirtualDom {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = attributes;
this.children = children;
}
render() {
let element = document.createElement(this.tagName);
let attributes = this.attributes;
for (let key in attributes) {
setAttribute(element, key, attributes[key]);
}
let children = this.children;
// 遍历子节点, 若 child 也是虚拟节点,递归调用 render,若是字符串,直接创建文本节点
children.forEach((child) => {
let childElement =
child instanceof VirtualDom
? child.render()
: document.createTextNode(child);
element.appendChild(childElement);
});
return element;
}
}
根据 tagName 创建标签后,借助工具方法 setAttribute 进行属性的创建;
对 children 每一项类型进行判断,如果是 VirtualDom 实例,进行递归调用 render 方法;
直到遇见文本节点类型,进行内容渲染。
真实 DOM 渲染
有了真实的 DOM 节点片段,我们趁热打铁,将真实的 DOM 节点渲染到浏览器上。
实现 renderDOM 方法:
const renderDom = (element, target) => {
target.appendChild(element);
};
截至目前的完整代码如下:
// 添加属性方法
const setAttribute = (node, key, value) => {
switch (key) {
case "style":
node.style.cssText = value;
break;
case "value":
let tagName = node.tagName || "";
tagName = tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
node.value = value;
} else {
node.setAttribute(key, value);
}
break;
default:
node.setAttribute(key, value);
break;
}
};
// 虚拟 DOM 类
class VirtualDom {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = attributes;
this.children = children;
}
render() {
let element = document.createElement(this.tagName);
let attributes = this.attributes;
for (let key in attributes) {
setAttribute(element, key, attributes[key]);
}
let children = this.children;
children.forEach((child) => {
let childElement =
child instanceof VirtualDom
? child.render()
: document.createTextNode(child);
element.appendChild(childElement);
});
return element;
}
}
// 生成虚拟 DOM 方法
function createVirtualDom(tagName, attributes, children) {
return new VirtualDom(tagName, attributes, children);
}
// 将真实的 DOM 节点渲染到浏览器上
const renderDom = (element, target) => {
target.appendChild(element);
};
// 执行方法 生成虚拟 DOM
const myVirtualDom = createVirtualDom("ul", { id: "ul1" }, [
createVirtualDom("li", { class: "li-stl1" }, ["item1"]),
createVirtualDom("li", { class: "li-stl1" }, ["item2"]),
createVirtualDom("li", { class: "li-stl1" }, ["item3"]),
]);
// 执行虚拟 DOM 的 render 方法,将虚拟 DOM 转换为真实 DOM
const realDom = myVirtualDom.render();
// 将真实 DOM 渲染倒浏览器上
renderDom(realDom, document.body);
到这就实现了从虚拟 DOM 创建到转换为真实 DOM,并渲染到浏览器上的过程,实现起来并不困难。
虚拟 DOM diff
有了上面的实现,可以产出一份虚拟 DOM,并转换为真实 DOM 渲染在浏览器中。
虚拟 DOM 也不是一尘不变的,当用户在特定操作后,会产出一份新的虚拟 DOM,开头我们也说了虚拟 DOM 的优势之一在于“能够精确的获取最小的、最必要的操作 DOM 的集合”。
那该如何得出前后两份虚拟 DOM 的差异,并交给浏览器需要更新的结果呢? 这就涉及到 DOM diff 的过程。
虚拟 DOM 是个树形结构,所以我们需要对两份虚拟 DOM 进行递归比较,将变化存储在一个变量中:
参数1:旧的虚拟 DOM 对象 参数2:新的虚拟 DOM 对象
const diff = (oldVirtualDom, newVirtualDom) => {
let differences = {};
// 递归虚拟 DOM 树,计算差异后结果放到 differencess 对象中
walkToDiff(oldVirtualDom, newVirtualDom, 0, differences);
// 返回 diff 计算结果
return differences;
};
walkToDiff 前两个参数是两个需要比较的旧/新虚拟 DOM 对象;第三个参数记录 nodeIndex,在删除节点时使用,初始为 0;第四个参数是一个闭包变量,记录 diff 结果。
waklToDiff 的具体实现为:
let initialIndex = 0;
const walkToDiff = (oldVirtualDom, newVirtualDom, index, differences) => {
let diffResult = [];
// 1.如果 newVirtualDom 不存在,说明该节点被移除,我们将 type 为 REMOVE 的对象放进 diffResult 数组,并记录 index
if (!newVirtualDom) {
diffResult.push({
type: "REMOVE",
index,
});
}
// 2.如果新旧节点都是文本节点,是字符串
else if (
typeof oldVirtualDom === "string" &&
typeof newVirtualDom === "string"
) {
// 比较文本是否相同,如果不同则记录新的结果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: "MODIFY_TEXT",
data: newVirtualDom,
index,
});
}
}
// 3.如果新旧节点类型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
// 比较属性是否相同
let diffAttributeResult = {};
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
// 记录差异,直接将 attributes 和 children 进行覆盖
diffAttributeResult[key] = newVirtualDom[key];
// 处理属性被删除的情况
if (key === "attributes") {
// 如果 diffAttributeResult 不含 oldVirtualDom["attributes"] 中的属性,说明在新的 vdom 中该属性被删除
for (let attr in oldVirtualDom["attributes"]) {
if (!diffAttributeResult["attributes"].hasOwnProperty(attr)) {
// 如果属性被删除则设置值为空,在渲染时对空值进行判断即可
diffAttributeResult["attributes"][attr] = "";
}
}
}
}
}
// 旧节点不存在的新属性
for (let key in newVirtualDom) {
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key];
}
}
// 如果该层级有差异,将差异结果记录到 diffResult 数组中
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: "MODIFY_ATTRIBUTES",
diffAttributeResult,
});
}
// 如果有子节点,遍历子节点
oldVirtualDom.children.forEach((child, i) => {
walkToDiff(child, newVirtualDom.children[i], ++initialIndex, differences);
});
}
// 4.else 说明节点类型不同,被直接替换了,我们直接将新的结果 push
else {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
// 如果不存在旧节点
if (!oldVirtualDom) {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
if (diffResult.length) {
differences[index] = diffResult;
}
};
添加 walkToDiff 方法后,整体测试下我们的代码:
const setAttribute = (node, key, value) => {
switch (key) {
case "style":
node.style.cssText = value;
break;
case "value":
let tagName = node.tagName || "";
tagName = tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
node.value = value;
} else {
node.setAttribute(key, value);
}
break;
default:
node.setAttribute(key, value);
break;
}
};
class VirtualDom {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = attributes;
this.children = children;
}
render() {
let element = document.createElement(this.tagName);
let attributes = this.attributes;
for (let key in attributes) {
setAttribute(element, key, attributes[key]);
}
let children = this.children;
children.forEach((child) => {
let childElement =
child instanceof VirtualDom
? child.render()
: document.createTextNode(child);
element.appendChild(childElement);
});
return element;
}
}
function createVirtualDom(tagName, attributes, children) {
return new VirtualDom(tagName, attributes, children);
}
const renderDom = (element, target) => {
target.appendChild(element);
};
const diff = (oldVirtualDom, newVirtualDom) => {
let differences = {};
walkToDiff(oldVirtualDom, newVirtualDom, 0, differences);
return differences;
};
let initialIndex = 0;
const walkToDiff = (oldVirtualDom, newVirtualDom, index, differences) => {
let diffResult = [];
// 如果 newVirtualDom 不存在,说明该节点被移除,我们将 type 为 REMOVE 的对象推进 diffResult 变量,并记录 index
if (!newVirtualDom) {
diffResult.push({
type: "REMOVE",
index,
});
}
// 如果新旧节点都是文本节点,是字符串
else if (
typeof oldVirtualDom === "string" &&
typeof newVirtualDom === "string"
) {
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: "MODIFY_TEXT",
data: newVirtualDom,
index,
});
}
}
// 如果新旧节点类型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
let diffAttributeResult = {};
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
diffAttributeResult[key] = newVirtualDom[key];
if (key === "attributes") {
for (let attr in oldVirtualDom["attributes"]) {
if (!diffAttributeResult["attributes"].hasOwnProperty(attr)) {
diffAttributeResult["attributes"][attr] = "";
}
}
}
}
}
for (let key in newVirtualDom) {
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key];
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: "MODIFY_ATTRIBUTES",
diffAttributeResult,
});
}
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(
child,
newVirtualDom.children[index],
++initialIndex,
differences
);
});
}
// else 说明节点类型不同,被直接替换了,我们直接将新的结果 push
else {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
if (!oldVirtualDom) {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
if (diffResult.length) {
console.log(index);
differences[index] = diffResult;
}
};
测试
const myVirtualDom1 = createVirtualDom("ul", { id: "ul1" }, [
createVirtualDom("li", { class: "li-stl1" }, ["item1"]),
createVirtualDom("li", { class: "li-stl1" }, ["item2"]),
createVirtualDom("li", { class: "li-stl1" }, ["item3"]),
]);
const myVirtualDom2 = createVirtualDom("ul", { id: "ul2" }, [
createVirtualDom("li", { class: "li-stl2" }, ["item4"]),
createVirtualDom("li", { class: "li-stl2" }, ["item5"]),
createVirtualDom("li", { class: "li-stl2" }, ["item6"]),
]);
diff(myVirtualDom1, myVirtualDom2);
得到比较后的结果数组
var result = {
"0": [
{
type: "MODIFY_ATTRIBUTES",
diffAttributeResult: {
attributes: { id: "ul2" },
children: [
{
tagName: "li",
attributes: { class: "li-stl2" },
children: ["item4"],
},
{
tagName: "li",
attributes: { class: "li-stl2" },
children: ["item5"],
},
{
tagName: "li",
attributes: { class: "li-stl2" },
children: ["item6"],
},
],
},
},
],
"1": [
{
type: "MODIFY_ATTRIBUTES",
diffAttributeResult: {
attributes: { class: "li-stl2" },
children: ["item4"],
},
},
],
"2": [{ type: "MODIFY_TEXT", data: "item4", index: 2 }],
"3": [
{
type: "MODIFY_ATTRIBUTES",
diffAttributeResult: {
attributes: { class: "li-stl2" },
children: ["item5"],
},
},
],
"4": [{ type: "MODIFY_TEXT", data: "item5", index: 4 }],
"5": [
{
type: "MODIFY_ATTRIBUTES",
diffAttributeResult: {
attributes: { class: "li-stl2" },
children: ["item6"],
},
},
],
"6": [{ type: "MODIFY_TEXT", data: "item6", index: 6 }],
};
测试结果符合我们的预期。
此刻我们已经通过 diff 方法对两个虚拟 DOM 进行比对,在 diff 方法中得到差异 differences。得到差异后如何更新视图呢?
拿到差异 differences,调用 patchDiff 方法
const patchDiff = (node, differences) => {
// 用来取差异数组中每项的索引
// 之所以用对象的形式,是因为在 renderDiff 中被递归传递,使用对象可以保证 index 的值不会重复,如果使用普通的值类型递归调用会出问题
let differ = { index: 0 };
renderDiff(node, differ, differences);
};
patchDiff 方法接收一个真实的 DOM 节点,它是需要进行更新的 DOM 节点,同时接收一个差异集合,该集合对接 diff 方法返回的结果。
在 patchDiff 方法内部,我们调用了 renderDiff 函数:
const renderDiff = (node, differ, differences) => {
// 获取差异数组中的每一项
let currentDiff = differences[differ.index];
// 真实 DOM 节点
let childNodes = node.childNodes;
// 递归调用自身
childNodes.forEach((child) => {
differ.index++;
renderDiff(child, differ, differences);
});
// 对于当前节点的差异调用 updateRealDom 方法进行更新
if (currentDiff) {
updateRealDom(node, currentDiff);
}
};
renderDiff 方法进行自身递归,对于当前节点的差异调用 updateRealDom 方法进行更新。
updateRealDom 对四种类型的 diff 进行处理:
const updateRealDom = (node, currentDiff) => {
currentDiff.forEach((dif) => {
switch (dif.type) {
case "MODIFY_ATTRIBUTES":
const attributes = dif.diffAttributeResult.attributes;
for (let key in attributes) {
// 不是元素节点就返回
if (node.nodeType !== 1) return;
const value = attributes[key];
if (value) {
setAttribute(node, key, value);
} else {
// 当 value 为空时,也就是属性值在新的虚拟 DOM 中被移除了
node.removeAttribute(key);
}
}
break;
case "MODIFY_TEXT":
node.textContent = dif.data;
break;
case "REPLACE":
let newNode =
dif.newNode instanceof VirtualDom
? render(dif.newNode)
: document.createTextNode(dif.newNode);
node.parentNode.replaceChild(newNode, node);
break;
case "REMOVE":
node.parentNode.removeChild(node);
break;
default:
break;
}
});
};
到这里简易版的虚拟 DOM 库就实现了,下面对代码进行测试。
完整代码:
const setAttribute = (node, key, value) => {
switch (key) {
case "style":
node.style.cssText = value;
break;
case "value":
let tagName = node.tagName || "";
tagName = tagName.toLowerCase();
if (tagName === "input" || tagName === "textarea") {
node.value = value;
} else {
node.setAttribute(key, value);
}
break;
default:
node.setAttribute(key, value);
break;
}
};
class VirtualDom {
constructor(tagName, attributes = {}, children = []) {
this.tagName = tagName;
this.attributes = attributes;
this.children = children;
}
render() {
let element = document.createElement(this.tagName);
let attributes = this.attributes;
for (let key in attributes) {
setAttribute(element, key, attributes[key]);
}
let children = this.children;
children.forEach((child) => {
let childElement =
child instanceof VirtualDom
? child.render() // 若 child 也是虚拟节点,递归进行
: document.createTextNode(child); // 若是字符串,直接创建文本节点
element.appendChild(childElement);
});
return element;
}
}
function createVirtualDom(tagName, attributes, children) {
return new VirtualDom(tagName, attributes, children);
}
const renderDom = (element, target) => {
target.appendChild(element);
};
const diff = (oldVirtualDom, newVirtualDom) => {
let differences = {};
// 递归树 比较后的结果放到 differences
walkToDiff(oldVirtualDom, newVirtualDom, 0, differences);
return differences;
};
let initialIndex = 0;
const walkToDiff = (oldVirtualDom, newVirtualDom, index, differences) => {
let diffResult = [];
// 如果 newVirtualDom 不存在,说明该节点被移除,我们将 type 为 REMOVE 的对象推进 diffResult 变量,并记录 index
if (!newVirtualDom) {
diffResult.push({
type: "REMOVE",
index,
});
}
// 如果新旧节点都是文本节点,是字符串
else if (
typeof oldVirtualDom === "string" &&
typeof newVirtualDom === "string"
) {
// 比较文本是否相同,如果不同则记录新的结果
if (oldVirtualDom !== newVirtualDom) {
diffResult.push({
type: "MODIFY_TEXT",
data: newVirtualDom,
index,
});
}
}
// 如果新旧节点类型相同
else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
// 比较属性是否相同
let diffAttributeResult = {};
for (let key in oldVirtualDom) {
if (oldVirtualDom[key] !== newVirtualDom[key]) {
diffAttributeResult[key] = newVirtualDom[key];
if (key === "attributes") {
// 如果 diffAttributeResult 不含 oldVirtualDom["attributes"] 中的属性,说明在新的 vdom 中该属性被删除
for (let attr in oldVirtualDom["attributes"]) {
if (!diffAttributeResult["attributes"].hasOwnProperty(attr)) {
diffAttributeResult["attributes"][attr] = "";
}
}
}
}
}
for (let key in newVirtualDom) {
// 旧节点不存在的新属性
if (!oldVirtualDom.hasOwnProperty(key)) {
diffAttributeResult[key] = newVirtualDom[key];
}
}
if (Object.keys(diffAttributeResult).length > 0) {
diffResult.push({
type: "MODIFY_ATTRIBUTES",
diffAttributeResult,
});
}
// 如果有子节点,遍历子节点
oldVirtualDom.children.forEach((child, index) => {
walkToDiff(
child,
newVirtualDom.children[index],
++initialIndex,
differences
);
});
}
// else 说明节点类型不同,被直接替换了,我们直接将新的结果 push
else {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
if (!oldVirtualDom) {
diffResult.push({
type: "REPLACE",
newVirtualDom,
});
}
if (diffResult.length) {
differences[index] = diffResult;
}
};
const patchDiff = (node, differences) => {
let differ = { index: 0 };
renderDiff(node, differ, differences);
};
const renderDiff = (node, differ, differences) => {
let currentDiff = differences[differ.index];
let childNodes = node.childNodes;
childNodes.forEach((child) => {
differ.index++;
renderDiff(child, differ, differences);
});
if (currentDiff) {
updateRealDom(node, currentDiff);
}
};
const updateRealDom = (node, currentDiff) => {
currentDiff.forEach((dif) => {
switch (dif.type) {
case "MODIFY_ATTRIBUTES":
const attributes = dif.diffAttributeResult.attributes;
for (let key in attributes) {
if (node.nodeType !== 1) return;
const value = attributes[key];
if (value) {
setAttribute(node, key, value);
} else {
node.removeAttribute(key);
}
}
break;
case "MODIFY_TEXT":
node.textContent = dif.data;
break;
case "REPLACE":
let newNode =
dif.newNode instanceof VirtualDom
? render(dif.newNode)
: document.createTextNode(dif.newNode);
node.parentNode.replaceChild(newNode, node);
break;
case "REMOVE":
node.parentNode.removeChild(node);
break;
default:
break;
}
});
};
// 虚拟 DOM1
const myVirtualDom1 = createVirtualDom("ul", { id: "ul1", class: "class1" }, [
createVirtualDom("li", { class: "li-stl1" }, ["item1"]),
createVirtualDom("li", { class: "li-stl1" }, ["item2"]),
createVirtualDom("li", { class: "li-stl1" }, ["item3"]),
]);
// 虚拟 DOM2
const myVirtualDom2 = createVirtualDom(
"ul",
{ id: "ul2", style: "color:pink;" },
[
createVirtualDom("li", { class: "li-stl2" }, ["item4"]),
createVirtualDom("li", { class: "li-stl2" }, ["item5"]),
createVirtualDom("li", { class: "li-stl2" }, ["item6"]),
]
);
将虚拟 DOM 转换为真实 DOM
var element = myVirtualDom1.render();
看到此时的 element 为真实的 DOM 节点,如下图所示
在这里插入图片描述
将真实 DOM 节点渲染到浏览器上
renderDom(element, document.body);
此时,真实的 DOM 节点已经被渲染到浏览器上,如下图所示
在这里插入图片描述
比较两个虚拟 DOM 差异
const differences = diff(myVirtualDom1, myVirtualDom2);
得到的差异对象 differences 如下图所示
在这里插入图片描述
分析差异,更新视图
patchDiff(element, differences);
执行后,id 更新为 ul2,并且移除了 class 属性,添加了 style 属性,其余值也修改正确
在这里插入图片描述
完结
尽管缺少了很多细节优化和边界问题的处理,但是我们的虚拟 DOM 实现的还是非常强大的,基本思想和 snabbdom 等一些虚拟 DOM 库高度一致。
网友评论