Na czym polega chaining w JavaScript?

Zawsze w ramach szkoleń z JavaScriptu, które prowadzę, jednym z ćwiczeń jest implementacja kalkulatora oraz własnej biblioteki DOM, które działałyby na zasadzie chainingu, czyli uruchamianiu metod po metodzie na danym obiekcie, po kropce, podobnie jak w jQuery.

W przypadku kalkulatora mogłoby to wyglądać np. tak:

calculator.add(2).multiply(3).subtract(3).getResult(); // 3

Działania wykonywane byłyby od lewej, nie zwracalibyśmy również uwagi na kolejność (a więc mnożenie byłoby równe dodawaniu). Zatem najpierw dodajemy do zera dwójkę, potem mnożymy to przez trzy, a na końcu odejmujemy trójkę. Wynikiem jest… trzy.

W wypadku biblioteczki DOMowej, przykładowe jej użycie mogłoby wyglądać następująco:

$("div > p").css("margin-left", "20px").click(function() {
    console.log("klik!");
});

Skupmy się jednak na kalkulatorze.

Przed przystąpieniem do implementacji zazwyczaj pojawia się wiele wątpliwości. Po pierwsze należy zauważyć, analizując nasz przykład, że najpierw uruchamia się funkcja add, potem multiply, następnie subtract, a na końcu getResult. Wszystko dzieje się po kropce.

Warto więc przypomnieć sobie, co oznacza konstrukcja foo.bar(), czyli dwie części kodu oddzielone kropką. Otóż używając takiego kawałka kodu spodziewamy się, że bar jest funkcją (w końcu mamy do czynienia z nawiasami, więc zapewne chcemy uruchomić jakąś funkcję), a foo obiektem. Otóż tylko wtedy taka konstrukcja ma sens – po prawej stronie kropki powinna być funkcja, a po lewej obiekt zawierający taką funkcję. Kod mógłby wyglądać więc tak:

var foo = { bar: function() { return "test"; } };
foo.bar(); // test

W naszym z kolei przypadku mamy kilka uruchomień funkcji po kropce. Zgadujemy więc, że to co będzie z lewej musi zwracać jakiś obiekt, zawierający metodę z prawej. A więc po odpaleniu calculator.add(2) spodziewamy się, że calculator będzie obiektem zawierającym metodę add. Następnie po uruchomieniu calculator.add(2).multiply(3) chcemy, by to, co zwróci wywołanie calculator.add(2), zawierało metodę multiply, ponieważ JS napotkał kropkę, co znaczy, że z lewej ma znaleźć się obiekt, a z prawej funkcja bądź inna własność z tego obiektu. I tak dalej. A więc każde wywołanie funkcji kończące się kropką ma zwrócić jakiś obiekt, by kod mógł zadziałać.

Rozpocznijmy implementację:

var calculator = {};

Tworzymy obiekt calculator, od którego wszystko się zacznie. Taki obiekt ma metodę add, w końcu nasz przykład wygląda tak:

calculator.add(2).multiply(3).subtract(3).getResult(); // 3

Piszemy!

var calculator = {
	add: function(n) {
	}
};

Nic prostszego, prawda?

Używamy tego tak:

calculator.add(2);

Ale my chcemy mieć więcej możliwości, chcemy teraz pomnożyć wszystko przez trzy:

calculator.add(2).multiply(3)

Wykorzystując obecny, skromny kod, dostajemy błąd. Nic dziwnego – to, co zwraca metoda add, nie zawiera funkcji multiply. Naprawmy nasz błąd:

var calculator = {
	add: function(n) {
		return {
			multiply: function(n2) {
			}
		};
	}
};

Wszystko działa, tylko co, gdyby po add nie było multiply, a na przykład odejmowanie lub kolejne dodawanie? Pisalibyśmy takie podobiekty w nieskończoność. Zdefiniujmy więc najpierw proste API, które będzie implementował kalkulator.

var calculator = {
	add: function() {},
	subtract: function() {},
	multiply: function() {},
	divide: function() {}
};

Wszystko fajnie. Brakuje tylko miejsca na przechowywanie wyniku:

var calculator = {
	add: function() {},
	subtract: function() {},
	multiply: function() {},
	divide: function() {},
	result: 0
};

Aktualny rezultat naszych operacji składowany jest w calculator.result. Początkowa jego wartość to 0. Uzupełnijmy teraz metody:

var calculator = {
	add: function(n) {
		if (typeof n === "number") {
			calculator.result += n;
		}
	},
	subtract: function(n) {
		if (typeof n === "number") {
			calculator.result -= n;
		}
	},
	multiply: function(n) {
		if (typeof n === "number") {
			calculator.result *= n;
		}
	},
	divide: function(n) {
		if (typeof n === "number" && n !== 0) {
			calculator.result /= n;
		}
	},
	getResult: function() { return calculator.result; },
	result: 0
};

W każdej z nich sprawdzamy, czy podana liczba jest rzeczywiście typy liczbowego (typeof number). Dodatkowo w dzieleniu divide nie chcemy dzielić przez zero.

Wszystko pięknie, tylko nadal nie działa nam zapis metod po kropce. Jak wiemy, aby uruchomić metodę poprzedzoną kropką, musi być ona składnikiem obiektu z lewej strony. A więc każda z naszych funkcji musi zwracać obiekt z tymi czteroma metodami.

var calculator = {
	add: function(n) {
		if (typeof n === "number") {
			calculator.result += n;
		}
		return calculator;
	},
	subtract: function(n) {
		if (typeof n === "number") {
			calculator.result -= n;
		}
		return calculator;
	},
	multiply: function(n) {
		if (typeof n === "number") {
			calculator.result *= n;
		}
		return calculator;
	},
	divide: function(n) {
		if (typeof n === "number" && n !== 0) {
			calculator.result /= n;
		}
		return calculator;
	},
	getResult: function() { return calculator.result; },
	result: 0
};

Jak łatwo zauważyć, każda z nich zwraca obiekt, do którego należy – a więc możemy być pewni, że będzie zawierał on wszystkie z interesujących nas funkcji, w końcu zwracamy cały kalkulator.

Pozostaje jednak jedna drażniąca kwestia. Otóż możemy łatwo ingerować w wynik – na przykład pisząc tak:

calculator.add(2).add(3);
calculator.result = 10;
calculator.getResult(); // 10

Byłoby bardzo miło ukryć dostęp do zmiennej przechowującej tymczasowy wynik. Możemy to zrobić korzystając z closures i wywołania funkcji anonimowej.

var calculator = (function() {
	var result = 0;
	var calculator = {
		add: function(n) {
			if (typeof n === "number") {
				result += n;
			}
			return calculator;
		},
		subtract: function(n) {
			if (typeof n === "number") {
				result -= n;
			}
			return calculator;
		},
		multiply: function(n) {
			if (typeof n === "number") {
				result *= n;
			}
			return calculator;
		},
		divide: function(n) {
			if (typeof n === "number" && n !== 0) {
				result /= n;
			}
			return calculator;
		},
		getResult: function() { return result; }
	};
	
	return calculator;
})();

Z obiektu calculator wydzieliliśmy result do oddzielnej zmiennej o tej samej nazwie. Jest ona dostępna w scope anonimowej funkcji, a więc tylko wewnątrz:

var calculator = (function() { 
	// o, tutaj 
})();

Wszystkie inne funkcje zdefiniowane wewnątrz niej będą miały do niej dostęp – tak działa mechanizm closures.

Voila! Podany przykład można by rozszerzyć o .prototype i tak dalej, co mogłoby wspierać dopisywanie pluginów. Można też dodać obsługę błędów. To jednak temat na następny artykuł.

PS

Nawiązując do pierwszego komentarza, równie dobrze można skorzystać z this:

var calculator = (function() {
	var result = 0;
	var calculator = {
		add: function(n) {
			if (typeof n === "number") {
				result += n;
			}
			return this;
		},
		subtract: function(n) {
			if (typeof n === "number") {
				result -= n;
			}
			return this;
		},
		multiply: function(n) {
			if (typeof n === "number") {
				result *= n;
			}
			return this;
		},
		divide: function(n) {
			if (typeof n === "number" && n !== 0) {
				result /= n;
			}
			return this;
		},
		getResult: function() { return result; }
	};
	
	return calculator;
})();

Ryzykujemy jednak w takim przypadku:

var dodaj = calculator.add;
dodaj(2).add(2).add(2).getResult();

Komentarze

1

AFAIK gdy funkja jest wywoływana jako metoda obiektu, ‘this’ wewnątrz niej odnosi się do samego obiektu, stąd w metodach możemy (zamiast calculator) zwracać “this”. Powód prosty – jak zechcemy zmienić nazwę z ‘kalkulator’ na ‘liczydlo’, nie będzie konieczności zmiany wszystkich wystąpień :>

2
var dodaj = calculator.add;
dodaj(2).add(2).add(2).getResult();

Zrób to sobie z this. ;-)

3

Ojtam, ojtam :>

4

This is not how jQuery implements “chaining”, in fact – jQuery doesn’t implement anything special, it’s just a constructor with a defined prototype whose methods return |this|.

All of the patterns shown here will share the same “result” variable:


var product = calculator.add(2).add(3).getResult(),
other;
console.log( product === 5 );
other = calculator.add(2).add(3).getResult();
console.log( other === 5 ); // false!!!
console.log( other ); // 10!

5

Sorry, it appears that translation completely destroys code examples.

https://gist.github.com/3fd8229d6e133c6760c9

6

Rick, I didn’t write it is exactly how jQuery implements chaining (since it’s done like you explained) – I rather used words ‘simirarly’ to show the whole concept. Anyway, good points on the thing that it will always store the same object – but it’s rather easy to fix and I will update the article with the proper fix.

7


var dodaj = calculator.add;
dodaj(2).add(2).add(2).getResult();

Zrób to sobie z this. ;-)

hm.. o ile się nie mylę można by ten problem obejść z pomocą domknięcia :) wtedy i wilk syty (nazwa calculator występuje raz) i owca cała (przypisanie metody do zmiennej niczego nie zepsuje)

np.

var calculator = (function() {
var result = 0;
var that = this;
var calculator = {
add: function(n) {
if (typeof n === "number") {
result += n;
}
return that;
},
(...)

krystjar
8

Twój kod niczego nie rozwiązuje, that będzie wskazywało na window.

9


var dodaj = calculator.add.bind(calculator);
dodaj(2).add(2).add(2).getResult(); //6

10

Zgadza się ;-)

11

ajj racja, mój błąd. kajam się ;)

ale da się też bez binda, ustawiając that na definicję z domknięcia zamiast na this, tym razem sprawdziłem :P http://jsfiddle.net/fgTNC/1/

krystjar
12

Kawał przydatnego i ładnie opisanego kodu. Gratuluję :)

13

Czyżby wracało stare, dobre ferrante ? :)

szalony_ivan
14

Czekam z niecierpliwością na kolejne artykuły z tej serii!

15

Myślę, że wykorzystanie wzorca fabryki do implementacji rozwiązuje powyższe problemy.

Przykład na poniższej stronie:
http://www.yarpo.pl/2011/01/11/wzorzec-lancuchowy-w-js-oparty-o-fabryke-obiektow/

16

Myślę, że można zrobić jeszcze jedną niewielką optymalizację. Można się pozbyć definiowania lokalnego obiektu calculator, który jest na końcu zwracany jako public i zwrócić od razu “anonimowy” obiekt:

var calculator = (function() {
var result = 0;
return {
add: function(n) {
// ..
}
// ..
};
})();

drummachina
17

asdfasdfsaf

Dodaj komentarz

Dozwolone tagi: <blockquote>, <code>, <strong>