노트 목록

동등비교와 리액트 핵심 요소

1장 : 리액트 개발을 위해 꼭 알아야 할 자바스크립트

1.1 자바스크립트의 동등 비교

Object.is는 메서드 명만 봤을 때 객체간의 동등 비교에 사용해야할 거 같은데 아니다.
Object.is는 +0,-0을 구분하고, NaN끼리 비교하면 같다고 처리를 해준다.
===Object.is는 객체 비교에는 별 차이가 없다.

-0 === +0; // true
Object.is(-0, +0); // false
 
NaN === 0 / 0; // false
Object.is(NaN, 0 / 0); // true
 
Object.is({}, {}); // false
 
const a = {
  hello: "hi",
};
const b = a;
 
Object.is(a, b); // true
a === b; // true
-0 === +0; // true
Object.is(-0, +0); // false
 
NaN === 0 / 0; // false
Object.is(NaN, 0 / 0); // true
 
Object.is({}, {}); // false
 
const a = {
  hello: "hi",
};
const b = a;
 
Object.is(a, b); // true
a === b; // true

리액트가 얕은 비교로 컴포넌트를 리렌더링 하는건 알았지만, 내부적으로 Object.is를 사용하고 별도로 구현한 shallowEqual을 직접 보는건 처음이여서 흥미로웠다.

shallowEqual은 총 4단계의 분기 처리를 통해 동등 비교를 한다.

  1. Object.is로 검사를 한다.
  2. Object.is를 통과하지 못한 값이 null이나 객체가 아닌지 확인한다.
  3. 3번 째의 분기문부터는 객체만 남았으므로 object key의 개수가 같은지 확인한다.
  4. objA의 키를 순회하면서 objB에도 같은 키가 있는지, 또는 같은 값이 있는지 확인한다.

4단계를 모두 거쳤으면 객체간의 얕은 비교를 통해 1 depth에 존재하는 값이 같다는 의미이므로 true를 반환한다.

const a = {
  hello: "hi",
};
const b = a;
 
shallowEqual(a, b); // 1. true
shallowEqual(NaN, NaN); // 1. true
shallowEqual(3, 4); // 2. false
shallowEqual({ a: "a", b: "b" }, { a: "a" }); // 3. false
shallowEqual({ a: "a", b: "b" }, { a: "a", c: "c" }); // 4. false
shallowEqual({ a: "a" }, { a: "a" }); // 5. true
shallowEqual({ b: "b", a: "a" }, { a: "a", b: "b" }); // 5. true
const a = {
  hello: "hi",
};
const b = a;
 
shallowEqual(a, b); // 1. true
shallowEqual(NaN, NaN); // 1. true
shallowEqual(3, 4); // 2. false
shallowEqual({ a: "a", b: "b" }, { a: "a" }); // 3. false
shallowEqual({ a: "a", b: "b" }, { a: "a", c: "c" }); // 4. false
shallowEqual({ a: "a" }, { a: "a" }); // 5. true
shallowEqual({ b: "b", a: "a" }, { a: "a", b: "b" }); // 5. true

만약 배열의 경우 내부 값의 변경을 신경쓰지 않고 두 개의 배열이 동일한 주소를 가르키는지 판단한다.

개인적으로 궁금한점

react에서 shallowEqual를 구현한게 있는데 테스트 코드에서는 왜 동일한 기능을 다시 만들어서 사용을 했나?
차이점은 Object.is 폴리필 하지 않은것 밖에 모르겠다..

shallowEqual Image

2장 : 리액트 핵심 요소 깊게 살펴보기

리액트에서 컴포넌트를 만들어서 사용할 때에는 반드시 대문자로 시작하는 컴포넌트를 만들어야만 사용 가능하다.
HTML의 태그명과 사용자가 만든 컴포넌트 태그명을 구분 짓기 위해서다.

  • JSXAttributes : JSXElement에 부여할 수 있는 속성이다.
    • JSXAttributeValue는 JSXAttributeName에 할당할 값이다. 보통 JSXAttributeValue에 다른 JSX 요소를 할당할 때에는 <Child attribute={<div>hello</div>}/> 와 같이 사용을 했었는데, <Child attribute=<div>hello</div>/> 와 같이 컴포넌트를 로 감싸지 않아도 된다. (처음 안 사실이긴 한데 에초에 eslint에서도 잡아주므로 쓸 일은 없을거 같다.)

JSX가 자바스크립트로 변환되는 과정

JSX는 결국 트랜스파일에 의해 React.createElement()와 같이 바뀌게 된다. 항상 컴포넌트 위에 import React from 'react'로 React를 import해야하는 이유이다.

  • 변경전
const ComponentA = <A required={true}>Hello World</A>;
const ComponentA = <A required={true}>Hello World</A>;
  • 변경후
var ComponentA = React.createElement(
  A,
  {
    required: true,
  },
  "Hello World"
);
var ComponentA = React.createElement(
  A,
  {
    required: true,
  },
  "Hello World"
);

하지만, 리액트 17부터는 import 구문 없어도 상관이 없다.(바벨과의 협력으로 인해) 아래와 같이 JSX변환이 이루어진다.

"use strict";
var _jsxRuntime = require("react/jsx-runtime");
 
var Component = (0, _jsxRuntime.jsx)(A, {
  required: true,
  children: "Hello World",
});
"use strict";
var _jsxRuntime = require("react/jsx-runtime");
 
var Component = (0, _jsxRuntime.jsx)(A, {
  required: true,
  children: "Hello World",
});

가상 DOM과 리액트 파이버


가상 DOM은 웹페이지가 표시해야 할 DOM을 일단 메모리에 저장하고 리액트가 실제 변경에 대한 준비가 완료됐을 때 실제 브라우저의 DOM에 반영한다.
가상 DOM에 대한 오해중 하나가 실제 DOM을 조작하는 것보다 빠르다고 알고있는 것이다.
가상 DOM과 리액트의 핵심은 화면에 표시되는 UI를 값으로 관리하고 이러한 흐름을 효율적으로 관리하기 위한 메커니즘이 리액트의 핵심이다.

리액트 파이버


파이버란?

  • 가상 DOM과 렌더링 과정 최적화를 해주는것이 리액트 파이버다.
  • 파이버는 단순한 자바스크립트 객체이다. 컴포넌트가 최초로 마운트되는 시점에 생성이 되고 이후 가급적이면 재사용된다.
  • 파이버 재조정자가 가상 DOM과 실제 DOM을 비교해서 둘 사이에 차이가 있으면 파이버를 기준으로 화면에 렌더링을 요청한다.
  • 파이버는 두 단계로 나뉜다.
    • 랜더 단계 : 사용자에게 노출되지 않는 비동기 작업을 수행한다. 우선순위를 지정하거나 중지시키거나 버리는 등의 작업이 일어난다.
    • 커밋 단계 : DOM에 실제 변경 사항을 반영하기 위한 작업, 동기적으로 작업이 수행되고 중단될 수가 없다. (더블 버퍼링이 수행됨)
  • 리액트 컴포넌트에 대한 정보를 1:1로 가지고 있다.

파이버 트리란?

  • 현재의 모습을 담은 파이버 트리와 작업 중인 상태를 나타내는 workInProgress트리 두개가 존재한다.
  • 리액트 파이버의 작업이 끝나면 workInProgress트리를 현재 트리로 바꾼다. (더블 버퍼링)
  • 리액트에서는 불완전한 트리를 보여주지 않기 위해 더블 버퍼링 기법을 사용하는 것이다. 이 더블 버퍼링은 커밋 단계에서 수행된다.

클래스형 컴포넌트와 함수형 컴포넌트


클래스형 컴포넌트의 componentDidCatch는 자식 컴포넌트에서 에러가 발생했을 때 실행이 된다.
최근 리액트 개발을 할 때 선언적으로 에러처리를 하고 싶을때 주로 사용이 된다.
최근에는 비동기 상태관리 라이브러리인 tanstack query에서도 errorboundary관련 옵션이 있어서 선언적으로 에러를 처리하기가 수월해졌다.

  • 클래스 컴포넌트의 단점

    • 클래스형 컴포넌트는 애플리케이션 내부 로직의 재사용이 어렵다.
    • HOC를 사용하거나 props를 넘겨주는 방식이 있지만, 래퍼 지옥(wrapper hell)에 빠져들 위험성이 커진다.
    • 트리쉐이킹이 되지 않아서 번들링 최적화를 하기가 힘들다.
  • 함수형 컴포넌트와 클래스 컴포넌트 차이

    • 함수형 컴포넌트는 렌더링이 일어날 때마다 그 순간의 값인 props와 state를 기준으로 렌더링된다.
    • 클래스형 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링이 일어난다.

리액트의 렌더링


  • 리액트에서의 렌더링은 최초 렌더링과 리렌더링에서 발생한다.
  • 리액트에서의 랜더링은 랜더 단계와 커밋 단계라는 총 두 단계로분리되어 실행이 된다.
  • 랜더 단계 : 컴포넌트를 랜더링하고 변경 사항을 계산한다. type,props,key 세 가지 중 하나라도 변경된 것이 있으면 변경이 필요한 컴포넌트로 체크해둔다.
  • 커밋 단계 : 렌더 단계의 변경 사항을 실제 DOM에 적용한다. 커밋 단계 이후 브라우저의 렌더링이 발생한다.

랜더 단계에서 변경 사항이 없다면 DOM 업데이트가 일어나지 않는다.

리액트 메모이제이션에 대한 의견


  • 섣부른 최적화는 독이다.

    • 메모이제이션은 메모리를 점유하게 되므로 비용이 든다.
  • 렌더링 과정은 비싸다. 모조리 메모이제이션해 버리자

    • 리액트는 이전 랜더링 결과를 다음 렌더링과 구별하기 위해 이미 이전 결과물을 저장하고 있으므로(재조정 알고리즘) memo로 지불해야 하는 비용은 props에 대한 얕은 비교이다.
    • 참조의 투명성을 위해 useMemo와 useCallback을 사용하자.

성능에 대해서 지속적으로 모니터링하고 관찰하는 것 보다 섣부른 메모이제이션 최적화가 주는 이점이 더 클 수 있다.

원래 섣부른 최적화를 하게 될 경우 성능상으로 오히려 악영향(메모이제이션 성능 비교 글)을 끼치기 때문에 부정적인 생각이 있었다.
하지만, 저자가 말한대로 지속적으로 모니터링하는 비용보다 섣부른 메모이제이션을 하는 비용이 더 적을수도 있을거 같다는 생각이 들었다.