프로토타입에 대해 정리하기 전에 이해하기 위한 개념 몇 개만 정리하고 시작하자.
먼저 프로퍼티의 생성시 value, writable, enumerable, configurable 등의 값을 가진 프로퍼티 어트리뷰트가 자동으로 정의된다. 이는 getOwnPropertyDescriptor 메서드를 사용하여 반환되는 프로퍼티 디스크립터 객체에서 확인할 수 있다.
프로퍼티는 데이터 프로퍼티와 접근자 프로퍼티로 구분할 수 있는데, 데이터 프로퍼티는 일반적으로 우리가 확인하는 프로퍼티들이고 접근자 프로퍼티는 get, set 등 자체적인 값은 없지만 데이터 프로퍼티의 값에 접근할 때 호출되는 접근자 함수로 구성된 프로퍼티다. Object.defineProperty(person, 'firstName', {프로퍼티 디스크립터 객체}) 형태로 프로퍼티를 정의할 수도 있다.
Object.preventExtensions, Object.seal, Object.freeze 메서드로 객체의 변경을 방지할 수 있고, 각각의 메서드는 역할이 조금씩 다르다.
다음으론 객체를 만드는데 사용하는 생성자 함수이다. 생성자 함수는 new 연산자와 함께 사용되어 객체를 생성한다. 생성자 함수는 빌트인 생성자 함수인 String, Object, Function, Date 등등 외에도 사용자 정의 생성자 함수를 정의할 수도 있다. 대표적인 예제만 하나 살펴보자.
function Circle(radius) {
//1. 암묵적으로 빈 객체를 생성하고 this에 바인딩함
//2. this에 바인딩 된 인스턴스 초기화
this.radius = radius
this.gettDiameter = function() {
return 2 * this.radius;
};
}
//3. 완성된 인스턴스가 바인딩된 this 암묵적 반환, 만약 명시적으로 return으로 반환시 암묵적 반환은 무시됨.
const circle1 = new Circle(5);
그리고 함수는 내부 메서드 [[call]]과 [[construct]]를 가진다. 일반 함수로써 호출시 call, 생성자 함수로써 호출시 construct가 호출된다. 여기서 constructor를 가지지 않는 함수에는 ES6 축약 표현을 사용한 메서드, 화살표 함수가 있다. 그 외에는 거의 가진다고 보면 된다.
무명의 리터럴로 생성 가능, 변수 등에 저장 가능, 매개변수로 전달 가능, 반환값으로 사용 가능한 객체는 일급 객체이다. 자바스크립트의 함수는 일급 객체이므로 객체와 동일하게 사용 가능하다고 생각하자.
그렇다면 함수가 가지는 프로퍼티는 무엇일까? 함수만이 가지는 데이터 프로퍼티는 arguments, caller, length, name, prototype가 있다. 간단히 정리하면 arguments는 유사 배열 객체로 함수 호출시 전달된 인수이다. for 문으로 순회할 수 있고, 가변 인자 함수 구현시 유용하다. 다만 배열 메서드는 사용할 수 없으므로 function sum(...args)처럼 Rest 파라미터를 사용하면 더 유용할 수도 있다.
length는 매개변수의 개수(인자와 다름에 주의하자), name는 함수의 이름(식별자와 다르다),prototype는 constructor만이 소유하는 객체인데 이후에 자세히 알아보자.
본격적으로 프로토타입을 알아보자. 개념 자체는 객체지향에 대한 조금의 사전지식이 있다면 이해하기 더 쉽다. 객체지향도 상속이라는 개념을 이용하여 객체들 사이의 관계를 구축하는데, 자바스크립트는 프로토타입을 기반으로 상속을 구현한 것으로 중복을 제거하는 데에 있어서 굉장히 유용하다. 간단한 예시로 짚고 넘어가보자.
function Circle(radius){
this.radius = radius;
}
//Circle 에서 상속받게 할 메서드를 Circle.prototype에 추가함.
Circle.prototype.getArea = function(){
return Math.PI * this.radius ** 2;
};
const circle1 = new Circle(1);
const circle2 = new Circle(2);
console.log(circle1.getArea === circle2.getArea); // true
이 예시에서 circle1,2는 부모 객체인 Circle의 Circle.prototype의 메서드와 프로퍼티를 상속받게 된다. 모든 프로퍼티는 __proto__ 접근자 프로퍼티로 자신의 프로토타입 내부 슬롯에 접근할 수 있지만(모든 객체는 Object.prototype에게서 상속받고, 이 접근자 프로퍼티는 Object.prototype의 프로퍼티이므로 사용할 수 있다.), 코드 내에서 직접 사용하는 것은 권장되지 않는 편이다. Object.prototype를 상속받지 않는 객체를 생성할 수도 있기 때문인데 이것에 관해서는 이후 자세히 다뤄보자.
또한 prototype 프로퍼티는 함수 객체만이 가지는데, 그 중에서도 생성자 함수로써 호출할 수 있는 것들이 가진다. 생성자 함수가 생성할 객체의 프로토타입을 가리키는데, 
직관적으로 이미지를 통해 이해해보자. 생성자 함수는 Letter이라고 가정하고, 최상위 프로토타입의 프로토타입은 물론 null이고, 그것에서 상속되어 Letter.prototype의 프로토타입에 들어간다. 여기서 .prototype이 prototype 프로퍼티이다. 이 프로퍼티는 자식 객체에게 상속해줄 프로토타입들을 가리킨다. 그리고 자식 객체의 프로토타입은 그 부모 객체의 prototype 프로퍼티의 값들을 물려받는 형태가 된다. 추가적으로 사진의 constructor 프로퍼티는 그 객체의 생성자 함수를 가리킨다. a의 경우 Letter, Letter의 경우 Function이 되겠다.
여기서 프로토타입의 결정 과정을 조금 자세히 알아보자. 객체는 리터럴, 생성자 함수, Object.create 메서드 등으로 생성되는데 이 모든 생성은 추상 연산 OrdinaryObjectCreate에 의해 생성된다. 이것에 전달되는 인수에 따라 프로토타입이 결정된다. 먼저 리터럴로 생성시엔 Object.prototype를 받고, Object() 생성자 함수도 마찬가지다. 사용자 정의 생성자 함수는 생서자 함수의 prototype 프로퍼티에 바인딩된 객체가 된다.
ex) const me = new Person('Lee'); => Person.prototype를 받음.
스코프 체인과 크게 다르지 않다. 객체의 어느 프로퍼티에 접근 했을 때 그것이 없을 경우, 부모 프로토타입의 프로퍼티를 순차적으로 검색하는 매커니즘을 말한다. 언제나 이 최상위 객체는 Object.prototype이며 이것의 프로토타입은 null을 가진다. 아무데서도 찾을 수 없었다면 undefined를 반환한다. 참고로 생성자 함수의 정적 메서드는 상속되지 않는다. (이후 서술) 그리고 상위 객체의 프로퍼티와 같은 이름으로 하위 객체를 덮어쓰면 그것을 오버라이딩이라고 하고, 기존의 상위 객체의 프로퍼티는 가려지는데 그것은 프로퍼티 섀도잉이라고 한다. 이 상태에서 하위 객체의 프로퍼티를 지우면 다시 상위 객체의 프로퍼티를 사용할 수 있고, 하위 객체에서 상위 객체의 프로퍼티를 지우는 것은 불가능하다.
예시를 먼저 보자.
const Person = (function (){
function Person(name) {
this.name = name;
}
Person.prototype = {
sayHello() {
console.log(`Hi! My name is ${this.name}`);
}
};
return Person;
}());
const me = new Person('Lee');
이 경우에서 Person 객체의 프로토타입은 객체 리터럴로 교체되었다. 교체되었기 때문에 기존의 constructor 프로퍼티는 대체되었고, me의 생성자 함수는 Person이 아닌 Object로 변경된다. 만약 객체 리터럴에 constructor 프로퍼티를 넣으면 다시 유기적으로 연결되게 할 수도 있다.
조금 다르게 교체할 수도 있는데, 위 예제에서 const parent={객체 리터럴}로 분리하고 Object.setPrototypeOf(me, parent);로 교체하는 방법도 존재한다. 이 경우도 다시 연결되게 할 수 있지만 Person.prototype로 접근할 수는 없게 된다. 프로토타입의 이름 자체가 parent로 바뀐 것이기 때문이다. 교체를 통한 상속 관계의 동적 변경은 번거로우므로 직접 교체하지 않는 편이 좋다고 한다. 이후 배울 Class에서 더욱 간편하게 다룰 수도 있다.
직접 상속은 Object.create 메서드를 통한 객체 생성법이다. const obj = Object.create(null);처럼 사용하며, 매개변수로는 프로토타입으로 지정할 객체를 전달하고, 옵셔능로는 프로퍼티 키와 프로퍼티 디스크립터 객체로 이뤄진 객체를 전달할 수 있다. 이 예제에서는 null은 Object.prototype의 프로토타입으로 프로토타입 체인의 종점이므로 이 프로토타입으로 생성한 객체에서는 Object.prototype.hasOwnProperty와 같은 메서드가 동작하지 않는 것을 참고할 필요가 있다. ES6부터는 const obj = {__proto__:parent} 처럼 직접 상속을 구현할 수도 있다.
먼저 instanceof는 객체 instanceof 생성자함수 형태로 사용하며 생성자함수의 프로토타입에 바인딩된 객체들 안에 좌변 객체가 존재하면 true이다. 단순히 constructor가 이어지느냐의 여부라고 생각하기보단 Object.prototype => Person.prototype => me처럼 그대로 이어지느냐의 여부가 중요하다고 생각하면 될 것 같다. 위에서 언급한 Object.setPrototypeOf로 교체시 흐름이 완전 교체되는 느낌이기에 이런 경우 연결이 끊긴다고 보면 된다.
in 연산자는 프로퍼티 in 객체로 사용하고 프로퍼티가 객체 내에 존재하면 true이다. 주의할 점은 상속받은 모든 프로토타입의 프로퍼티를 확인한다는 점이다. 하위 객체라도 Object.prototype의 toString가 존재하냐고 물어본다면 true를 줄 것이다. 이를 해결하려면 Object.prototype.hasOwnProperty를 사용하면 상속받은 것은 false를 주게 할 수 있다.
마지막으로 for in문은 for (변수선언문 in 객체) {코드블럭} 으로 사용하며, 객체의 모든 프로퍼티를 변수에 할당하며 코드블럭을 순회한다. 여기서 어떤 프로퍼티가 할당되는지에 대해선 [[Enumerable]]라는 프로퍼티 어트리뷰트의 값이 true인 경우이다. 물론 상속받은 것을 제외하려면 Object.prototype.hasOwnProperty로 알아보며 돌려야겠다. 최근엔 Object.keys,values,entries(뒤의 두개는 ES8부터)로 더 간단히 분류하여 접근할 수도 있다.
위의 생성자 함수의 정적 메서드는 상속되지 않는다고 했다. 정적 프로퍼티/메서드는 인스턴스를 생성하지 않아도 참조하거나 호출 가능한 프로퍼티/메서드이다. 예를 보자.
function Person(name){
this.name = name;
}
Person.staticProp = 'static prop';
Person.staticMethod = function() {
console.log('staticMethod');
}
이 경우에서 staticProp과 staticMethod는 this에 대한 참조가 없다. 그렇다면 인스턴스와 유기적인 관계가 없다고 생각할 수 있다. 이 경우 const me = new Person('Lee');처럼 인스턴스의 생성 없이 Person.staticMethod()로 호출 가능하다. 역으로 me.staticMethod()로는 불가능하다. 프로토타입 체인 상에 존재하지 않고 정적 메서드로 독립적으로 존재하기 때문이다.