阅读这篇文章之前建议大家跟先阅读我之前的文章Vue1.0原理刨析、代码实现,从根本上对比Vue1.0和Vue2.0的区别,以及各方面优化。
一些对比
一句话清楚Vue1.0和2.0的区别
本质区别就是:Watcher内部update函数作用不一样。
- 1.0 update:调用函数直接更新dom。(这篇Vue1.0原理刨析、代码实现有明确讲解。)
- 2.0 update:初始化时:将虚拟dom转化成真实dom并添加到页面当中。更新时:将oldVnode与newVnode进行diff算法,完成更新。
Vue1.0的劣势与Vue2.0的优势
比如我代码是这样的:
<template>
<p>{{bar}}</p>
<p>{{foo}}</p>
<p>{{bar}}</p>
</template>
new Vue({
data(){
bar:"bar",
foo:"foo"
}
})
在Vue1.0中:当前组件就会生成3个Watcher
核心就是将每个节点对应的
update函数通过闭包保存下来,set的时候通过dep.notify()内部调用Watcher内部的更新函数执行更新。(这一点在开始提到的Vue1.0原理刨析、代码实现可以看到)
这样的更新可以说很精确,但是你想想项目大的时候,这么多闭包,不崩才怪。所以我之前也听过Vue只适合做小项目,不适合做大项目就是因为这个原因。
在Vue2.0中:这个组件只会创建一个Watcher
核心就是初始化之后将当前组件的虚拟dom保存下来,
set的时候通过dep.notify()内部调用Watcher执行更新,但是Watcher调用内部更新函数执行更新的时候,更新函数会将newVnode和oldVnode通过diff算法进行对比,以实现精确更新。
从字面意思我们也可以知道Vue2.0的性能更更更更更强,准确性也完全不输Vue1.0。
虚拟Dom的优势
- 可以很方便实现跨平台,uni-app就是由虚拟Dom衍生出来的技术。
- 很轻量,虚拟Dom名字高大上,其实就是一个对象。
代码实现Vue2.0
我们Vue2.0的实现分如下几步:
- 接收用户参数
- 响应式处理
- 代理
- 依赖收集的俩个关键类:
Watcher、Dep - 虚拟dom的初始化以及diff算法更新dom
前期准备
一个html文件,一个js文件即可
写代码之前,我们首先要明白的一点,我们平时在vue中写temllate的意义最终都是为了生成render函数,如果有render,最终会以render为准。将不再解析template。
// 在index.html文件中
<div id="app">
</div>
<script>
const app = new Vue({
el: "#app",
data: {
count: 1
},
render(h) {
return h("div", { class: "box" }, [
h("p", null, this.count + ""),
h("p", null, this.count + ""),
h("p", null, this.count + ""),
h("p", null, this.count + ""),
]);
}
})
setInterval(() => {
app.count++
}, 1000)
</script>
// 在Vue.js文件中
class Vue{
...
}
1.接收用户参数
options就是用户传入的参数,也就是new Vue({})时传入构造函数的参数。
代码实现:
class Vue {
constructor(options) {
// 1.接收用户参数
this.$options = options;
this.$data = options.data;
// 2.响应式处理
// 3.代理
// 4.虚拟dom的初始化以及更新
}
}
2.响应式处理
Vue是典型的MVVM框架,当我们this.count = xxx时,页面会重新渲染,而想要实现这一功能,响应式处理是必不可少的一步,也可以叫做属性拦截。
这一步的核心就是利用Object.defineProperty()这个api来实现。
代码实现:
class Vue {
constructor(options) {
// 1.接收用户参数
this.$options = options;
this.$data = options.data;
// 2.响应式处理
observe(this.$data);
// 3.代理
// 4.虚拟dom的初始化以及更新
}
}
function observe(obj) {
if (Array.isArray(obj)) {
// TODO 数组的响应式处理(本文暂时只做对象的响应式处理)
} else if (typeof obj === "object") {
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
}
function defineReactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() {
console.log("get", key);// 如果访问这个属性控制到会输出
return value;
},
set(newVal) {
if (newVal !== value) {
console.log("set", key);// 如果设置这个属性控制台会输出
value = newVal;
}
},
});
}
3.代理
我们可以通过app.xxx访问到data中的某个属性,这就是因为代理的作用。
const app = new Vue({
data:{
count:1
}
})
console.log(app.count)//1
代码实现:
class Vue {
constructor(options) {
// 1.接收用户参数
this.$options = options;
this.$data = options.data;
// 2.响应式处理
observe(this.$data);
// 3.代理
proxy(this);
// 4.虚拟dom的初始化以及更新
}
}
function observe(obj) {...
}
function defineReactive(obj, key, value) {...
}
function proxy(instance) {
Object.keys(instance.$data).forEach((key) => {
defineReactive(instance, key, instance.$data[key]);
});
}
到这一步,你就可以访问app.count,而不是app.$data.count。
4.实现Dep、Watcher
Dep、Watcher作为依赖收集的核心类,我们这里先将他实现,后续在挂载、更新的时候,会详细讲到如何进行依赖收集,我们先将准备工作做好。
代码实现
// Dep
class Dep {
constructor() {
this.deps = new Set();// 存储Watcher的数组
}
// 添加Watcher的方法
addDep(watcher) {
this.deps.add(watcher);
}
// 通知Watcher更新方法
notify() {
this.deps.forEach((fn) => {
fn && fn.update();
});
}
}
// Watcher
class Watcher {
constructor(vm, fn) {
this.$vm = vm; // Vue实例
this.$fn = fn; // 更新方法(1.0和2.0的主要区别就在这)
this.getter();
}
getter() {
// 触发依赖收集
Dep.target = this;
this.$fn.call(this.$vm);
Dep.target = null;
}
update() {
this.getter();
}
}
修改defineReactive方法,在里面实例化一个Dep,用于之后的更新,因为当前是闭包环境,所以dep实例会被保存下来。
function defineReactive(obj, key, value) {
const dep = new Dep(); // 实例化Dep
Object.defineProperty(obj, key, {
get() {
Dep.target && dep.addDep(Dep.target); // 访问到当前key的时候,将Watcher添加到dep当中
return value;
},
set(newVal) {
if (newVal !== value) {
value = newVal;
dep.notify(); // set的时候通知Watcher更新
}
},
});
}
5.挂载、更新
这一步主要是将生成虚拟dom挂载到宿主元素中,以及更新时跟oldVnode进行对比,如果子节点不是文本,则进行diff算法。
Vue构造函数主要实现以下方法:
$mount方法:就是我们熟悉的new Vue().$mount,接收一个元素选择器,挂载到宿主节点上。h方法:传入参数,返回虚拟dom。_update方法:传入虚拟Dom,判断是更新还是初始化,给patch传入相应的参数。_patch_方法:更新和初始化真正执行的函数。updateChildren方法:进行diff算法,更新节点。$createElement方法:传入虚拟Dom,返回真实dom。
一、实现h、$mount方法
参数:
- $mount
- el:元素选择器
- h
- tag:标签
- props:属性参数
- children:子节点。可以是文本或者子节点数组 重点:
这一步还需要额外做的就是依赖收集,将updateComponent这个更新函数保存到Watcher实例。
并且,Watcher做俩件事:
- 调用
updateComponent(),执行更新函数updateComponent执行时,先调用render函数,当前key被读取(示例代码中是this.count),并触发当前key的get方法,从而完成依赖收集。
代码实现:
class Vue {
constructor(options) {
...,
// 4.虚拟dom的初始化以及更新
if (options.el) {
this.$mount(options.el);
}
}
$mount(el) {
// 获取宿主节点,并保存到当前实例上
this.$el = document.querySelector(el);
// 生成updateComponent函数
const updateComponent = function () {
const { render } = this.$options; // 读取render函数
const vnode = render.call(this, this.h); // 生成虚拟dom
this._update(vnode); // 直接调用update函数,进行更新
};
// 创建Watcher实例,添加到dep当中
new Watcher(this, updateComponent);
}
h(tag, props, children) {
return { tag, props, children };
}
}
二、实现_update方法
参数:
- vnode:虚拟dom 重点:
这一步主要是判断当前实例有没有oldVnode,如果有就更新,如果没有就初始化,从而给_patch_传入相应的参数,真正的执行函数是patch
代码实现:
class Vue {
...,
_update(vnode) {
// 主要做:区分”初始化“,还是”更新“之后调用patch
const prevVnode = this.prevVnode;
if (prevVnode) {
// TODO更新
this._patch_(prevVnode, vnode);
} else {
// 初始化
this._patch_(this.$el, vnode);
}
}
}
三、_patch_方法实现
参数:
- oldVnode:旧虚拟dom(初始化时是真实dom、更新时是之前存储的旧虚拟dom)
- newVnode:新虚拟dom 思路:
_patch_函数分三步:
- 初始化:将newVnode转换成真实dom,并插入,同时移除旧节点
- 更新:双方的子元素进行对比,如果双方都是子节点数组,则进行diff比对
- 最后统一在当前实例保存新的虚拟dom,用于下一次更新做对比
代码实现:
class Vue {
...,
_patch_(oldVnode, newVnode) {
if (oldVnode.nodeType) {
// 初始化走这里
const parent = oldVnode.parentElement; // 找到父节点
const ele = this.$createElement(newVnode);// 将newVnode转化为真实dom,同时赋值给ele
parent.insertBefore(ele, oldVnode.nextSibling);// 父节点插入新生成的真实dom
parent.removeChild(oldVnode);// 移除老的真实dom
} else {
// 更新时走这里
const oldCh = oldVnode.children; // 获取oldVnode的子节点
const newCh = newVnode.children; // 获取newVnode的子节点
const el = (newVnode.el = oldVnode.el); // 将oldVnode的真实dom节点赋值给newVnode。因为oldVnode马上要被替换了
// 如果newCh是字符
if (typeof newCh === "string") {
// 如果old也是字符,进行文本更新
if (typeof oldCh === "string") {
if (oldCh != newCh) {
el.textContent = newCh;
}
} else {
// oldCh是数组也同样进行文本替换
el.textContent = newCh;
}
} else {
// 如果newCh是节点数组,oldCh是文本,先清空oldCh,再添加新节点
if (typeof oldCh === "string") {
el.innerHTML = "";
newCh.forEach((child) => {
el.appendChild(this.$createElement(child));
});
} else {
// 如果双方都是子节点数组,进行diff算法
this.updateChildren(el, oldCh, newCh);
}
}
}
this.prevVnode = newVnode;// 保存newVnode
}
}
四、updateChildren实现
参数:
- el:真实dom节点
- oldCh:旧虚拟dom数组
- newCh:新虚拟dom数组 讲解:
我们看diff算法,首先要明白的一点就是:最终结果一切以newCh为准,
这个函数里面,我只是简单的实现了diff算法,没有做Vue那么多优化,不过也实现了对应的更新功能。如果想去看详细的diff算法详解,请参考我的这篇文章Vue2.0详解diff算法。源码里面也一样都是在updateChildren里面做diff。
代码实现:
class Vue {
...,
updateChildren(el, oldCh, newCh) {
const parentElement = el.parentElement; // 获取该dom的父元素
const len = Math.min(oldCh.length, newCh.length); // 找出长度最少的数组
for (let i = 0; i < len; i++) {
this._patch_(oldCh[i], newCh[i]); // 不找相同节点,直接进行更新
}
// 扫尾工作
if (newCh.length < oldCh.length) {
// 如果newCh长度小于oldCh,说明是删除操作
oldCh.slice(len).forEach((child) => {
parentElement.removeChild(child.el);
});
} else if (newCh.length > oldCh.length) {
// 如果newCh长度大于oldCh,说明是添加操作,遍历newCh,然后每个vnode,调用$createElement()生成真实dom,添加到父节点上
newCh.slice(len).forEach((child) => {
const ele = this.$createElement(child);
parentElement.appendChild(ele);
});
}
}
}
五、$createElement实现
参数:
- vnode:虚拟dom 描述:
$createElement主要做:
{tag:"div",props:{class:"container"},children:1}
// 转变成
<div class="container">1</div>
考虑到children是数组的情况,所以要进行递归遍历。
代码实现:
class Vue {
...,
// 传入vnode,转换为真实dom
$createElement(vnode) {
const el = document.createElement(vnode.tag); // 创建元素
// 设置属性
Object.keys(vnode.props || {}).forEach((key) => {
el.setAttribute(key, vnode.props[key]);
});
if (vnode.children) {
if (
typeof vnode.children === "string" ||
typeof vnode.children === "number"
) {
el.textContent = vnode.children; //子元素是字符,直接设置节点内容
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
el.appendChild(this.$createElement(child));
});
}
}
vnode.el = el; //保存真实节点,diff的时候要用
return el;
}
}
附录:
贴上一份完整代码,可以直接复制粘贴运行:
class Vue {
constructor(options) {
// 1.接收用户参数
this.$options = options;
this.$data = options.data;
// 2.响应式处理
observe(this.$data);
// 3.代理
proxy(this);
// 4.虚拟dom的初始化以及更新
if (options.el) {
this.$mount(options.el);
}
}
$mount(el) {
// 获取宿主节点
this.$el = document.querySelector(el);
// 生成updateComponent函数
const updateComponent = function () {
const { render } = this.$options; // 读取render函数
const vnode = render.call(this, this.h); // 生成虚拟dom
this._update(vnode); // 直接调用update函数
};
// 创建Watcher实例,添加到dep当中
new Watcher(this, updateComponent);
}
h(tag, props, children) {
return { tag, props, children };
}
_update(vnode) {
// 主要做:区分”初始化“,还是”更新“之后调用patch
const prevVnode = this.prevVnode;
if (prevVnode) {
// TODO更新
this._patch_(prevVnode, vnode);
} else {
// 初始化
this._patch_(this.$el, vnode);
}
}
_patch_(oldVnode, newVnode) {
if (oldVnode.nodeType) {
// 初始化
const parent = oldVnode.parentElement; // 找到父节点
const ele = this.$createElement(newVnode);
parent.insertBefore(ele, oldVnode.nextSibling);
parent.removeChild(oldVnode);
} else {
const oldCh = oldVnode.children;
const newCh = newVnode.children;
const el = (newVnode.el = oldVnode.el); //保存dom节点,因为oldVnode马上要被替换了
// 如果newCh是字符
if (typeof newCh === "string") {
// 如果old也是字符,进行文本更新
if (typeof oldCh === "string") {
if (oldCh != newCh) {
el.textContent = newCh;
}
} else {
// oldCh是数组也同样进行文本替换
el.textContent = newCh;
}
} else {
// 如果newCh是节点数组,oldCh是文本,先清空再添加节点
if (typeof oldCh === "string") {
el.innerHTML = "";
newCh.forEach((child) => {
el.appendChild(this.$createElement(child));
});
} else {
// 如果双方都是子节点数组,进行diff算法
this.updateChildren(el, oldCh, newCh);
}
}
}
this.prevVnode = newVnode;
}
updateChildren(el, oldCh, newCh) {
const parentElement = el.parentElement;
const len = Math.min(oldCh.length, newCh.length);
for (let i = 0; i < len; i++) {
this._patch_(oldCh[i], newCh[i]);
}
if (newCh.length < oldCh.length) {
oldCh.slice(len).forEach((child) => {
parentElement.removeChild(child.el);
});
} else if (newCh.length > oldCh.length) {
newCh.slice(len).forEach((child) => {
const ele = this.$createElement(child);
parentElement.appendChild(ele);
});
}
}
// 传入vnode,转换为真实dom
$createElement(vnode) {
const el = document.createElement(vnode.tag); // 创建元素
Object.keys(vnode.props || {}).forEach((key) => {
el.setAttribute(key, vnode.props[key]);
}); // 设置属性
if (vnode.children) {
if (
typeof vnode.children === "string" ||
typeof vnode.children === "number"
) {
console.log("子节点");
el.textContent = vnode.children; //子元素是字符,直接设置节点内容
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
el.appendChild(this.$createElement(child));
});
}
}
vnode.el = el; //保存真实节点,diff的时候要用
return el;
}
}
/**
* 响应式处理
*/
function observe(obj) {
if (Array.isArray(obj)) {
// TODO 数组的响应式处理
} else if (typeof obj === "object") {
Object.keys(obj).forEach((key) => {
defineReactive(obj, key, obj[key]);
});
}
}
function defineReactive(obj, key, value) {
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
Dep.target && dep.addDep(Dep.target);
return value;
},
set(newVal) {
if (newVal !== value) {
value = newVal;
dep.notify();
}
},
});
}
/**
* 代理
*/
function proxy(instance) {
Object.keys(instance.$data).forEach((key) => {
defineReactive(instance, key, instance.$data[key]);
});
}
/**
* 依赖收集
*/
class Dep {
constructor() {
this.deps = new Set();
}
addDep(watcher) {
this.deps.add(watcher);
}
notify() {
this.deps.forEach((fn) => {
fn && fn.update();
});
}
}
class Watcher {
constructor(vm, fn) {
this.$vm = vm;
this.$fn = fn;
this.getter();
}
getter() {
Dep.target = this;
this.$fn.call(this.$vm);
Dep.target = null;
}
update() {
this.getter();
}
}
看一下成果:
总结:
文章写完了,代码量不多,但可以看到Vue整个初始化和更新的过程,建议大家一步一步调试看一下代码是如何运行的。如果大家有什么问题,欢迎大家下方评论,我看到会立马回复,谢谢大家。
最后附上最近新写的两篇文章,可以结合一起看: