简单实现Vue响应式原理@郝晨光

前言

Vue的数据双向绑定,响应式原理,其实就是通过Object.defineProperty()结合发布者订阅者模式来实现的。
我们可以先试着拆分一下Vue的核心模块。

  1. Vue构造函数,集中以下模块实现MVVM。
  2. Observer 通过Object.definePropty进行数据劫持
  3. Dep 发布订阅者,添加观察者者以及在数据发生改变的时候通知观察者
  4. Watcher 观察者,对数据进行观察以及保存数据修改需要触发的回调
  5. Compiler 模板编译器,对HTML模板进行编译,提取其中的变量并转化为数据。

Vue数据劫持结合发布者订阅者模式工作流程

对于整个Vue响应式的简单实现来说,在文中并不能做过多的介绍,只能依靠读者自己去试,按照注释来进行理解。推荐的学习方式就是通过本文的实现代码一步一步的自己实现一下,然后再自己实现的基础上自己编写注释。



正文

先看一下我们最终实现的效果吧

HTML

<div id="app">
	<p>哈哈</p>
	<h1 @click="setName">{{msg}}</h1>
	<h1>{{a.b}}</h1>
	<input type="text" v-model="a.b">
</div>

JavaScript

new Vue({
	data() {
		return {
			msg: '呃呃呃呃呃',
			name: '郝晨光',
			a: {
				b: 'bbbbb'
			}
		}
	},
	methods: {
		setName() {
			this.msg = '哈哈';
		}
	},
	created() {
		this.msg = '郝晨光哈哈';
		console.log('实例初始化完成')
	},
	mounted() {
		console.log('DOM挂载完成')
	}
}).$mount('#app'); // 此处通过el属性绑定也是没有任何问题的

Vue简单实现


Vue构造函数
// Vue构造函数
function Vue(options) {
	// 如果当前Vue不是通过new 关键字调用,就进行报错
	if(!(this instanceof arguments.callee)) {
		error('Vue是一个构造函数,必须通过new关键字调用!');
	}
	// 如果是的话,就接着执行_init方法
	this._init(options);
}
// 实例化Vue的方法
Vue.prototype._init = function(options) {
	// 先将options保存在Vue的this.$options上
	this.$options = options;
	// 再拿到对应的data中的值,没有默认为空对象
	this.$data = initData(this.$options) || {};
	// 拿到对应的方法,没有默认为空对象
	this.$methods = this.$options.methods || {};
	// 进行数据劫持
	new Observer(this.$data);
	// 对数据和方法进行代理
	proxyData(this, this.$data);
	proxyData(this, this.$methods);
	// 生命周期created函数
	this.$options.created.apply(this);
	// 如果有el属性的话,自动调用$mount方法,挂载到DOM节点中
	if(this.$options.el) {
		this.$mount(this.$options.el);
	}
};

// $mount方法,将Vue实例挂载到DOM节点上
Vue.prototype.$mount = function(el) {
	// 拿到对应的DOM节点
	let $el = typeof el === 'string'
			?
			document.querySelector(el)
			: el.nodeType === 1
				?
				el
				:
				error('el必须是一个选择器或者是一个DOM节点!');
	// 将DOM保存在$el属性上
	this.$el = $el;
	// 通过Compiler编译器进行编译
	new Compiler(this.$el, this);
	// 调用mounted生命周期钩子函数
	this.$options.mounted.apply(this);
	// 返回当前的Vue实例,保证外部能够拿到正确的Vue实例
	return this;
};

// 初始化Vue实例的data
function initData(options) {
	// 拿到data的数据类型
	const type = typeof options.data;
	// 如果是function的话,调用函数拿到对象,否则直接返回对象
	return type === 'function' ? options.data() : options.data;
}

// 对data内的数据进行代理
function proxyData(target, proxy) {
	// 拿到对象上的所有key值组成的数组,并进行遍历
	Object.keys(proxy).forEach(key => {
		// 通过Object.defineProperty方法对数据进行代理
		Object.defineProperty(target, key, {
			get() {
				return proxy[key];
			},
			set(newValue) {
				proxy[key] = newValue;
			}
		})
	});
}

// 错误信息
function error(info) {
	throw new Error(info);
}
Observer数据劫持
// 数据劫持
function Observer(data) {
	// Observer必须是一个构造函数,如果不是通过new关键字调用的话,
	// 在内部使用new关键字。
	if(!(this instanceof arguments.callee)) {
		return new arguments.callee(data);
	}
	// 如果data不是一个对象的话,提示错误,
	// 因为只有对象才能调用Object.defineProperty
	if(!data || typeof data !== 'object') {
		error('代理的data必须是一个对象')
	}
	// 调用observe方法
	this.observe(data);
}

Observer.prototype.observe = function(data) {
	if(!data || typeof data !== 'object') {
		return;
	}
	// 获取对象上的键值数组并对它进行遍历
	Object.keys(data).forEach(key => {
		// 调用数据劫持方法
		this.defineReactive(data, key, data[key]);
		// 判断如果当前的值还是对象的话,递归劫持
		if(typeof data[key] === 'object') {
			this.observe(data[key]); // 递归劫持所有的值
		}
	})
};

Observer.prototype.defineReactive = function(data, key, value) {
	// 保存this
	const _this = this;
	// 添加观察者
	let dep = new Dep();
	// 数据劫持
	Object.defineProperty(data, key, {
		enumerable: true, // 可枚举的
		configurable: true, // 可删除的
		// 代理get
		get() {
			// 当前Dep.target是指的Watcher(订阅者)实例,
			// 向dep实例中添加Watcher实例
			Dep.target && dep.addSub(Dep.target);
			return value;
		},
		// 代理set
		set(newValue) {
			// 如果新的值和旧的值不相等的情况下
			if(newValue !== value) {
				// 重新调用observe劫持数据
				_this.observe(newValue);
				// 设置新的值
				value = newValue;
				// dep实例通知订阅者进行修改
				dep.notify();
			}
		}
	})
};
Dep发布者
// Dep发布者将要执行的函数统一存储在一个数组中管理,
// 当达到某个执行条件时,循环这个数组并执行每一个成员。
function Dep() {
	this.subs = [];
}

// 在发布者Dep实例上添加订阅者
Dep.prototype.addSub = function(watcher) {
	this.subs.push(watcher);
};

// 通知订阅者进行修改
Dep.prototype.notify = function() {
	// 遍历所有的订阅者,调用订阅者上的update方法进行修改。
	this.subs.forEach(watcher => watcher.update());
};
Watcher订阅者
// 订阅者
function Watcher(vm, variable, callback) {
	// 保存vm实例
	this.vm = vm;
	// 保存需要修改的属性
	this.variable = variable;
	// 保存属性修改时需要触发的回调
	this.callback = callback;
	// 保存属性的初始值,并将当前订阅者添加到发布者上
	this.value = this.get();
}

Watcher.prototype.get = function() {
	// 将当前的 watcher 添加到Dep发布者的静态属性上
	Dep.target = this;
	// 获取到当前的属性值
	let value = CompilerUtil.getValue(this.vm, this.variable);
	// 在Dep发布者的静态属性上清除当前 watcher
	Dep.target = null;
	// 返回拿到的值
	return value;
};

Watcher.prototype.update = function() {
	// 发生修改的时候,重新获取值
	let newValue = CompilerUtil.getValue(this.vm, this.variable);
	// 先获取旧的值
	let oldValue = this.value;
	// 如果两个值不等的话,调用修改DOM的回调函数
	if(newValue !== oldValue) {
		this.callback(newValue);
	}
};
Compiler模板编译器
// Compiler模板编译器
function Compiler(el, vm) {
	// 先拿到需要编译的DOM节点
	this.el = el.nodeType === 1 ? el : document.querySelector(el);
	// 拿到当前的vm实例
	this.vm = vm;
	// 如果当前的el存在,就开始编译
	if(this.el) {
		// 将真实的DOM转换为文档碎片
		let fragment = this.vNodeFragment(this.el);
		// 调用compile方法进行编译
		this.compile(fragment);
		// 编译完成之后再添加到真实DOM中
		this.el.appendChild(fragment);
	}
}

// DOM文档片段
Compiler.prototype.vNodeFragment = function(el) {
	// 创建文档片段
	let fragment = document.createDocumentFragment();
	let firstChild;
	// 遍历当前所有的DOM子节点
	while (firstChild = el.firstChild) {
		// 将真实DOM节点添加到文档片段中
		fragment.appendChild(firstChild);
	}
	// 返回虚拟文档片段
	return fragment;
};

// 进行编译
Compiler.prototype.compile = function(fragment) {
	// 拿到文档片段的所有子节点
	// 必须通过childNodes拿,因为childNodes不会忽略文本节点。
	let children = fragment.childNodes;
	// 转换为真实数组并进行遍历
	Array.prototype.slice.call(children).forEach(node => {
		// 如果当前是元素节点的话,继续递归遍历,并编译元素节点
		if(node.nodeType === 1) {
			this.compile(node); // 对当前节点内的子节点进行递归遍历
			this.compileElement(node); // 编译元素节点
		}else {
			// 否则是文本节点,就开始编译文本
			this.compileText(node);
		}
	})
};

// 编译元素节点
Compiler.prototype.compileElement = function (node) {
	// 获取到元素所有的属性
	let attrs = node.attributes;
	// 转换为真实数组并进行遍历
	Array.prototype.slice.call(attrs).forEach(attr => {
		// 获取到当前的属性名
		let attrName = attr.name;
		// 判断当前的属性是否是指令
		if(attrName.includes('v-')) {
			// 如果是指令的话,拿到当前的属性值
			let value = attr.value;
			// 拿到当前的指令名
			let [,type] = attrName.split('-');
			// 对当前指令执行编译
			CompilerUtil[type](node, this.vm, value);
			// 判断当前属性是否是事件
		}else if(attrName.includes('@')) {
			// 拿到事件名称
			let event = attrName.slice(1);
			// 拿到事件需要触发的方法名称
			let method = attr.value;
			// 对当前元素添加DOM事件
			CompilerUtil.addEvent(node, event, method, this.vm);
		}
	})
};

// 编译文本节点
Compiler.prototype.compileText = function (node) {
	let content = node.textContent; // 获取文本节点的内容
	let reg = /\{\{(.+?)\}\}/g; // 匹配模板编译器的内容
	// 如果能匹配到模板编译器
	if(reg.test(content)) {
		// 编译文本节点
		CompilerUtil.text(node, this.vm, content);
	}
};
模板编译工具
// 模板编译工具对象
const CompilerUtil =  {
	// 文本编译的回调函数
	textUpdater(node, value) {
		node.textContent = value;
	},
	// input编译的回调函数
	modelUpdater(node, value) {
		node.value = value;
	},
	// 获取vm实例中对应的值
	getValue(vm, variable) {
		// 获取对象的属性
		variable = variable.split('.');
		// 通过reduce方法递归遍历vm.$data,拿到最终在vm实例中的属性值
		return variable.reduce((prev, next) => prev[next], vm.$data);
	},
	// 获取文本中变量对应的内容
	getTextValue(vm, variable) {
		// 通过正则匹配,拿到属性名
		let reg = /\{\{([^}]+)\}\}/g;
		return variable.replace(reg, ($0, $1) => {
			// 通过属性名,调用getValue方法,获取属性值
			return this.getValue(vm, $1);
		})
	},
	// 设置Value
	setValue(vm, variable, newValue) {
		// 获取对象的属性名
		variable = variable.split('.');
		// 通过reduce方法遍历
		return variable.reduce((prev, next, index) => {
			// 如果当前是匹配的属性名的话
			if(index === variable.length - 1) {
				// 给当前的属性设置值
				return prev[next] = newValue;
			}
			// 如果不是就返回继续计算
			return prev[next];
		}, vm.$data);
	},
	// 双向数据绑定 v-model的简单实现
	model(node, vm, variable) {
		// 获取到双向数据绑定的修改方法
		let updateFn = this.modelUpdater;
		// 获取到对应的值
		let value = this.getValue(vm, variable);
		// 添加订阅者, 给订阅者添加回调
		new Watcher(vm, variable, newValue => {
			// 当数据发生修改的时候,就触发当前回调,修改元素节点的值
			updateFn && updateFn(node, newValue);
		});
		// 将v-model属性从DOM节点上删除
		node.removeAttribute('v-model');
		// 给当前元素节点添加input事件
		node.addEventListener('input', e => {
			// 拿到对应的值
			let value = e.target.value;
			// 设置值
			this.setValue(vm, variable, value);
		});
		// 初次渲染的时候,也要设置一次值
		updateFn && updateFn(node, value);
	},
	// 添加事件
	addEvent(node, event, method, vm) {
		// 给元素删除事件符
		node.removeAttribute('@'+event);
		// 给元素添加事件
		node.addEventListener(event, (...args) => {
			// 调用vm上的方法,并传入参数
			vm[method].apply(vm, args);
		})
	},
	// 编译文本节点的变量
	text(node, vm, variable) {
		// 文本节点的修改函数
		let updateFn = this.textUpdater;
		// 获取到文本节点变量的值
		let value = this.getTextValue(vm, variable);
		// 定义正则
		let reg = /\{\{(.+?)\}\}/g;
		// 通过正则匹配变量,给变量添加观察者
		variable.replace(reg, ($0, $1) => {
			// 当解析模板遇到变量的时候,应该使用观察者监听这个变量
			new Watcher(vm, $1, newValue => {
				// 观察者的回调函数,当数据发生改变就触发该回调
				updateFn && updateFn(node, newValue);
			})
		});
		// 第一次设置值
		updateFn && updateFn(node, value);
	}
};


结束

参考文章链接:
一起学习、手写MVVM框架
前端 实现一个简易版的vue,了解vue的运行机制
JS实现一个简易版的vue

如果本文对您有帮助,可以看看本人的其他文章:
前端常见面试题(十六)@郝晨光
前端常见面试题(十五)@郝晨光
前端常见面试题(十四)@郝晨光

结言
感谢您的查阅,本文由郝晨光整理并总结,代码冗余或者有错误的地方望不吝赐教;菜鸟一枚,请多关照
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值