两百行代码实现简易vue框架

3,551 阅读6分钟

本文主要是通过vue原理及特点自己实现的简易vue框架,和源码相比不乏有些粗糙,但是对于JavaScript功底薄、阅读源码有些困难的同学来说,也算是一种探究vue原理的有效方式。

所以本文适合以下同学阅读

  • 已经会使用vue框架的常见功能
  • JavaScript功底较弱
  • 迫切想了解vue原理,但阅读vue源码感到困难

后续我会继续实现更多的功能,如果有更好的实现方法,也可以一起交流改进,欢迎指教。

源码地址:github.com/mmdctjj/vue…

在开始前,有必要介绍下几个JavaScript函数

一、准备

1. Object.defineProperty(obj, prop, desc)

功能:给对象定义属性

参数:

  • obj: 目标对象
  • prop: 定义的属性
  • desc: 属性描述符
var obj = {
    name: "晓明",
    age: 18
};
Object.defineProperty(obj, "info", {
    get: function () {
        return "我是黄" + this.name + ", 都听我的";
    },
    set: function (nv) {
        this.name = nv;
    }
});
console.log(obj.info); // 我是黄晓明,都听我的
obj.info = "总裁";
console.log(obj.info); // 我是黄总裁,都听我的

2. Object.keys(obj)

功能:枚举对象属性

参数:对象

返回:包含各个属性的字符串数组对象

 Object.keys(obj).forEach(key => {
     console.log(key, obj[key])
 })
 
 // name:晓明
 // age:18

3. Array.prototype.slice.call()

功能:给类数组对象添加数组的slice方法

参数:类数组对象

function args(n1,n2,n3){
    Array.prototype.slice.call(arguments).forEach(arg => {
        console.log(arg)
    })
}

args(1,2,3)

// 1
// 2
// 3

4. Node.nodeType

功能:返回Node的节点类型

返回值:1 代表元素节点;2 代表属性节点;3 代表文本节点

5.RegExp

RegExp是JavaScript内置的正则构造函数

var regex1 = /\w+/; // 字面量方法
var regex2 = new RegExp('\\w+'); // 内置对象实例化创建

实例的方法:

test()

exec()

RegExp静态属性: $_

$1-$9

$`: 匹配左侧文本,对应leftContent

$':匹配右侧文本,对应rightContent

let reg = /\{\{(.*)\}\}/
let textContent = '123{{name}}456'
reg.test(textContent)

console.log(RegExp.$_) // 123{{name}}456
console.log(RegExp.$1) // name
console.log(RegExp["$`"]) // 123
console.log(RegExp["$'"]) // 456

另外还得说说正式的vue框架的特点,这对后面的实现是大有裨益的。

二、了解vue特点

1. mvvm模式

众所周知,vue属于mvvm模式,mvvm模式m代表数据model。v代表view视图,而vm代表将view和model联系起来的桥梁

一个mvvm框架工作的基本原理就是vm通过解析模板对数据的需求,将model的数据渲染在view层供用户预览和交互,同时接受用户的交互,根据交互内容,修改model中对应的数据,同时改变依赖该数据的view层节点,更新显示的数据。用流程表示如下:

2. vue的基本特性

  1. 数据代理

数据代理是指Vue(构造函数)通过Object.defineProperty把data选项的属性以getter和setter的方式全部转vue实例的根属性。如下例,访问实例的a和访问data的a是等价的

var data = { a: 1 }

// 直接创建一个实例
var vm = new Vue({
  data: data
})
vm.a === data.a // => true
  1. 可以解析模板
  2. 支持事件绑定
  3. 通过数据劫持实现响应式

现在就开始构建自己的框架吧

三、实现

1.数据代理

实现数据代理的思路就是将data每个属性添加到vm实例上,这样可以通过访问实例的属性值来更改data中的属性值,在理解了Object.defineProperty方法后实现是很简单的,如下

class Vue {
  constructor(options) {
    let vm = this;
    this.$options = options;
    this._data = this.$options.data;
    Object.keys(this._data).forEach(key => {
      vm._proxy(key);
    });
  }
  _proxy(key) {
    let vm = this;
    Object.defineProperty(vm, key, {
      configurable: false,
      enumerable: true,
      get: () => vm._data[key],
      set: newVal => (vm._data[key] = newVal)
    });
  }
}

2.模板解析

实现模板解析需要分析模板解析做了哪些事,然后才能一层一层的实现模板解析的功能。

总的来说,模板解析的时候做了如下三件事

  1. 将节点取出来
  2. 生成新的dom节点
  3. 将生成的dom插入页面

代码实现如下

// 这里需要说明下如何获取渲染的节点,
// 在vue中,通常会指定一个dom元素作为容器,来挂载所有的vue组件
// 在读取渲染的节点时,就是从这个容器开始一层一层的解析dom节点
// 获取每个节点的属性和文本节点,亦或是子节点
// 所以在创建编译类是需要将el作为参数传入,同时也需要vm实例
// 方便获取实例的属性值
class Compile {
	constructor(el, vm) {
		this.$vm = vm
		this.$el = document.querySelector(el);
		if (this.$el) {
			// 1.将el元素节点取出来
			this.$fragment = this.createFragmentObj(this.$el)
			// 2.生成相应的dom节点
			this.createVdom(this.$fragment);
			// 3.将生成的dom插入到页面
			this.$el.appendChild(this.$fragment)
		}
	}
}

但是在实现每一步的 时候因为节点类型的不同,需要做不同的处理,接下来做进一步的分析。

首先,模板解析的时候需要读取到需要渲染数据的节点,以及需要的是哪些数据;

vue里使用插值表达式来存放需要渲染的属性或者变量,另外还可以通过v-text和v-html指令绑定需要渲染的属性,

所以根据需要渲染的节点类型,分为属性节点和文本节点,

使用代码实现上述过程如下:

class Compile {
	constructor(el, vm) {
		this.$vm = vm
		this.$el = document.querySelector(el);
		if (this.$el) {
			// 1.将el元素节点取出来
			this.$fragment = this.createFragmentObj(this.$el)
		}
	}
	// 创建fragment对象
	createFragmentObj(el) {
		let fragment = document.createDocumentFragment();
		let child;
		while (child = el.firstChild) {
			fragment.appendChild(child);
		}
		return fragment;
	}
}
其次,根据需要的属性,生成相应的dom;
// 创建dom
createVdom(fragment) {
	// 取出所有子节点
	let childNodes = fragment.childNodes;
	Array.prototype.slice.call(childNodes).forEach(childNode => {
		// 取出文本节点
		let text = childNode.textContent;
		// 匹配出大括号表达式
		let reg = /\{\{(.*)\}\}/;
		// 根据节点类型分别编译
		// 如果是文本节点并且包含大括号表达式
		if (childNode.nodeType === 3 && reg.test(text)) {
			// 如果是文本节点
			this.compileText(childNode, RegExp.$1);
		} else if (childNode.nodeType === 1 && !childNode.hasChildNodes()) {
			// 如果是dom节点且没有子元素
			this.compileInnerHTML(childNode);
		} else if (childNode.nodeType === 1 && childNode.hasChildNodes()) {
			// 如果是dom节点并且还有子元素就调用createVdom回到上面(其实这是递归的方法)
			this.createVdom(childNode);
		}
	});
}

在属性节点过滤找到渲染的指令,以及对应的属性名称;

// 编译innerHTML节点
compileInnerHTML(node) {
	Object.keys(node.attributes).forEach(key => {
		let exp = node.attributes[key]["nodeValue"];
		let val = this.$vm[node.attributes[key]["nodeValue"]];
		// 普通指令渲染
		switch (node.attributes[key]["nodeName"]) {
			case "v-text":
				this.updataDomVal(node, exp, "textContent", val);
				break;
			case "v-html":
				this.updataDomVal(node, exp, "innerHTML", val);
				break;
		}
	});
}

在文本节点,需要匹配插值表达式,以及表达式中的属性名称。

// 编译文本节点
compileText(node, temp) {
	this.updataDomVal(node, temp, "textContent", this.$vm[temp]);
}

本来各个节点的更新都可以在各自的处理函数中完成更新,但是前面说过,mvvm会在每个更新的节点设置监听器Watcher,当这个节点的属性值发生变化时会通知所有依赖这个属性的节点作出更新,如果这样我们依然在每个节点的处理函数里设置监听器就显得十分笨重和多余,所以这里将所有的更新封装在一个函数里了,这样会使代码简洁很多

// 更新节点
updataDomVal(node, exp, domType, domValue) {
    // 你不懂Watcher类没关系,先忽略这些,后面会慢慢讲到这个类
	// 标记每个使用data属性对象的dom节点位置, 并一直监听,当有变化时,会被dep实例捕获
	new Watcher(this.$vm, node, exp, (newVal, oldVal) => {
		node[domType] = newVal;
	});
	// 这里是具体的赋值
	node[domType] = domValue;
}
最后,将生成的dom插入到当前节点;
// 将生成的Vdom插入到页面
// 和传统的dom操作不同,这样的操作可以减少频繁操作dom的性能损耗
this.$el.appendChild(this.$fragment)

3.事件绑定

做完上面的工作,一个简易的vue渲染功能已经完成了,作为和用户的交互平台,最重要的就是交互,所以接下来实现事件绑定机制。

事件绑定和使用指令十分类似,都是利用节点的attributes属性来实现的,只是指令的名称不用,事件绑定专用的指令是v-on,所以将所有有v-on的属性过滤出来,在methods中寻找绑定的方法

compileInnerHTML(node) {
	Object.keys(node.attributes).forEach(key => {
        // 事件指令解析
        if (node.attributes[key]["nodeName"].search("v-on") != -1) {
            // 获取事件类型
        	let eventType = node.attributes[key]["nodeName"].split(":")[1];
        	// 获取事件名称
        	let eventName = node.attributes[key]["nodeValue"];
        	// 在methods中寻找绑定的方法
        	let event = this.$vm.$options.methods[eventName];
        	// 给当前节点添加相应事件
        	node.addEventListener(eventType, () => {
        	    // 将事件中的this指定为vm实例
        		event.bind(this.$vm)();
        	});
        	// 执行完之后移除相应事件
        	node.removeEventListener(eventType, () => {
        		event.bind(this.$vm)();
        	});
        }
    }
}

4.响应式系统

关于响应式原理,官网说的已经有过专门的介绍。和官网不同的是,我没有使用组件,或者你可以将每个使用属性的节点当做一个组件,每个使用过的属性的地方都对应一个 watcher,实例在第一次渲染的时候把“接触”过的数据属性记录为依赖。之后当依赖项的setter触发时,会通知 watcher从而使它关联的属性的节点重新渲染

在实现响应式系统之前,我们需要理清依赖和监听器的对应关系,搞清楚这个,整个过程就会一目了然。为了说明它们关系,我特意做了一个关系图来帮助理解

首先,只要视图中使用了某个属性,就会为该属性实例化一个依赖类,该类会有一个订阅列表subList,来存放所有使用该属性的节点的watcher;

// 这个标志是为了保存watcher实例
Dep.target = null
// 创建依赖类,捕获每个监听点的变化
class Dep {
	constructor() {
		this.subList = [];
	}
	// 建立依赖给dep和watcher
	depend() {
		Dep.target.addDep(this)
	}
	// 添加watcher到sublist中
	addSub(sub) {
		this.subList.push(sub)
	}
	// 通知所有watcher值改变了
	notify(newVal) {
		this.subList.forEach(sub => {
			sub.updata(newVal)
		})
	}
}

其次,每个使用属性的节点都会实例化一个watcher类,该类就是监听器,它关联了属性和节点,当属性的setter被触发时会通知节点重新渲染。

let uid = 0;
// 创建监听类,监听每个渲染数据地方
class Watcher {
	constructor(vm, node, exp, callback) {
		// 每个watcher的唯一标识
		this.uid = uid++;
		this.$vm = vm;
		// 每个watcher监听节点
		this.node = node;
		// 每个watcher监听节点的属性名称表达式
		this.exp = exp;
		// 每个watcher监听节点的回调函数
		this.callback = callback;
		// 每个watcher监听的节点列表
		this.depList = {};
		// 每个监听节点的初始值
		this.value = this.getVal();
	}
	addDep(dep) {
		if (!this.depList.hasOwnProperty(dep.uid)) {
			dep.addSub(this);
		}
	}
	updata(newVal) {
		this.callback.call(this.$vm, newVal, this.value)
	}
	getVal() {
		// 获取值时将当前watcher指向Dep.target,方便在数据劫持get函数里建立依赖关系
		Dep.target = this;
		// 获取当前节点位置值
		let val = this.$vm[this.exp];
		// 获取完之后将Dep.target设置为null
		Dep.target = null;
		return val;
	}
}

需要重点说明的是并不是直接在数据代理的时候就建立watcher和dep联系的,因为有的时候会直接给vm实例添加新的属性,但是data中并不存在该属性,这也是官网特意说明要注意的,正确的做法是在data对象里检测属性的变化触发setter,所以正在数据变化到触发watcher总共经历了两次setter,第一次是数据代理时触发的setter,在该setter触发了data中属性的setter

// 创建观察者类,观察data属性的变化
class Observer {
	constructor(data, vm) {
		this.data = data;
		this.$vm = vm;
		this.walk();
	}
	walk() {
		Object.keys(this.data).forEach(key => {
			this.defineReactive(key, this.data[key]);
		})
	}
	defineReactive(key, val){
		// 每个属性实例化dep对象,存放它所有的监听者
		let dep = new Dep();
		// 重新定义data对象的属性,以便给属性添加get方法和set方法
		Object.defineProperty(this.data, key, {
			configurable: false,
			enumerable: true,
			get: () => {
				if (Dep.target) {
					dep.depend();
				}
				return val;
			},
			set: (newVal) => {
				if (val !== newVal) {
					dep.notify(newVal);
				}
				val = newVal;
				return
			}
		})
	}
}

所有需要的类都已经实现了,在实例化vue过程中,会开始一系列的工作

每个 Vue 实例在被创建时都要经过一系列的初始化过程——例如,
需要设置数据监听、编译模板、将实例挂载到 DOM 并在数据变化
时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩
子的函数,这给了用户在不同阶段添加自己的代码的机会。

所以,还需要将监听数据变化、模板编译的过程加入到实例化vm的过程中

class Vue {
	constructor(options) {
		let vm = this;
		this.$options = options;
		this._data = this.$options.data;
		// 代理data中的每个属性
		Object.keys(this._data).forEach(key => {
			vm.proxy(key);
		});
		// 劫持data中的属性,当值发生变化时重新编译变化的节点
		new Observer(this._data, vm)
		// 编译节点到页面
		this.$compile = new Compile(
			this.$options.el ? this.$options.el : document.body,
			vm
		);
	}
}

以上就是所有的实现过程,谢谢大家