前端架构学习笔记(三)封装一个简单的 Vue.js

前端架构学习笔记(三)封装一个简单的 Vue.js

如果有问题,或者有更好的解决方案,欢迎大家分享和指教。

交个朋友或进入前端交流群:-GuanEr-

本笔记的目的是更加深入完善的理解响应式。

一、最终目标

  • 编译DOM,解析插值表达式,将表达式对应的值渲染在页面上(简单解析,不考虑 {{}} 中复杂的运算);
  • 编译元素节点,解析到元素上绑定的指令,执行对应操作,html 指令,text 指令,model指令;
  • 解析元素节点上绑定的 @xxxx 的事件
  • 数据的动态响应和双向绑定效果

二、重要的类

  • Vue 类,创建 Vue 实例,确定被编译的范围
  • Compile 类,模板编译器,解析模板,分析表达式、指令、事件,并执行对应操作
  • Observer 类,执行数据响应化处理
  • Watcher 类,管理视图对数据的订阅,执行视图对数据更新的响应
  • Dep 类,管理数据的依赖,通知指定数据的依赖,执行更新操作

三、实践目录

// 为了方便理解,本次实践不拆分复杂的目录树,后期如果觉得繁杂,可以再做拆分

project // 文件夹
  index.html  // html 内容,包含对 index.js 使用的测试用例
  index.js // 本次封装的全部代码

四、index.html 内容(对于封装的调用,测试用例)

index.html 希望引入 index.js 之后,以下功能都能正常实现。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<div id="app">
  <!-- 插值表达式 -->
  {{counter}}
  <p>{{counter}}</p>
  <h2>{{msg}}</h2>

  <!-- 指令 html text -->
  <p my-html="msg2"></p>
  <p my-text="msg2"></p>
  <p my-text="msg3"></p>

  <!-- model指令的双向响应 -->
  <input type="text" my-model="inpVal">
  <p>{{inpVal}}</p>

  <!-- 事件的绑定与实现 -->
  <button @click="clickFn">点击</button>
</div>

<script src="./index.js"></script>
<script>
  const app = new MyVue({
    el: '#app',
    data: {
      counter: 1,
      msg: 'hello world',
      msg2: '<div style="color: #f00;">你好世界</div>',
      msg3: '世界你好',
      inpVal: 'this is input init value',
    },
    methods: {
      clickFn() {
        this.counter++;
      }
    }
  });
</script>
</body>
</html>

五、index.js 的封装

1. 数据响应式的实现

在封装 Vue 类之前,我们要先了解 Vue 实现数据动态响应的方式(此处只考虑对象)。

Vue2 中是通过 Object.defineProperty 函数,为 data 中的数据添加了 gettersetter,数据每次被视图调用时,都会创建一个订阅者,数据每次被赋值时,通知这个数据的订阅者,让它们执行对应的操作。

以上便是 Vue2 实现数据响应的过程。

所以我们先封装一个函数,该函数的功能就是为指定的对象添加 gettersetter

1.1 一个简单的拦截函数
function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      // todo 数据被调用,出现了一个订阅者,要在此处保留这个订阅者
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        // todo 数据被赋值,要在此处通知该数据的订阅者执行相关操作
        val = newVal;
      }
    }
  });
}
1.2 对象嵌套情况下的响应式

为了拆分功能,我们用一个新的 observe 函数实现指定对象的拦截响应,以及嵌套对象的递归。

也可以不拆分,此处拆分是为了模仿源码,更好理解源码的封装模式。因为 Vue2 中,将数组和对象的响应式分开处理,所以 observe 函数中,还有一些关于数组的处理,这里省略了,所以 observe 函数的根本功能是,判断一个数据的类型,并执行对应的响应处理。

function defineReactive(obj, key, val) {
  observe(val);
  // 初次绑定时,val 如果是对象,继续响应
  Object.defineProperty(obj, key, {
    get() {
      // todo 数据被调用,出现了一个订阅者,要在此处保留这个订阅者
      console.log('get' + key);
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        console.log('set' + key);
        observe(val);
        // todo 数据被赋值,要在此处通知该数据的订阅者执行相关操作
        val = newVal;
      }
    }
  });
}

function observe(obj) {
  if(typeof obj !== 'object' || obj === null) {
    return;
  }

  Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
}

// 以下示范测试数据,理解完 observe 的嵌套递归之后,就可以删除了
const data = {
  num: 1,
  child: {
    str: 'test'
  }
};
observe(data);
data.num; // get num
data.child; // get child
data.child.str; // get child, get str

2. 封装一个 Observer

我们知道,在 Vue 中,对象的响应式,不仅仅是为其添加 gettersetter 那么简单,还有收集依赖分派更新等等动作。

Observe 类就是为了更加统一、方便的管理响应式。

Observe 类的功能如下:

  • 判断一个数据的类型(object/array),执行对应的响应式数据的创建
  • 管理依赖收集和数据更新

Observer 类的封装,以及结合了 Observer 类的 observe 函数和 defineReactive 函数:

function defineReactive(obj, key, val) {
  observe(val);

  Object.defineProperty(obj, key, {
    get() {
      console.log('get ' + key);
      // todo 收集依赖
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        console.log('set ' + key);
        observe(newVal);
        val = newVal;
        // todo 分派更新
      }
    }
  });
}

// observe 函数将在初始化一个 Vue 实例的时候被调用
function observe(obj) {
  if(typeof obj !== 'object' || obj === null) {
    return;
  }

  // 实例化一个 Ovserver 类
  new Observer(obj);
}

class Observer {
  constructor(value) {
    this.value = value;

    // 根据数据类型执行对应的操作
    if(Array.isArray(value)) {
      this.observerArray(value);
    } else {
      this.walk(value);
    }
  }

  // 对象响应式处理
  walk(obj) {
    Object.keys(obj).forEach(key => defineReactive(obj, key, obj[key]));
  }

  // 数组响应式处理
  observerArray(arr) {
    // todo
  }
}

3. MyVue 类的封装

我们想要实现的 MyVue 类的功能:

  • 处理用户传入的 datamethodsel
  • 为了让数据的存取更安全,将 data 代理到 Vue 实例上,我们就可以通过 MyVue 实例 vm 直接获取 data,否则就要这样获取 vm.$data.xxx
  • 编译模板的 DOM 内容(Compile 类,将在后文中实现)
// data 的代理函数
function proxy(vm) {
  Object.keys(vm.$data).forEach(key => {
    Object.defineProperty(vm, key, {
      get() {
        return vm.$data[key];
      },
      set(v) {
        vm.$data[key] = v;
      }
    });
  });
}

class MyVue {
  constructor(options) {
    this.$options = options || {};
    this.$data = options.data || {};
    this.$methods = options.methods || {};

    // data 响应式处理
    observe(this.$data);

    // 数据代理,将实例上 $data 的所有数据绑定给 this 本身
    proxy(this);

    // 编译 DOM 内容,即将封装,封装好之后,即可取消注释
    // new Compile(options.el, this); 
  }
}

我们可以再回头看一下 MyVue 类的调用,并测试,传入的 data 中的数据,是否能被 MyVue 实例成功调用:

const app = new MyVue({
  el: '#app',
  data: {
    counter: 1,
    msg: 'hello world',
    //... 其余数据
  },
  methods: {
    // 函数
  }
});

// 测试是否能通过 Vue 实例访问数据,并且实现数据拦截(如果控制台 log 出 get counter 和 get msg,则说明绑定成功)
app.counter; // get counter
app.msg; // get msg

4. Compile 类的封装

4.1 Compile 类编译模板,需要实现的功能:
  • 处理插值表达式 {{表达式}}
  • 处理指令和事件 my-html, my-text, @event
  • 插值表达式、指令对应数据的初始化和更新
4.2 Compile 的过程:
  • 遍历实例化 MyVue 实例时指定的 el 对应的DOM
  • 判断子节点类型,如果是文本节点,判断其内容是否是插值表达式(这里我们就简单的实现文本内容是{{数据}}形式的插值表达式解析)
  • 子节点类型如果是元素节点,解析器所有 attributes,遍历它们,判断是否是指令/事件,如果是,执行对应操作
  • 子节点如果是元素节点,还要遍历该子节点的所有子节点(深度遍历)
4.3 代码

进行到这里的时候就可以把 MyVue 类构造函数中下面的代码取消注释

 // new Compile(options.el, this); 
class Compile {
  constructor(el, vm) {
    // el: new MyVue 时指定的元素选择器 vm 当前的 vue 实例
    this.$vm = vm;
    this.$el = document.querySelector(el); // 不考虑边界判断

    if(this.$el) {
      this.compile(this.$el);
    }
  }

  // 解析器
  compile(el) {
    /* *
    * 遍历 el 子节点,判断他们的类型,做相应的处理 
    * 注意这里一定是 childNodes,因为 children 只能获取到元素子节点
    */
    const childNodes = el.childNodes;
    childNodes.forEach(node => {
      // 获取元素类型
      const type = node.nodeType;
      // 元素节点
      if(type === 1) {
        const attrs = node.attributes;
        Array.from(attrs).forEach(attr => {
          const attrName = attr.name;
          const exp = attr.value;
          // 判断是否为指令
          if(this.isDirective(attrName)) {
            const dir = attrName.substr(3);
            // 判断该指令在系统中是否存在,如果存在,执行对应操作
            this[dir] && this[dir](node, exp);
          }

          // 如果当前属性是想绑定一个事件
          if(this.isEvent(attrName)) {
            // 事件名称
            const dir = attrName.substr(1);
            // 添加对应事件监听
            this.eventHandler(node, exp, dir);
          }
        });
      } else if(this.isInter(node)) {
        // 编译动态文本节点(插值表达式)
        this.compileText(node);
      }

      // 如果当前元素有子节点,要递归判断
      if(childNodes) {
        this.compile(node);
      }
    });
  }
  // 是否为指令
  isDirective(attr) {
    return attr.startsWith('my-');
  }

  // 是否为事件
  isEvent(attr) {
    return attr.startsWith('@');
  }

  // 是否为插值表达式
  isInter(node) {
    return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent);
  }

  text(node, exp) {
    // 处理指令 my-text
    this.update(node, exp, 'text');
  }

  textUpdater(node, value) {
    node.textContent = value;
  }

  html(node, exp) {
    // 处理指令 my-html
    this.update(node, exp, 'html');
  }

  htmlUpdater(node, value) {
    node.innerHTML = value;
  }

  // 编译文本节点
  compileText(node) {
    // RegExp 表示与正则表达式匹配的第一个子匹配字符串
    this.update(node, RegExp.$1, 'text');
  }

  update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);
  }

  eventHandler(node, exp, dir) {
    const fn = this.$vm.$methods[exp];
    node.addEventListener(dir, fn.bind(this.$vm));
  }

  // model
  model(node, exp) {
    this.update(node, exp, 'model');

    node.addEventListener('input', e => {
      // 将新的值赋值给数据即可
      this.$vm[exp] = e.target.value;
    });
  }

  // 只完成赋值更新
  modelUpdater(node, value) {
    // 仅考虑 input
    node.value = value;
  }
}

  • 在这个封装中,拆分了好多过程,大家可以结合源码和代码执行的步骤,仔细思考一下,为什么要做这样的拆分,不拆分可不可以,在被拆分的功能中,是不是还有其他逻辑要处理。
  • Compile 封装结束后,页面就可以解析到 MyVue 实例的 data
  • 上面的封装只能页面初始化的时候解析一次,想要在数据改变时,也能动态的更新视图,或者 input 的数据变化时,也能更新到 data,需要更进一步的响应处理

5. Watcher 类和 Dep

通过上面的代码,我们可以知道,在模板被编译的过程中,每次,某个数据被视图调用,我们就知道,这个数据在此处,被调用了,下次如果该数据更新了,那么这个调用要重新执行一次,其实这是一个订阅模式。

我们可以声明一个 Watcher 类,来管理这种视图对数据的订阅。

5.1 Watcher

我们当然也可以直接使用函数、数组这种方式保留数据被订阅时的回调,然后等到数据被赋值时,依次执行。

但是为了让整个项目中的响应更完善,更系统,我们将这些动作,都封装在 Watcher 类中。

// 监听器,负责依赖的更新
class Watcher {
  constructor(vm, key, cb) {
    // 保留视图的 MyVue 实例
    this.vm = vm;
    // 保留该 vm 中被订阅的数据 key
    this.key = key; 
    // 保留 key 被订阅时,需要执行的操作,其实就是 Compile 类中,数据执行的 update 函数
    this.cb = cb; 
  }

  update() {
    // 执行实际的更新操作
    this.cb.call(this.vm, this.vm[this.key]);
  }
}

下一个问题就是,在什么地方,如何使用这个 Watcher,我们要做的是,保留每个数据被调用时执行的那个操作(update),等到下一次,这个数据被赋值时,重新执行这些操作。

回到 Compile 类中,我们观察到,无论哪个数据被调用,都会经过 Compile 类的 update 函数

class Compile {
  ...
  update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);
  }
  ...
}

当一个节点被编译时,compile 函数判断到这个节点调用了一个数据,就会调用 update 函数,update 接收到这个信息,判断该数据对应的操作是否存在,如果存在,分发任务。

无论哪个数据,做什么操作,都会先被 Compile 类的 update 分发,所以我们在 update 函数内部,保留数据被订阅时,数据所属的实例(MyVue实例),数据名(key),和 vm 中,该实例对应的操作。

我们暂时可以这样保留所有数据的更新:

  • 全局的位置,声明一个数组 watches
  • 每次有数据被视图订阅时,我们就将该数据对应操作通过 Watcher 类保留
  • 将这个 Watcher 实例添加到 watches 数组中
  • 数据被赋值时,执行 watches 数组中的任务

代码实现:

// index.js 第一行,全局位置
const watches = [];
// Compile 类的 update 函数
class Compile {
  ...
  update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);

    // 2. 保留操作,并且添加到 watches 数组中
    matches.push(new Watcher(this.$vm, exp, function (val) {
      // 这里的 fn,其实就是 exp 对应的 updator
      // val 之所以要传入,是因为此时并不知道 exp 被更新的值
      // exp 最新的值,是在这个数据被赋值时,再在 Watcher 的 update 里获取
      fn && fn(node, val);
    }));
  }
  ...
}
// defineReactive 函数
function defineReactive(obj, key, val) {
  observe(val);
  Object.defineProperty(obj, key, {
    get() {
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        observe(val);
        val = newVal;

        // 每当数据被赋值时,就让 matches 中所有的任务都执行
        matches.forEach(item => {
          item.update();
        });
      }
    }
  });
}

这样是实现了数据响应的功能,但是也存在一非常严重的问题。watches 数组中,保留的是每一个数据被某个视图订阅的任务列表,每次在数据被赋值时,我们遍历了 matches 数组,让里面所有的任务都执行了,也包含那些未改变的数据的视图任务。

// 假如经过编译之后 matches 数组中的任务列表如下:
matches: 
[
  数据 A 被视图 V1 调用的任务, // 任务一
  数据 B 被视图 V2 调用的任务, // 任务二
  数据 B 被视图 V3 调用的任务, // 任务三
  数据 C 被视图 V4 调用的任务, // 任务四
  数据 C 被视图 V5 调用的任务, // 任务五
  数据 C 被视图 V6 调用的任务, // 任务六
  数据 D 被视图 V7 调用的任务, // 任务七
  数据 D 被视图 V8 调用的任务, // 任务八
]

当数据 D 被更新时,只需要执行任务七和任务八,但是根据我们现在的逻辑,matches 数据被遍历,其中所有的任务都会执行

所以我们需要更有规律的管理数据和其所有 Wathcer

Vue 源码中,这个工具就是 Dep 类。

5.2 Dep

Dep 类的核心内容

  • 每个数据都应该有一个 Dep
  • 这个 Dep 拥有一个数组
  • 该数组管理订阅了该数组的所有任务
  • 每次该数据被更新时,Dep 会通知其管理的任务数组,让他们执行对应任务
// 管理依赖,通知数据相关 watcher,执行更新操作
class Dep {
  constructor() {
    // 声明一个数组,管理数据被订阅时的任务列表
    this.deps = []; 
  }

  addDep(watcher) {
    // 每当数据被新的视图订阅时,都要为该数据添加一个 watcher
    this.deps.push(watcher);
  };

  notify() {
    this.deps.forEach(dep => dep.update());
  }
}

Dep 类声明好了之后,我们需要一次做以下几点。

5.2.1 在什么时候创建 Dep 实例

根据我们前面的分析,每个数据,都应该拥有一个 Dep 实例。如果这些数据是对象,我们可以将该对象的 Dep 实例添加成一个键,然后使用。但是 MyVue 实例中,并不是所有的数据都是对象,也有可能是数字,字符串,null等等。

我们可以通过闭包的方式,实现为每个数据添加一个 Dep 实例。

在数据传入 MyVue 的最初,每个数据都要经历一次 defineReactive 函数,我们就可以在这个时候,声明一个 Dep 实例。

function defineReactive(obj, key, val) {
  const dep = new Dep();
  
  ... // 其余代码
}
5.2.2 在什么时候收集依赖

依赖的收集肯定不在 Compileupdate 函数中,因为此处,每个数据被订阅,都会执行,此时收集的依赖,依旧无法对应到指定数据,所以我们还是结合 Dep 实例的闭包,在数据被调用时,收集依赖。

数据被调用,且此时是被视图调用,那就收集一个依赖。

依赖是被收集到 Dep 实例的 deps 数组中的,而 Dep 实例又是 definedReactive 函数的局部变量,那么我们在 Compile 类的 update 函数中创建的 watcher,就需要一个中转的过程,才能被添加到数据对应的 deps 数组中。

我们可以暂时使用一个全局变量 _tempWatcher 实现这个中转收集依赖的过程。

// index.js 第一行,全局位置
let _tempWathcer = [];
// Compile 的 update 函数
class Compile {
  ...
  update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);

    _tempWatcher = new Watcher(this.$vm, exp, function (val) {
      fn && fn(node, val);
    })
    /* 
    * 注意这个举动是在触发收集依赖,
    * 因为每次代码执行到这里的时候,对应数据已经被取过值了
    * 就是在上面的 fn 执行的地方
    *
    * 所以要通过这种方式触发依赖
    *  
    * 如果不想通过这种方式唤起 getter,可以把 new Watcher 放到上面的步骤1之前
    * 但是这样会破坏代码的阅读顺序,体验不好
    */
    this.$vm[exp];
    _tempWatcher = null;
  }
  ...
}
// defineReactive 函数,经过上面的封装,我们就可以在这里收集依赖,分派更新了
function defineReactive(obj, key, val) {
  observe(val);
  Object.defineProperty(obj, key, {
    get() {
      _tempWatcher && dep.addDep(_tempWatcher);
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        observe(val);
        val = newVal;

        // 根据闭包原则,此时执行的任务列表,就是该数据对应的任务列表,数据与数据的任务不会相互污染
        dep.notify();
      }
    }
  });
}
5.2.3 更好的代码模式

虽然实现了功能,但是我们暴露了一个全局变量 _tempWatcher,这显然是不合理和不安全的,所以我们可以将 _tempWatcher 绑定成 Dep 的一个静态属性。

我们还可以归置一下 Watcher 的创建和添加,将对应功能放到对应的类中,让所有代码都分工明确。

// Compile 的 update 函数
class Compile {
  ...
  update(node, exp, dir) {
    // 1. 初始化
    const fn = this[dir + 'Updater'];
    fn && fn(node, this.$vm[exp]);

    // 2. 在这里只做依赖创建
    new Watcher(this.$vm, exp, function (val) {
      fn && fn(node, val);
    })
  }
  ...
}
// Watcher 类
class Watcher {
  constructor(vm, key, cb) {
    this.vm = vm;
    this.key = key;
    this.cb = cb;

    // 在此处触发依赖收集
    Dep.target = this;
    this.vm[this.key];
    Dep.target = null;
  }

  // 未来被 Dep 调用
  update() {
    // 执行实际的更新操作
    this.cb.call(this.vm, this.vm[this.key]);
  }
// defineReactive
function defineReactive(obj, key, val) {
  observe(val);

  const dep = new Dep();

  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      Dep.target && dep.addDep(Dep.target);
      return val;
    },
    set(newVal) {
      if(newVal !== val) {
        observe(newVal);
        val = newVal;

        dep.notify();
      }
    }
  });
}

扫码拉你进入前端技术交流群

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

锋利的二丫

如果对您有帮助,请博主喝杯奶茶

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值