vue的解析
对于vue,很多人都不陌生,但是对于框架原理的理解大家各有不同,这里实现vue1x版本的核心概念。
全局变量
用于数组拦截
const originProto = Array.prototype;
const arrayProto = Object.create(originProto); // 备份数组原型
Vue构造函数
这里只实现vue中最核心的几个概念
- 数据响应式 observe
- 数据代理 proxy
- dom编译 Compile
- 函数响应 @click && methods
- 自定义指令 v-text v-model v-html
class Vue {
constructor(options) {
this.$options = options; // 保存传入的选项
this.$data = options.data; // 保存传入的响应式数据
observe(this.$data); // 将数据进行响应式监听
proxy(this, "$data"); // 数据代理
new Compile(options.el, this); // 编译
}
}
observe
主体函数,多封装一层的原因是方便用于递归
function observe(obj) { // 数据监听
if (typeof obj !== "object" || obj === null) return;
new Observer(obj);
}
核心 Observer 类
class Observer { // 执行数据响应化(分辨数据是对象还是数组)
constructor(obj) {
if (Array.isArray(obj)) {
obj.__proto__ = arrayProto; // 原型拦截
this.arrayCover(obj);
} else {
this.walk(obj);
}
}
walk(obj) {
Object.keys(obj).forEach(key => {
const value = obj[key];
defineReactive(obj, key, value);
})
}
arrayCover(obj) {
const methods = ["push", "pop", "shift", "unshift"];
methods.forEach(method => {
// 覆盖操作
arrayProto[method] = function() {
originProto[method].apply(this, arguments); // 执行原来数组本身操作
Dep.update.notify(); // 通知更新
Dep.update = null; // 清空
}
})
// 这里接收的obj是一个数组,那么数组里面可能也有对象或者数组,所以要继续递归遍历
const keys = Object.keys(obj);
keys.forEach(key => observe(obj[key]));
}
}
defineReactive
对数据的读取和设置进行监听和通知
function defineReactive(obj, key, value) {
observe(value); // 如果监听的是一个对象,再次进行递归处理
// 因为每个响应式数据都会走这个函数,所以在这里实例化dep
const dep = new Dep(); // 这里实例化的每个dep由于闭包关系 所以会和接收到的 key 进行一一对应。
Object.defineProperty(obj, key, {
get() {
console.log(`访问属性: ${key} => ${ value }`);
Dep.update = dep; // 当对数组进行push pop等操作时 会先触发 get 但是无法触发 set 导致视图不更新,这里保存一个对应的dep用于更新
Dep.target && dep.addDep(Dep.target); // 依赖关系收集 传入的是当前数据对应的watcher实例。
return value;
},
set(newVal) {
if (newVal !== value) {
value = newVal;
console.log(`设置属性: ${key} => ${ newVal }`);
observe(newVal); // 如果新值是对象,那么给这个对象添加数据响应式
dep.notify(); // 更新dep下收集到的所有对应key的
}
}
})
}
Dep类
保存多个watcher实例,用于批量更新多个响应数据
class Dep { // 保存watcher实例的依赖类,因为一个属性可能有多个使用的地方,所以一次要更新多个
constructor() {
this.deps = [];
}
addDep(watcher) {
this.deps.push(watcher); // 保存所有wathcer
}
notify() {
this.deps.forEach(watcher => watcher.update()) // 遍历执行每个wather的内部更新函数,让dom视图更新
}
}
Compile类
对dom进行编译,且在此过程中收集dom中使用了响应式数据的地方,被收集的dom会添加一个更新函数,如果将来某个数据发生变更,直接调用对应的更新函数传入最新的值即可完成dom更新
class Compile { // 编译模板,初始化视图,收集依赖(有哪些地方使用了响应式数据)
constructor(el, vm) {
this.$vm = vm;
this.$el = document.querySelector(el); // 保存dom文档对象 比如常见的 id="app";
if (this.$el) this.compile(this.$el); // 进行dom文档编译
}
compile(el) {
const childNodes = el.childNodes || el; // 当没有子节点时,就遍历当前节点。
Array.from(childNodes).forEach(node => { // 拿到每个dom节点
if (this.isElement(node)) this.compileElement(node); // 编译元素
if (this.isInterpolation(node)) this.compileText(node); // 是否是插入的动态属性值
if (node.childNodes && node.childNodes.length > 0) this.compile(node.childNodes); // 如果有子节点 继续递归编译
})
}
compileElement(node) { // 编译元素
const { attributes } = node;
Array.from(attributes).forEach(attr => {
const attrName = attr.name;
const exp = attr.value;
if (this.isDrictive(attrName)) { // 如果是指令 触发对应的指令函数
const dir = attrName.substring(2);
this[dir] && this[dir](node, exp)
}
if (this.isEvent(attrName)) {
// 获取事件名 @click => click
const dir = attrName.substring(1);
this.eventHandler(node, exp, dir);
}
})
}
compileText(node) { // 编译插值文本
const attrName = (RegExp.$1).trim();// 此时的RegExp.$1 是前面 isInterpolation 中匹配到的表达式内容(双花括号中间的内容),一次赋值之后,再次调用后会覆盖前一次匹配结果,不用担心重复。
// const matchingValue = this.$vm[attrName];
// node.textContent = matchingValue;
this.update(node, attrName, "text"); // 插值文本使用text指令
}
update(node, exp, dir) { // 接收节点、对应的表达式、对应的指令 来处理对应的事件
// 初始化数据到dom
const fn = this[dir + "Updater"]; // 对应指令或者属性的真正更新函数
fn && fn(node, this.$vm[exp]);
// 对每个指令或动态的属性添加对应的更新函数
new Watcher(this.$vm, exp, val => { // 这里使用闭包 保留初始化时该节点使用的渲染函数和对应节点, 最新的值由watcher传回。
fn && fn(node, val);
})
}
// 对元素节点类型不了解的前往该链接查看 https://www.runoob.com/jsref/prop-node-nodetype.html
isElement(node) { // 是元素类型
return node.nodeType === 1;
}
isInterpolation(node) { // 如果匹配到是文本内容,且有双花括号标记
return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent); // 取双花括号括号中的值,如 {{ name }} => name
}
isDrictive(attr) { // 判断是不是vue指令
return attr.indexOf("v-") === 0;
}
isEvent(dir) {
return dir.indexOf("@") === 0;
}
eventHandler(node, exp, dir) { // exp => 函数名 dir => 要监听的函数事件 === @click="myClick" => exp = myClick, dir = click。
const fn = this.$vm.$options.methods && this.$vm.$options.methods[exp];
fn && node.addEventListener(dir, fn.bind(this.$vm)); // 返回methods中的指定函数 并绑定当前vue实例
}
text(node, exp) { // 指令v-text
this.update(node, exp, "text");
//node.textContent = this.$vm[exp]; // 取对应vue实例下的属性的value值赋给node节点文本内容 比如 new Vue({ data: { name: "chen" } }) => 经过vue数据代理后 => vm.data.name === vm.name
}
textUpdater(node, val) {
node.textContent = val;
}
html(node, exp) { // 指令函数 如 <hl v-html="vhtml">标题</h1> => (vhtml = "<p>这是一段标签插入</p>") => node (h1标签) === vhtml
this.update(node, exp, "html")
}
htmlUpdater(node, val) {
node.innerHTML = val;
}
model(node, exp) {
this.update(node, exp, "model"); // 完成赋值和更新
// 事件监听
node.addEventListener("input", e => {
this.$vm[exp] = e.target.value; // 这里目前也只考虑简单input事件监听,拿到input输入框的新值之后,直接赋值给vm对应的属性,触发该数据set监听即可
})
}
modelUpdater(node, val) {
node.value = val; // 目前只考虑表单元素的赋值,所以直接赋值节点的value即可
}
}
Watcher 类
收集依赖,以待将来dep调用更新
class Watcher { // 数据改变时更新使用了响应式数据对应的dom
constructor(vm, key, updater) {
this.vm = vm; // vm实例
this.key = key; // 对应数据的key
this.updater = updater; // 对应数据的更新函数
Dep.target = this; // 保存当前需要监听的watcher,在defineReactive进行精确赋值
this.vm[this.key]; // 这里会触发 defineReactive 中的 get读取,然后上面又保存了对应的watcher实例,就能一一绑定。
Dep.target = null; // 等关系建立完成之后,重新置空,等待下一个watcher的建立,直至数据全部绑定完成
}
update() { // 将来dep会调用
this.updater.call(this.vm, this.vm[this.key]); // 这里把作用域转移到vm实例下,且把最新的值传入
}
}
数据代理 proxy
vue中经常可以看见我们在data中定义的数据,但是可以直接使用this.xx 进行获取,其实原理非常简单,请看以下代码
function proxy(vm, agentPropertyName) { // 代理vm下的数据,如 vm.$data.name 映射成 vm.name 可以进行正确的访问
const watchData = vm[agentPropertyName];
Object.keys(watchData).forEach(key => {
Object.defineProperty(vm, key, {
get() {
return watchData[key];
},
set(newVal) {
watchData[key] = newVal;
}
})
})
}
html页面示例
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head>
<body>
<div id="app">
<p @click="handClick"> add age</p>
{{ name }}
<p v-text="age"></p>
<h3>双向绑定的标签</h3>
<input type="text" v-model="html">
<span v-html="html"></span>
<span @click="addAddress">添加地址:</span>
<span>{{ address }}</span>
</div>
</body>
<script src="./vue/index.js">
</script>
<script>
const vm = new Vue({
el: "#app",
data: {
address:["成都"],
name: "陈",
age: 25,
html: "<p> 插入的一段文本 </p>"
},
methods: {
handClick() {
this.age += 1;
},
addAddress() {
this.address.push("武侯区")
}
}
})
console.log("vmmm",vm.$data.name);
</script>
</html>
结语
以上代码实现了vue1x的核心理念,vue2x中依然延续了部分实现,但是由于每一个key中都会实例化一个watcher,导致大量的闭包存在十分消耗内存,且更新dom时是直接覆盖,无法复用,所以vue2x中新增了虚拟dom diff算法来降低dom操作消耗和更新消耗,并且将每个key一个watcher 变成了 一个组件一个watcher,每个组件对应一个render函数,如果数据响应直接调用该render函数即可,降低了内存消耗。