实现一个双向绑定效果

期望最终实现一个数据与 DOM 双向绑定的效果,使用方式类似于 vue,如下所示。

const vm = new SimpleVue({
  el: '#app',
  data: {
    value: '',
  },
  mounted() {
    setTimeout(() => {
      this.value = '12';
    }, 1000);
  },
  methods: {
    handleClick() {
      alert(this.value);
    },
  },
});

直接上代码,其主要用到观察与订阅设计模式,其中observe是观察者,Watcher属于订阅者,代码中注释写的比较详细,就不赘述了。

SimpleVue.js

首先是主入口 SimpleVue.js 的代码。

export default class SimpleVue {
  constructor(options) {
    this.data = options.data;
    this.methods = options.methods;

    Object.keys(this.data).forEach(this._proxyKey.bind(this));

    observe(this.data);
    // 解析与编译模板
    new Compile(options.el, this);
    // 调用mounted钩子
    options.mounted.call(this);
  }

  // 将data中的属性挂载到this上
  _proxyKey(key) {
    Object.defineProperty(this, key, {
      get: () => {
        return this.data[key];
      },
      set: (value) => {
        this.data[key] = value;
      },
    });
  }
}

// 观察者,主要是用来监听data的改变
// 在这里主要起到了数据Model与Dom操作之间的桥梁作用
// 在数据获取的时候添加订阅,订阅中Watcher处理DOM更新相关的事情
// 在设置数据时遍历订阅列表,执行订阅列表中的订阅事件,相应的,Watcher就能收到通知,执行回调
function observe(data) {
  if (!data || typeof data !== 'object') {
    return;
  }
  Object.keys(data).forEach((key) => {
    defineReactive(data, key, data[key]);
  });
}

function defineReactive(data, key, value) {
  const dep = new Dep();
  // 递归观察数据
  observe(value);
  Object.defineProperty(data, key, {
    get: () => {
      // Dep.target其实是一个Watcher,在获取数据时将Watcher添加到订阅数组中
      if (Dep.target) {
        dep.add(Dep.target);
      }
      return value;
    },
    set: (newValue) => {
      // 更新data数据时,会从订阅列表中取出所有订阅,通知Watcher执行相关的更新操作
      if (newValue !== value) {
        value = newValue;
        dep.notify();
      }
    },
  });
}

Dep.js

主要用来添加订阅和通知。

export default class Dep {
  constructor() {
    // 订阅列表
    this.subs = [];
  }

  // 添加订阅
  add(sub) {
    this.subs.push(sub);
  }

  // 遍历订阅列表,执行Watcher动作
  notify() {
    this.subs.forEach((sub) => {
      // 执行watcher中的update
      sub.update();
    });
  }
}

Watcher.js

订阅的之后的动作怎么触发,Watcher 登场。

// Watcher是订阅列表中的每一个动作
// 举个例子,比如微信订阅公众号,当作者在公众号发布了文章后,订阅过该公众号的读者将会收到文章更新的推送,那么这里的文章推送可以看做是订阅主体执行的动作,即这里的Watcher
export default class Watcher {
  constructor(vm, exp, cb) {
    this.vm = vm;
    this.exp = exp;
    this.cb = cb;
    // 在实例化时强制执行get
    this.value = this.get();
  }

  update() {
    this.run();
  }
  run() {
    const value = this.vm.data[this.exp];
    const oldValue = this.value;
    // 当value值不一样的时候再执行相关动作
    if (value !== oldValue) {
      this.value = value;
      // Watcher的动作触发
      this.cb.call(this.vm, value);
    }
  }
  // 在执行get的时候会将当前的Watcher实例赋值给Dep.target,之后在执行data属性值的获取
  // 在获取value的时候,会触发data的get,那么SimpleVue类中data的这条数据就会被添加订阅,即dep.add(Dep.target)
  get() {
    Dep.target = this;
    const value = this.vm.data[this.exp];
    Dep.target = null;
    return value;
  }
}

Compile.js

一切都准备就绪了,Model 数据变化了,我们需要将它更新到 DOM 中,Compile 来负责处理。

export default class Compile {
  constructor(el, vm) {
    this.vm = vm;
    this.el = document.querySelector(el);
    this.fragment = null;
    this.init();
  }

  init() {
    if (this.el) {
      // 因为遍历解析的过程有多次操作 dom 节点,为提高性能和效率,会先将跟节点 el 转换成文档碎片 fragment 进行解析编译操作,解析完成,再将 fragment 添加回原来的真实 dom 节点中
      this.fragment = this.nodeToFragment(this.el);
      this.compileElement(this.fragment);
      this.el.appendChild(this.fragment);
    } else {
      throw new Error('Dom element not exist');
    }
  }
  nodeToFragment(el) {
    const fragment = document.createDocumentFragment();
    // 从第一个子元素开始遍历
    let child = el.firstChild;
    // 遍历所有的子元素,将其碎片化处理
    while (child) {
      fragment.appendChild(child);
      child = el.firstChild;
    }
    return fragment;
  }
  // 解析与编译节点元素,比如{{}}语法,v-model,v-on等等
  compileElement(el) {
    const childNodes = el.childNodes;
    Array.prototype.forEach.call(childNodes, (node) => {
      const reg = /\{\{(.*)\}\}/;
      const text = node.textContent;
      if (this.isElementNode(node)) {
        // 如果是一个html元素,那么需要解析这个html元素
        this.compile(node);
      } else if (this.isTextNode(node) && reg.test(text)) {
        // 如果是一个文本节点并且是相关的语法结构,即:{{}},就解析文本
        this.compileText(node, reg.exec(text)[1]);
      }

      // 递归处理所有节点
      if (node.childNodes && node.childNodes.length) {
        this.compileElement(node);
      }
    });
  }
  compile(node) {
    const nodeAttrs = node.attributes;
    // 因为是一个普通的html元素,那么这些指定是添加在节点属性上的,所以需要遍历所有属性,找出匹配项
    // 判断符合条件的几种情况,指令,如v-on,v-model等
    Array.prototype.forEach.call(nodeAttrs, (attr) => {
      const attrName = attr.name;
      const exp = attr.value;
      const dir = attrName.substring(2);
      if (this.isDirective(attrName)) {
        if (this.isEventDirective(dir)) {
          this.compileEvent(node, this.vm, exp, dir);
        } else {
          this.compileModel(node, this.vm, exp);
        }
        // 内部的指令解析后在html节点上删除,因为这些属性并不一定是html标准的属性
        node.removeAttribute(attrName);
      }
    });
  }
  compileText(node, exp) {
    const initText = this.vm[exp];
    // 初始化
    this.updateText(node, initText);
    // 注册监听,监听的是this.vm[exp]的变化
    new Watcher(this.vm, exp, (value) => {
      // 订阅事件(sub)触发后的动作
      // Dep中的sub.update执行后,最终会执行这里
      this.updateText(node, value);
    });
  }
  compileEvent(node, vm, exp, dir) {
    // 解析v-on:click形式的事件绑定等
    // 解析后给节点绑定上相关事件监听
    const eventType = dir.split(':')[1];
    const cb = vm.methods && vm.methods[exp];
    if (eventType && cb) {
      node.addEventListener(eventType, cb.bind(vm), false);
    }
  }
  compileModel(node, vm, exp) {
    let val = vm[exp];
    this.modelUpdater(node, val);
    // v-model内部其实是监听input事件和设置input的value实现的双向绑定
    node.addEventListener('input', (e) => {
      const newValue = e.target.value;
      // 实现 view 到 model 的绑定
      this.vm[exp] = newValue;
    });
    // 注册监听,监听的是this.vm[exp]的变化
    new Watcher(vm, exp, (value) => {
      // 当data中exp值变化的时候,也希望同时反馈到node节点的value中
      node.value = typeof value === 'undefined' ? '' : value;
    });
  }
  modelUpdater(node, value) {
    node.value = typeof value === 'undefined' ? '' : value;
  }
  updateText(node, value) {
    node.textContent = typeof value === 'undefined' ? '' : value;
  }
  isEventDirective(dir) {
    return dir.indexOf('on:') === 0;
  }
  isDirective(attr) {
    return attr.indexOf('v-') === 0;
  }
  isElementNode(node) {
    return node.nodeType === 1;
  }
  isTextNode(node) {
    return node.nodeType === 3;
  }
}

实际效果

那么实际运行效果如何?如下所示。

See the Pen
Untitled
by Leevare (@leevare)
on CodePen.

如果您觉得本文对您有用,欢迎捐赠或留言~
微信支付
支付宝

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注