vue 源码解析+手写 (vue1.x实现)

251 阅读5分钟

vue的解析

对于vue,很多人都不陌生,但是对于框架原理的理解大家各有不同,这里实现vue1x版本的核心概念。

全局变量

用于数组拦截

    const originProto = Array.prototype;
    const arrayProto = Object.create(originProto); // 备份数组原型

Vue构造函数

这里只实现vue中最核心的几个概念

  1. 数据响应式 observe
  2. 数据代理 proxy
  3. dom编译 Compile
  4. 函数响应 @click && methods
  5. 自定义指令 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函数即可,降低了内存消耗。