knockout的监控数组实现 - 司徒正美

本文深入探讨了Knockout.js框架中监控数组的实现原理,详细介绍了其核心方法如push、pop等的运作机制,并分析了比较数组差异以高效更新DOM的策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

knockout应该是博客园群体中使用最广的MVVM框架,但鲜有介绍其监控数组的实现。最近试图升级avalon的监控数组,决定好好研究它一番,看有没有可借鉴之外。

ko.observableArray = function(initialValues) {
                initialValues = initialValues || [];

                if (typeof initialValues != 'object' || !('length' in initialValues))
                    throw new Error("The argument passed when initializing an observable array must be an array, or null, or undefined.");

                var result = ko.observable(initialValues);
                ko.utils.extend(result, ko.observableArray['fn']);
                return result.extend({'trackArrayChanges': true});
            };

这是knockout监控数组的工厂方法,不需要使用new关键字,直接转换一个普通数组为一个监控数组。你也可以什么也不会,得到一个空的监控数组。

var myObservableArray = ko.observableArray();    // Initially an empty array
myObservableArray.push('Some value');            // Adds the value and notifies obs

// This observable array initially contains three objects
var anotherObservableArray = ko.observableArray([
    { name: "Bungle", type: "Bear" },
    { name: "George", type: "Hippo" },
    { name: "Zippy", type: "Unknown" }
]);
console.log(typeof anotherObservableArray)//function

虽说是监控数组,但它的类型其实是一个函数。这正是knockout令人不爽的地方,将原本是字符串,数字,布尔,数组等东西都转换为函数才行使用。

这里有一个ko.utils.extend方法,比不上jQuery的同名方法,只是一个浅拷贝,将一个对象的属性循环复制到另一个之上。

extend: function(target, source) {
                if (source) {
                    for (var prop in source) {
                        if (source.hasOwnProperty(prop)) {
                            target[prop] = source[prop];
                        }
                    }
                }
                return target;
            },

result 是要返回的函数,它会被挂上许多方法与属性。首先是 ko.observableArray['fn']扩展包,第二个扩展其实可以简化为

result.trackArrayChanges = true

我们来看一下 ko.observableArray['fn']扩展包,其中最难的是pop,push,shift等方法的实现

ko.observableArray['fn'] = {
    'remove': function(valueOrPredicate) {//值可以是原始数组或一个监控函数
        var underlyingArray = this.peek();//得到原始数组
        var removedValues = [];
        var predicate = typeof valueOrPredicate == "function" && !ko.isObservable(valueOrPredicate) ? valueOrPredicate : function(value) {
            return value === valueOrPredicate;
        };//确保转换为一个函数
        for (var i = 0; i < underlyingArray.length; i++) {
            var value = underlyingArray[i];
            if (predicate(value)) {
                if (removedValues.length === 0) {
                    this.valueWillMutate();//开始变动
                }
                removedValues.push(value);
                underlyingArray.splice(i, 1);//移除元素
                i--;
            }
        }
        if (removedValues.length) {//如果不为空,说明发生移除,就调用valueHasMutated
            this.valueHasMutated();
        }
        return removedValues;//返回被移除的元素
    },
    'removeAll': function(arrayOfValues) {
        // If you passed zero args, we remove everything
        if (arrayOfValues === undefined) {//如果什么也不传,则清空数组
            var underlyingArray = this.peek();
            var allValues = underlyingArray.slice(0);
            this.valueWillMutate();
            underlyingArray.splice(0, underlyingArray.length);
            this.valueHasMutated();
            return allValues;
        }
        //如果是传入空字符串,null, NaN
        if (!arrayOfValues)
            return [];
        return this['remove'](function(value) {//否则调用上面的remove方法
            return ko.utils.arrayIndexOf(arrayOfValues, value) >= 0;
        });
    },
    'destroy': function(valueOrPredicate) {//remove方法的优化版,不立即移除元素,只是标记一下
        var underlyingArray = this.peek();
        var predicate = typeof valueOrPredicate == "function" && !ko.isObservable(valueOrPredicate) ? valueOrPredicate : function(value) {
            return value === valueOrPredicate;
        };
        this.valueWillMutate();
        for (var i = underlyingArray.length - 1; i >= 0; i--) {
            var value = underlyingArray[i];
            if (predicate(value))
                underlyingArray[i]["_destroy"] = true;
        }
        this.valueHasMutated();
    },
    'destroyAll': function(arrayOfValues) {//removeAll方法的优化版,不立即移除元素,只是标记一下
        if (arrayOfValues === undefined)//不传就全部标记为destroy
            return this['destroy'](function() {
                return true
            });

        // If you passed an arg, we interpret it as an array of entries to destroy
        if (!arrayOfValues)
            return [];
        return this['destroy'](function(value) {
            return ko.utils.arrayIndexOf(arrayOfValues, value) >= 0;
        });
    },
    'indexOf': function(item) {//返回索引值
        var underlyingArray = this();
        return ko.utils.arrayIndexOf(underlyingArray, item);
    },
    'replace': function(oldItem, newItem) {//替换某一位置的元素
        var index = this['indexOf'](oldItem);
        if (index >= 0) {
            this.valueWillMutate();
            this.peek()[index] = newItem;
            this.valueHasMutated();
        }
    }
};

//添加一系列与原生数组同名的方法
ko.utils.arrayForEach(["pop", "push", "reverse", "shift", "sort", "splice", "unshift"], function(methodName) {
    ko.observableArray['fn'][methodName] = function() {
        var underlyingArray = this.peek();
        this.valueWillMutate();
        this.cacheDiffForKnownOperation(underlyingArray, methodName, arguments);
        var methodCallResult = underlyingArray[methodName].apply(underlyingArray, arguments);
        this.valueHasMutated();
        return methodCallResult;
    };
});

//返回一个真正的数组
ko.utils.arrayForEach(["slice"], function(methodName) {
    ko.observableArray['fn'][methodName] = function() {
        var underlyingArray = this();
        return underlyingArray[methodName].apply(underlyingArray, arguments);
    };
});

cacheDiffForKnownOperation 会记录如何对元素进行操作

target.cacheDiffForKnownOperation = function(rawArray, operationName, args) {
                    // Only run if we're currently tracking changes for this observable array
                    // and there aren't any pending deferred notifications.
                    if (!trackingChanges || pendingNotifications) {
                        return;
                    }
                    var diff = [],
                            arrayLength = rawArray.length,
                            argsLength = args.length,
                            offset = 0;

                    function pushDiff(status, value, index) {
                        return diff[diff.length] = {'status': status, 'value': value, 'index': index};
                    }
                    switch (operationName) {
                        case 'push':
                            offset = arrayLength;
                        case 'unshift':
                            for (var index = 0; index < argsLength; index++) {
                                pushDiff('added', args[index], offset + index);
                            }
                            break;

                        case 'pop':
                            offset = arrayLength - 1;
                        case 'shift':
                            if (arrayLength) {
                                pushDiff('deleted', rawArray[offset], offset);
                            }
                            break;

                        case 'splice':
                            // Negative start index means 'from end of array'. After that we clamp to [0...arrayLength].
                            // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice
                            var startIndex = Math.min(Math.max(0, args[0] < 0 ? arrayLength + args[0] : args[0]), arrayLength),
                                    endDeleteIndex = argsLength === 1 ? arrayLength : Math.min(startIndex + (args[1] || 0), arrayLength),
                                    endAddIndex = startIndex + argsLength - 2,
                                    endIndex = Math.max(endDeleteIndex, endAddIndex),
                                    additions = [], deletions = [];
                            for (var index = startIndex, argsIndex = 2; index < endIndex; ++index, ++argsIndex) {
                                if (index < endDeleteIndex)
                                    deletions.push(pushDiff('deleted', rawArray[index], index));
                                if (index < endAddIndex)
                                    additions.push(pushDiff('added', args[argsIndex], index));
                            }
                            ko.utils.findMovesInArrayComparison(deletions, additions);
                            break;

                        default:
                            return;
                    }
                    cachedDiff = diff;
                };
            };

            ko.utils.findMovesInArrayComparison = function(left, right, limitFailedCompares) {
                if (left.length && right.length) {
                    var failedCompares, l, r, leftItem, rightItem;
                    for (failedCompares = l = 0; (!limitFailedCompares || failedCompares < limitFailedCompares) && (leftItem = left[l]); ++l) {
                        for (r = 0; rightItem = right[r]; ++r) {
                            if (leftItem['value'] === rightItem['value']) {
                                leftItem['moved'] = rightItem['index'];
                                rightItem['moved'] = leftItem['index'];
                                right.splice(r, 1);         // This item is marked as moved; so remove it from right list
                                failedCompares = r = 0;     // Reset failed compares count because we're checking for consecutive failures
                                break;
                            }
                        }
                        failedCompares += r;
                    }
                }
            };

但这里没有sort, reverse方法的处理,并且它是如何操作DOM呢?由于它很早就转换为监控函数,但用户调用这些方法时,它就会在内部调用一个叫getChanges的方法

function getChanges(previousContents, currentContents) {
                    // We try to re-use cached diffs.
                    // The scenarios where pendingNotifications > 1 are when using rate-limiting or the Deferred Updates
                    // plugin, which without this check would not be compatible with arrayChange notifications. Normally,
                    // notifications are issued immediately so we wouldn't be queueing up more than one.
                    if (!cachedDiff || pendingNotifications > 1) {
                        cachedDiff = ko.utils.compareArrays(previousContents, currentContents, {'sparse': true});
                    }

                    return cachedDiff;
                }

里面有一个compareArrays方法,会计算出如何用最少的步骤实现DOM的改动,从而减少reflow。

ko.utils.compareArrays = (function() {
                var statusNotInOld = 'added', statusNotInNew = 'deleted';

                // Simple calculation based on Levenshtein distance.
                function compareArrays(oldArray, newArray, options) {
                    // For backward compatibility, if the third arg is actually a bool, interpret
                    // it as the old parameter 'dontLimitMoves'. Newer code should use { dontLimitMoves: true }.
                    options = (typeof options === 'boolean') ? {'dontLimitMoves': options} : (options || {});
                    oldArray = oldArray || [];
                    newArray = newArray || [];

                    if (oldArray.length <= newArray.length)
                        return compareSmallArrayToBigArray(oldArray, newArray, statusNotInOld, statusNotInNew, options);
                    else
                        return compareSmallArrayToBigArray(newArray, oldArray, statusNotInNew, statusNotInOld, options);
                }

                function compareSmallArrayToBigArray(smlArray, bigArray, statusNotInSml, statusNotInBig, options) {
                    var myMin = Math.min,
                            myMax = Math.max,
                            editDistanceMatrix = [],
                            smlIndex, smlIndexMax = smlArray.length,
                            bigIndex, bigIndexMax = bigArray.length,
                            compareRange = (bigIndexMax - smlIndexMax) || 1,
                            maxDistance = smlIndexMax + bigIndexMax + 1,
                            thisRow, lastRow,
                            bigIndexMaxForRow, bigIndexMinForRow;

                    for (smlIndex = 0; smlIndex <= smlIndexMax; smlIndex++) {
                        lastRow = thisRow;
                        editDistanceMatrix.push(thisRow = []);
                        bigIndexMaxForRow = myMin(bigIndexMax, smlIndex + compareRange);
                        bigIndexMinForRow = myMax(0, smlIndex - 1);
                        for (bigIndex = bigIndexMinForRow; bigIndex <= bigIndexMaxForRow; bigIndex++) {
                            if (!bigIndex)
                                thisRow[bigIndex] = smlIndex + 1;
                            else if (!smlIndex)  // Top row - transform empty array into new array via additions
                                thisRow[bigIndex] = bigIndex + 1;
                            else if (smlArray[smlIndex - 1] === bigArray[bigIndex - 1])
                                thisRow[bigIndex] = lastRow[bigIndex - 1];                  // copy value (no edit)
                            else {
                                var northDistance = lastRow[bigIndex] || maxDistance;       // not in big (deletion)
                                var westDistance = thisRow[bigIndex - 1] || maxDistance;    // not in small (addition)
                                thisRow[bigIndex] = myMin(northDistance, westDistance) + 1;
                            }
                        }
                    }

                    var editScript = [], meMinusOne, notInSml = [], notInBig = [];
                    for (smlIndex = smlIndexMax, bigIndex = bigIndexMax; smlIndex || bigIndex; ) {
                        meMinusOne = editDistanceMatrix[smlIndex][bigIndex] - 1;
                        if (bigIndex && meMinusOne === editDistanceMatrix[smlIndex][bigIndex - 1]) {
                            notInSml.push(editScript[editScript.length] = {// added
                                'status': statusNotInSml,
                                'value': bigArray[--bigIndex],
                                'index': bigIndex});
                        } else if (smlIndex && meMinusOne === editDistanceMatrix[smlIndex - 1][bigIndex]) {
                            notInBig.push(editScript[editScript.length] = {// deleted
                                'status': statusNotInBig,
                                'value': smlArray[--smlIndex],
                                'index': smlIndex});
                        } else {
                            --bigIndex;
                            --smlIndex;
                            if (!options['sparse']) {
                                editScript.push({
                                    'status': "retained",
                                    'value': bigArray[bigIndex]});
                            }
                        }
                    }

                    // Set a limit on the number of consecutive non-matching comparisons; having it a multiple of
                    // smlIndexMax keeps the time complexity of this algorithm linear.
                    ko.utils.findMovesInArrayComparison(notInSml, notInBig, smlIndexMax * 10);

                    return editScript.reverse();
                }

                return compareArrays;
            })();

最后会跑到setDomNodeChildrenFromArrayMapping 里面执行相关的操作

for (var i = 0, editScriptItem, movedIndex; editScriptItem = editScript[i]; i++) {
                        movedIndex = editScriptItem['moved'];
                        switch (editScriptItem['status']) {
                            case "deleted":
                                if (movedIndex === undefined) {
                                    mapData = lastMappingResult[lastMappingResultIndex];

                                    // Stop tracking changes to the mapping for these nodes
                                    if (mapData.dependentObservable)
                                        mapData.dependentObservable.dispose();

                                    // Queue these nodes for later removal
                                    nodesToDelete.push.apply(nodesToDelete, ko.utils.fixUpContinuousNodeArray(mapData.mappedNodes, domNode));
                                    if (options['beforeRemove']) {
                                        itemsForBeforeRemoveCallbacks[i] = mapData;
                                        itemsToProcess.push(mapData);
                                    }
                                }
                                lastMappingResultIndex++;
                                break;

                            case "retained":
                                itemMovedOrRetained(i, lastMappingResultIndex++);
                                break;

                            case "added":
                                if (movedIndex !== undefined) {
                                    itemMovedOrRetained(i, movedIndex);
                                } else {
                                    mapData = {arrayEntry: editScriptItem['value'], indexObservable: ko.observable(newMappingResultIndex++)};
                                    newMappingResult.push(mapData);
                                    itemsToProcess.push(mapData);
                                    if (!isFirstExecution)
                                        itemsForAfterAddCallbacks[i] = mapData;
                                }
                                break;
                        }
                    }
//下面是各种回调操作

整个实现比 avalon 复杂得不是一点半点啊,这是太迷信算法的下场。其实像shift, unshift, pop, push, splice等方法,我们一开始就能确定如何增删,不用跑到compareArrays 里面,最麻烦的sort, reverse方法,也可以通过将父节点移出DOM树,排好再插回去,就能避免reflow了。

这个文档是我自己原创制作的,在别的网上肯定是没有的。 而且做得非常好看,和非常准确。 如果下载的人多,将会把中英文对照的版本也上传。 Knockout是一个以数据模型(data model)为基础的能够帮助你创建富文本,响应显示和编辑用户界面的JavaScript类库。任何时候如果你的UI需要自动更新(比如:更新依赖于用户的行为或者外部数据源的改变),KO能够很简单的帮你实现并且很容易维护。 重要特性: 优雅的依赖追踪 - 不管任何时候你的数据模型更新,都会自动更新相应的内容。 Elegant dependency tracking - automatically updates the right parts of your UI whenever your data model changes. 声明式绑定 - 浅显易懂的方式将你的用户界面指定部分关联到你的数据模型上。 Declarative bindings - a simple and obvious way to connect parts of your UI to your data model. You can construct a complex dynamic UIs easily using arbitrarily nested binding contexts. 轻易可扩展 - 几行代码就可以实现自定义行为作为新的声明式绑定。 Trivially extensible - implement custom behaviors as new declarative bindings for easy reuse in just a few lines of code. 额外的好处: 纯JavaScript类库 – 兼容任何服务器端和客户端技术 Pure JavaScript library - works with any server or client-side technology 可添加到Web程序最上部 – 不需要大的架构改变 Can be added on top of your existing web application - without requiring major architectural changes 简洁的 - Gzip之前大约25kb Compact - around 13kb after gzipping 兼容任何主流浏览器 - (IE 6+、Firefox 2+、Chrome、Safari、其它) Works on any mainstream browser - (IE 6+, Firefox 2+, Chrome, Safari, others) 采用行为驱动开发 - 意味着在新的浏览器和平台上可以很容易通过验证。 Comprehensive suite of specifications - (developed BDD-style) means its correct functioning can easily be verified on new browsers and platforms 开发人员如果用过Silverlight或者WPF可能会知道KO是MVVM模式的一个例子。如果熟悉 Ruby on Rails 或其它MVC技术可能会发现它是一个带有声明式语法的MVC实时form。换句话说,你可以把KO当成通过编辑JSON数据来制作UI用户界面的一种方式… 不管它为你做什么 Developers familiar with Ruby on Rails, ASP.NET MVC, or other MV* technologies may see MVVM as a real-time form of MVC with declarative syntax. In another sense, you can think of KO as a general way to make UIs for editing JSON data… whatever works for you :) OK, 如何使用它? 简单来说:声明你的数据作为一个JavaScript 模型对象(model object),然后将DOM 元素或者模板(templates)绑定到它上面. The quickest and most fun way to get started is by working through the interactive tutorials. Once you’ve got to grips with the basics, explore the live examples and then have a go with it in your own project. KO和jQuery (或Prototype等)是竞争关系还是能一起使用? 所有人都喜欢jQuery! 它是一个在页面里操作元素和事件的框架,非常出色并且易使用,在DOM操作上肯定使用jQuery,KO解决不同的问题。 Everyone loves jQuery! It’s an outstanding replacement for the clunky, inconsistent DOM API we had to put up with in the past. jQuery is an excellent low-level way to manipulate elements and event handlers in a web page. I certainly still use jQuery for low-level DOM manipulation. KO solves a different problem. 如果页面要求复杂,仅仅使用jQuery需要花费更多的代码。 例如:一个表格里显示一个列表,然后统计列表的数量,Add按钮在数据行TR小于5调的时候启用,否则就禁用。jQuery 没有基本的数据模型的概念,所以需要获取数据的数量(从table/div或者专门定义的CSS class),如果需要在某些SPAN里显示数据的数量,当添加新数据的时候,你还要记得更新这个SPAN的text。当然,你还要判断当总数>=5条的时候禁用Add按钮。 然后,如果还要实现Delete功能的时候,你不得不指出哪一个DOM元素被点击以后需要改变。 As soon as your UI gets nontrivial and has a few overlapping behaviors, things can get tricky and expensive to maintain if you only use jQuery. Consider an example: you’re displaying a list of items, stating the number of items in that list, and want to enable an ‘Add’ button only when there are fewer than 5 items. jQuery doesn’t have a concept of an underlying data model, so to get the number of items you have to infer it from the number of TRs in a table or the number of DIVs with a certain CSS class. Maybe the number of items is displayed in some SPAN, and you have to remember to update that SPAN’s text when the user adds an item. You also must remember to disable the ‘Add’ button when the number of TRs is 5. Later, you’re asked also to implement a ‘Delete’ button and you have to figure out which DOM elements to change whenever it’s clicked. Knockout实现有何不同? 使用KO非常简单。将你的数据描绘成一个JavaScript数组对象myItems,然后使用模板(template)转化这个数组到表格里(或者一组DIV)。不管什么时候数组改变, UI界面也会响应改变(不用指出如何插入新行或在哪里插入),剩余的工作就是同步了。例如:你可以声明绑定如下一个SPAN显示数据数量(可以放在页面的任何地方,不一定非要在template里): It’s much easier with KO. It lets you scale up in complexity without fear of introducing inconsistencies. Just represent your items as a JavaScript array, and then use a foreach binding to transform this array into a TABLE or set of DIVs. Whenever the array changes, the UI changes to match (you don’t have to figure out how to inject new TRs or where to inject them). The rest of the UI stays in sync. For example, you can declaratively bind a SPAN to display the number of items as follows: There are <span data-bind="text: myItems().count"></span> items就是这些!你不需要写代码去更新它,它的更新依赖于数组myItems的改变。同样, Add按钮的启用和禁用依赖于数组 myItems 的长度,如下: That’s it! You don’t have to write code to update it; it updates on its own when the myItems array changes. Similarly, to make the ‘Add’ button enable or disable depending on the number of items, just write: <button data-bind="enable: myItems().count < 5">Add</button>之后,如果你要实现Delete功能,不必指出如何操作UI元素,只需要修改数据模型就可以了。 Later, when you’re asked to implement the ‘Delete’ functionality, you don’t have to figure out what bits of the UI it has to interact with; you just make it alter the underlying data model. 总结:KO没有和jQuery或类似的DOM 操作API对抗竞争。KO提供了一个关联数据模型和用户界面的高级功能。KO本身不依赖jQuery,但是你可以一起同时使用jQuery, 生动平缓的UI改变需要真正使用jQuery。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值