new (i this) w JavaScript - co naprawdę robi to słowo kluczowe

Słowo new w JS wygląda na krótkie, ale silnik uruchamia za nim cztery kroki naraz - tworzy pusty obiekt, podpina this, linkuje go do prototype'u funkcji i zwraca cały obiekt z powrotem. Bez new to samo trzeba napisać ręcznie z Object.create.
Słowo kluczowe new jest jednym z tych operatorów w JavaScripcie, które wyglądają na małe, a robią kilka rzeczy naraz. Wpisujesz new UserCreator('Eva', 9) i dostajesz gotowy obiekt z polami, podpiętym this i działającymi metodami. Tylko że pod tą jedną linijką siedzi cztero- (czasem trzy-) krokowy przepis, który silnik wykonuje za każdym razem - i bez new musiałbyś go odtworzyć ręcznie z Object.create i kilkoma wpisaniami this.
Ten post jest o tym, co new zdejmuje z głowy programisty, jak to robi krok po kroku i dlaczego klasy z ES2015 to ten sam mechanizm pod spodem, tylko ładniej zapisany.
#Po co w ogóle new - prototypowa natura JS
Każdy obiekt w JavaScripcie ma w środku link do innego obiektu - swojego prototype’u.
W kodzie odczytujesz ten link przez Object.getPrototypeOf(obj), a ustawiasz przez Object.setPrototypeOf(obj, proto). Kiedyś pisało się to samo jako obj.__proto__ (property na obiekcie zamiast wywołania funkcji) - dziś już ta forma jest niezalecana, ale wciąż spotkasz ją w starszych tutorialach, bibliotekach i kursach. Specyfikacja nazywa to [[Prototype]]. Cztery nazwy - to wszystko ten sam link.
Po co to wszystko? Gdy próbujesz odczytać jakieś pole z obiektu, a tam go nie ma, JavaScript zagląda do prototype’u. Jeśli i tam nie znajdzie - zagląda do prototype’u prototype’u. I tak dalej, aż dotrze do Object.prototype (poza nim już nic nie ma). Ten ciąg prototype’ów to prototype chain - i to na niej opiera się dziedziczenie w JS.
Bez new te połączenia trzeba ustawiać samemu. Najczystsza metoda to Object.create(proto) - tworzy nowy obiekt, którego __proto__ od razu wskazuje na podany prototype:
const userMethods = {
greet() {
return `cześć, jestem ${this.name}`;
},
addOne() {
this.score++;
},
};
function createUser(name, score) {
const newUser = Object.create(userMethods);
newUser.name = name;
newUser.score = score;
return newUser;
}
const u = createUser("Eva", 9);
u.greet(); // 'cześć, jestem Eva'
u.addOne();
u.score; // 10To działa - i jest dokładnie tym, co new robi pod spodem - tylko każdy taki ręczny konstruktor trzeba pisać samodzielnie. Pusty obiekt, ustawienie prototype’u, wpisanie pól, zwrot. Cztery linijki, w których łatwo o return zapomnieć i nagle zwracać undefined.
new to skrót do tej samej rzeczy: mówisz silnikowi „weź funkcję, którą zdefiniowałem, potraktuj ją jako konstruktor i wykonaj te cztery kroki za mnie”.
#Co new robi automatycznie
Wywołanie new SomeFunction(args) uruchamia funkcję w specjalnym trybie, w którym silnik dorzuca cztery rzeczy:
- Tworzy nowy, pusty obiekt - po prostu
{}, bez żadnych pól ani metod. - Łączy nowy obiekt z
SomeFunction.prototype- przez ustawienie wewnętrznego linku[[Prototype]].SomeFunction.prototypeto po prostu obiekt, na którym trzymasz metody wspólne dla wszystkich instancji konstruktora - silnik JS tworzy go automatycznie razem z funkcją. - Wykonuje ciało konstruktora - z
thiswskazującym na nowy obiekt. Każdethis.foo = ...w środku zapisuje pole bezpośrednio do tego obiektu. - Zwraca nowy obiekt jako wynik
new ...- sam z siebie, bezreturn. Dlategoconst u = new UserCreator('Eva', 9)daje ci instancję jakou.
Czasami spotyka się to opisane jako trzy kroki (z punktów wyżej zlepia się 1 + 4 w „tworzy i zwraca”), ale rozłożenie na cztery jest jaśniejsze: pokazuje, że wykonanie ciała funkcji to osobna faza wciśnięta między ustawieniem prototype’u a zwróceniem obiektu.
function UserCreator(name, score) {
this.name = name;
this.score = score;
}
UserCreator.prototype.greet = function () {
return `cześć, jestem ${this.name}`;
};
const user1 = new UserCreator("Eva", 9);
user1.greet(); // 'cześć, jestem Eva'#Krok po kroku: co dzieje się w new UserCreator('Eva', 9)
Idziemy linia po linii, jakbyśmy byli silnikiem.
- Ewaluacja wyrażenia
new UserCreator('Eva', 9). Silnik widzinew, więc wie, że ma uruchomićUserCreatorw trybie konstruktora, a nie zwykłej funkcji. - Tworzy się nowy, pusty obiekt - powiedzmy
{}. Nie ma jeszcze żadnych pól. - Łączy się ten obiekt z
UserCreator.prototype- przez link prototype’u. Dzięki temu chwilę późniejuser1.greet()zadziała - bo gdygreetnie jest na samymuser1, JavaScript pójdzie po tym linku i znajdzie funkcję na prototype’ie. - Wewnątrz
UserCreatorthisustawia się na nowy obiekt - a parametrynameiscoredostają wartości'Eva'i9z argumentów. - Wykonuje się ciało funkcji.
this.name = nameprzypisuje'Eva'jako polenamenowego obiektu.this.score = scoreprzypisuje9. - Funkcja kończy się bez
return. Wyrażenienew ...zwraca ten nowo utworzony obiekt.
Po tym wszystkim zmienna user1 wskazuje na strukturę:
{
name: 'Eva',
score: 9,
__proto__: UserCreator.prototype, // tu siedzi greet()
}Dwa własne pola pochodzą z argumentów konstruktora, a link do współdzielonych metod silnik dorzucił sam.
#Gdzie naprawdę żyje metoda greet
Jednym z efektów new jest oddzielenie pól od metod. Pola siedzą na samym obiekcie (name, score), bo zostały wpisane przez this.something = ... w konstruktorze. Metody siedzą na prototype funkcji - jeden raz, dla wszystkich obiektów, które kiedykolwiek powstaną przez ten konstruktor.
const user1 = new UserCreator("Eva", 9);
const user2 = new UserCreator("Lin", 7);
// pola - każda instancja ma własne
user1.name; // 'Eva'
user2.name; // 'Lin'
// metoda - jedna wspólna, leży na prototypie
user1.greet === user2.greet; // true - obie instancje sięgają po tę samą funkcję
user1.greet === UserCreator.prototype.greet; // true - i to ta z prototype'u- name:'Eva'
- score:9
- __proto__:UserCreator.prototype
- greet:ƒ () { ... }
- constructor:ƒ UserCreator
Kliknij wyrażenie poniżej, żeby zobaczyć, gdzie silnik faktycznie znajduje tę wartość.
Nie ma duplikacji. greet istnieje raz, na UserCreator.prototype. Każde nowe wywołanie z new tworzy obiekt, który ma do tego prototype’u link, ale nie kopiuje funkcji do siebie. Inaczej mówiąc: setki instancji = jedno miejsce z metodami w pamięci, a nie setki kopii.
Jest to ten sam mechanizm, co przy Object.create z userMethods na początku - tylko z new powiązanie ustawia się samoczynnie, a wspólne metody trzymasz na prototype konstruktora zamiast w osobnym obiekcie obok.
#Konwencja: konstruktor pisze się z dużej litery
Bo w JavaScripcie nie ma osobnej składni dla „to jest konstruktor”. Konstruktor to po prostu funkcja - i patrząc na userCreator(...) nie da się od razu poznać, czy to:
const user = userCreator("Eva", 9); // factory function?
const user = new userCreator("Eva", 9); // konstruktor?Stąd społeczność umówiła się: nazwy konstruktorów zaczynają się dużą literą (UserCreator, Counter, Date), nazwy zwykłych funkcji małą. To czysto wzrokowy sygnał - „tę funkcję wywołaj z new, tamtej nie”. Linter (np. ESLint z regułą new-cap) potrafi to wymusić mechanicznie.
A co się stanie, jeśli pomylisz i wywołasz konstruktor bez new?
function UserCreator(name, score) {
this.name = name;
this.score = score;
}
const user = UserCreator('Eva', 9); // brak new!
console.log('user:', user); // undefined
console.log('globalThis.name:', globalThis.name); // 'Eva' - wyciekło do globalThis Bez new cały mechanizm się nie wydarza. Silnik nie tworzy nowego obiektu, nie linkuje prototype’u, nie przypisuje go do this. To, co dzieje się zamiast tego, zależy od trybu wykonania:
- Bez
'use strict'(zwykły skrypt):thiswskazuje na obiekt globalny -globalThis, czyliwindoww przeglądarce alboglobalw Node.this.name = 'Eva'zapisuje wtedynamena obiekcie globalnym, skąd potem wycieka do każdego innego skryptu. - W trybie strict (
'use strict'na górze pliku albo dowolny moduł ES):thisjestundefined, więcthis.name = 'Eva'od razu rzucaTypeError: Cannot set properties of undefined.
(Skąd w ogóle bierze się wartość this w wywołaniu - to temat osobny; opisałem go w poście o execution context. new po prostu nadpisuje to, co domyślnie ustawiłby silnik, świeżym obiektem.)
W obu przypadkach rezultat jest niepoprawny - i dlatego konwencja PascalCase jest tak ważna w starszym, „klasycznym” stylu JS. Klasy, o których za chwilę, ten problem rozwiązują na poziomie silnika.
#ES2015: class to ten sam mechanizm
Od ES2015 nie trzeba pisać konstruktorów ręcznie - składnia class daje nakładkę na to wszystko, co opisaliśmy wyżej. Pod spodem nadal działa prototype chain i nadal new wykonuje te same cztery kroki - tylko zapis jest porządniejszy.
class UserCreator {
constructor(name, score) {
this.name = name;
this.score = score;
}
greet() {
return `cześć, jestem ${this.name}`;
}
}
const user1 = new UserCreator("Eva", 9);
user1.greet(); // 'cześć, jestem Eva'
// nadal prototypowo:
typeof UserCreator; // 'function'
Object.getPrototypeOf(user1) === UserCreator.prototype; // true
user1.greet === UserCreator.prototype.greet; // trueCo class daje dodatkowo:
-
Wymusza wywołanie z
new- jeśli zapomnisz i napiszeszUserCreator('Eva', 9)beznew, silnik od razu rzucaTypeError. Bez klasy ta sama pomyłka przeszłaby po cichu (thisuciekłoby do obiektu globalnego albo zostałoundefined, jak pokazaliśmy wyżej). Klasa zamienia cichy bug w jawny błąd już w momencie wywołania. -
Wszystko w jednym bloku - bez klasy konstruktor pisałeś osobno (
function UserCreator(...) { ... }), a każdą metodę osobno przezUserCreator.prototype.greet = function () { ... }. Z klasąconstructori wszystkie metody siedzą razem wewnątrzclass UserCreator { ... }- bez doczepiania niczego ręcznie doprototype. Dochodzą też dwie rzeczy, których przy zwykłej funkcji prawie nikt nie pisał:static- pole albo metoda przypięta do samej klasy, nie do instancji. CzyliUserCreator.maxScorezamiastuser1.maxScore- coś, co dotyczy „wszystkich userów w ogóle”, a nie konkretnego usera.#field- pole prywatne. Z zewnątrz klasy nie da się go odczytać ani podmienić; jest widoczne tylko dla kodu w środku ciała klasy.
-
Dziedziczenie przez
extendsisuper- dziedziczenie znaczy tyle: budujesz nową klasę na bazie istniejącej. Nowa dostaje wszystkie pola i metody starej, a ty dokładasz tylko to, co ją odróżnia. Pisze się to tak:class Admin extends UserCreator { constructor(name, score, permissions) { super(name, score); // odpal konstruktor UserCreator-a z tymi argumentami this.permissions = permissions; // i dołóż coś od siebie } }Adminma od rękiname,scorei metodęgreetzUserCreator-a, plus własnepermissions. Słowosuperto odwołanie do klasy nadrzędnej -super(...)w konstruktorze odpala konstruktor rodzica, asuper.someMethod()w metodzie woła metodę rodzica. Bez klas to samo trzeba było zrobić ręcznie -Admin.prototype = Object.create(UserCreator.prototype)plusUserCreator.call(this, name, score)wewnątrz - i każda z tych linii to było miejsce na łatwą do przeoczenia pomyłkę.
Mechanika tworzenia obiektu się nie zmieniła - klasy „są zbudowane na prototypach” (dosłowny cytat z MDN), a new wykonuje za nimi te same cztery kroki.
Ale klasy mają też kilka rzeczy, których nie da się odtworzyć zwykłą funkcją:
- ciało klasy zawsze działa w trybie strict - niezależnie od tego, jak wygląda reszta kodu;
- klasa nie jest hoistowana - przed jej deklaracją do niej nie sięgniesz (TDZ jak przy
let/const); - prywatne pola
#field- prawdziwa prywatność wbudowana w język, nie do obejścia z zewnątrz; extends/super()- dziedziczenie z osobnym modelem wywołania konstruktora rodzica.
Klasa nie jest więc czystym syntactic sugar - to prototype chain plus kilka rzeczy, których prototype chain sam z siebie nie umie. Jeśli rozumiesz new na poziomie funkcji, klasy zaczynają się tu od tego samego mechanizmu - tylko z dodatkowymi gwarancjami.
#Puenta
Sprowadza się to do jednej rzeczy: new to skrót do czterech kroków, które bez niego musiałbyś wystukać ręcznie - utworzyć pusty obiekt, podpiąć go do this, połączyć linkiem z prototype funkcji i go zwrócić. Wszystko, co dzieje się dalej, to konsekwencje tych czterech kroków:
- pola siedzą na samym obiekcie, metody na jego
prototype, thisw środku konstruktora wskazuje na tę nową instancję,- bez
newcały mechanizm się nie wydarza ithisucieka do obiektu globalnego albo zostajeundefined, - klasy z ES2015 to ten sam silnik pod spodem, tylko z czytelniejszym zapisem i obowiązkowym
new.
Gdy new przestaje wyglądać jak słowo kluczowe, a zaczyna jak skrócony zapis: Object.create(proto) plus przypisanie pól plus zwrot obiektu, dziedziczenie prototypowe i klasy układają się w jeden spójny mechanizm zamiast trzech oddzielnych pomysłów.
Komentarze
Wczytywanie komentarzy…