zoukankan      html  css  js  c++  java
  • vue的双向绑定

    撸一个vue的双向绑定

     

    1、前言

    说起双向绑定可能大家都会说:Vue内部通过Object.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化成getter/setter,当数据变化时通知视图更新。虽然一句话把大概原理概括了,但是其内部的实现方式还是值得深究的,本文就以从简入繁的形式给大家撸一遍,让大家了解双向绑定的技术细节。

    2、来一个简单的版本

    让我们的数据变得可观测,实现原理不难,利用Object.defineProperty重新定义对象的属性描述符。

     /**
        * 把一个对象的每一项都转化成可观测对象
        * @param { Object } obj 对象
        */
        function observable(obj) {
            if (!obj || typeof obj !== 'object') {
                return;
            }
            let keys = Object.keys(obj);
            keys.forEach((key) => {
                defineReactive(obj, key, obj[key])
            })
            return obj;
        }
        /**
        * 使一个对象转化成可观测对象
        * @param { Object } obj 对象
        * @param { String } key 对象的key
        * @param { Any } val 对象的某个key的值
        */
        function defineReactive(obj, key, val) {
            Object.defineProperty(obj, key, {
                get() {
                    console.log(`${key}属性被读取了`);
                    return val;
                },
                set(newVal) {
                    console.log(`${key}属性被修改了`);
                    val = newVal;
                }
            })
        }
        let car = observable({
            'brand': 'BMW',
            'price': 3000
        })
        //测试
        console.log(car.brand);
    

    3、一步一步实现一个观察者模式的双向绑定

    先给一张思维导向图吧(图盗的,链接:https://www.cnblogs.com/libin-1/p/6893712.html),本文章不涉及Compile部分。
    这张图我就不解释,我们先跟着一步一步的把代码撸出来,再回头来看这张图,问题不大。

    建议在读之前一定要了解观察者模式和发布订阅模式以及其区别,一篇简单的文章总结了一下两种模式的区别(链接:https://www.cnblogs.com/chenlei987/p/10504956.html),Vue的双向绑定使用的就是观察者模式,其中Dep对象就是观察者的目标对象,而Watcher就是观察者,然后等待Dep对象的通知更新的,其中update方法是由watcher自己管理的,并非如发布订阅模式由目标对象去管理,在观察者模式中,目标对象管理的订阅者列表应该是Watcher本身,而不是事件/订阅主题。

    3.1、声明一个Vue类,并将data里面的数据代理到Vue实例上面。

    var Vue = (function (){
        class Vue{
                constructor (options = {}){
    
                    //简化处理
                    this.$options = options;
    
                    let data = (this._data = 
                        typeof this.$options.data == 'function' ? 
                            this.$options.data() 
                            : 
                            this.$options.data);
                    Object.keys(data).forEach(key =>{ this._proxy(key) });
    
                    // 监听数据
                    //observe(data);
                }
                _proxy (key){
                    //用this这个对象去代理 this._data这个对象里面的key
                    Object.defineProperty(this, key, {
                        configurable: true,
                        enumerable: true,
                        set: (val) => {
                            this._data[key] = val
                        },
                        get: () =>{
                            return this._data[key]
                        }
                    })
                }
                
            }
            return Vue;
    }
    let VM = new Vue({
        data (){
            return {
                a: 1,
                arr: [1,2,3,4,5,6]
            }
        },
    });
    //说明 _proxy代理成功了
    console.log(VM.a);
    VM.a = 2;
    console.log(VM.a);
    

    3.2、让data里面的数据变得可观测,开启observe之旅

    注:下面我所说的"data里面"就是指vue实例的data属性。
    上面代码Vue类的constructor里面我注释了一行代码,下面我取消注释,并且开始定义observe函数

    // 监听数据
     observe(data);

    在定义observe方法之前,首先明白我们observe要做什么?
    实参是data数据,我们要遍历整个data数据的key,为data数据的每一个key都用Object.defineProperty去重新定义它的 getter和setter函数,从而使其可观测。

    class Observer{
                constructor (value){
                    this.value = value;
                    this.walk(value);
                }
                // 遍历属性值并监听
                walk(value) {
                    Object.keys(value).forEach(key => this.convert(key, value[key]));
                }
                // 执行监听的具体方法
                convert(key, val) {
                    defineReactive(this.value, key, val);
                }
            }
            function defineReactive(obj, key, val) {
                const dep = new Dep();
                // 给当前属性的值添加监听
                let chlidOb = observe(val);
                Object.defineProperty(obj, key, {
                    enumerable: true,
                    configurable: true,
                    get: () => {
                      
                      //do something
                      //  if (Dep.target) {
                        //    dep.depend();
                        //}
                        return val;
                    },
                    set: newVal => {
                        if (val === newVal) return;
                        val = newVal;
                        
                        //do something
                        // 对新值进行监听
                        //chlidOb = observe(newVal);
                        // 通知所有订阅者,数值被改变了
                        //dep.notify();
                    },
                });
            }
    
            function observe(value) {
                 // 当值不存在,或者不是复杂数据类型时,不再需要继续深入监听
                if (!value || typeof value !== 'object') {
                    return;
                }
                return new Observer(value);
            }
    

    看到在get和set函数里面的do something了吗,可以理解为在data里面的每个key的设置和获取都被我们截取到了,在每个key的设置和获取时我们可以干些事情了。比如更数据对应的DOM。
    要做什么呢?
    get函数: 从思维图图1可以看出需要把当前的Watcher添加进Dep对象,等待数据更新,调用回调。
    set函数: 数据更新,Dep对象通知所有订阅的watcher更新,调用回调,更新视图。

    3.3、Watcher

    先声明一个Watcher类,用于添加进Dep对象并通知更新视图使用。

     let uid = 0;
            class Watcher {
                constructor(vm, expOrFn, cb) {
                    // 设置id,用于区分新Watcher和只改变属性值后新产生的Watcher
                    this.id = uid++;
    
                    this.vm = vm; // 被订阅的数据一定来自于当前Vue实例
                    this.cb = cb; // 当数据更新时想要做的事情
                    this.expOrFn = expOrFn; // 被订阅的数据
                    this.val = this.get(); // 维护更新之前的数据
                }
                // 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
                update() {
                    this.run();
                }
                addDep(dep) {
                    // 如果在depIds的hash中没有当前的id,可以判断是新Watcher,因此可以添加到dep的数组中储存
                    // 此判断是避免同id的Watcher被多次储存
                    //这里要是不限制重复,你会发现在响应的过程中,Dep实例下的subs会成倍的增加watcher。多输入几个字浏览器就卡死了。
                    if (!dep.depIds.hasOwnProperty(this.id)) {
                        dep.addSubs(this);
                        dep.depIds[this.id] = dep;
                    }
                }
                run() {
                    const val = this.get();
                    if (val !== this.val) {
                        this.val = val;
                        this.cb.call(this.vm, val);
                    }
                }
                get() {
                    // 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
                    Dep.target = this;
                    //注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
                    const val = this.vm._data[this.expOrFn]; 
                    
                    //初始化执行一遍回调
                    this.cb.call(this.vm, val);
    
                    //  置空,用于下一个Watcher使用
                    Dep.target = null;
                    return val;
                }
            }
    

    上面代码我们先从constructor看起,接受三个参数,vm当前的vue实例,expOrFn实例化时该watcher实例所 代表/处理 的"data里面"(‘data里面’上面有解释,这里提醒一下)的哪个值,cb,回调函数,也就是当数据更新后需要做什么(自然是更新DOM咯)。
    然后在constructor里面还调用了 this.get()。详细看一下get函数的定义,两行代码需要注意:

    // 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
    Dep.target = this;
    //注意:在这里获取该属性 从而就触发了defineProperty的get方法,该watcher已经进入Dep的subs队列了
    const val = this.vm._data[this.expOrFn]; 
    

    Dep.target = this;确定了当前的活动的watcher实例,Dep.target我们可以认为它是一个全局变量,用于存放当前活动的watcher实例。
    const val = this.vm._data[this.expOrFn];获取数据,这句话其实就已经触发了其自身的getter方法(这点要注意,不然你连流程都理解不通)。
    进入了getter方法,也就把当前活动的实例的watcher添加进dep对象等待更新。
    添加进Dep对象后,置空,用于下一个Watcher使用 Dep.target = null;

    3.4、Dep

    一直在说dep对象,我们一定要知道dep对象就是观察者模式里面的目标对象,用于存放watcher和负责通知更新的。
    下面来定义一个Dep对象,放到class Watcher前面。 注意Dep的作用范围.

    class Dep{
                constructor (){
                    this.depIds = {}; // hash储存订阅者的id,避免重复的订阅者
                    //订阅者列表  watcher实例列表
                    this.subs = [];
                }
                depend (){
                    Dep.target.addDep(this);//相当于调用this.addSubs 将 watcher实例添加进订阅列表 等待通知更新
                    //本来按照我们的理解,在denpend里面是需要将watcher添加进 Dep对象, 等待通知更新的,所以应该调用 this.addSubs(Dep.target)
                    //但是由于需要解耦 所以 先调用 watcher的addDep 在addDep中调用Dep实例的addSubs
                    //简化理解就是 将 watcher实例添加进订阅列表 等待通知更新
                }
                addSubs (sub) {
                    //这里的sub肯定是watcher实例
                    this.subs.push(sub);
                }
                notify (){
                    //监听到值的变化,通知所有订阅者watcher更新
                    this.subs.forEach((sub) =>{
                        sub.update();
                    });
                }
            }
             Dep.target = null;//存储当前活动的watcher
    

    再改改defineReactive,把注释打开

    function defineReactive(obj, key, val) {
                const dep = new Dep();
                // 给当前属性的值添加监听
                let chlidOb = observe(val);
                Object.defineProperty(obj, key, {
                    enumerable: true,
                    configurable: true,
                    get: () => {
                        // 如果Dep类存在target属性,将其添加到dep实例的subs数组中
                        // target指向一个Watcher实例,每个Watcher都是一个订阅者
                        // Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
                        if (Dep.target) {
                            dep.depend();
                        }
                        return val;
                    },
                    set: newVal => {
                        if (val === newVal) return;
                        val = newVal;
                        // 对新值进行监听
                        chlidOb = observe(newVal);
                        // 通知所有订阅者,数值被改变了
                        dep.notify();
                    },
                });
            }
    

    然后起一个watcher来监听

    3.5、让数据响应起来

    先给Vue暴露一个方法 $watcher 可以调用实例化Watcher。

    class Vue{
                constructor (options = {}){
    
                    //简化处理
                    this.$options = options;
    
                    let data = (this._data = 
                        typeof this.$options.data == 'function' ? 
                            this.$options.data() 
                            : 
                            this.$options.data);
                    Object.keys(data).forEach(key =>{ this._proxy(key) });
    
                    // 监听数据
                    observe(data);
                }
                // 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
                $watch(expOrFn, cb) {
                    //property需要监听的属性  cb在监听到更新后的回调
                    new Watcher(this, expOrFn, cb);
                }
                _proxy (key){
                    //用this这个对象去代理 this._data这个对象里面的key
                    Object.defineProperty(this, key, {
                        configurable: true,
                        enumerable: true,
                        set: (val) => {
                            this._data[key] = val
                        },
                        get: () =>{
                            return this._data[key]
                        }
                    })
                }
            }
    

    3.6、测试: 声明一个实例

    html部分

     <h3>Vue双向绑定</h3>
        <input type="text" id="input">
        <p id="react"></p>
        <h3>Vue数组双向绑定</h3>
        <input type="text" id="arr-input">
        <p id="arr-reat"></p>
    
    let reactElement = document.querySelector("#react");
        let input = document.getElementById('input');
        input.addEventListener('keyup', function (e) {
            VM.a = e.target.value;
        });
    
        VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时
    
    
        //数组的响应并不能实现
        let arrReactElement = document.querySelector("#arr-reat");
        let arrInput = document.getElementById('arr-input');
        arrInput.addEventListener('keyup', function (e) {
            VM.arr.push(e.target.value);
            console.log(VM.arr);
        });
        VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a并没有发生改变时
    

    VM.$watch就可以实例化一个watcher,从而去劫持data里面某个属性的改变,在改变时调用回调函数。
    数组的改变并没有实现。上面的代码见https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E7%AE%80%E5%8D%95%E5%AE%9E%E7%8E%B0.html

    4、对数组的支持

    在说这个之前我们先去看一看vue官网对于数组更新检测的说明,链接:https://cn.vuejs.org/v2/guide/list.html#%E6%95%B0%E7%BB%84%E6%9B%B4%E6%96%B0%E6%A3%80%E6%B5%8B


    总的来说,对于数组支持更新的只是数组原型上的方法,对于vm.items[index] = newValue是不支持的。
    其实Object.defineProperty对于数组都是不支持的,根据消息vue3.0用的proxy对于数组得到了完美的支持,但是兼容性不怎么样。
    既然vue实现了对数组原型方法的支持,那么我们也来让我们的例子对数组方法也支持吧。
    原理不难,vue对于所有的数组原型方法都写了一层hack,让其支持更新。那么下面我们就一步一步来实现。

    4.1、准备一套数组原型方法的hack

    /**
            * Define a expOrFn.
            */
            function def(obj, key, val, enumerable) {
                Object.defineProperty(obj, key, {
                    value: val,
                    enumerable: !!enumerable,
                    writable: true,
                    configurable: true
                });
            }
    
            //数组改变的监听
            var arrayProto = Array.prototype;
            var arrayMethods = Object.create(arrayProto);
            var methodsToPatch = [
                'push',
                'pop',
                'shift',
                'unshift',
                'splice',
                'sort',
                'reverse'
            ];
            /**
            * Intercept mutating methods and emit events
            */
            methodsToPatch.forEach(function (method) {
                // cache original method
                var original = arrayProto[method];
                def(arrayMethods, method, function mutator() {
                    var args = [], len = arguments.length;
                    while (len--) args[len] = arguments[len];
    
                    var result = original.apply(this, args);
                    var ob = this.__ob__;
                    var inserted;
                    switch (method) {
                        case 'push':
                        case 'unshift':
                            inserted = args;
                            break
                        case 'splice':
                            inserted = args.slice(2);
                            break
                    }
                    if (inserted) { ob.observeArray(inserted); }
                    // notify change
                    ob.dep.notify(); //调用该数组下的 __ob__.dep 详细可见class Observer的constructor里的注释
                    return result
                });
            });
    

    上面代码准备了一个arrayMethods的对象,它继承自Array.prototype,并且对methodsToPatch里面的方法进行了改写,后面我们会把arrayMethods这个对象挂到"data里面"每个数组下,让该数组调用数组原生方法,比如[].push其实调用的是arrayMethods里面被改写的方法,从而在该数组改变时获取到该数组的更新。
    下面开始挂载arrayMethods对象,在挂载我之前我们看到有一个this.__ob__属性,这里的this指向要观测的数组。这个__ob__就是前面的observe对象,并且每个observe下面还有一个dep对象。下面我们来理清楚这层关系。

    class Observer{
        constructor (value){
            this.value = value;
    
            //下面两行代码虽然很简单,但是我们需要从这里理清楚关系
            //假如 有数据如 {a: [1,2,3], b: 1},  然后调用oberve(vm.a),vm当前vue实例
            //会自动挂载 __ob__ 和 __ob__.dep
            // 那么对数组a进行oberserve的对象就是a.__ob__, 它所对应的dep对象就是 a.__ob__.dep
            //详细使用可以在对数组的方法进行hack的时候 使用到
            def(value, '__ob__', this);//让被监听的数据都带上一个不可枚举的属性 __ob__ 代表observe对象
            this.dep = new Dep();//首先每个oberserve实例下有一个dep对象
            
            
            //在这里处理数组
            if (Array.isArray(value)){
                //调用数组的hack方法, 让数组也能被监听  arrayMethods
                var arrayKeys = Object.getOwnPropertyNames(arrayMethods);
                for (var i = 0, l = arrayKeys.length; i < l; i++) {
                    var key = arrayKeys[i];
                    def(value, key, arrayMethods[key]);
                }
            }   
            else{
                //对象 遍历key  添加监听
                this.walk(value);
            }
        }
        //Observer的其他方法
        //...
    }
    

    上面代码首先给每个值挂载__ob__属性(不可枚举),然后给每个Obeserve对象挂载Dep对象。然后根据value的类型,如果是数组就会挂载arrayMethods方法。
    现在我们来理清数组在哪里依赖收集,在哪里通知更新的。
    在对数组hack的方法里面(上上一段代码)有一段ob.dep.notify(); 这里通知更新,所以依赖收集也一定要收集到value.__ob__.dep对象里面,两个dep对象应该是相同的,下面我们来看看依赖收集写在哪里的。

    function defineReactive(obj, key, val) {
                const dep = new Dep();
                // 给当前属性的值添加监听
                let childOb = observe(val);
                Object.defineProperty(obj, key, {
                    enumerable: true,
                    configurable: true,
                    get: () => {
                        // 如果Dep类存在target属性,将其添加到dep实例的subs数组中
                        // target指向一个Watcher实例,每个Watcher都是一个订阅者
                        // Watcher实例在实例化过程中,会读取data中的某个属性,从而触发当前get方法
                        if (Dep.target) {
                            dep.depend();
                            if (childOb) {
                                childOb.dep.depend();
                                if (Array.isArray(val)) {
                                    dependArray(val);
                                }
                            }
                        }
                        return val;
                    },
                    set: newVal => {
                        if (val === newVal) return;
                        val = newVal;
                        // 对新值进行监听
                        childOb = observe(newVal);
                        // 通知所有订阅者,数值被改变了
                        dep.notify();
                    },
                });
            }
            function dependArray(value) {
                for (var e = (void 0), i = 0, l = value.length; i < l; i++) {
                    e = value[i];
                    e && e.__ob__ && e.__ob__.dep.depend();
                    if (Array.isArray(e)) {
                        dependArray(e);
                    }
                }
            }
    

    数组虽然在Object.defineProperty里面set方法无法响应,但是get方法是没有问题的,所以在数组get的时候,判断val如果是array,会调用value.__ob__.dep.depend进行依赖收集。与上面依赖通知使用了同意个dep对象,也就是挂载在自身的__ob__.dep。
    写到这里我们就完全实现对数组原生方法的支持了。
    下面看一下效果 代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E5%AF%B9%E6%95%B0%E7%BB%84%E7%9A%84%E6%94%AF%E6%8C%81.html

    4.2测试代码

    html部分

    <h3>Vue双向绑定</h3>
        <input type="text" id="input">
        <p id="react"></p>
        <h3>Vue数组双向绑定</h3>
        <input type="text" id="arr-input">
        <p id="arr-reat"></p>
        <h3>Vue对nextTick实现</h3>
        <button id="addBtn">加100000次</button>
        <p id="react-tick"></p>
    
    let reactElement = document.querySelector("#react");
        let input = document.getElementById('input');
        input.addEventListener('keyup', function (e) {
            VM.a = e.target.value;
        });
    
        VM.$watch('a', val => reactElement.innerHTML = val); //监听属性 a 当a发生改变时
    
    
        //数组的响应并能实现
        let arrReactElement = document.querySelector("#arr-reat");
        let arrInput = document.getElementById('arr-input');
        arrInput.addEventListener('keyup', function (e) {
            VM.arr.push(e.target.value);
            console.log(VM.arr);
        });
        VM.$watch('arr', val => arrReactElement.innerHTML = val); //监听属性 a 当a发生改变时
    
    
        let reactTick = document.querySelector("#react-tick");
        VM.$watch('tickData', val => {
            console.log(val);
            reactTick.innerHTML = val;
        }); //监听属性 a 当a发生改变时
        document.querySelector('#addBtn').addEventListener('click', function () {
            for (let i = 0; i < 100000; i++) {
                VM.tickData = i;
            }
        }, false)
    

    效果:

    5、对nextTick的支持

    vue官网对nextTick的解释:

    nextTick如果自己实现就是在下一个envet loop执行,不在本次同步任务中执行。
    自己实现一个简单的:

    //nextTick的实现
    let callbacks = [];
    let pending = false;
    
    function nextTick(cb) {
        callbacks.push(cb);
        if (!pending) {
            pending = true;
            setTimeout(flushCallbacks, 0);
        }
    }
    function flushCallbacks() {
        pending = false;
        const copies = callbacks.slice(0);
        callbacks.length = 0;
        for (let i = 0; i < copies.length; i++) {
            copies[i]();
        }
    }
    

    简单理解: 在本次event loop中收集cb(任务),放到下一个event loop去执行。 关于不知道event loop的可以参考这篇文章:https://www.cnblogs.com/chenlei987/p/10479433.html,我总结的很简单。我参考的http://www.ruanyifeng.com/blog/2014/10/event-loop.html。
    在理解event loop的同时也需要同时了解 microtask和macrotask的区别。
    好了言归正传,在vue的'data里面'某个属性发生了改变,并被观测到后,调用了watcher.update,并不会立即调用watcher.run去更新视图,它会经过nextTick之后再更新视图,说起来有点牵强。
    还是第四部=步的代码,没有实现对nextTick的优化。
    代码:

    <h3>Vue双向绑定</h3>
        <input type="text" id="input">
        <p id="react"></p>
        <h3>Vue数组双向绑定</h3>
        <input type="text" id="arr-input">
        <p id="arr-reat"></p>
        <h3>Vue对nextTick实现</h3>
        <button id="addBtn">加1000次</button>
        <p id="react-tick"></p>
    
    let reactTick = document.querySelector("#react-tick");
        VM.$watch('tickData', val => {
            console.log(val);
            reactTick.innerHTML = val;
        }); //监听属性 a 当a发生改变时
        document.querySelector('#addBtn').addEventListener('click', function () {
            for (let i = 0; i < 1000; i++) {
                VM.tickData = i;
            }
        }, false)
    

    效果是这样的:

    现在的效果是VM.tickData加1000次,那么cb(回调)就会调用1000次,这样是非常影响性能的,我们想要的效果是无论VM.tickData在本次event loop加多少次,都不会触发回调,只需要在VM.tickData加完之后,触发一次最终的cb(回调)就ok了。
    下面我们就来实现这种优化,代码不多。

    //nextTick的实现
                let callbacks = [];
                let pending = false;
    
                function nextTick(cb) {
                    callbacks.push(cb);
                    if (!pending) {
                        pending = true;
                        setTimeout(flushCallbacks, 0);
                    }
                }
                function flushCallbacks() {
                    pending = false;
                    const copies = callbacks.slice(0);
                    callbacks.length = 0;
                    for (let i = 0; i < copies.length; i++) {
                        copies[i]();
                    }
                }
                
                let has = {};
                let queue = [];
                let waiting = false;
                function queueWatcher(watcher) {
                    const id = watcher.id;
                    if (has[id] == null) {
                        has[id] = true;
                        queue.push(watcher);
    
                        if (!waiting) {
                            waiting = true;
                            nextTick(flushSchedulerQueue);
                        }
                    }
                }
                function flushSchedulerQueue() {
                    let watcher, id;
    
                    for (index = 0; index < queue.length; index++) {
                        watcher = queue[index];
                        id = watcher.id;
                        has[id] = null;
                        watcher.run();
                    }
    
                    waiting = false;
                }
    

    然后更改Watcher里面的update方法,并不直接调用watcher.run,而是经过queueWatcher控制

    update() {
        queueWatcher(this);
        // this.run();
    }

    代码地址:https://gitee.com/cchennlleii/MyTest/blob/master/vueReactive/vue-reactive%E5%AF%B9nextTck%E7%9A%84%E6%94%AF%E6%8C%81.html

    6、总结

    如果面试官问我关于双向绑定的问题,从这三个方面去回答,Object.definproperty,观察者模式,nextTick,当然,你需要把这三个点联系起来去描述,相信我你把上面的看懂了,联系起来完全没问题的,你是最棒的!

    7、本文参考:

    https://codepen.io/xiaomuzhu/pen/jxBRgj/
    https://www.jianshu.com/p/2df6dcddb0d7

  • 相关阅读:
    WPF TextBox 一些设置技巧
    Rust 初始配置
    Framework​Element.​Find​Name 根据名字查找控件
    C# SQLite 数据库操作
    MP3 信息读取
    C# event 事件学习
    Nginx 整合 Lua 实现动态生成缩略图
    Spring Cloud 入门 之 Config 篇(六)
    Spring Cloud 入门 之 Zuul 篇(五)
    Flyway 简单入门教程
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/11071912.html
Copyright © 2011-2022 走看看