Forwading Refs

next.js를 사용하던 중 next/link의 Link 에서 forwarding Ref를 사용하라는 에러를 받았다.

그래서 알아보기로 하였다.

DOM 에 ref 전달하기

아래 코드는 button DOM 요소를 렌더링하는 FancyButton 컴포넌트이다.

1
2
3
function FancyButton(props) {
return <button className="FancyButton">{props.children}</button>;
}

FancyButton 컴포넌트의 경우 button이나 input같은 DOM요소와 같이 어플리케이션 전체에 걸쳐서 재사용될 것이다.

그리고 포커스, 선택, 애니메이션을 관리하기 위해서는 이런 DOM 노드에 접근하는 것이 불가피하다.

React.forwardRef

React.forwardRef를 이용하면 ref를 button DOM 요소로 직접 전달할 수 있다.

1
2
3
4
5
6
7
8
9
const FancyButton = React.forwardRef((props, ref) => (
<button ref={ref} className="FancyButton">
{props.children}
</button>
));

const ref = React.createRef();
// 또는 const ref= useRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

요약

ref를 전달하기 위해선 ref라는 속성명 외의 다른 속성명을 사용해서 전달해야 한다.
하지만 React.forwardRef를 사용하면 ref를 props로 전달할 수 있다!

주의사항

컴포넌트 라이브러리에서 fowardRef를 사용하기 시작할 때 변경사항으로 간주하고 새로운 중요 버전을 릴리즈해야한다.

또한 forwardRef가 존재할 때 조건부로 적용하는 것도 권장되지 않는다.

window.matchMedia

window.matchMedia(mediaQueryString) 메서드는 미디어쿼리 문자열의 분석결과를 나타내는 MediaQueryList 객체를 반환한다.

객체는 media,matches,onchange 세 가지의 속성을 지닌다.

  1. media
    matchMedia()의 parameter로 넣은 MediaQueryString이다.
  2. matches
    boolean 값으로 MediaQueryString과 일치하면 true, 일치하지 않으면 false값을 지닌다.
  3. onchange
    해당 미디어 쿼리가 변화할 경우 실행될 이벤트이다.
    1
    2
    3
    let mediaQueryList = window.matchMedia(mediaQueryString);
    mediaQueryList.addListener((e) => {});
    //mediaQueryList.onchange=(e)=>{};

아래의 코드는 prefers-color-scheme속성이 dark인지 확인하는 코드이다.

1
window.matchMedia("(prefers-color-scheme:light:dark)");
1
2
3
4
5
const result = {
media: "(prefers-color-scheme: dark)",
matches: false,
onchange: null,
};

ref

MediaQueryList.addListener()
MediaQueryList
prefers-color-scheme

V8 히든클래스

자바스크립트는 동적 언어로 객체에서 속성을 즉시 추가하거나 제거할 수 있다. 그러나 이러한 동적조회는 성능 저하의 원인이다.

동적 조회

동적 언어에서 객체에 속성을 추가하거나 제거할 경우 선형탐색 후 속성에 접근하게 된다. 이럴 경우 프로퍼티에 접근할 경우 객체의 모든 속성에 대한 탐색이 필요하다.

해결방안

V8엔진은 이를 해결하기 위해 히든클래스라는 개념을 도입하였다.
자바스크립트에서 객체를 생성할 경우 새로운 히든 클래스를 생성한다.

예시

아래의 코드는 빈 객체를 생성하는 코드이다.

1
const object1 = {};

V8은 이 코드를 기반으로 새로운 히든 클래스를 만든다.
편의상 객체로 표현하였다.

1
c01 = {};

아래의 코드는 object1 객체에 속성을 추가하는 코드이다.

1
object1.name = "박성현";

아래의 코드를 실행할 경우 V8은 이전 히든클래스(c01)의 모든 프로퍼티를 상속하여 새로운 히든클래스(c02)를 만든다.

그 후 object1은 c02를 참조하고 V8은 c01을 계속해서 참조한다.

이렇게 되면 컴파일러가 프로퍼티 이름에 접근할 때 offset을 통해 접근하여 사전형 탐색을 우회할 수 있다.

1
2
c01 = {}; //V8이 참조중
c02 = { offset0: name }; //object1이 참조중

아래의 코드는 object1 객체에 또 다른 속성을 추가하는 코드이다.

1
object1.age = 20;
1
2
3
c01 = {}; //V8이 참조중
c02 = { offset0: name };
c03 = { offset0: name, offset1: age }; //object1이 참조중

V8은 이러한 히든 클래스를 재사용한다.
만약 다음과 같은 코드를 작성하였을 경우 c01을 재사용 한다.

1
object2 = {};
1
2
3
c01 = {}; //V8과 object2가 참조중
c02 = { offset0: name };
c03 = { offset0: name, offset1: age }; //object1이 참조중

하지만 다음과 같이 name,age 외의 프로퍼티를 추가하여 c03을 재사용할 수 없는 경우 다시 새로운 클래스를 만든다.
V8은 c02를 재사용하여 새로운 트랜지션 트리를 만들고 히든클래스인 c4를 만들어 추가한다.

1
object2.school = "konkuk univ";
1
2
3
4
c01 = {}; //V8과 object2가 참조중
c02 = { offset0: name };
c03 = { offset0: name, offset1: age }; //object1이 참조중
c04 = { offset0: name, offset1: school }; //object2가 참조중, c02와 연결된다

성능 개선 방안

V8엔진의 히든클래스를 이용하여 성능개선을 하기 위해선 동적 프로퍼티 추가를 줄여야한다.

예를들어 반복문 안에서 객체의 동적 프로퍼티를 추가하는 대신 반복문 외부에서 프로퍼티를 만들어 사용하는 것이 좋다.

즉 기존의 히든클래스를 재사용할 떄 성능이 향상된다.

ref

V8의 히든 클래스 이야기
자바스크립트 성능의 비밀 (V8과 히든 클래스)
Fast properties in V8

hexo + github 블로그 다크모드 깜빡임 해결

렌더링이 시작된 후에 defer속성으로 받아온 스크립트가 실행되다보니 화면 전환중에 흰화면으로 잠시 전환되는 현상이 발생하였다.

다음과 같은 방법으로 수정하였다.

body의 기본 배경 색 없애기

먼저 7번째 줄에 있는 body의 background-color를 none으로 수정하였다.

\theme\icarus\css\include\style\base.styl

1
$body-background-color ?= none;

기존 js파일을 두 개로 나누기

기존 js파일의 경우 init 함수에서 버튼 설정과 다크모드 로드를 동시에 해주었는데,
다크모드 로드는 렌더링 전에 실행되어야 하고 버튼 설정은 렌더링 후에(최소 DOM트리 생성 이후) 실행되어야 한다.
때문에 파일을 두 개로 나누었다.

darkmode.js엔 chinsun9님의 게시글을 읽고 matchMedia를 읽어서 실행하는 부분을 추가하였다.
(너무 친절하게 답변해주셨다..!)

localstorage와 matchMedia를 이용하여 다크모드를 로드하는 함수이다.
\themes\icarus\source\js\darkmode.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function init() {
const darkScheme = window.matchMedia("(prefers-color-scheme:dark)");
const theme = localStorage.getItem("theme");
if (theme === "dark") {
document.body.classList.add("dark");
return;
}
if (theme === "light") {
return;
}

if (darkScheme) document.body.classList.add("dark");

localStorage.setItem("theme", darkScheme ? "dark" : "light");
}

버튼 관련 설정을 하는 js 파일이다.
hexo\themes\icarus\source\js\darkmodebutton.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const darkModeButton = document.querySelector("#changeModeButton");
const darkScheme = window.matchMedia("(prefers-color-scheme:dark)");

darkScheme.addEventListener("change", (e) => {
console.log("hidark");
const newColorScheme = e.matches ? "dark" : "light";
localStorage.setItem("theme", newColorScheme);

if (newColorScheme === "dark") {
document.body.classList.add("dark");
btn.innerText = "🌙";
return;
}
document.body.classList.remove("dark");
btn.innerText = "🌞";
});

function toggleTheme() {
console.log("hibutton");
document.body.classList.toggle("dark");
darkModeButton.innerText = document.body.classList.contains("dark")
? "🌙"
: "🌞";
localStorage.setItem(
"theme",
document.body.classList.contains("dark") ? "dark" : "light"
);
}

function initButton() {
console.log("initButton");
const data = localStorage.getItem("theme");
if (data == "dark") {
darkModeButton.innerText = "🌙";
return;
}
darkModeButton.innerText = "🌞";
}

initButton();
darkModeButton.addEventListener("click", toggleTheme);

작성한 js파일의 위치 지정해주기

  1. darkmodebutton.js
    버튼은 렌더링후에(최소 DOM트리 생성 이후) 실행되어야 하므로 아래부분에 추가해주었다.
    \themes\icarus\layout\common\scripts.jsx
    56번쨰 줄에 darkmodebutton.js추가

    1
    2
    <script src={url_for("/js/main.js")} defer></script>
    <script src={url_for("/js/darkmodebutton.js")} defer></script>
  2. darkmode.js
    body 최상단에서 실행하기위해 head태그에서 js파일을 로드했다.
    적다보니 preload속성을 추가해도 좋을 것 같다.

    hexo\themes\icarus\layout\common\head.jsx

    1
    <script src={url_for("/js/darkmode.js")}></script>
  3. darkmode.js의 init 함수
    다크모드를 로드하는 건 렌더링 이전에 실행되어야한다.
    때문에 body태그 최상단에 rendering block 요소로 defer나 async 속성을 지정하지 않고 넣어주었다.
    hexo\themes\icarus\layout\layout.jsx

    1
    2
    3
    4
    <html lang={language ? language.substr(0, 2) : ""}>
    <Head site={site} config={config} helper={helper} page={page} />
    <body class={`is-${columnCount}-column`}>
    <script>init();</script>

ref

[A1] 웹뷰에서 다크모드 상속받기: 일관성있는 사용자 경험을 위하여
blog fix 다크 모드 화면 깜박임 제거

nextjs + mongo 연동하기

nextjs+ mongoDB 연동을 해보았다.

.env 설정

루트 디렉토리에 .env 파일을 만들어준다.
.gitignore가 존재할 경우 .env도 추가해주어야 한다.

.env

1
MONGODB_URL=mongodb+srv://<사용자명>:<사용자 비밀번호>@cluster0.gvztc.mongodb.net/<DB이름>?retryWrites=true&w=majority

mongooose 설치

nodejs와 연동하기 위해 mongoose 모듈을 설치해준다.

1
npm i mongoose

db 연결하기

캐시를 통해 핫리로드가 가능하게 하고 데이터베이스를 불러온다.

공식문서에서 가져왔다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import mongoose from "mongoose";

const MONGODB_URI = process.env.MONGODB_URI;

if (!MONGODB_URI) {
throw new Error(
"Please define the MONGODB_URI environment variable inside .env.local"
);
}

/**
* Global is used here to maintain a cached connection across hot reloads
* in development. This prevents connections growing exponentially
* during API Route usage.
*/
let cached = global.mongoose;

if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}

async function dbConnect() {
if (cached.conn) {
return cached.conn;
}

if (!cached.promise) {
const opts = {
bufferCommands: false,
};

cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose;
});
}
cached.conn = await cached.promise;
return cached.conn;
}

export default dbConnect;

스키마 정의하고 모델 반환하기

스키마를 정의해주고 모델을 반환한다.
db에 해당 스키마가 존재하지 않을 경우 생성하고 모델을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import mongoose from "mongoose";

const ProductSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
maxlength: 60,
},
desc: {
type: String,
required: true,
maxlength: 200,
},
img: {
type: String,
required: true,
},
prices: {
type: [Number],
required: true,
},
extraOptions: {
type: [
{
text: { type: String, required: true },
price: { type: Number, required: true },
},
],
},
},
{ timestamps: true }
);

export default mongoose.models.Product ||
mongoose.model("Product", ProductSchema);

사용하기

next.js에서는 api를 만드는 기능을 제공한다.
express와 비슷하다. RESTful한 API도 생성이 가능하다.

1
2
3
4
5
/pages/api/product/index.js 주소/api/product로 접근 가능

/pages/api/product/[id].js 주소/api/product/asdf로 {id:asdf}에 접근 가능

/pages/api/product/[...data].js 주소/api/user/의 하위 경로들로 전부 접근 가능

아래 코드는 주소/api/product를 통해 접근 가능하다.

/pages/api/products/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import dbConnect from "../../../util/mongo";
import Product from "../../../models/Product";
export default async function handler(req, res) {
const { method } = req;

dbConnect();

if (method === "GET") {
try {
const products = await Product.find();
res.status(200).json(products);
} catch (err) {
res.status(500, json(err));
}
}

if (method === "POST") {
try {
const product = await Product.create(req.body);
res.status(201).json(product);
} catch (err) {
res.status(500).json(err);
}
}
}

lazy initial state

리액트 공식문서에 정리된 내용이다.

lazy initial state

보통 useState는 다음과 같이 사용한다.

1
const [state, setState] = useState(initialState);

리액트에게 initialState가 필요한 순간은 첫 렌더링시 뿐이다.

하지만 함수형 컴포넌트의 경우 리렌더링마다 함수 본문을 실행한다.
이는 값이 필요하지 않더라도 계속해서 계산한다는 이야기이다.

아래 코드와 같이 initialState 계산비용이 비싼 경우 성능에 영향을 끼칠 수 있다.

1
const [state, setState] = useState(someExpensiveComputation(props));

이런 불필요한 계산을 방지하기 위해 리액트에선 lazy initial state라는 기능을 제공한다.
useState 안에 콜백함수를 제공할 시 초기 렌더링 시에만 사용하고 그 이후의 렌더링에는 무시된다.

다음과 같은 코드 작성시 첫 렌더링 시에만 계산한다.

1
const [state, setState] = useState(() => someExpensiveComputation(props));

정리

  1. initial state의 계산비용이 클 경우에 useState에 콜백함수를 전달하여 첫 렌더링 시에만 값을 계산할 수 있다.

  2. 단순 원시값(number,string,etc..)의 경우 계산이 필요하지 않기 때문에 오히려 성능 저하로 연결될 수 있다.

vanilla js로 componentDidMount 구현하기 1

자려하면 여러 아이디어들이 떠오른다.

오늘은 html의 load이벤트를 통해 React의 componentDidMount()를 구현할 수 있지 않을까 하는 생각이 들었다.

무시하고 자려했으나 잠이 안 와서 간단하게 실험해보기로 했다.

첫 번째 시도

HTML의 요소들에 onload로 콘솔에 hi 태그명을 찍어주었다.

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head onload="console.log('hi head')">
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body onload="console.log('hi body')">
<div class="content" onload="console.log('hi head')"></div>
</body>
</html>

하지만 작동하지 않았다.

두 번째 시도

load이벤트와 onload

mdn을 통해 load와 onload를 찾아보았다.

load

load mdn을 참고하였다.

load이벤트는 전체 문서 혹은 이미지, css stylesheet와 같은 종속 리소스가 로드될 때 발생한다고 되어있다.

onload

마찬가지로 onload mdn을 참고하였다.

load이벤트가 발생하는 대상인 이미지, window, XMLHttpRequest에 핸들러를 등록할 수 있다고 되어있다.

두 번째 시도

다른 방법은 없을까 고민하던 도중 blocking resouce인 <script> 태그를 이용하여 구현해주면 어떨까 하는 아이디어가 떠올랐다.

asyncdefer속성을 지정하지 않은 script태그의 경우 파싱을 멈추고 스크립트를 로드하는 즉시 실행하므로 될 것 같았다.

바로 코드로 옮겼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="container">
<script>
console.log("container load!");
</script>
<div id="item">1</div>
<script>
console.log("item 1 load!");
</script>
<div id="item">2</div>
<script>
console.log("item 2 load!");
</script>
<div id="item">3</div>
<script>
console.log("item 3 load!");
</script>
<div id="item">4</div>
<script>
console.log("item 4 load!");
</script>
</div>
</body>
</html>

알 맞게 작동하였다.

세 번째 시도

세 번째 시도(코드 함수화 하기)

DOMElementDidMount 함수를 작성하여 함수화 하였다..!

callback fungtion과 js의 나머지 매개변수 문법을 이용하여 callback function을 실행해주었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="container">
<div class="item1">1</div>
<div class="item2">2</div>
<div class="item3">3</div>
<div class="item4">4</div>
</div>
<script>
function DOMElementDidMount($selector, $func, ...$params) {
const $target = [...document.querySelectorAll($selector)];

$target.forEach((ele) => {
const $callfunc = document.createElement("script");
const $textContext = document.createTextNode(
`(${$func})(${$params})`
);

$callfunc.appendChild($textContext);
ele.prepend($callfunc);
});
}
const hi = (...$params) => console.log(`hi ${$params.join(" ")}`);
DOMElementDidMount(".container", hi, '"hello"', '"container"');
DOMElementDidMount(".item1", hi, '"hello"', '"item1"');
DOMElementDidMount(".item2", hi, '"hello"', '"item2"');
DOMElementDidMount(".item3", hi, '"hello"', '"item3"');
DOMElementDidMount(".item4", hi, '"hello"', '"item4"');
</script>
</body>
</html>

알맞은 위치에 script 태그가 추가되었다.

세 번째 결과

콘솔에도 알맞게 출력하고

세 번째 콘솔

크롬의 성능탭에서도 5개의 prepend 이벤트가 순서대로 발생함을 볼 수있다.

세 번째 성능탭

한계

위 방법은 두 가지 한계가 있었다.(내가 모르는 한계가 더 있을 것이다.)

  1. 성능의 심한 저하
    $target이 된 요소마다 script 태그를 집어넣으므로 계속해서 HTML 파싱을 막게된다.
    이는 성능의 큰 저하로 연결된다.

  2. 재사용성의 부재
    처음 랜더링 될 때 한 번을 제외하고는 계속 호출하거나 재사용하기가 어렵다.

개선방안

클래스형을 사용하여 실제 HTML을 수정한 후 최하단에서 정의한 DOMElementDidMount()함수를 호출하면 되지 않을까 싶었다.

느낀점

예전에 vanilla js로 웹컴포넌트를 구현하는 아티클을 읽은며 따라해본 적이 있는데 그 때는 필요성을 잘 느끼지 못했다.

한 번 흉내내려고 해보니 개념도 확실하게 잡히고 확실한 필요성을 느꼈다.

리액트가 왜 클래스형 컴포넌트로 시작했는지를 느꼈다..!(계기는 사소하고 이상하지만!)

React.memo 없이 성능개선하기

며칠 전 리액트의 렌더링 성능 최적화를 위한 diff 알고리즘과 재조정 알고리즘에 대해 공부했었다.

공부한 내용을 바탕으로 TOAST UI에 작성된 리액트 렌더러를 최적화하는 간단한 트릭을 읽고 정리해보았다.

리액트는 diff 알고리즘을 통해 state나 props가 변화할 때 마다 새로운 엘리먼트 트리를 만든다.

이 말은 즉 마지막 렌더링 떄와 동일한 리액트 엘리먼트를 넘긴다면 리액트는 그 엘리먼트를 리랜더링하지 않는다는 의미이다.

예제 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// codesandbox: https://codesandbox.io/s/react-codesandbox-g9mt5

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
<Logger label="counter" />
</div>
);
}

ReactDOM.render(<Counter />, document.getElementById("root"));

위 예제 코드에선 첫 렌더링에 counter rendered가 콘솔에 기록되고,
Counter의 count 값이 변화할 때 마다 counter rendered가 콘솔에 기록될 것이다.
여기서 <Logger label="counter"/> 엘리먼트는 렌더링할 때 변하지 않는 정적인 엘리먼트이다.

UI 기술 객체

counter 함수를 호출시 다음과 같은 ui 기술 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 몇 가지를 제거한 예제
const counterElement = {
type: "div",
props: {
children: [
{
type: "button",
props: {
onClick: increment, // 클릭 이벤트 핸들러 함수
children: "The count is 0",
},
},
{
type: Logger, // Logger 컴포넌트 함수
props: {
label: "counter",
},
},
],
},
};

이중 버튼을 눌러 count를 변경할 시 바뀌는 값은 button 엘리먼트의 props의 이벤트 핸들러 함수와 children이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment, // 변경
children: "The count is 1", // 변경
},
},
{
type: Logger, // 불변
props: {
label: "counter", // 불변
},
},
],
},
};

하지만 리액트는 재조정 알고리즘에 따라 전체를 새로운 것으로 변경한다.(props를 갱신한다.)

모든 엘리먼트들의 type은 동일하고 Logger 엘리먼트의 label 속성도 변하지 않는다.
하지만 객체의 속성이 이전의 props 객체와 같더라도 props 객체 자체는 매 번 렌더링 할 떄마다 변경된다.

이렇게 Logger의 props 객체는 변경되기 떄문에 리액트는 새로운 props 객체에 기반한 JSX 객체를 얻기 위해 Logger 함수를 다시 실행한다.

다음과 같이 이런 점을 반영하여 리액트에 jsx 엘리먼트를 만들어 전달해주면 이러한 과정을 생략할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// codesandbox: https://codesandbox.io/s/react-codesandbox-o9e9f

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter(props) {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
{props.logger}
</div>
);
}

ReactDOM.render(
<Counter logger={<Logger label="counter" />} />,
document.getElementById("root")
);

위 코드의 ui 기술 객체는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment,
children: "The count is 1",
},
},
// Logger element 자체가 불변한다.
{
type: Logger,
props: {
label: "counter",
},
},
],
},
};

Logger 엘리먼트 자체가 변경되지 않았기 떄문에 리액트는 이러한 최적화를 제공할 수 있다.
떄문에 다시 렌더링 할 필요 없는 Logger 엘리먼트를 렌더링 하지 않는다.

때문에 위 코드는 처음 렌더링시에만 counter rendered를 콘솔에 기록한다.

이 방법은 리액트가 props객체 전체만 체크한다는 것을 제이하고 기본적으로 React.memo 동작 방식과 같다.

정리

게시글에서 제안하는 성능을 개선하기 위한 실용적인 방법은 다음과 같다.

  1. 렌더링 비용이 비싼 컴포넌트는 덜 렌더링 되는 상위요소로 끌어 올린다.

  2. 그리고 해당 컴포넌트를 props로 내려준다.

느낀점

신기한 접근인 것 같다.
마찬가지로 자바스크립트의 메모리 사용 방식으로 인해 가능한 방식인 것 같다.

React fiber

React Fiber?

  • 리액트 재조정 알고리즘(Reconciliation Algorithm)을 보완한 알고리즘이다.

  • React 16에서 도입되었다.

  • Fiber라는 용어는 React의 데이터 구조 혹은 아키텍처를 의미하며 DOM 트리의 Node를 나타내는 ‘fiber’에서 유래되었다.

React Fiber의 개선사항

재조정 알고리즘(Reconciliation Algorithm)의 경우 재귀적으로 동작하여 중간에 멈출 수 없다.

Fiber는 정해진 우선순위에 따라 작업을 일시중지, 재개, 중단할 수 있는 작은 단위로 나누어 더욱 유동적으로 동작할 수 있도록 한다.

특히 애니메이션의 경우 재조정 알고리즘(Reconciliation Algorithm)에서는 추가로 랜더링 알고리즘이 돌아가야하고 16ms 내에 랜더링 될 수 없었다.

React Fiber에선 애니메이션 랜더링 작업을 작은 단위로 나누어 브라우저가 쉬고 있을 때(idle 상태일 떄) 조금씩 랜더링을 지시하는 방식을 통해 더욱 유연하게 애니메이션을 랜더링 할 수 있게한다.

작동방식

  1. Reconciliation
  • UI에 랜더링할 변경사항을 계산한다.
  • 계산이 끝나면 React는 변경사항을 예약한다
  • 실제 변경이 반영되지는 않는 단계다.
  • 작업단위 마다의 우선순위를 설정하므로 하나의 작업이 여러개의 단위로 나뉘어 각각 다른 우선순위를 지닌다.
  1. Commit
  • React가 DOM에 랜더링을 지시한다.
  • Reconciliation은 중단될 수 있지만 Commit은 중단될 수 없다.

ref

리액트 fiber
리액트 fiber 소개

리액트 재조정(Reconciliation)

React에서 diff 알고리즘을 만들기 위해 고려한 재조정 알고리즘을 정리해보았다.

선행 개념

리액트 재조정에 대한 설명 전에 간단이 알아두어야 할 용어이다.

엘리먼트 (Element)

  • 실제 화면에 랜더링할 DOM 노드의 정보이다.
  • 즉 js의 불변객체이다.
  • 일반적으로 jsx로 적혀지고 아래와 같이 저장된다.
1
2
3
4
5
<div clasName="hi">
<div>
<div></div>
</div>
</div>

다음과 같이 type(문자열,컴포넌트 함수/클래스)과 props(객체)필드로 표현된다.

1
2
3
4
5
6
7
8
9
{
type:"div"
props:{
className:'user_info',
children:[
...
]
}
}
  • 엘리먼트들로 이뤄진 트리를 엘리먼트 트리라고 부르며, 이것이 메모리 상에만 존재하는 가상 DOM(virtual DOM)이다.
  • React가 실제로 화면에 랜더링하는 대상이다.

컴포넌트 엘리먼트 (Component Element)

  • type이 컴포넌트 클래스/함수(대문자로 시작)인 엘리먼트이다.

  • 사용자가 직접 정의한 컴포넌트이며 입력으로 props를 받으면 랜더링할 엘리먼트 트리를 반환한다.

  • 위 엘리먼트 트리의 노드는 다른 DOM 엘리먼트나 컴포넌트 엘리먼트들로 이루어진다.

  • React는 랜더링을 위해서 모든 컴포넌트 엘리먼트들에게 어떠한 엘리먼트를 반환해야 하는지 묻는다.

  • 클래스형 컴포넌트

    • 지역 상태를 가질 수 있다.
    • 인스턴스에 대응하는 DOM노드가 생성, 수정, 삭제될 때의 동작을 제어할 수 있다.(생명주기,life cycle)
  • 함수형 컴포넌트

    • render()함수만 가지는 클래스형 컴포넌트와 동일하다.

엘리먼트 관련 핵심 아이디어

  • 엘리먼트들끼리는 서로 섞이거나 중첩되는것이 가능하다.
  • 즉 다른 컴포넌트를 정의하는데에 있어서 재사용이 가능하다.
  • 컴포넌트들의 분리를 가능하게 하여 복잡한 UI를 쉽게 구성할 수 있다.

컴포넌트 인스턴스(Component Instance)

  • 클래스의 인스턴스이다.
  • 클래스 내부에서 this키워드를 통해 참조하는 대상이다.
  • 지역상태를 저장하고 생명주기(life cycle), 이벤트에 대한 반응을 제어할 때 유용하다.

DOM 엘리먼트(DOM Element)

  • 말 그대로 DOM에 존재하는 엘리먼트들이다.
  • type이 태그 이름에 해당하는 문자열(소문자로 시작)하는 엘리먼트이다.
  • props 정보를 통해 해당 노드의 attribute들을 표현한다.

동기

state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 반환한다.
React는 만들어진 트리에 맞게 효과적으로 UI를 갱신할 필요가 있다.

하나의 트리를 가지고 다른 트리로 변환하기 위한 연산 수를 구하는 알고리즘 문제를 푸는 해결책은
주로 O(n3)의 복잡도를 가진다.

React는 더욱 효율적인 계산을 위해 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현하였다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.

  2. 개발자가 key prop를 통해 여러 랜더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할 지 표시해 줄 수 있다

    • 이는 key element에 index를 쓰는것이 지양 되는 이유이다.

비교 알고리즘(Diffing Algorithm)

두 개의 트리를 비교할 때 React는 두 엘리먼트의 루트 엘리먼트부터 비교한다. 이후의 동작은 루트 엘리먼트의 타입에 따라 달라진다.

엘리먼트의 타입이 다른 경우

두 루트 엘리먼트의 타입이 다르면 React는 기존의 트리(루트 엘리먼트와 하위 컴포넌트들)를 버리고 새로운 트리를 구축한다.

ex) <a>에서 <img>로 바뀌는 경우

트리를 버릴 때 이전 DOM 노드들은 모두 파괴된다.

  1. 컴포넌트 인스턴스는 componentWillUnmount()가 실행된다.
  2. 새로운 트리가 만들어진다.
  3. 새로운 트리가 DOM에 삽입될 때 UNSAFE_componentWillMount()가 실행되고 이후 componentDidMount()가 실행된다.
  4. 이전 트리와 관련된 모든 state가 사라진다.

또한 루트 엘리먼트 아래의 모든 컴포넌트도 언마운트 되고 그 state도 사라진다.

아래와 같은 비교에선 이전 Counter는 사라지고 새로 다시 마운트가 된다.

1
2
3
4
5
6
7
<div>
<Counter/>
</div>

<span>
<Counter/>
</span>

DOM 엘리먼트의 타입이 같은 경우

같은 타입의 React DOM 엘리먼트를 비교할 때 React는 두 엘리먼트의 attribute를 확인하여 변경된 attribute만 갱신한다.

아래의 비교에선 React는 현재 DOM 노드상의 className만 수정한다.

1
2
3
<div className="before" title="stuff" />

<div className="after" title="stuff" />

style이 갱신될 떄 또한 React는 변경된 attribute만을 갱신한다.

아래의 비교에선 React는 현재 DOM 노드 상의 style에서 color만들 수정한다.

1
2
3
<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

같은 타입의 컴포넌트 엘리먼트

컴포넌트가 갱신되면 인스턴스는 렌더링간 state를 유지한다.
새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신한다.

이 때 해당 인스턴스의 UNSAFE_componentWillReceiveProps(),UNSAFE_componentWillUpdate(),componentDidUpdate를 호출한다.

다음으로 render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리한다.

자식에 대한 재귀적 처리

DOM 노드들의 자식들을 재귀적으로 처리할 때 React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.

아래의 변경은 마지막 요소에 <li?>third</li>를 추가하였다.
first가 일치함을 확인하고 second가 일치하는 것을 확인 후 third를 트리에 추가한다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>first</li>
<li>second</li>
</ul>

<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>

하지만 이 경우 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않다.
아래의 경우 처음과 마지막까지 모두 순회하여 변경하거나 자식 엘리먼트 모두를 변경해야한다.
이는 성능저하의 요인이 된다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>

<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>

keys

이러한 문제를 해결하기 위해 React에선 key 속성을 지원한다.
자식들이 key를 가지고 있다면 React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인한다.

아래의 변경은 key를 추가하여 엘리먼트의 맨 앞에 자식을 추가하는 경우도 효율적으로 작동하도록 하였다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

이것이 배열의 index가 key로 권장되지 않는 이유이다.
항목들이 재배열되지 않을 경우 유용하지만 삭제와 재배열이 필요할 경우 비효율적으로 동작한다.

컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용되기 떄문에 인덱스를 key로 사용할 경우 항목의 순서가 바뀌었을 때 key또한 바뀌어 state가 정상적으로 작동하지 않을 수 있다.

또한 key는 형제 사이에서만 유일하면 된다.

고려사항

위의 재조정 알고리즘(Reconciliation Algorithm)이 랜더링 전후에 변경된 부분만을 변경하는 알고리즘이다.

react는 휴리스틱에 의존하고 있기 때문에 다음과 같은 것을 고려해야한다.

  1. React는 다른 컴포넌트 타입의 경우 전부 버리고 교체해버린다.
    때문에 비슷한 결과물을 출력하는 두 컴포넌트를 작성해야 한다면 같은 타입으로 만드는 것이 더 나을 수도 있다.

  2. key는 변하지 않고 예상 가능하며 유일해야한다.
    그렇지 않을 경우 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있다.

느낀 점

불변성을 지키기에 용이하고 값을 직접 변경하지 않아 가능한 방식이라고 느꼈다.

또한 직접적인 DOM조작이 지양되는 이유라고 생각했다.

ref

리액트 엘리먼트
리액트 공식문서