用 Proxy 实现 JavaScript 响应式系统:从零开始构建 Vue.js 的核心特性

157 阅读6分钟

今天我们要聊聊如何用 Proxy 来实现一个简单的响应式系统。如果你对前端开发有所了解,尤其是使用过 Vue.js 或者 React 这样的框架,你一定知道“响应式”这个词。它意味着当数据发生变化时,视图会自动更新,反之亦然。这种机制让开发者可以专注于业务逻辑,而不需要操心手动刷新页面或重新渲染组件。

什么是响应式?

简单来说,响应式就是让数据和视图之间建立一种联系,使得任何一方的变化都能及时反映到另一方。想象一下你在编辑器里打字,每个字母的输入都会立刻显示在屏幕上;或者当你修改了某个表单字段,相应的结果区域也会跟着改变。这就是响应式的魅力所在——无缝连接数据与界面,提供流畅的用户体验。

ES5时代的响应式:Object.defineProperty

在 ES5 的时代,我们主要依靠 Object.defineProperty() 方法来创建响应式对象。通过这个方法,我们可以为对象的属性定义 getter 和 setter,从而在读取或设置属性值的时候执行自定义代码。可以理解为拦截一下对对象属性的操作。

    <div id="show1"></div>
    <div id="show2"></div>
    <button id="add">点击+1</button>
    <button id="times2">点击*2</button>
    <script>
        // 定义全局变量 count1 并初始化为 1
        let count1 = 1;
        // 定义全局变量 count2 并初始化为 2
        let count2 = 2;

        // 创建一个对象 obj,包含 count1 和 count2 两个属性
        const obj = {
            count1: 1,
            count2: 2,
        }
        
        // 将 obj.count1 的值显示在 id 为 show1 的 div 元素中
        document.getElementById('show1').innerHTML = obj.count1;
        // 将 count2 的值显示在 id 为 show2 的 div 元素中
        document.getElementById('show2').innerHTML = count2;

        // 使用 Object.defineProperty 为 obj 的 count1 属性添加 getter 和 setter 方法
        Object.defineProperty(obj, 'count1', {
            // getter 方法,当读取 obj.count1 时触发
            get:function() {
                
                console.log('读取了 count1');
                // 返回全局变量 count1 的值
                return count1;
                //return obj.count1;
            },
            // setter 方法,当设置 obj.count1 时触发
            set:function(newVal) {
                
                console.log('设置了 count1');
                // 更新全局变量 count1 的值为新值
                count1 = newVal;
                // obj.count1 = newVal;
                // 更新 id 为 show1 的 div 元素的内容为新的 count1 值
                document.getElementById('show1').innerHTML = count1;
            }
        })
        
        // 为 id 为 add 的按钮添加点击事件监听器
        document.getElementById('add').addEventListener('click',function(){
            // 点击按钮时,将 obj.count1 的值加 1
            obj.count1++;
        })
        
        // 你可以试着自己完成 count2 的响应式。
    </script>


不要在gettersetter中访问或修改被重新定义的属性,否则会无限调用

screenshots.gif 虽然 Object.defineProperty() 能够满足基本需求,但它有一个很大的局限性:必须对每个需要响应式的属性单独进行定义。这意味着如果对象有很多属性,或者属性是动态添加的,维护起来就会变得非常麻烦。

ES6之后的新选择:Proxy

幸运的是,随着 ES6 标准的到来,JavaScript 引入了一个更强大的工具——ProxyProxy 允许我们在不改变原始对象的前提下,拦截并控制对该对象的各种操作(如属性访问、赋值、删除等)。这为我们实现更高效、更灵活的响应式系统提供了可能。

使用 Proxy 创建响应式对象

使用 Proxy 来完成之前的例子:

    <div id="container">1</div>
    <div id="count">2</div>
    <button id="button">+1</button>
    <button id="btn">点击*2</button>
    <script>
        // 匿名函数-》立即执行 + 回调函数(事件处理函数、定时器、文件操作)
        (function () {
            //函数作用域
            //console.log(this); //window
            //观察者模式
            function watch(target, func) {
                // es6 proxy 对象代理
                const proxy = new Proxy(target, {
                    get: function (target, prop) {
                        console.log(`读取了${prop}`)
                        return target[prop]
                    },
                    set: function (target, prop, value) {
                        target[prop] = value
                        func(prop, value)
                    }
                })
                return proxy
            }
            // 暴露给全局
            this.watch = watch
        })()

        let obj = {
            value: 1,
            count: 1
        }
        console.log(obj)
        var newObj = watch(obj, function (key, newValue) {
            if (key === 'value') {
                document.getElementById('container').innerHTML = newValue
            }

            if (key === 'count') {
                document.getElementById('count').innerHTML = newValue
            }
        })


        document.getElementById('button')
            .addEventListener('click', function () {
                // 交给代理对象
                newObj.value++
            })

        document.getElementById('btn')
            .addEventListener('click', function () {
                newObj.count *= 2
            })
    </script>
匿名函数立即执行(IIFE)
(function () {
    // 函数作用域
    function watch(target, func) {
        const proxy = new Proxy(target, {
            get: function (target, prop) {
                console.log(`读取了 ${prop}`);
                return target[prop];
            },
            set: function (target, prop, value) {
                target[prop] = value;
                func(prop, value); // 触发回调函数更新视图
            }
        });
        return proxy;
    }

    // 暴露给全局
    this.watch = watch;
})();
创建 watch 函数
  • watch 函数:这个函数接收两个参数——target(要代理的目标对象)和 func(每当目标对象的属性被修改时调用的回调函数)。watch 的目的是返回一个新的 Proxy 对象,该对象会拦截对 target 的所有访问和修改。
使用 Proxy 实现代理
  • get 拦截器:当读取属性时,返回原始对象上的相应属性值。

    javascript
    深色版本
    get: function (target, prop) {
        console.log(`读取了 ${prop}`);
        return target[prop];
    }
    
  • set 拦截器:当设置属性时,更新原始对象上的属性值,并调用传入的回调函数 func 来处理任何必要的副作用,比如更新 DOM。

    javascript
    深色版本
    set: function (target, prop, value) {
        target[prop] = value;
        func(prop, value); // 触发回调函数更新视图
    }
    
暴露 watch 函数给全局
  • this.watch = watch; :将 watch 函数赋值给全局对象(通常是 window),这样可以在其他地方直接调用它。
创建响应式对象并初始化
javascript
深色版本
let obj = {
    value: 1,
    count: 1
};
console.log(obj);

var newObj = watch(obj, function (key, newValue) {
    if (key === 'value') {
        document.getElementById('container').innerHTML = newValue;
    }

    if (key === 'count') {
        document.getElementById('count').innerHTML = newValue;
    }
});
  • 创建普通对象 obj:包含两个属性 valuecount,初始值都为 1。
  • 调用 watch 函数:传入 obj 和一个回调函数,用来更新对应的 DOM 元素内容。watch 函数返回的是经过 Proxy 包装后的 newObj,以后对 newObj 的操作都会触发相应的 getset 拦截器。
绑定事件监听器
javascript
深色版本
document.getElementById('button')
    .addEventListener('click', function () {
        newObj.value++;
    });

document.getElementById('btn')
    .addEventListener('click', function () {
        newObj.count *= 2;
    });
  • 绑定点击事件:分别为两个按钮绑定了点击事件监听器。当用户点击“+1”按钮时,newObj.value 会自增;当点击“点击*2”按钮时,newObj.count 会被乘以 2。由于 newObj 是通过 Proxy 创建的,所以每次修改它的属性时,都会触发 set 拦截器,进而执行回调函数更新相关 DOM 元素的内容。
响应式机制的工作流程
  1. 初始化阶段

    • 用户加载页面后,JavaScript 代码被执行,创建了 obj 和 newObj(即 Proxy 包装的对象)。
    • 同时,为两个按钮绑定了点击事件监听器。
  2. 交互

    • 当我点击“+1”按钮时,newObj.value++ 被执行。这会触发 Proxyset 拦截器,更新 obj.value 的值,并调用回调函数更新 #container 的文本内容。
    • 类似地,当用户点击“点击*2”按钮时,newObj.count *= 2 被执行。这也会触发 set 拦截器,更新 obj.count 的值,并调用回调函数更新 #count 的文本内容。
  3. 自动更新

    • 每次属性值发生变化时,Proxy 的 set 拦截器都会捕获到变化,并通过回调函数自动更新相关的 DOM 元素,从而实现了数据驱动视图更新的效果。

总结

通过上述步骤,我们可以看到 Proxy 在这里扮演了一个非常重要的角色——它不仅允许我们拦截对对象属性的所有访问和修改,还提供了一种简便的方式来添加额外的行为(如更新 DOM)。这种机制使得数据和视图之间建立了紧密的联系,任何一方的变化都能及时反映到另一方,这就是所谓的“响应式”