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了。