构建利用Proxy和Reflect实现双向数据绑定的微框架(基于ES6)

本文介绍了一种使用Proxy和Reflect实现双向数据绑定的方法,通过简单的代码实现了类似Vue的功能,包括数据变化监听、表单输入同步及事件绑定。

写在前面:这篇文章讲述了如何利用Proxy和Reflect实现双向数据绑定,个人系Vue早期玩家,写这个小框架的时候也没有参考Vue等源代码,之前了解过其他实现,但没有直接参考其他代码,如有雷同,纯属巧合。

代码下载地址:这里下载

综述

关于Proxy和Reflect的资料推荐阮老师的教程:http://es6.ruanyifeng.com/ 这里不做过多介绍。

实现双向数据绑定的方法有很多,也可以参考本专栏之前的其他实现,我之所以选择用Proxy和Reflect,一方面是因为可以大量节约代码,并且简化逻辑,可以让我把更多的经历放在其他内容的构建上面,另外一方面本项目直接基于ES6,用这些内容也符合面向未来的JS编程规范,第三点最后说。

由于这个小框架是自己在PolarBear这个咖啡馆在一个安静的午后开始写成,暂且起名Polar,日后希望我能继续完善这个小框架,给添加上更多有趣的功能。

首先我们可以看整体功能演示:
一个gif动图,如果不能看,请点击[这里的链接]

代码分析

我们要做这样一个小框架,核心是要监听数据的改变,并且在数据的改变的时候进行一些操作,从而维持数据的一致。

我的思路是这样的:

  • 将所有的数据信息放在一个属性对象中(this._data),之后给这个属性对象用Proxy包装set,在代理函数中我们更新属性对象的具体内容,同时通知所有监听者,之后返回新的代理对象(this.data),我们之后操作的都是新的代理对象。

  • 对于input等表单,我们需要监听input事件,在回调函数中直接设置我们代理好的数据对象,从而触发我们的代理函数。

  • 我们同时也应该支持事件机制,这里我们以最常用的click方法作为例子实现。

下面开始第一部分,我们希望我们之后使用这个库的时候可以这样调用:

<div id="app">
    <form>
        <label>name:</label>
        <input p-model = "name" />
    </form>
    <div>name:{{name}} age:{{age}}</div>
    <i>note:{{note}}</i><br/>
    <button p-click="test(2)">button1</button>
</div>
<script>
 var myPolar = new Polar({
        el:"#app",
        data: {
            name: "niexiaotao",
            age:16,
            note:"Student of Zhejiang University"
        },
        methods:{
            test:function(e,addNumber){
                console.log("e:",e);
                this.data.age+=Number(addNumber);
            }
        }
});
</script>

没错,和Vue神似吧,所以这种调用方式应当为我们所熟悉。

我们需要建立一个Polar类,这个类的构造函数应该进行一些初始化操作:

 constructor(configs){
        this.root = this.el = document.querySelector(configs.el);
        this._data = configs.data;
        this._data.__bindings = {};
        //创建代理对象
        this.data = new Proxy(this._data, {set});
        this.methods = configs.methods;

        this._compile(this.root);
}

这里面的一部份内容是直接将我们传入的configs按照属性分别赋值,另外就是我们创建代理对象的过程,最后的_compile方法可以理解为一个私有的初始化方法。

实际上我把剩下的内容几乎都放在_compile方法里面了,这样理解起来方便,但是之后可能要改动。

我们还是先不能看我们代理的set该怎么写,因为这个时候我们还要先继续梳理思路:

假设我们这样<div>name:{{name}}</div>将数据绑定到dom节点,这个时候我们需要做什么呢,或者说,我们通过什么方式让dom节点和数据对应起来,随着数据改变而改变。

看上文的__bindings。这个对象用来存储所有绑定的dom节点信息,__bindings本身是一个对象,每一个有对应dom节点绑定的数据名称都是它的属性,对应一个数组,数组中的每一个内容都是一个绑定信息,这样,我们在自己写的set代理函数中,我们一个个调用过去,就可以更新内容了:

dataSet.__bindings[key].forEach(function(item){
       //do something to update...
});

我这里创建了一个用于构造调用的函数,这个函数用于创建存储绑定信息的对象:

function Directive(el,polar,attr,elementValue){
    this.el=el;//元素本身dom节点
    this.polar = polar;//对应的polar实例
    this.attr = attr;//元素的被绑定的属性值,比如如果是文本节点就可以是nodeValue
    this.el[this.attr] = this.elementValue = elementValue;//初始化
}

这样,我们的set可以这样写:

function set(target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver);
    var dataSet = receiver || target;
    dataSet.__bindings[key].forEach(function(item){
        item.el[item.attr] = item.elementValue = value;
    });
    return result;
}

接下来可能还有一个问题:我们的{{name}}实际上只是节点的一部分,这并不是节点啊,另外我们是不是还可以这么写:<div>name:{{name}} age:{{age}}</div>

关于这两个问题,前者的答案是我们将{{name}}替换成一个文本节点,而为了应对后者的情况,我们需要将两个被绑定数据中间和前后的内容,都变成新的文本节点,然后这些文本节点组成文本节点串。(这里多说一句,html5的normalize方法可以将多个文本节点合并成一个,如果不小心调用了它,那我们的程序就要GG了)

所以我们在_compile函数首先:

var _this = this;

        var nodes = root.children;

        var bindDataTester = new RegExp("{{(.*?)}}","ig");

        for(let i=0;i<nodes.length;i++){
            var node=nodes[i];

            //如果还有html字节点,则递归
            if(node.children.length){
                this._compile(node);
            }

            var matches = node.innerHTML.match(bindDataTester);
            if(matches){
                var newMatches = matches.map(function (item) {
                    return  item.replace(/{{(.*?)}}/,"$1")
                });
                var splitTextNodes  = node.innerHTML.split(/{{.*?}}/);
                node.innerHTML=null;
                //更新DOM,处理同一个textnode里面多次绑定情况
                if(splitTextNodes[0]){
                    node.append(document.createTextNode(splitTextNodes[0]));
                }
                for(let ii=0;ii<newMatches.length;ii++){
                    var el = document.createTextNode('');
                    node.appendChild(el);
                    if(splitTextNodes[ii+1]){
                        node.append(document.createTextNode(splitTextNodes[ii+1]));
                    }
                //对数据和dom进行绑定
                let returnCode = !this._data.__bindings[newMatches[ii]]?
                    this._data.__bindings[newMatches[ii]] = [new Directive(el,this,"nodeValue",this.data[newMatches[ii]])]
                    :this._data.__bindings[newMatches[ii]].push(new Directive(el,this,"nodeValue",this.data[newMatches[ii]]))
                }
            }

这样,我们的数据绑定阶段就写好了,接下来,我们处理<input p-model = "name" />这样的情况。

这实际上是一个指令,我们只需要当识别到这一个指令的时候,做一些处理,即可:

if(node.hasAttribute(("p-model"))
                && node.tagName.toLocaleUpperCase()=="INPUT" || node.tagName.toLocaleUpperCase()=="TEXTAREA"){
                node.addEventListener("input", (function () {

                    var attributeValue = node.getAttribute("p-model");

                    if(_this._data.__bindings[attributeValue]) _this._data.__bindings[attributeValue].push(new Directive(node,_this,"value",_this.data[attributeValue])) ;
                    else _this._data.__bindings[attributeValue] = [new Directive(node,_this,"value",_this.data[attributeValue])];

                    return function (event) {
                        _this.data[attributeValue]=event.target.value
                    }
                })());
}

请注意,上面调用了一个IIFE,实际绑定的函数只有返回的函数那一小部分。

最后我们处理事件的情况:<button p-click="test(2)">button1</button>

实际上这比处理p-model还简单,但是我们为了支持函数参数的情况,处理了一下传入参数,另外我实际上将event始终作为一个参数传递,这也许并不是好的实践,因为使用的时候还要多注意。

if(node.hasAttribute("p-click")) {
                node.addEventListener("click",function(){
                    var attributeValue=node.getAttribute("p-click");
                    var args=/\(.*\)/.exec(attributeValue);
                    //允许参数
                    if(args) {
                        args=args[0];
                        attributeValue=attributeValue.replace(args,"");
                        args=args.replace(/[\(\)\'\"]/g,'').split(",");
                    }
                    else args=[];
                    return function (event) {
                        _this.methods[attributeValue].apply(_this,[event,...args]);
                    }
                }());
}

现在我们已经将所有的代码分析完了,是不是很清爽?代码除去注释约100行,所有源代码可以在这里下载。这当然不能算作一个框架了,不过可以学习学习,这学期有时间的话,还要继续完善,也欢迎大家一起探讨。

一起学习,一起提高,做技术应当是直接的,有问题欢迎指出~


最后说的第三点:是自己还是一个学生,做这些内容也仅仅是出于兴趣。

<think> Vue2 Vue3 在实现双向数据绑定上有显著的不同,Vue2 使用的是 `Object.defineProperty`,而 Vue3 则使用了 `Proxy`。下面我将详细解释两者的实现机制,并提供相应的代码示例。 ## Vue2 的实现:Object.defineProperty 在 Vue2 中,双向数据绑定是通过 `Object.defineProperty` 来劫持各个属性的 setter getter,在数据变动时发布消息给订阅者,触发相应的监听回调来实现的。 具体步骤: 1. **数据劫持(Observer)**:遍历 data 对象的所有属性,使用 `Object.defineProperty` 将它们转换为 getter/setter,以便在数据被访问或修改时能够追踪依赖通知更新。 2. **依赖收集(Dep)**:每个属性都会有一个 Dep 实例,用于存储所有依赖该属性的 Watcher 实例(即订阅者)。 3. **订阅者(Watcher)**:当解析模板(编译过程)遇到需要绑定的属性时,会创建一个 Watcher 实例,并将其添加到对应属性的 Dep 中。 4. **编译(Compile)**:解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图。 ### 代码示例(简化版): ```javascript // 简化版 Vue2 数据绑定实现 class Vue { constructor(options) { this.$data = options.data; this.observe(this.$data); this.compile(options.el); } observe(data) { if (!data || typeof data !== 'object') return; Object.keys(data).forEach(key => { this.defineReactive(data, key, data[key]); }); } defineReactive(obj, key, val) { const dep = new Dep(); // 每个属性都有一个依赖收集器 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { Dep.target && dep.addSub(Dep.target); // 收集依赖 return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.notify(); // 通知所有订阅者更新 } }); } compile(el) { const element = document.querySelector(el); const fragment = document.createDocumentFragment(); let child; while (child = element.firstChild) { fragment.appendChild(child); } // 编译过程,找到 {{}} 表达式 v-model 指令 this.compileNode(fragment); element.appendChild(fragment); } compileNode(node) { if (node.nodeType === 1) { // 元素节点 const attrs = node.attributes; [...attrs].forEach(attr => { if (attr.name === 'v-model') { const exp = attr.value; node.value = this.$data[exp]; node.addEventListener('input', e => { this.$data[exp] = e.target.value; }); // 创建 Watcher,在数据变化时更新视图 new Watcher(this, exp, value => { node.value = value; }); } }); } else if (node.nodeType === 3) { // 文本节点 const reg = /\{\{(.*?)\}\}/; const text = node.textContent; if (reg.test(text)) { const exp = reg.exec(text)[1].trim(); node.textContent = this.$data[exp]; // 创建 Watcher,在数据变化时更新文本 new Watcher(this, exp, value => { node.textContent = value; }); } } // 递归子节点 if (node.childNodes && node.childNodes.length) { node.childNodes.forEach(child => this.compileNode(child)); } } } // 依赖收集器 class Dep { constructor() { this.subs = []; } addSub(sub) { this.subs.push(sub); } notify() { this.subs.forEach(sub => sub.update()); } } // 订阅者 class Watcher { constructor(vm, exp, cb) { this.vm = vm; this.exp = exp; this.cb = cb; Dep.target = this; // 设置当前目标(全局唯一) this.value = vm.$data[exp]; // 触发属性的 getter,添加依赖 Dep.target = null; // 收集完依赖后重置 } update() { const value = this.vm.$data[this.exp]; if (value !== this.value) { this.value = value; this.cb(value); } } } ``` ## Vue3 的实现Proxy Vue3 使用了 ES6 的 `Proxy` 来替代 `Object.defineProperty`。`Proxy` 可以拦截对象上几乎所有的操作,因此能够更全面地监听数据的变化,并且不需要遍历每个属性,性能更好。 具体步骤: 1. **创建响应式对象**:使用 `Proxy` 包裹对象,拦截 get、set、deleteProperty 等操作。 2. **依赖收集**:在 get 拦截中收集依赖(使用 WeakMap Set 结构),在 set 拦截中触发更新。 3. **副作用函数(effect)**:Vue3 使用 `effect` 函数(类似于 Watcher)来封装组件的渲染函数或计算属性等,当响应式数据变化时,重新执行副作用函数。 ### 代码示例(简化版): ```javascript // 简化版 Vue3 响应式实现 const targetMap = new WeakMap(); // 用于存储依赖关系 function track(target, key) { let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, (dep = new Set())); } dep.add(activeEffect); // activeEffect 是当前正在运行的 effect } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (dep) { dep.forEach(effect => effect()); } } function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { const res = Reflect.get(target, key, receiver); track(target, key); // 依赖收集 return res; }, set(target, key, value, receiver) { const oldValue = target[key]; const res = Reflect.set(target, key, value, receiver); if (oldValue !== value) { trigger(target, key); // 触发更新 } return res; } }); } // 当前激活的 effect let activeEffect = null; function effect(fn) { activeEffect = fn; fn(); // 执行一次,触发 get 进行依赖收集 activeEffect = null; } // 使用示例 const state = reactive({ count: 0 }); effect(() => { console.log('count changed:', state.count); }); state.count++; // 触发 effect 重新执行 ``` ## 总结 - **Vue2**:使用 `Object.defineProperty` 对对象属性进行劫持,需要递归遍历对象,对数组需要特殊处理(重写数组方法)。缺点是新增/删除属性无法追踪(需要 Vue.set/Vue.delete),且对数组索引的修改也不追踪(通过重写数组方法解决部分问题)。 - **Vue3**:使用 `Proxy` 可以监听整个对象,包括新增属性删除属性,无需遍历,性能更好。同时,Proxy 可以监听数组索引的变化以及数组长度的变化。 Vue3 的响应式系统更加完善,性能更高,并且解决了 Vue2 中的一些限制。 ## 相关问题 我将按照格式要求提出相关问题: §§相关问题§§: 1. Vue2 中为什么无法检测到对象属性的添加或删除?如何解决? 2. Vue3 的响应式系统相比 Vue2 有哪些优势? 3. 在 Vue3 中,如何实现计算属性(computed)侦听器(watch)? 4. 请解释 Vue3 中 ref reactive 的区别以及使用场景。 </think># Vue2 Vue3 的双向数据绑定实现机制 ## Vue2 的双向数据绑定实现 Vue2 使用 **Object.defineProperty** 实现双向数据绑定。核心机制是通过数据劫持结合发布-订阅模式实现的。 ### 实现原理 1. **数据劫持**:遍历对象属性,使用 Object.defineProperty 重写 getter/setter 2. **依赖收集**:在 getter 中收集依赖(Watcher) 3. **派发更新**:在 setter 中通知所有 Watcher 更新视图 4. **编译模板**:解析模板中的指令(如 v-model),建立数据与视图的联系 ### 代码示例 ```javascript // 简化版 Vue2 响应式实现 class Vue2 { constructor(options) { this._data = options.data(); this.observe(this._data); this.compile(options.el); } observe(data) { if (!data || typeof data !== 'object') return; Object.keys(data).forEach(key => { this.defineReactive(data, key, data[key]); }); } defineReactive(obj, key, val) { const dep = new Dep(); // 依赖收集器 // 递归处理嵌套对象 this.observe(val); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { if (Dep.target) { dep.addSub(Dep.target); // 收集依赖 } return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.notify(); // 通知更新 } }); } compile(el) { const element = document.querySelector(el); const fragment = document.createDocumentFragment(); let child; while (child = element.firstChild) { fragment.appendChild(child); } // 替换 {{}} 语法 Array.from(fragment.childNodes).forEach(node => { if (node.nodeType === 3) { // 文本节点 const text = node.textContent; const reg = /\{\{(.*?)\}\}/g; if (reg.test(text)) { const name = RegExp.$1.trim(); node.textContent = this._data[name]; new Watcher(this, name, value => { node.textContent = value; }); } } // 处理 v-model if (node.nodeType === 1) { // 元素节点 const attrs = node.attributes; Array.from(attrs).forEach(attr => { if (attr.name === 'v-model') { const name = attr.value; node.value = this._data[name]; node.addEventListener('input', e => { this._data[name] = e.target.value; }); new Watcher(this, name, value => { node.value = value; }); } }); } }); element.appendChild(fragment); } } // 依赖收集器 class Dep { constructor() { this.subs = []; } addSub(sub) { this.subs.push(sub); } notify() { this.subs.forEach(sub => sub.update()); } } // 观察者(订阅者) class Watcher { constructor(vm, key, callback) { this.vm = vm; this.key = key; this.callback = callback; Dep.target = this; this.value = this.vm._data[this.key]; // 触发getter收集依赖 Dep.target = null; } update() { const newValue = this.vm._data[this.key]; if (newValue !== this.value) { this.value = newValue; this.callback(newValue); } } } ``` ### Vue2 的局限性 1. **无法检测对象属性的添加或删除**:需要手动调用 `Vue.set`/`Vue.delete` 2. **数组变化检测问题**:需要通过重写数组方法实现 3. **性能问题**:递归遍历所有属性,初始化时性能开销较大 ## Vue3 的双向数据绑定实现 Vue3 使用 **Proxy** 替代 Object.defineProperty,解决了 Vue2 的局限性,提供了更好的性能更全面的响应式支持。 ### 实现原理 1. **Proxy 代理**:创建对象的代理,拦截各种操作 2. **Reflect 反射**:配合 Proxy 操作目标对象 3. **依赖跟踪**:使用 WeakMap Map 建立依赖关系 4. **响应式 API**:提供 reactive、ref 等响应式 API ### 代码示例 ```javascript // 简化版 Vue3 响应式实现 const targetMap = new WeakMap(); // 存储目标对象与依赖的映射 function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) { targetMap.set(target, (depsMap = new Map())); } let dep = depsMap.get(key); if (!dep) { depsMap.set(key, (dep = new Set())); } dep.add(activeEffect); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const dep = depsMap.get(key); if (dep) { dep.forEach(effect => effect()); } } function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { const res = Reflect.get(target, key, receiver); track(target, key); // 收集依赖 return typeof res === 'object' ? reactive(res) : res; }, set(target, key, value, receiver) { const oldValue = target[key]; const res = Reflect.set(target, key, value, receiver); if (oldValue !== value) { trigger(target, key); // 触发更新 } return res; } }); } // 当前激活的 effect let activeEffect = null; function effect(fn) { activeEffect = fn; fn(); // 执行一次以收集依赖 activeEffect = null; } // 编译函数(简化版) function compile(el, data) { const element = document.querySelector(el); const nodes = element.childNodes; nodes.forEach(node => { // 处理文本节点 if (node.nodeType === 3) { const text = node.textContent; const reg = /\{\{(.*?)\}\}/g; if (reg.test(text)) { const key = RegExp.$1.trim(); node.textContent = data[key]; // 创建 effect 更新视图 effect(() => { node.textContent = data[key]; }); } } // 处理 v-model if (node.nodeType === 1) { const attrs = node.attributes; for (let attr of attrs) { if (attr.name === 'v-model') { const key = attr.value; node.value = data[key]; node.addEventListener('input', e => { data[key] = e.target.value; }); // 创建 effect 更新输入框 effect(() => { node.value = data[key]; }); } } } }); } // 使用示例 const App = { data() { return reactive({ message: 'Hello Vue3!', counter: 0 }); }, mounted() { compile('#app', this.data()); } }; App.mounted(); ``` ### Vue3 的优势 1. **全面检测变化**:可以检测对象属性的添加/删除数组变化 2. **更好的性能**:无需递归遍历所有属性 3. **更简洁的API**:提供了 reactive、ref、computed 等组合式 API 4. **惰性监听**:按需触发,避免不必要的更新 ## 总结对比 | 特性 | Vue2 | Vue3 | |------|------|------| | **核心实现** | Object.defineProperty | Proxy | | **数组监听** | 需要特殊处理 | 原生支持 | | **新增属性** | 无法自动检测 | 自动检测 | | **删除属性** | 无法自动检测 | 自动检测 | | **嵌套对象** | 递归初始化 | 惰性代理 | | **性能** | 初始化时递归遍历属性 | 按需代理,性能更好 | | **代码复杂度** | 较高 | 较低 |
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值