稍微学一下 MVVM 原理

vue.jpg

博客原文

介绍

本文通过仿照 Vue ,简单实现一个的 MVVM,希望对大家学习和理解 Vue 的原理有所帮助。

前置知识

nodeType

nodeType 为 HTML 原生节点的一个属性,用于表示节点的类型

Vue 中通过每个节点的 nodeType 属性是1还是3判断是元素节点还是文本节点,针对不同类型节点做不同的处理。

DocumentFragment

DocumentFragment是一个可以被 js 操作但不会直接出发渲染的文档对象,Vue 中编译模板时是现将所有节点存到 DocumentFragment 中,操作完后再统一插入到 html 中,这样就避免了多次修改 Dom 出发渲染导致的性能问题。

Object.defineProperty

Object.defineProperty接收三个参数 Object.defineProperty(obj, prop, descriptor), 可以为一个对象的属性 obj.prop t通过 descriptor 定义 get 和 set 方法进行拦截,定义之后该属性的取值和修改时会自动触发其 get 和 set 方法。

从零实现一个类 Vue

以下代码的 git 地址:以下代码的 git 地址

目录结构

├── vue
│   ├── index.js
│   ├── obsever.js
│   ├── compile.js
│   └── watcher.js
└── index.html

实现的这个 类 Vue 包含了4个主要模块:

  • index.js 为入口文件,提供了一个 Vue 类,并在类的初始化时调用 obsever 与 compile 分别进行数据拦截与模板编译;
  • obsever.js 中提供了一个 Obsever 类及一个 Dep 类,Obsever 对 vue 的 data 属性遍历,给所有数据都添加 getter 与 setter 进行拦截,Dep 用于记录每个数据的依赖;
  • compile.js 中提供了一个 Compile 类,对传入的 html 节点的所有子节点遍历编译,分析 vue 不同的指令并解析 {{}} 的语法;
  • watcher.js 中提供了一个 Watcher 类,用于监听每个数据的变化,当数据变化时调用传入的回调函数;

入口文件

在 index.html 中是通过 new Vue() 来使用的:

<div id="app">
  <input type="text" v-model="msg">
  {{ msg }}
  {{ user.name }}
</div>
<script>
  const vm = new Vue({
    el: '#app',
    data: {
      msg: 'hello',
      user: {
        name: 'pan'
      }
    }
  })
</script>

因此入口文件需提供这个 Vue 的类并进行一些初始化操作:

class Vue {
  constructor(options) {
    // 参数挂载到实例
    this.$el = document.querySelector(options.el);
    this.$data = options.data;
    if (this.$el) {
      // 数据劫持
      new Observer(this.$data);
      // 编译模板
      new Compile(this.$el, this);
    }
  }
}

Compile

index.js 中调用了 new Compile() 进行模板编译,因此这里需要提供一个 Compile 类:

class Compile {
  constructor(el, vm) {
    this.el = el;
    this.vm = vm;
    if (this.el) {
      // 将 dom 转入 fragment 内存中
      const fragment = this.node2fragment(this.el);
      // 编译  提取需要的节点并替换为对应数据
      this.compile(fragment);
      // 插回页面中去
      this.el.appendChild(fragment);
    }
  }
  // 编译元素节点  获取 Vue 指令并执行对应的编译函数(取值并更新 dom)
  compileElement(node) {
    const attrs = node.attributes;
    Array.from(attrs).forEach(attr => {
      const attrName = attr.name;
      if (this.isDirective(attrName)) {
        const expr = attr.value;
        let [, ...type] = attrName.split('-');
        type = type.join('');
        // 调用指令对应的方法更新 dom
        CompileUtil[type](node, this.vm, expr);
      }
    })
  }
  // 编译文本节点  判断文本内容包含 {{}} 则执行文本节点编译函数(取值并更新 dom)
  compileText(node) {
    const expr = node.textContent;
    const reg = /\{\{\s*([^}\s]+)\s*\}\}/;
    if (reg.test(expr)) {
      // 调用文本节点对应的方法更新 dom
      CompileUtil['text'](node, this.vm, expr);
    }
  }
  // 递归遍历 fragment 中所有节点判断节点类型并编译
  compile(fragment) {
    const childNodes = fragment.childNodes;
    Array.from(childNodes).forEach(node => {
      if (this.isElementNode(node)) {
        // 元素节点  编译并递归
        this.compileElement(node);
        this.compile(node);
      } else {
        // 文本节点
        this.compileText(node);
      }
    })
  }
  // 循环将 el 中每个节点插入 fragment 中
  node2fragment(el) {
    const fragment = document.createDocumentFragment();
    let firstChild;
    while (firstChild = el.firstChild) {
      fragment.appendChild(firstChild);
    }
    return fragment;
  }
  isElementNode(node) {
    return node.nodeType === 1;
  }
  isDirective(name) {
    return name.startsWith('v-');
  }
}

这里利用了 nodeType 区分 元素节点 还是 文本节点,分别调用了 compileElement 和 compileText。

compileElement 及 compileText 中最终调用了 CompileUtil 的方法更新 dom。

CompileUtil = {
  // 获取实例上对应数据
  getVal(vm, expr) {
    expr = expr.split('.');
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  },
  // 文本节点需先去除 {{}} 并利用正则匹配多组
  getTextVal(vm, expr) {
    return expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      return this.getVal(vm, arguments[1]);
    })
  },
  // 从 vm.$data 上取值并更新节点的文本内容
  text(node, vm, expr) {
    expr.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (...arguments) => {
      // 添加数据监听,数据变化时调用回调函数
      new Watcher(vm, arguments[1], () => {
        this.updater.textUpdater(node, this.getTextVal(vm, expr));
      })
    })
    this.updater.textUpdater(node, this.getTextVal(vm, expr));
  },
  // 从 vm.$data 上取值并更新输入框内容
  model(node, vm, expr) {
    // 添加数据监听,数据变化时调用回调函数
    new Watcher(vm, expr, () => {
      this.updater.modelUpdater(node, this.getVal(vm, expr));
    })
    // 输入框输入时修改 data 中对应数据
    node.addEventListener('input', e => {
      const newValue = e.target.value;
      this.setVal(vm, expr, newValue);
    })
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  },
  updater: {
    textUpdater(node, value) {
      node.textContent = value;
    },
    modelUpdater(node, value) {
      node.value = value;
    }
  }
}

getVal 方法用于处理嵌套对象的属性,如传入表达式 expr 为 user.name 的情况,利用 reduce 从 vm.$data 上拿到。

Observer

index.js 中调用了 new Observer() 进行数据劫持,Vue 实例 data 属性的每项数据都通过 defineProperty 方法添加 getter setter 拦截数据操作将其定义为响应式数据,因此这里首先需要提供一个 Observer 类:

class Observer {
  constructor(data) {
    // 遍历 data 将每个属性定义为响应式
    this.observer(data);
  }
  observer(data) {
    if (!data || typeof data !== 'object') {
      return;
    }
    for (const [key, value] of Object.entries(data)) {
      this.defineReactive(data, key, value);
      // 当属性为对象则需递归遍历
      this.observer(value);
    }
  }
  // 定义响应式属性
  defineReactive(obj, key, value) {
    const that = this;
    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      // 获取数据时调用
      get() {
        // 将 Watcher 实例存入依赖
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 设置数据时调用
      set(newVal) {
        if (newVal !== value) {
          // 当新值为对象时,需遍历并定义对象内属性为响应式
          that.observer(newVal);
          value = newVal;
          // 通知依赖更新
          dep.notify();
        }
      }
    })
  }
}

定义为响应式数据后再对其取值和修改是会触发对应的 get 和 set 方法。
取值时将改值本身返回,并先判断是否有依赖目标 Dep.target,如果有则保存起来。
修改值时先手动将原值修改并通知保存的所有依赖目标进行更新操作。

这里对每项数据都通过创建一个 Dep 类实例进行保存依赖和通知更新的操作,因此需要写一个 Dep 类:

class Dep {
  constructor() {
    this.subs = [];
  }
  addSub(watcher) {
    this.subs.push(watcher);
  }
  notify() {
    this.subs.forEach(watcher => watcher.update());
  }
}

Dep 中有一个数组,用于保存数据的依赖目标(watcher),notify 遍历所有依赖并调用其 update 方法进行更新。

Watcher

通过上面的 Observer 可以知道,每项数据在被调用时可能会有依赖目标,依赖目标需要被保存并在取值时调用 notify 通知更新,且通过 Dep 可以知道依赖目标是一个有 update 方法的对象实例。

因此需要创建一个 Watcher 类:

class Watcher {
  constructor(vm, expr, cb) {
    this.vm = vm;
    this.expr = expr;
    this.cb = cb;
    // 记录旧值
    this.value = this.get();
  }
  getVal(vm, expr) {
    expr = expr.split('.');
    return expr.reduce((prev, next) => {
      return prev[next];
    }, vm.$data);
  }
  get() {
    Dep.target = this;
    // 获取 data 会触发对应数据的 get 方法,get 方法中从 Dep.target 拿到 Watcher 实例
    let value = this.getVal(this.vm, this.expr);
    Dep.target = null;
    return value;
  }
  // 对外暴露的方法,获取新值与旧值对比后若不同则触发回调函数
  update() {
    let newValue = this.getVal(this.vm, this.expr);
    let oldValue = this.value;
    if (newValue !== oldValue) {
      this.cb(newValue);
    }
  }
}

依赖目标就是 Watcher 的实例,对外提供了 update 方法,调用 update 时会重新根据表达式 expr 取值与老值对比并调用回调函数。
这里的回调函数就是对应的更新 dom 的方法,在 compile.js 中的 model 及 text 方法中有执行 new Watcher() ,在模板解析时就为每项数据添加了监听:

model(node, vm, expr) {
  // 添加数据监听,数据变化时调用回调函数
  new Watcher(vm, expr, () => {
    this.updater.modelUpdater(node, this.getVal(vm, expr));
  })
  this.updater.modelUpdater(node, this.getVal(vm, expr));
},

Watcher 中很巧妙的一点就是,模板编译之前已经将所有添加了数据拦截,在 Watcher 的 get 方法中调用 getVal 取值时会触发该数据的 getter 方法,因此这里在取值前通过 Dep.target = this; 将该 Watcher 实例暂存,对应数据的 getter 方法中又将该实例作为依赖目标保存到了自身对应的 Dep 实例中。

总结

这样就实现了一个简易的 MVVM 原理,里面的一些思路还是非常值得反复体会学习的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容