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

本文主要是经过vue原理及特色本身实现的简易vue框架,和源码相比不乏有些粗糙,可是对于JavaScript功底薄、阅读源码有些困难的同窗来讲,也算是一种探究vue原理的有效方式。html

因此本文适合如下同窗阅读vue

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

后续我会继续实现更多的功能,若是有更好的实现方法,也能够一块儿交流改进,欢迎指教。node

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

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

1、准备

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

功能:给对象定义属性github

参数:数组

  • 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)

功能:枚举对象属性app

参数:对象框架

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

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框架的特色,这对后面的实现是大有裨益的。

2、了解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. 经过数据劫持实现响应式

如今就开始构建本身的框架吧

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
		);
	}
}
复制代码

以上就是全部的实现过程,谢谢你们