动手实践去实现 Vue 2.0 的核心原理
Posted 前端迷
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动手实践去实现 Vue 2.0 的核心原理相关的知识,希望对你有一定的参考价值。
整篇 Vue2.0 核心源码,差不多写了一个多半月,由于文章太长,分两篇分享,通过动手实践去实现 Vue 2.0 的核心原理,进一步对 Vue 核心原理的理解和认识。
加上现在面试要求越来越高,无论是 Vue 源码还是 React 源码,是经常被面试到的,可以说是必问。虽然听起来撸源码很高大上、很复杂,但是每一个复杂的事物都是由简单构成的,如果通过内部看原理,其实就是基础+数据结构的还有一些设计模式的实现。
说实话,这个月,小鹿肝熬的有点多。后续会把这部分都整理到《大前端面试小册》中去,会根据面试内容进行优化和补充,肝就完事了!
目录
为什么使用 Vue?
从前端这么些年的发展史来看,从网页设计年代到了现在大前端时代的来临,各种各样的技术层出不穷。尤其是在前端性能优化方面,为了避免页面的回流和重绘,前辈们总结出了各种解决优化方案,基本都是尽量的减少 DOM 操作。
Vue 的诞生,是一个很大的优化方案,直接用虚拟 DOM 映射真实 DOM,来进行更新,避免了直接操作真实 DOM 带来的性能缺陷。
为了好理解呢,我们换个通俗一点的说法,当页面涉及到操作 DOM 的时候,我们不直接进行操作,因为这样降低了前端页面的性能。而是将 DOM 拿到内存中去,在内存中更改页面的 DOM ,这时候我们操作 DOM 不会导致每次操作 DOM 就会造成不必要的回流和重绘。更新完所有 DOM 之后,我们将更新完的 DOM 再插入到页面中,这样大大提高了页面的性能。
虽然这样讲有些欠妥或者不标准,其实 Vue 的虚拟 DOM 的作用可以这样去理解,也是为了照顾到一些刚刚接触到 Vue 的初学者。本篇写作的目的不是去写一高大上的术语,而是能将分享到的内容让大部分看明白,就已经足够了。
你会学到什么?
本篇主要仅供个人 Vue 源码学习记录,主要以 Vue2.0 为主。
主要分享整个 Vue2.0 源码的核心功能,会将一下几个功能通过删减,通过代码对核心原理部分展开分享,一些用到的变量和函数方法可能与源码中不相同,由于时间和精力有限,只分享核心内容部分。主要包括以下几个核心部分:
1、响应式原理(MVVM)
2、模板编译 (Compile)
3、依赖追踪
4、虚拟 DOM (VDDOM)
5、patch
6、diff 算法
带着问题去学习
有问题才有学习的动力和激情,如果毫无目的的只扒源码,显然是非常枯燥的,前期在挖源码的时候,小鹿是带着一下几个疑问去探索原理的,你是否也存在和小鹿一样的 vue 问题呢?
1、双向绑定是怎么实现的?
2、vue 标签中的指令内部又是如何解析的?
3、什么是虚拟 DOM,它比传统的真实 DOM 有什么优势?
4、当数据更新时,虚拟 DOM 如果对比新老节点更新真实 DOM 的?
5、页面多个地方操作 DOM,内部如何实现优化的?
......
以上几个个问题,前期给我带来了探索源码的动力。当看了源码一个月过去之后,这个期间通过动手实践和总结,发现这些东西都是在最原本的事物基础上进行改进和优化,尤其是对基本功(JS、数据结构与算法)的重要性,越是简单的东西,越是新事物的组成部分。简单,简而不单,单而不简。能让你创新出新的事物,万物皆如此。
Vue2.0 整体概括
初始化 Vue 实例 ==》 设置数据劫持(Object.defineProperty) ==》模板编译(compile) ==》渲染(render function) ==》转化为虚拟 DOM(Object) ==》对比新老虚拟DOM(patch、diff)==》 更新视图(真实 dom)
1、传入实例参数
当我们开始写 Vue 项目时,首先初始化一个 Vue 实例,传入一个对象参数,参数中包括一下几个重要属性:
1{
2 el: '#app',
3 data: {
4 student: {
5 name: '公众号:小鹿动画学编程',
6 age: 20,
7 }
8 }
9 computed:{
10 ...
11 }
12 ...
13}
1) el:将渲染好的 DOM 挂载到页面中(可以传入一个 id,也可以传入一个 dom 节点)。
2) data:页面所需要的数据(对象类型,至于为什么,会在数据劫持内容说明)。
3) computed:计算属性,随着 data 中的数据变化,来更新页面关联的计算属性。
4) methods:实例所用到的方法集合。
除此之外,还有一些生命周期钩子函数等其他内容。
2、设置数据劫持
所谓的数据劫持,当 Vue 实例上的 data 中的数据改变时,对应的视图所用到的 data 中数据也会在页面改变。所以我们需要给 data 中的所有数据设置一个监听器,监听 data 的改变和获取,一旦数据改变,监听器会触发,通知页面,要改变数据了。
1 Object.defineProperty(obj, key, {
2 get() {
3 return value;
4 },
5 set: newValue => {
6 console.log(---------------更新视图--------------------)
7 }
8 }
数据劫持的实现就是给每一个 data绑定 Object.defineProperty()。对于 Object.defineProperty()的用法,自己详细看 MDN ,这也是 MVVM的核心实现 API,下遍很多东西都是围绕着它转。
3、模板编译(compile)
拿到传入 dom 对象和 data 数据了,如果将这些 data 渲染到 html 所对应的 {{student.age}}、v-model="student.name" 等标签中,这个过程就是模板编译的过程,主要解析模板中的指令、class、style等等数据。
1// 把当前节点放到内存中去(因为频繁渲染造成回流和重绘)
2let fragment = this.nodefragment(this.el);
3
4// 把节点在内存中替换(编译模板,数据编译)
5this.compile(fragment);
6
7// 把内容塞回页面
8this.el.appendChild(fragment);
我们通过 el 拿到 dom 对象,然后将这个当前的 dom 节点拿到内存中去,然后将数据和 dom 节点进行替换合并,然后再把结果塞会到页面中。下面会根据代码实现,具体展开分享。
4、虚拟 DOM(Virtual DOM)
所谓虚拟 DOM,其实就是一个 javascript对象,说白了就是对真实 DOM 的一个描述对象,和真实 dom做一个映射。
1// 真实 DOM
2<div>
3 <span>HelloWord</span>
4</div>
5
6
7// 虚拟 DOM —— 以上的真实 DOM 被虚拟 DOM 表示如下:
8{
9 children:(1) [{…}] // 子元素
10 domElement: div // 对应的真实 dom
11 key: undefined // key 值
12 props: {} // 标签对应的属性
13 text: undefined // 文本内容
14 type: "div" // 节点类型
15 ...
16}
一旦页面数据有变化,我们不直接操作更新真实 DOM,而是更新虚拟 DOM,又因为虚拟 DOM和真实 DOM有映射关系,所有真实 DOM也被简洁更新,避免了回流和重绘造成性能上的损失。
对于虚拟 DOM,主要核心涉及到 diff算法,新老虚拟结点如何检查差异的,然后又是如何进行更新的,后边会展开一点点讲。
5、对比新老虚拟 DOM(patch)
patch 主要是对更新后的新节点和更新前的节点进行比对,比对的核心算法就是 diff 算法,比如新节点的属性值不同,新节点又增加了一个子元素等变化,都需要通过这个过程,将最后新的虚拟 DOM 更新到视图上,呈现最新的变化,这个过程是一个核心部分,面试也是经常问到的。
6、更新视图(update view)
当第一次加载 Vue 实例的时候,我们将渲染好的数据挂载到页面中。当我们已经将实例挂载到了真实 dom 上,我们更新数据时,新老节点对比完成,拿到对比的最新数据状态,然后更新到视图上去。
注意:以下代码并非原封不动的源代码,为了能够清晰易懂,只是将一些核心原理进行抽离,通过自己实现的代码来展开分享,为了避免不必要的争议,请自行翻看源代码。
实现一个双向绑定
一、响应式原理
我们都用过 Vue 中的 v-model 实现输入框和数据的双向绑定,其实就是 MVVM框架的核心原理实现。
下面我们动手来实现一个 MVVM 双向绑定。
1<!DOCTYPE html>
2<html lang="en">
3
4<head>
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7 <meta http-equiv="X-UA-Compatible" content="ie=edge">
8 <title>Document</title>
9</head>
10
11<body>
12 <div id="app">
13 <input type="text" v-model="student.name">
14 {{student.age}}
15 </div>
16 <script src="./node_modules/vue/dist/vue.min.js"></script>
17 <script>
18 let vm = new Vue({
19 el: '#app',
20 data: {
21 student: {
22 name: '公众号:小鹿动画学编程',
23 age: 20,
24 }
25 }
26 })
27 </script>
28</body>
29
30</html>
1、初始化
初始化 Vue 实例,这个过程会做很多事情,比如初始化生命周期、data、computed、Method 等。我们将实例中传入的数据,进行在构造函数中接收。
1class Vue {
2 // 传参接收
3 constructor(options) {
4 this.$el = options.el;
5 this.$data = options.data;
6 let computed = options.computed;
7 let methods = options.methods;
8
9 // 判断 $el 根元素是否存在
10 if (this.$el) {
11 // 1、数据劫持
12 new Observer(this.$data);
13
14 // 2、computed 实现
15 this.relatedComputed(computed);
16
17 // 3、methods 实现
18 this.relatedMethods(methods);
19
20 // 4、编译模板
21 new Compile(this.$el, this);
22
23 // ....
24
25 }
26 }
27}
以上代码中,判断当前 $el 是否存在,如果存在,就开始初始化响应式系统以及 computed 、methods的实现,最后编译模板,显示在视图上。
2、数据劫持
响应式的原理就是通过 Object.defineProperty 数据劫持来实现的,也就上述代码中的 new Observer(this.$data)过程,这个过程发生了什么?以及如何对 data 中各种类型数据进行监听的,下面直接看核心实现原理部分。
先看整体的实现代码,然后分别进行拆分讲解:
1class Observer {
2 constructor(data) {
3 this.observer(data);
4 }
5
6 // 观察者(监听对象的响应式)
7 observer(obj) {
8 // 判断是否为对象
9 if (typeof obj !== "object" || obj == null) return obj;
10
11 // 实时响应数组中对象的变化
12 if (Array.isArray(obj)) {
13 Object.setPrototypeOf(obj, proto);
14 this.observerArray(obj);
15 } else {
16 // 遍历对象 key value 监听值的变化
17 for (let key in obj) {
18 this.defineReactive(obj, key, obj[key]);
19 }
20 }
21 }
22
23 defineReactive(obj, key, value) {
24 // value 可能是对象,需要进行递归
25 this.observer(value);
26 Object.defineProperty(obj, key, {
27 get() {
28 return value;
29 },
30 set: newValue => {
31 if (newValue !== value) {
32 // 传入的可能也是对象,需要递归
33 this.observer(value);
34 value = newValue;
35 console.log('-------------------------视图更新-----------------------------')
36 }
37 }
38 });
39 }
首先,声明一个 Observer 类,接收传入 data 中要给页面渲染的数据。
1class Observer {
2 constructor(data) {
3 this.observer(data);
4 }
5}
调用 this.observer(data) 方法,遍历 data 中的每个数据进,都通过 Object.defineProperty() 方法设置上监听。
3、监听对象
observer() 方法实现主要用于实时响应数组中对象的变化。
1observer(obj) {
2 // 判断是否为对象
3 if (typeof obj !== "object" || obj == null) return obj;
4
5 // 遍历对象 key value 监听值的变化
6 for (let key in obj) {
7 this.defineReactive(obj, key, obj[key]);
8 }
9}
10
11defineReactive(obj, key, value) {
12 // 递归创建 响应式数据,性能不好
13 this.observer(value); // 递归
14 Object.defineProperty(obj, key, {
15 get() {
16 return value;
17 },
18 set: newValue => {
19 if (newValue !== value) {
20 // 设置某个 key 的时候,可能是一个对象
21 this.observer(value); // 递归
22 value = newValue;
23 console.log('-------------------------视图更新-----------------------------')
24 }
25 }
26 });
data 是一个对象,我们对 data 数据对象进行遍历,通过调用 defineReactive 方法,给每个属性分别设置监听(set 和 get 方法)。
我们对属性设置的监听,只是第一层设置了监听,如果属性值是个对象,我们也要进行监听。或者我们在给 Vue 实例 vm 中 data 赋值的时候,也可能是个对象,如下情况:
1data: {
2 student: {
3 name: '小鹿',
4 age: 20,
5 address:{ // address 也是一个对象类型的值,需要对 address 中的属性值进行监听
6 country:'china'
7 province:'shandong',
8 }
9 }
10},
所以我们要进行递归,也给其设置响应式。
1...
2
3defineReactive(obj, key, value) {
4 // 递归创建 响应式数据,性能不好
5 this.observer(value); // 递归
6 ...
7}
8...
9
10...
11set: newValue => {
12 if (newValue !== value) {
13 // 设置某个 key 的时候,可能是一个对象
14 this.observer(value); // 递归
15 value = newValue;
16 console.log('-------------------------视图更新-----------------------------')
17 }
18 }
19...
设置好之后,当我们运行程序,给 vm 设置某一值的时候,会触发视图的更新。
4、监听数组
上述我们只对对象的属性进行监听,但是我们希望监听的是个数组,对于数组,用Object.defineProperty() 来设置是不起作用的(具体原因见 MDN),所以不能用此方法。
如果数组中存放的是对象,我们也应该监听属性的变化,比如监听数组中 name 的变化。
1{
2 d: [1, 2, 3, { name: "小鹿" }]
3};
首先,我们判断当前传入的如果是数组类型,我们就调用 observerArray 方法。
1// 判断传入的参数如果是数组,则执行 observerArray 方法
2if (Array.isArray(obj)) {
3 this.observerArray(obj);
4}
observerArray 方法的具体实现如下:
1// 遍历数组中的对象,并设置监听
2observerArray(obj) {
3 for (let i = 0; i < obj.length; i++) {
4 let item = obj[i];
5 this.observer(item); // 如果数组中是对象会被 defineReactive 监听
6 }
7}
当我们进行下方更改值时,视图被触发更新。
1// 初始化 data 中的值
2{
3 d: [1, 2, 3, { name: "小鹿" }]
4}
5
6// 更改数组中的对象属性的值
7vm.$data.d[3].name = "11"; // 此时视图会更新
还有一点就是,当我们给当前的数组添加元素时,也要触发视图进行更新,比如通过下方的方式更改数组。
1// 通过 push 向 data 中的数组中添加一个值
2vm.$data.d.push({ age: "15" });
除此之外,数组中添加数据的 API 有 push、unshift、splice ,我们可以通过重写这三个原生方法,对其调用时,进行触发视图更新。
1let arrProto = Array.prototype; // 数组原型上的方法
2let proto = Object.create(arrProto); // 复制原型上的方法
3
4// 重写数组的三个方法
5[`push`, `unshift`, `splice`].forEach(method => {
6 proto[method] = function(...args) {
7 // 这个数组传入的对象也应该进行监控
8 let inserted; // 默认没有插入新的数据
9 switch (method) {
10 case `push`:
11 case `unshift`:
12 inserted = args;
13 break;
14 case `splice`:
15 inserted = args.slice(2); // 截出传入的数据
16 break;
17 default:
18 break;
19 }
20 console.log("---------------视图更新-----------------");
21 observerArray(inserted); // 如果数组增加的值是对象类型,需要对其设置监听
22 arrProto[method].call(this, ...args);
23 };
24});
25
26// 实时响应数组中对象的变化
27if (Array.isArray(obj)) {
28 Object.setPrototypeOf(obj, proto); // 如果是数组,就设置重写的数组原型对象
29 this.observerArray(obj);
30} else {
31 // 遍历对象 key value 监听值的变化
32 for (let key in obj) {
33 this.defineReactive(obj, key, obj[key]);
34 }
35}
好了,我们来测试一下,数组被监听到,视图已更新。
5、computed 实现
computed主要是计算属性,每当我们计算属性所依赖的 data 属性发生变化时,通过计算,也要更新视图上的数据。如下实例,如果我们动态改变 this.student.name 属性值,页面中的 getNewName 也会发生改变。
1let vm = new Vue({
2 el: '#app',
3 data: {
4 student: {
5 name: '小鹿',
6 age: 20,
7 },
8 },
9 computed: {
10 getNewName() {
11 return this.student.name + ‘公众号:小鹿动画学编程’;
12 }
13 },
14})
其实内部的原理做法就是让 computed 内的计算属性也依赖于 data 数据,data 变,computed 依赖的数据也变。
1relatedComputed(computed) {
2 for (let key in computed) {
3 Object.defineProperty(this.$data, key, {
4 get: () => {
5 return computed[key].call(this);
6 }
7 });
8 }
9}
6、methods 实现
我们通常调用方法是通过 vm 实例来调用方法的,所以我们要把 methods 挂载到 vm 实例上。
1// methods
2relatedMethods(methods) {
3 for (let key in methods) {
4 Object.defineProperty(this, key, {
5 get: () => {
6 return methods[key];
7 }
8 })
9 }
10}
7、vm.$data 代理到 vm 实例上
我们一般可以通过 vm.$data.student.name = '小鹿' ,但是还可以使用 vm.student.name = ‘小鹿’。我们可以通过代理,将 vm.$data 代理到 vm 上。
1// 代理 vm.$data
2proxyVm(data) {
3 for (let key in data) {
4 // 绑定到 vm 上
5 Object.defineProperty(this, key, {
6 get() {
7 return data[key];
8 },
9 set(newValue) {
10 data[key] = newValue;
11 }
12 });
13 }
14}
依赖收集
1、为什么进行依赖收集
我们 data 中的数据,有时候我们在页面不同地方需要使用,所以当我们动态改变 data 数据的时候,如下:
1<div>{{student.name}}</div>
2<ul>
3 <li>1</li>
4 <li>{{student.name}}</li>
5</ul>
6
7vm.$data.student.name = 'xiaolu '
我们对视图中,所有依赖 data 属性中的值进行更新,那么我们需要对依赖的数据的视图进行数据依赖收集,当数据变化的时候,就对所依赖数据的视图更新。对于依赖收集,需要使用观察者-订阅者模式。
2、观察者 Watcher
观察中的 get() 主要用于获取当前表达式(如:student.name)的 未更新之前的值,当数据更新时,我们就调用 update 方法,就拿出新值和老值对比,如果有变化,我们就更新相对应的视图。
1// 观察者
2class Watcher {
3 /**
4 * @param {*} vm 当前实例
5 * @param {*} expr 观察的值表达式
6 * @param {*} cb 回调函数
7 */
8 constructor(vm, expr, cb) {
9 this.vm = vm;
10 this.expr = expr;
11 this.cb = cb;
12 // 默认存放一个老值(取出当前表达式的值)
13 this.oldValue = this.get();
14 }
15
16 get() {
17 Dep.target = this;
18 let value = CompileUtil.getValue(this.vm, this.expr);// 根据视图中的表达式,取 data 中的值
19 Dep.target = null; // 不取消任何值取值 都会添加 water
20 return value;
21 }
22
23 // -> 数据变化后,会调用观察者的 update 方法
24 update() {
25 let newValue = CompileUtil.getValue(this.vm, this.expr);// 根据视图中的表达式,取 data 中的值
26 if (newValue !== this.oldValue) {
27 this.cb(newValue);
28 }
29 }
30}
3、订阅者
订阅者中主要通过 addSub 方法增加观察者,通过 notify 通知观察者,调用观察者的 update 进行更新相应的视图。
1// 订阅者
2class Dep {
3 constructor() {
4 this.subs = []; // 存放所有的 watcher
5 }
6
7 // 订阅
8 addSub(watcher) {
9 this.subs.push(watcher);
10 }
11
12 // 通知
13 notify() {
14 this.subs.forEach(watcher => watcher.update());
15 }
16}
4、依赖收集
在我们更新视图的时候进行依赖收集,给每个属性创建一个发布订阅的功能,当我们的值在 set 中改变时,我们就触发订阅者的通知,让各个依赖该数据的视图进行更新。
1defineReactive(obj, key, value) {
2 // 递归创建 响应式数据,性能不好
3 this.observer(value);
4 let dep = new Dep(); // 给每一个属性都加上一个具有发布订阅的功能
5 Object.defineProperty(obj, key, {
6 get() {
7 // 创建 watcher 时,会取到响应内容,并且把 watcher 放到了全局上
8 Dep.target && dep.addSub(Dep.target); // 增加观察者
9 return value;
10 },
11 set: newValue => {
12 if (newValue !== value) {
13 // 设置某个 key 的时候,可能是一个对象
14 this.observer(value);
15 value = newValue;
16 console.log('-------------------------视图更新-----------------------------')
17 dep.notify(); // 通知
18 }
19 }
20 });
剩下的就是我们调用 new Watcher 地方了,这个过程在编译模板里边。
三、编译模板
对于模板的编译,我们首先需要判断传入的 el 类型,然后拿到页面的结点到内存中去,把节点上有数据编译的地方,比如:v-model、v-on、{{student.name}} 进行数据的替换,然后再塞回页面,就完成的页面的显示。
1// 编译类
2class Compile {
3 constructor(el, vm) {
4 // 判断 el 传入的类型
5 this.el = this.isElementNode(el) ? el : document.querySelector(el);
6 this.vm = vm;
7
8 // 把当前节点放到内存中去 —— 之所以塞到内存中,是因为频繁渲染造成回流和重绘
9 let fragment = this.nodefragment(this.el);
10
11 // 把节点在内存中将表达式和命令等进行数据替换
12 this.compile(fragment);
13
14 // 把内容塞回页面
15 this.el.appendChild(fragment);
16 }
17}
1、将 DOM 拿到内存
首先我们之前已经声明好 data 了,如下:
1 let vm = new Vue({
2 el: '#app',
3 data: {
4 student: {
5 name: '小鹿',
6 age: 20,
7 },
8 }
9 })
然后我们需要拿到页面的模板,将页面中的一些指令(v-model="student.name")或者表达{{student.name}} 的结点替换成我们对应的属性值。
我们需要通过传入的 el 属性值先拿到页面的 dom 到内存中。
1/**
2 * 将 DOM 拿到内存中
3 * @param {*} node DOM
4 */
5nodefragment(node) {
6 let fragment = document.createDocumentFragment();
7 let firstChild;
8 while ((firstChild = node.firstChild)) {
9 fragment.appendChild(firstChild);
10 }
11 return fragment;
12}
2、数据替换
我们下一步需要将页面中的这些表达式,替换成相对应的 data 中的属性值,那么页面就将完成的呈现出带有数据的视图来。
1<div id="app">
2 <input type="text" v-model="student.name">
3 {{student.age}}
4</div>
通过上边的方法,已经将所有的页面结点循环遍历拿到。下一步开始进行一层层的遍历,将数据在内存中进行替换。
1/**
2 * 核心编译方法
3 * 编译内存中的 DOM 节点
4 * @param {*} node
5 */
6compile(node) {
7 let childNodes = node.childNodes;
8 [...childNodes].forEach(child => {
9 // 判断当前的是元素还是文本节点
10 if (this.isElementNode(child)) {
11 this.compileElement(child);
12 // 如果是元素的话,需要把自己传进去,再去遍历子节点
13 this.compile(child);
14 } else {
15 this.compileText(child); // 文本节点有 {{student.age}}
16 }
17 });
18}
19
20/**
21 * 判断当前传入的节点是不是元素节点
22 * @param {*} node 节点
23 */
24isElementNode(node) {
25 return node.nodeType == 1; // 1 代表元素节点
26}
this.isElementNode(child)
页面是由很多的 node 结点构成,在上边的页面中,v-model="student.name" 主要存在与元素节点中,{{student.age}} 表达式的值存在于文本节点中,所以我们需要通过 this.isElementNode(child) 进行判断当前是否为元素节点,然后对当前节点进行不同的处理。
对于元素节点,我们调用 compileElement(child)方法,当然,元素节点中可能存在子节点的情况,所以我们需要递归判断元素节点里是否还有子节点,再次调用 this.compile(child); 方法。
我们以解析 v-model 指令为例,开始对节点进行解析判断赋值。
1<input type="text" v-model="student.name">
1/**
2 * 编译元素节点 —— 判断是否存在 v- 指令
3 * @param {*} node
4 */
5compileElement(node) {
6 let attributes = node.attributes;
7 [...attributes].forEach(attr => {
8 // type = "text" v-model="student.name"
9 let { name, value: expr } = attr; // name:v-model expr:"student.name"
10 // 判断当前是否存在属性为 v- 的指令
11 if (this.isDirective(name)) {
12 // v-html v-bind v-model
13 let [, directive] = name.split("-");
14 let [directiveName, eventName] = directive.split(":"); // v-on:click
15 // 调用不同的指令来处理
16 CompileUtil[directiveName](node, expr, this.vm, eventName);
17 }
18 });
19}
20
21/**
22 * 判断是够是 v- 开头的指令
23 * @param {*} attrName
24 */
25isDirective(attrName) {
26 return attrName.startsWith("v-");
27}
同时我们还有一个工具类 CompileUtil,主要用于把对应的 data 数据插入到对应节点中。
上一步中,我们通过 let [directiveName, eventName] = directive.split(":") 解析出了 directiveName= v-model ,eventName = student.name。
然后我们将两个参数 directiveName 和 eventName 传入工具类对象中。
1// node: 当前节点 expr:当前表达式(student.name) vm:当前 vue 实例
2CompileUtil[directiveName](node, expr, this.vm, eventName);
通过调用不同的指令进行不同的处理。
1/**
2 * 工具类(把数据插入到 DOM 中)
3 * expr: 指令的值(v-model="student.name" 中的 student.name)
4 */
5let CompileUtil = {
6 // ---------------------- 匹配指令或者表达式的函数 ----------------------
7 // 匹配 v-model
8 model(node, expr, vm) {
9 let fn = this.updater["modelUpdater"];
10 new Watcher(vm, expr, newValue => {
11 // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
12 fn(node, newValue);
13 });
14 // 给 input 绑定事件
15 node.addEventListener("input", e => {
16 let value = e.target.value; // 获取用户输入的内容
17 this.setValue(vm, expr, value);
18 });
19 let value = this.getValue(vm, expr);
20 fn(node, value);
21 },
22
23 // ---------------- 其他用到的工具函数 -------------------
24 // $data取值 [student, name]
25 getValue(vm, expr) {
26 return expr.split(".").reduce((data, current) => {
27 return data[current];
28 }, vm.$data);
29 },
30
31 // 给 vm.$data 中数据赋值
32 setValue(vm, expr, value) {
33 expr.split(".").reduce((data, current, index, arr) => {
34 // 如果遍历取到最后一个,我就给赋值
35 if (index == arr.length - 1) {
36 return (data[current] = value);
37 }
38 return data[current];
39 }, vm.$data);
40 },
41
42 // -------------- 给对应的 dom 进行赋值 -------------------
43 updater: {
44 modelUpdater(node, value) {
45 // 处理指令结点 v-model
46 node.value = value;
47 }
48 }
49};
以上就会触发这个函数:
1// 匹配 v-model
2model(node, expr, vm) {
3 let fn = this.updater["modelUpdater"];
4 new Watcher(vm, expr, newValue => {
5 // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
6 fn(node, newValue);
7 });
8 // 给 input 绑定事件
9 node.addEventListener("input", e => {
10 let value = e.target.value; // 获取用户输入的内容
11 this.setValue(vm, expr, value);
12 });
13 let value = this.getValue(vm, expr);
14 fn(node, value);
15},
同时我们看到了 new Watch 对该属性创建一个观察者,用于以后数据更新时,通知视图进行相应的更新的。
1new Watcher(vm, expr, newValue => {
2 // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
3 fn(node, newValue);
4});
同时又给 input 绑定了一个事件,用于实现对 input 框的监听,相对应的 data 也要更新,这就实现了v-model输入框的双向绑定功能。
1// 给 input 绑定事件
2node.addEventListener("input", e => {
3 let value = e.target.value; // 获取用户输入的内容
4 this.setValue(vm, expr, value);
5});
每当 data 数据被改变,我们就触发 this.updater 中的视图更新函数。
1let fn = this.updater["textUpdater"];
2fn(node, value);
1// 给 dom 文本结点赋值数据
2updater: {
3 modelUpdater(node, value) {
4 // 处理指令结点 v-model
5 node.value = value;
6 }
7}
对于文本节点,调用 this.compileText(child) 方法和以上同样的实现方法。这一部分的整体实现代码如下:
1**
2 * 工具类(把数据插入到 DOM 中)
3 * expr: 指令的值(v-model="school.name" 中的 school.name)
4 */
5let CompileUtil = {
6 // $data取值 [school, name]
7 getValue(vm, expr) {
8 return expr.split(".").reduce((data, current) => {
9 return data[current];
10 }, vm.$data);
11 },
12
13 // 给 vm.$data 中数据赋值
14 setValue(vm, expr, value) {
15 expr.split(".").reduce((data, current, index, arr) => {
16 // 如果遍历取到最后一个,我就给赋值
17 if (index == arr.length - 1) {
18 return (data[current] = value);
19 }
20 return data[current];
21 }, vm.$data);
22 },
23
24 // 匹配 v-model
25 model(node, expr, vm) {
26 let fn = this.updater["modelUpdater"];
27 new Watcher(vm, expr, newValue => {
28 // 给输入框添加一个观察者,如果数据更新了,会触发此方法,将新值付给 input
29 fn(node, newValue);
30 });
31 // 给 input 绑定事件
32 node.addEventListener("input", e => {
33 let value = e.target.value; // 获取用户输入的内容
34 this.setValue(vm, expr, value);
35 });
36 let value = this.getValue(vm, expr);
37 fn(node, value);
38 },
39
40 html(node, expr, vm) {
41 //xss
42 let fn = this.updater["htmlUpdater"];
43 new Watcher(vm, expr, newValue => {
44 console.log(newValue);
45 fn(node, newValue);
46 });
47 let value = this.getValue(vm, expr);
48 fn(node, value);
49 },
50
51 // 获取 {{a}} 中的值
52 getContentValue(vm, expr) {
53 // 遍历表达式 将内容 重新特换成一个完整的内容 返还出去
54 return expr.replace(/{{(.+?)}}/g, (...args) => {
55 return this.getValue(vm, args[1]);
56 });
57 },
58
59 // v-on:click="change"
60 on(node, expr, vm, eventName) {
61 node.addEventListener(eventName, e => {
62 vm[expr].call(vm, e);
63 });
64 },
65
66 // 可能存在 {{a}} {{b}} 多个样式
67 text(node, expr, vm) {
68 let fn = this.updater["textUpdater"];
69 let content = expr.replace(/{{(.+?)}}/g, (...args) => {
70 // 给表达式 {{}} 中的值添加一个观察者,如果数据更新了,会触发此方法
71 new Watcher(vm, args[1], () => {
72 fn(node, this.getContentValue(vm, expr)); // 返回一个全新的字符串
73 });
74 return this.getValue(vm, args[1]);
75 });
76 fn(node, content);
77 },
78
79// 给 dom 文本结点赋值数据
80updater: {
81 modelUpdater(node, value) {
82 // 处理指令结点 v-model
83 node.value = value;
84 },
85 textUpdater(node, value) {
86 // 处理文本结点 {{}}
87 node.textContent = value;
88 },
89 htmlUpdater(node, value) {
90 // 处理指令结点 v-html
91 node.innerHTML = value;
92 }
93}
94};
3、塞回页面
此时,我们将渲染好的 fragment 塞回到真实 DOM中就可以正常显示了。
1this.el.appendChild(fragment);
当我们在输入框中输入数据时,相对应的视图上 {{student.name}} 的地方进行实时的更新;当我们通过 vm.$data.student.name 改变数据时,输入框内的数据也会发生改变。
从头到尾我们实现了一个双向绑定。
以上是关于动手实践去实现 Vue 2.0 的核心原理的主要内容,如果未能解决你的问题,请参考以下文章