vue 中的数据双向绑定, 其实一句话就可以说清楚了: 利用 Object.defineProperty(), 并且把内部解耦为 Observer, Dep, 并使用 Watcher 相连.
那根据这句话我们可以把整一个简单的 MVVM 框架粗分为以下四个模块:
1. 模板编译(Compile)
2. 数据劫持(Observer)
3. 订阅发布(Dep)
4. 观察者(Watcher)
我们就根据这四个模块来分析, 手写一个 MVVM 框架.
想看源码的, 请直接下滑到最后.
MVVM 类
和 Vue 类似, 我们构建一个 MVVM 类, 通过 new 指令创建一个 MVVM 实例, 并传入一个类型为对象的参数 option, 包含当前实例的作用域 el 和模板绑定的数据 data.
- class MVVM {
- constructor(options) {
- // 挂载实例
- this.$el = options.el;
- this.$data = options.data;
- // 编译模板
- if(this.$el) {
- // 数据劫持 把对象的所有属性 改成带 set 和 get 方法的
- new Observer(this.$data)
- // 将数据代理到实例上, 直接操作实例即可, 不需要通过 vm.$data 来进行操作
- this.proxyData(this.$data)
- // 用数据和元素进行编译
- new Compile(this.$el, this)
- }
- }
- proxyData(data) {
- Object.keys(data).forEach(key => {
- Object.defineProperty(this, key, {
- get() {
- return data[key]
- },
- set(newValue) {
- data[key] = newValue
- }
- })
- })
- }
- }
MVVM 类整合了所有的模块, 作为连接 Compile 和 Observer 的桥梁.
模板编译(Compile)
Compile
compile 在编译模板的时候, 其实是从指令和文本两个方面来处理的.
- class Compile {
- constructor(el, vm) {
- // 判断是否为 DOM, 若不是, 自己获取
- this.el = this.isElementNode(el) ? el : document.querySelector(el);
- this.vm = vm;
- if (this.el) {
- // 1. 将真实 DOM 放进内存中
- let fragment = this.node2fragment(this.el);
- // 2. 开始编译 提取想要的元素节点 v-model 和 文本节点 {{}}
- this.compile(fragment);
- // 3. 将编译好的 fragment 重新放回页面
- this.el.appendChild(fragment);
- }
- }
- /**
- * 辅助方法
- * 是否为元素节点
- * @isElementNode
- * 是否为指令
- * @isDirective
- */
- isElementNode(node) {
- return node.nodeType === 1;
- }
- isDirective(name) {
- return name.includes("v-");
- }
- /**
- * 核心方法
- */
- compileElement(node) {
- // v-model v-text
- let attrs = node.attributes; // 取出当前节点的属性
- Array.from(attrs).forEach(attr => {
- let attrName = attr.name;
- if (this.isDirective(attrName)) {
- // 判断属性名是否包含 v-model
- // 取到对应的值, 放到节点中
- let expr = attr.value;
- let [, type] = attrName.split("-"); // 解构赋值 v-model-->model
- // 调用对应的编译方法, 编译哪个节点, 用数据替换掉表达式
- CompileUtil[type](node, this.vm, expr);
- }
- });
- }
- compileText(node) {
- let expr = node.textContent; // 取出文本中的内容
- let reg = /\{\{([^]+)\}\}/g; // {{a}} {{b}} {{c}}
- if (reg.test(expr)) {
- // 调用编译文本的方法, 编辑哪个节点, 用数据替换掉表达式
- CompileUtil["text"](node, this.vm, expr);
- }
- }
- // 递归
- compile(fragment) {
- let childNodes = fragment.childNodes;
- Array.from(childNodes).forEach(node => {
- if (this.isElementNode(node)) {
- // 如果是元素的节点, 则继续深入检查
- // 编译元素
- this.compileElement(node);
- this.compile(node);
- } else {
- // 文本节点
- // 编译文本
- this.compileText(node);
- }
- });
- // Array.from()方法是将一个类数组对象或者可遍历对象转换成一个真正的数组
- }
- // 将 el 中的内容全部放进内存中
- node2fragment(el) {
- // 文档碎片 内存中的 dom 节点
- let fragment = document.createDocumentFragment();
- let firstChild;
- // 把值赋给变量 取不到后返回 null,null 作为条件
- while ((firstChild = el.firstChild)) {
- // 使用 appendChild() 方法从一个元素向另一个元素中移动
- fragment.appendChild(firstChild);
- }
- return fragment; // 内存中的节点
- }
- }
- CompileUtil
CompileUtil 是一个对象工具, 配合 Copmpile 使用.
- let CompileUtil = {
- model(node, vm, expr) {
- let updateFn = this.updater["modelUpdater"];
- /**
- *
- * 这里应该加一个监控, 数据变化了 应该调用 watch 的 callback
- * (这里只是记录原始的值 watcher 的 update 没有执行, 只有属性的 set 执行的时候, 才会执行 cb 回调, 重新进行真实数据绑定)
- *
- */
- new Watcher(vm, expr, newValue => {
- // 当值变化后会调用 cb 将新的值传递过来
- updateFn && updateFn(node, this.getVal(vm, expr));
- });
- node.addEventListener("input", e => {
- let newValue = e.target.value;
- // 监听输入事件, 将输入的内容设置到对应数据上
- this.setVal(vm, expr, newValue);
- });
- updateFn && updateFn(node, this.getVal(vm, expr));
- },
- text(node, vm, expr) {
- // 文本处理
- let updateFn = this.updater["textUpdater"];
- let value = this.getTextVal(vm, expr);
- expr.replace(/\{\{((?:.|\r?\n)+?)\}\}/g, (...args) => {
- new Watcher(vm, args[1], newValue => {
- // 如果数据变化了, 文本节点需要重新获取依赖的属性, 更新文本中的内容
- updateFn && updateFn(node, this.getTextVal(vm, expr));
- });
- });
- updateFn && updateFn(node, value);
- },
- getTextVal(vm, expr) {
- // 获取编译文本后的结果
- let value = this.parseText(expr);
- let result = '';
- value.tokens.forEach((item) => {
- if(item.hasOwnProperty('@binding')) {
- result += this.getVal(vm, item['@binding'])
- } else {
- result += item
- }
- })
- return result
- },
- parseText(text) {
- const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
- if (!tagRE.test(text)) {
- return;
- }
- const tokens = [];
- const rawTokens = [];
- let lastIndex = (tagRE.lastIndex = 0);
- let match, index, tokenValue;
- while ((match = tagRE.exec(text))) {
- index = match.index;
- // push text token
- if (index> lastIndex) {
- rawTokens.push((tokenValue = text.slice(lastIndex, index)));
- tokens.push(JSON.stringify(tokenValue));
- }
- // tag token
- const exp = match[1].trim();
- tokens.push(`_s(${exp})`);
- rawTokens.push({ "@binding": exp });
- lastIndex = index + match[0].length;
- }
- if (lastIndex <text.length) {
- rawTokens.push((tokenValue = text.slice(lastIndex)));
- tokens.push(JSON.stringify(tokenValue));
- }
- return {
- expression: tokens.join("+"),
- tokens: rawTokens
- };
- },
- setVal(vm, expr, value) {
- expr = expr.split(".");
- return expr.reduce((prev, next, currentIndex) => {
- if (currentIndex === expr.length - 1) {
- return (prev[next] = value);
- }
- return prev[next];
- }, vm.$data);
- },
- getVal(vm, expr) {
- // 获取实例上对应的数据
- expr = expr.split("."); // {{message.a}} [message, a]
- // vm.$data.message => vm.$data.message.a
- return expr.reduce((prev, next) => {
- return prev[next.trim()];
- }, vm.$data);
- /**
- * 关于 reduce:
- * arr.reduce(callback,[initialValue])
- */
- },
- updater: {
- // 文本更新
- textUpdater(node, value) {
- node.textContent = value;
- },
- // 输入框更新
- modelUpdater(node, value) {
- node.value = value;
- }
- }
- };
再次认识到正则表达式的重要性.
在处理 {{}} 模板引擎的时候, 遇到一个 bug, 在一个 DOM 节点里, 如果有个有多个 {{}}{{}} 会显示为 undefined, 后来仔细阅读了 vueJs 的源码, 借鉴其中 parseText()方法, 进行处理, 得以解决.
数据劫持(Observer)
什么是数据劫持?
在访问或修改对象的某个属性时, 通过一段代码拦截这个行为, 进行额外的操作, 或者修改返回的结果.
数据劫持的作用是什么?
它是双向数据绑定的核心方法, 通过劫持对象属性的 setter 和 getter 操作, 监听数据的变化, 同时也是后期 ES6 中很多语法糖底层实现的核心方法.
使用
Object.defineProperty()
做数据劫持, 有什么弊端?
1, 不能监听数组的变化
2, 必须遍历对象的每个属性
3, 必须深层遍历嵌套的对象
MVVM 中的数据劫持
- class Observer {
- constructor(data) {
- this.observe(data)
- }
- observe(data) {
- // 要对这个 data 数据, 将原有的属性改成 set 和 get 的形式
- // defineProperty 针对的是对象
- if(!data || typeof data !== 'object') {
- return
- }
- // 将数据一一劫持, 先获取到 data 的 key 和 value
- Object.keys(data).forEach(key => {
- // 定义响应式变化
- this.defineReactive(data, key, data[key])
- this.observe(data[key]) // 深度递归劫持
- })
- // 关于 Object.keys() 返回一个包含对象的属性名称的数组
- }
- // 定义响应式
- defineReactive(obj, key, value) {
- let that = this;
- let dep = new Dep(); // 每个变化的数据 都会对应一个数组, 这个数组是存放所有更新的操作
- Object.defineProperty(obj, key, {
- enumerable: true, // 是否能在 for...in 循环中遍历出来或在 Object.keys 中列举出来
- configurable: true, // false, 不可修改, 删除目标属性或修改属性性以下特性
- get() {
- Dep.target && dep.addSub(Dep.target)
- return value;
- },
- set(newValue) {
- if(newValue != value) {
- that.observe(newValue); // 如果设置的是对象, 继续劫持
- value = newValue;
- dep.notify(); // 通知所有人 数据更新了
- }
- }
- })
- }
- }
订阅发布(Dep)
其实发布订阅说白了就是把要执行的函数统一存储在一个数组 subs 中管理, 当达到某个执行条件时, 循环这个数组并执行每一个成员.
- class Dep {
- constructor() {
- // 订阅数组
- this.subs = [];
- }
- // 添加订阅
- addSub(watcher) {
- this.subs.push(watcher);
- }
- // 将消息通知给所有人
- notify() {
- this.subs.forEach(watcher => watcher.update());
- }
- }
观察者(Watcher)
Watcher 类的作用是, 获取更改前的值存储起来, 并创建一个 update 实例方法, 当值被更改时, 执行实例的 callback 以达到视图的更新.
- class Watcher{ // 因为要获取 oldValue, 所以需要 "数据" 和 "表达式"
- constructor(vm, expr, cb) {
- this.vm = vm;
- this.expr = expr;
- this.cb = cb;
- // 先获取 oldValue 保存下来
- this.value = this.get();
- }
- getVal(vm, expr) {
- expr = expr.split('.');
- return expr.reduce((prev, next) => {
- return prev[next.trim()]
- }, vm.$data);
- }
- get() {
- // 在取值之前先将 watcher 保存到 Dep 上
- Dep.target = this;
- 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);
- }
- }
- }
最后
最后当然是要检测一下, 我们的写的代码是不是能正常运行.
- <!DOCTYPE HTML>
- <HTML lang="en">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0"
- />
- <meta http-equiv="X-UA-Compatible" content="ie=edge" />
- <title>
- Document
- </title>
- </head>
- <body>
- <div id="app">
- <!-- 双向数据绑定 靠的是表单 -->
- <input type="text" v-model="message.a" />
- <div>
- {{ message.a }} 啦啦啦
- </div>
- {{ message.a }} {{ b }}
- </div>
- </body>
- </HTML>
- <script src="observer.js"></script>
- <script src="watcher.js"></script>
- <script src="compile.js"></script>
- <script src="dep.js"></script>
- <script src="mvvm.js"></script>
- <script>
- let vm = new MVVM({
- el: "#app",
- data: {
- message: { a: "wlf" },
- b: "biubiubiu"
- }
- });
- </script>
总结
我们根据下图(参考《深入浅出 vue.JS》), 将整个流程再梳理一遍:
流程图. jpg
在 new MVVM() 后, MVVM 会进行初始化即实例化 MVVM, 在这个过程中, 模板绑定的数据 data 通过 Observer 数据劫持, 转换成了 getter/setter 的形式, 来监听数据的变化, 当被设置的对象被读取的时候会执行 getter 函数, 当它被赋值的时候会执行 setter 函数.
当页面渲染的时候, 会读取所需对象的值, 这个时候会触发 getter 函数从而将 Watcher 添加到 Dep 中进行依赖收集, 添加订阅.
当对象的值发生变化时, 会触发对应的 setter 函数, setter 会调用 dep.notify()通知之前依赖收集得到的 Dep 中的每一个 Watcher, 也就是遍历 subs 这个数组, 告诉它们自己的值改变了, 需要重新渲染视图. 这时候这些 Watcher 就会开始调用 update() 来更新视图.
源码地址:
https://github.com/lostimever/MVVM
来源: http://www.jianshu.com/p/0b4b68dab9a1