简单实现VUE双向数据绑定

前言

VUE是当下比较热门的一个前端框架,其显著特点就是双向数据绑定,即data更新view,view更新data,但其具体实现一直模模糊糊,这次就来搞个明明白白。

核心原理

其核心原理就是数据劫持,数据劫持是用Object.definePorperty()来实现的,看代码

let data = {}
let num = 0;
Object.defineProperty(data, 'num', {
    enumerable: true,
    configurable: true,
    get() {
        return num;
    },
    set(v) {
        num = v;
    }
});

每当获取data['num']的值时就会触发它的get方法,设置data['num']的值时就会触发它的set方法,这样就实现了数据劫持,一旦数据发生一些改变,就可以监听到,然后去实现一些自定义的功能,例如:更新view

第一步,确定初始化方式

就模仿VUE的初始化好了,首先我们写HTML

<div id="app">
    <h1>{{info}}</h1>
    <input type="text" v-model="info">
    <button v-on:click="btnClick">点击</button>
</div>

接着,对它进行初始化

    let ymvue = new YMVue({
        el: '#app',
        data: {
            info: 'hello world'
        },
        methods: {
            mounted() {
                console.log(this)
            },
            btnClick() {
                this.hello = 'Hello Vue'
            }
        }
    });

第二步,生成观察者

VUE用的是观察者模式,观察者的主要功能就是数据劫持,那我们定义一个观察者Guard,当我们初始化一个YMVue对象之后,就把它交给观察者Guard,然后遍历它的data集合,并对里面的数据进行劫持。

    function Guard(obj) {
        this.obj = obj;   // YMVue对象
        this.start(obj.data);
    }

    Guard.prototype = {
        start(data) {
            if (data && typeof data === 'object') {
                Object.keys(data).forEach((key) => {
                    this.addGuard(data, key, data[key]);
                });
            }
        },
        addGuard(data, key, val) {
            let self = this;
            this.start(data[key]);
            // let dep = new Dep();
            Object.defineProperty(data, key, {
                enumerable: true,
                configurable: true,
                get() {
                    //if (Dep.target) {
                    //    dep.addSub(Dep.target);
                    //}
                    return val;
                },
                set(v) {
                    if (val === v) {
                        return;
                    }
                    val = v;
                    //dep.update();
                }
            })
        }
    };

上面代码中注释代码仅作暂时注释,待下面进行说明

第三步,生成订阅者

如果给观察者传入一个回调函数callback,那么在触发set方法后执行回调,好像是实现了所有功能。
但是,but,一个数据的更新可能会触发无数个方法,如果通过给观察者传入回调,那就必须对同一个数据进行多次劫持,那自然是不得行了,所以需要个订阅者,把数据更新后带来的连锁反应订阅到这个数据上去。

    function BindCallbackToGuard(obj, name, callback) {
        this.obj = obj;             // YMVue对象
        this.callback = callback;   // 回调函数
        this.name = name;           // 订阅的数据
        this.value = this.get();
    }

    BindCallbackToGuard.prototype = {
        excute() {
            let val = this.obj.data[this.name];
            let oldVal = this.value;
            if (val !== oldVal) {
                this.value = val;
                this.callback.call(this.obj, val, oldVal);
            }
        },
        get() {
            Dep.target = this;                    // 把自己设为订阅对象 
            let value = this.obj.data[this.name]; // 触发观察者的get函数
            Dep.target = null;                    
            return value;
        }
    };

第四步,创建一个订阅者容器

第二步和第三步的代码中都有一个Dep,那Dep到底是什么呢
Dep其实是一个订阅者容器,每当有一个订阅者订阅了自己,观察者就把它放进订阅者容器里面,然后当观察者监听到数据有变的时候,就去遍历订阅者容器,然后执行每个订阅者订阅的方法。

    function Dep() {
        this.subs = []
    }

    Dep.prototype = {
        addSub(sub) {
            this.subs.push(sub);
        },
        update() {
            this.subs.forEach((sub) => {   // sub是一个订阅者
                sub.excute();
            })
        }
    };

这时候,就可以取消第二步代码中的注释了

第五步,生成DOM解析器

上面我们已经把功能都搞定了,现在就需要把那些特殊的HTML跟这些代码联系到一起,这时候就需要一个DOM解析器,其中对DOM元素的操作我们用到了文档碎片Fragment

    function Analysis(containerId, obj) {
        this.obj = obj;    // YMVue对象
        this.dom = document.querySelector(containerId);
        this.fragment = null;
        this.init();
    }

    Analysis.prototype = {
        init() {
            if (this.dom) {
                this.fragment = this.switchToFragment(this.dom);     // 将NODE节点转换为文档碎片
                this.analysisElement(this.fragment);            // 开始解析
                this.dom.appendChild(this.fragment);            // 用文档碎片替换node节点
            } else {
                console.log('Dom 元素不存在');
            }
        },
        switchToFragment() {
            let fragment = document.createDocumentFragment();
            let child = this.dom.firstChild;
            while (child) {
                fragment.append(child);
                child = this.dom.firstChild;
            }
            return fragment;
        },
        analysisElement(dom) {
            let childNodes = dom.childNodes;
            [].slice.call(childNodes).forEach((node) => {
                let reg = /\{\{(.*)\}\}/;
                let text = node.textContent;

                if (this.isElementNode(node)) {      // 元素节点
                    this.analysisAttr(node);
                } else if (this.isTextNode(node) && reg.test(text)) {  // 文本节点
                    this.bindText(node, reg.exec(text)[1]);
                }

                if (node.childNodes && node.childNodes.length) {
                    this.analysisElement(node);
                }
            });
        },
        analysisAttr(node) {
            let nodeAttrs = node.attributes;
            Array.prototype.forEach.call(nodeAttrs, (attr) => {
                let name = attr.name;          // 属性名称
                if (this.isCommand(name)) {    // 属性名以'v-'开头
                    let value = attr.value;    // 属性值
                    let command = name.substring(2);
                    if (this.isEventCommand(command)) {  // 事件指令,比如v-on:click
                        this.bindEvent(node, value, command);
                    } else { // v-model 指令
                        this.bindModel(node, value)
                    }
                    node.removeAttribute(name);  // 移除后,页面上不显示
                }
            })
        },

        bindText(node, name) {
            let text = this.obj[name];
            node.textContent = text || '';
            // 添加一个订阅者
            new BindCallbackToGuard(this.obj, name, function (v) {
                node.textContent = v;
            });

        },
        bindEvent(node, name, command) {
            let eventType = command.split(':')[1];
            let method = this.obj.methods && this.obj.methods[name];
            if (eventType && method) {
                node.addEventListener(eventType, method.bind(this.obj), false);
            }
        },
        bindModel(node, name) {
            let self = this;
            let text = this.obj[name];
            node.value = text || '';
            
            // 添加一个订阅者
            new BindCallbackToGuard(this.obj, name, function (v) {
                node.value = v;
            });
            // 监听input事件,当它的value改变时,同时更新其绑定值
            node.addEventListener('input', function (e) {
                let newVal = e.target.value;
                if (text === newVal) {
                    return;
                }
                self.obj[name] = newVal;
                text = newVal;
            }, false);
        },
        isCommand(attr) {
            return attr.indexOf('v-') === 0;
        },
        isEventCommand(dir) {
            return dir.indexOf('on:') === 0;
        },
        isElementNode(node) {
            return node.nodeType === 1;
        },
        isTextNode(node) {
            return node.nodeType === 3;
        }
    };

第六步,定义初始化类

现在所有的功能基本完成,就差一个初始类YMVue

    function YMVue(opts) {
        this.data = opts.data;
        this.methods = opts.methods || {};

        Object.keys(this.data).forEach((key) => {
            this.proxy(key);      // 设置代理
        });
        new Guard(this);           // 初始化观察者
        new Analysis(opts.el, this);     // dom解析
        this.methods.mounted && this.methods.mounted.call(this);  // 初始化完成后,执行mounted方法
    }

    YMVue.prototype = {
        proxy(key) {  
            let self = this;
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return self.data[key];
                },
                set(v) {
                    self.data[key] = v;
                }
            })
        }
    };

初始化类有一个代理proxy,它有什么用呢?
因为VUE改变的数据的操作是this.info='xxxx',但是this指向的是VUE对象,info又在data集合里,所以应该是this.data.info='xxx',那取消中间的data就需要用到代理。

结束

OK,大功告成,虽然功能距离真正的VUE还差的远,但是简单的数据双向绑定就这么实现了,写成一个插件,随便在哪都可以用起来。
完整代码在这

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

推荐阅读更多精彩内容

  • 前言 使用vue也好有一段时间了,虽然对其双向绑定原理也有了解个大概,但也没好好探究下其原理实现,所以这次特意花了...
    指尖跳动阅读 7,976评论 0 16
  • 这方面的文章很多,但是我感觉很多写的比较抽象,本文会通过举例更详细的解释。(此文面向的Vue新手们,如果你是个大牛...
    Ivy_2016阅读 15,366评论 8 64
  • 本文是lhyt本人原创,希望用通俗易懂的方法来理解一些细节和难点。转载时请注明出处。文章最早出现于本人github...
    lhyt阅读 2,193评论 0 4
  • 从源码分析双向绑定 这部分代码,是源码的简化版,相对比较容易理解。 html代码: 从html代码,vue仅仅从初...
    zdxhxh阅读 410评论 0 0
  • 第一步 编写程序 以github上的N皇后问题为例 测试一下运行 第二步 混淆 打开Oxyry网站,将代码复制到左...
    yiqingxu阅读 3,109评论 1 3