클릭 이벤트의 발생 순서

최근 진행한 프로젝트에서 click 이벤트 대신 mousedown 이벤트를 사용하여 클릭 관련 문제를 해결하였다.

1
2
3
4
<form>
<input />
<div></div>
</form>
1
2
3
4
5
6
7
8
9
10
11
12
form {
position: relative;
}
div {
display: none;
position: absolute;
top: 100%;
...;
}
form:focus-within div {
display: block;
}

form 내부의 div는 평소에 display:none으로 보이지 않지만 form에 focus-within 선택자를 이용하여 자신이나 자식이 focus되었을 경우 div를 보이게 하였다.

그리고 이 div요소에 클릭 이벤트를 등록하였는데, 클릭 이벤트가 작동하지 않았다.

focus가 해제되는 blur이벤트가 click이벤트보다 먼저 발생했기 때문이다.

클릭 이벤트는 mousedown과 mouseup이 연달아 발생하는 이벤트로 mousedown -> mouseup -> click 순서대로 발생한다.

mousedown이 감지되면 blur이벤트가 발생하고 div요소에 display:none이 적용된 후 mouseup, click이벤트가 발생하기 때문에 작동하지 않았던 것이였다.

click대신 mousedown에 이벤트를 등록하여 이벤트가 정상적으로 작동하도록 하였다.

Ref

마우스 이벤트

React 요소 바깥 클릭 감지하기

React에서 요소 바깥을 클릭할 경우에 이벤트를 발생시켜야 하는 경우가 있다.

useRef로 DOM element를 참조하여 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const wrapper = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (e.target instanceof Element && wrapper.current?.contains(e.target)) {
//wrapper 내부의 요소일 경우의 callback 실행
return;
}
//wrapper 외부의 요소일 경우의 callback 실행
};
window.addEventListener("click", (e) => handleClickOutside(e));
return () => {
window.removeEventListener("click", (e) => handleClickOutside(e));
};
}, [wrapper.current]);

wrapper로 내부와 외부를 나눌 요소를 정의한다.

정의한 후 window 전역에 이벤트 핸들러를 추가한다.

이벤트 내부에서 이벤트 전파의 원리를 이용하여 contains 메소드를 통해 이벤트의 발생 위치를 검사한다.

이벤트의 발생 위치에 따라 콜백을 실행하면 된다.

그 후 useEffect에서 cleanup 함수로 전역에 추가된 이벤트를 제거해주면 된다.

React Context API 렌더링 성능 개선방안

React에서 Context API와 상태관리에서 언급했듯이 Context API는 context의 참조하지 않는 값이 변경되더라도 전체가 렌더링되는 성능 관련 이슈가 있다.

이에 대한 해결방안을 알아보았다.

1. Context 분할

Context 분할을 통해 해결할 수 있다.

1
2
3
4
5
6
7
const state1 = {
familyname: "park",
};

const state2 = {
firstname: "sunghyeon",
};

하지만 상태가 많아져 유지보수에 큰 어려움을 겪게될 수 있다.

2. React.memo

React.memo를 사용하는 것으로도 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return (
<div>
First Name:
<input
value={state.firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
);
};
export default React.memo(FirstName);

위와같이 단순히 export 시점에 React.memo를 추가하여도 해당 컴포넌트에더 useContext를 통해 NameContext가 반환되고 있어 NameContext가 바뀔 시 렌더링이 진행된다.

때문에 props drilling을 섞어서 해결해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const InnerFirstName = React.memo(({ firstName, dispatch }) => (
<div>
First Name:{" "}
<input
value={firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
));
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return <InnerFirstName firstName={state.firstName} dispatch={dispatch} />;
};
export default FirstName;

이렇게 작성하게 되면 FirstName 컴포넌트는 여전히 리렌더링 되지만 NameContext의 firstName값이 변경되지 않는다면 InnerFirstName 컴포넌트는 리렌더링되지 않는다.

단순히 Wrapper Component를 만드는 것이므로 성능상의 문제가 되지 않는다.

3. useMemo

React.memo가 다른 부가적인 값에 의해 의도한 대로 동작하지 않는 경우 useMemo를 사용하여 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const FirstName = () => {
const [state, dispatch] = React.useContext(NameContext);
console.log("render FirstName");
return useMemo(
() => (
<div>
First Name:{" "}
<input
value={state.firstName}
onChange={(event) => {
dispatch({ type: "setFirstName", firstName: event.target.value });
}}
/>
</div>
),
[firstName, dispatch]
);
};
export default FirstName;

방법 2와 동일하게 FirstName 컴포넌트의 렌더를 막지는 못하지만, return에서 useMemo를 사용함으로써 렌더링을 강제로 막는 모습이다.

하지만 위 방법은 관리하는 state,props가 늘어날 때마다 useMemo의 dependency에도 추가를 해줘야해서 가장 유지보수가 힘든 방법이다.

Ref

ContextAPI 렌더링 이슈

EventTarget 형식에 ... 속성이 없습니다. 대처법

event와 target의 타입을 분리해서 지정해주면 된다.

1
2
3
4
5
const onDragEnd = (e: React.DragEvent) => {
if (!wrapper.current) return;
const target = e.target as HTMLDivElement;
...
}

React Hooks의 동작원리

순수함수로 작성된 함수형 컴포넌트는 어떻게 내부 상태를 관리하는지 궁금했었는데, 얼마전 클로저를 이용하여 관리한다는 것을 알게되었다.
더 확실하게 알고자 Hooks의 동작원리에 대해 알아보았다.

클로저

클로저는 함수가 속한 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프 밖에서 실행될 떄에도 이 스코프에 접근할 수 있게 하는 기능을 뜻한다.

이는 렉시컬 스코핑과 관련이 있다.

아래의 코드는 예시 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function useState(initialValue) {
var _val = initialValue;
function state() {
return _val;
}
function setState(newVal) {
_val = newVal;
}
return [state, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo()); // 0
setFoo(1);
console.log(foo()); // 1

useState 내부에서 _val이라는 지역변수를 만들고 state 함수와 setState함수로 지역변수를 노출하지 않고 변경한다.

state는 getter 함수로 구현해야 하지만, foo와 setFoo를 사용하여 내부 변수에 접근하고 조작할 수 있다는 것이다.

이 둘은 useState의 스코프에 대한 접근권한을 가지고 있고, 이를 클로저라고 한다.

함수형 컴포넌트에서 사용하기

아래의 코드는 위에서 만든 useState 복제본을 함수형 컴포넌트에 적용한 코드이다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = useState(0);
return {
click: () => setCount(count() + 1),
render: () => console.log("render:", { count: count() }),
};
}
const C = Counter();
C.render(); // render: { count: 0 }
C.click();
C.render(); // render: { count: 1 }

DOM으로 렌더링하는 대신 상태를 console.log로 출력하도록 하였다.

오래된 클로저

오래된 클로저는 클로저로 인해 갇혀있던 값이 업데이트 되지 않고 남아있는 현상이다.

원시값의 경우 참조가 없기 때문에 발생한다.

실제 React API와 동일하게 만들려면 state가 함수가 아닌 변수여야 한다. 단순히 _val을 함수로 감싸지 않고 노출하면 버그가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
function useState(initialValue) {
var _val = initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
}
var [foo, setFoo] = useState(0);
console.log(foo); // 0
setFoo(1); //
console.log(foo); // 0

_val은 원시값이기 때문에 참조가 없다.

때문에 useState의 호출에서 _val을 참조하고 다시는 변경되지 않는다.
즉 foo는 initialValue값만을 가지고 setFoo는 lexical scope의 _var를 수정한다.

때문에 setFoo를 사용하여 _var를 수정하여도 foo의 주소는 바뀌지 않는다.

모듈 안에서의 클로저

이는 우리의 클로저를 또 다른 클로저의 내부로 이동시켜서 해결할 수 있다. 즉 다른 실행컨텍스트에 _val을 묶어둠으로 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const MyReact = (function () {
let _val;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

모듈패턴을 사용하여 구성된 코드로, React와 마찬가지로 컴포넌트의 상태를 추적한다. 또한 매번 항상 올바른 클로저를 통해 내부의 _val값을 할당할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = MyReact.useState(0);
return {
click: () => setCount(count + 1),
render: () => console.log("render:", { count }),
};
}
let App;
App = MyReact.render(Counter); // render: { count: 0 }
App.click();
App = MyReact.render(Counter); // render: { count: 1 }

useEffect 복제하기

useEffect도 다음과 같이 작성할 수 있다.

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const MyReact = (function () {
let _val, _deps;
return {
render(Component) {
const Comp = Component();
Comp.render();
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const hasChangedDeps = _deps
? !depArray.every((el, i) => el === _deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
_deps = depArray;
}
},
useState(initialValue) {
_val = _val || initialValue;
function setState(newVal) {
_val = newVal;
}
return [_val, setState];
},
};
})();

function Counter() {
const [count, setCount] = MyReact.useState(0);
MyReact.useEffect(() => {
console.log("effect", count);
}, [count]);
return {
click: () => setCount(count + 1),
noop: () => setCount(count),
render: () => console.log("render", { count }),
};
}
let App;
App = MyReact.render(Counter);
// 이펙트 0
// render {count: 0}
App.click();
App = MyReact.render(Counter);
// 이펙트 1
// render {count: 1}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1}
App.click();
App = MyReact.render(Counter);
// 이펙트 2
// render {count: 2}

의존성 배열이 변경될 때 useEffect가 다시 실행되므로, 이를 추적하는 별도의 변수 _deps를 추가하였다.

마법이 아니라 배열일 뿐

지금까지 만든 복제본은 잘못 구현된 싱글톤 패턴이다.
즉 각각 하나 이상이 존재하면 버그가 발생한다.

여러개의 상태를 버그 없이 관리하기 위해선 배열형태로 관리할 수 있다.

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
const MyReact = (function () {
let hooks = [],
currentHook = 0;
return {
render(Component) {
const Comp = Component();
Comp.render();
currentHook = 0;
return Comp;
},
useEffect(callback, depArray) {
const hasNoDeps = !depArray;
const deps = hooks[currentHook]; // type: array | undefined
const hasChangedDeps = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if (hasNoDeps || hasChangedDeps) {
callback();
hooks[currentHook] = depArray;
}
currentHook++;
},
useState(initialValue) {
hooks[currentHook] = hooks[currentHook] || initialValue; // type: any
const setStateHookIndex = currentHook;
const setState = (newState) => (hooks[setStateHookIndex] = newState);
return [hooks[currentHook++], setState];
},
};
})();

setStateHookIndex는 setState에서 currentHook 변수가 덮어 씌워지는 것을 방지하는데에 사용된다.

이를 제거할 경우 오래된 클로저 문제를 일으켜 setState를 다시 호출했을 때에 동작하지 않는다.

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
function Counter() {
const [count, setCount] = MyReact.useState(0);
const [text, setText] = MyReact.useState("foo"); // 두번 째 상태 Hook!
MyReact.useEffect(() => {
console.log("effect", count, text);
}, [count, text]);
return {
click: () => setCount(count + 1),
type: (txt) => setText(txt),
noop: () => setCount(count),
render: () => console.log("render", { count, text }),
};
}

let App;
App = MyReact.render(Counter);
// 이펙트 0 foo
// render {count: 0, text: 'foo'}
App.click();
App = MyReact.render(Counter);
// 이펙트 1 foo
// render {count: 1, text: 'foo'}
App.type("bar");
App = MyReact.render(Counter);
// 이펙트 1 bar
// render {count: 1, text: 'bar'}
App.noop();
App = MyReact.render(Counter);
// // 이펙트가 실행되지 않음
// render {count: 1, text: 'bar'}
App.click();
App = MyReact.render(Counter);
// 이펙트 2 bar
// render {count: 2, text: 'bar'}

위와 같이 기본적인 개념은 Hooks의 배열과 Hook이 호출될 때 증가하고 컴포넌트가 렌더링될 때 초기화되는 인덱스를 갖는 것이다.

Hook 규칙 도출하기

이러한 동작원리가 최상위에서만 Hook을 호출해야하는 이유이다.

Ref

Learning JavaScript Design Patterns
[번역] 심층 분석: React Hook은 실제로 어떻게 동작할까?

React에서 Context API와 상태관리

Context API를 이용하면 일일이 props를 넘겨주지 않고도 컴포넌트 트리 전체에 데이터를 제공할 수 있다.

한계

다시 렌더링 할지 여부를 정할 때 참조를 확인하기 때문에 Provider의 부모가 렌더링 될 때마다 불필요하게 하위 컴포넌트가 다시 렌더링되는 문제가 발생할 수 있다. 예를 들어 아래의 코드는 value가 바뀔 때 마다 매번 새로운 객체가 생성되므로 Provider가 렌더링 될 때마다 하위에서 구독하고 있는 컴포넌트 모두가 렌더링 된다.

1
2
3
4
5
6
7
8
9
10
11
12
class App extends React.Component {
render() {
return (
<MyContext.Provider
value={{ something: "something", somthing2: "something2" }}
>
<Toolbar />
<Toolbar2 />
</MyContext.Provider>
);
}
}

즉 Toolbar 에서는 value.something을 Toolbar2에서는 value.something2를 사용한다고 하더라도 value가 갱신될 때 마다 새 객체를 만들어 내므로 실제 사용하는 props와 상관없이 다시 렌더링 된다.

그럼 어떨 때 사용해야 하나

상태관리에 적합하지 않은 것이 한계이므로, 자주 바뀌지 않는 상태를 어플리케이션 전역에서 사용하려 할 때는 유용하다.

예를 들자면 @emotion/react와 같은 라이브러리에서 ThemeProvider를 Context api로 구현해놓았다.

Ref

React 에서 Context API 와 상태관리

Promise 다양한 응용

Promise의 성질을 이용하여 다양한 응용을 할 수 있다.

시간초과 추가하기

1
2
3
4
5
6
7
8
9
10
const awaitTimeout = (delay) =>
new Promise((resolve) => setTimeout(resolve, delay));

awaitTimeout(300).then(() => console.log("Hi"));
// 300ms 뒤에 Hi가 출력됨

const f = async () => {
await awaitTimeout(300);
console.log("Hi");
};

위의 코드는 delay를 받아서 delay 뒤에 resolve 하는 Promise로 래핑한 함수이다.

Promise.race를 이용하여 시간초과 로직을 추가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const awaitTimeout = (delay, reason) =>
new Promise(
(resolve, reject) =>
setTimeout(() => (reason === undefined ? resolve() : reject(reason))),
delay
);

const wrapPromise = (promise, delay, reason) =>
Promise.race([promise, awaitTimeout(delay, reason)]);

wrapPromise(fetch("https://cool.api.io/data.json"), 3000, {
reason: "Fetch timeout",
})
.then((data) => {
console.log(data.message);
})
.catch((data) => console.log(`Failed with reason: ${data.reason}`));

promise.race는 가장 먼저 resolve된 데이터만을 사용하는 함수이다.

이러한 성질을 이용하여 delay안에 promise가 resolve되지 못할 시 에러를 발생시킨다.

시간초과를 위한 함수들을 하나의 객체로 정의하였다.

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
42
43
44
class Timeout {
constructor() {
this.ids = [];
}

set = (delay, reason) =>
new Promise((resolve, reject) => {
const id = setTimeout(() => {
if (reason === undefined) resolve();
else reject(reason);
this.clear(id);
}, delay);
this.ids.push(id);
});

wrap = (promise, delay, reason) =>
Promise.race([promise, this.set(delay, reason)]);

clear = (...ids) => {
this.ids = this.ids.filter((id) => {
if (ids.includes(id)) {
clearTimeout(id);
return false;
}
return true;
});
};
}

const myFunc = async () => {
const timeout = new Timeout();
const timeout2 = new Timeout();
timeout.set(6000).then(() => console.log("Hello"));
timeout2.set(4000).then(() => console.log("Hi"));
timeout
.wrap(fetch("https://cool.api.io/data.json"), 3000, {
reason: "Fetch timeout",
})
.then((data) => {
console.log(data.message);
})
.catch((data) => console.log(`Failed with reason: ${data.reason}`))
.finally(() => timeout.clear(...timeout.ids));
};

더욱 더 독립적으로 사용할 수 있게 되었다.

디바운싱

클로저와 Promise를 이용하여 디바운싱을 구현할 수 있다.

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
const debouncePromise = (fn, ms = 0) => {
let timeoutId;
const pending = [];
return (...args) =>
new Promise((res, rej) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const currentPending = [...pending];
pending.length = 0;
Promise.resolve(fn.apply(this, args)).then(
(data) => {
currentPending.forEach(({ resolve }) => resolve(data));
},
(error) => {
currentPending.forEach(({ reject }) => reject(error));
}
);
}, ms);
pending.push({ resolve: res, reject: rej });
});
};

const fn = (arg) =>
new Promise((resolve) => {
setTimeout(resolve, 1000, ["resolved", arg]);
});
const debounced = debouncePromise(fn, 200);
debounced("foo").then(console.log);
debounced("bar").then(console.log);

위 코드는 ms 만큼의 delay 후에 마지막 호출의 값을 반환한다. 이전에 호출된 함수들은 마지막 호출과 동일한 데이터를 반환한다.

호출될 경우 외부 pending 배열을 복사한 후 이를 초기화한다.

ms동안 새로운 호출이 없을 경우 복사한 pending 배열을 순회하며 resolve나 reject를 호출한다.

Ref

How can I add a timeout to a promise in JavaScript?

Debounce promise

Promise

UsingPromise

debounce function implemented with promises

제어 컴포넌트와 비제어 컴포넌트

리액트의 제어컴포넌트와 비제어 컴포넌트의 개념에 대해 알아보자

제어 컴포넌트

제어 컴포넌트는 사용자의 입력을 기반으로 자신의 state를 관리하고 업데이트 하는 컴포넌트이다.

state를 신뢰 가능한 단일 출처로 두고 state에 의해 컴포넌트가 제어된다.

사용자의 입력으로 state를 관리하기 때문에 사용자의 입력값과 state가 실시간으로 동기화된다.

장점

state를 신뢰가능한 단일 출처로 둔다.

즉 사용자의 입력에 따라 state를 변화시키고 state에 의해 업데이트되기 때문에 안정적이다.

때문에 데이터의 유효성을 다룰때에도 훨씬 좋다.

단점

사용자가 입력하는 모든 데이터들에 의해 동기화된다.

가령 <input/>에 text를 제공할 경우 매 타자마자 동기화된다.

=> 이는 쓰로틀링이나 디바운싱을 통해 해결할 수 있다.

비제어 컴포넌트

비제어 컴포넌트는 state로 관리하는게 아닌 DOM 자체에서 데이터를 관리하는 컴포넌트이다.

보통 state가 아닌 useRef를 관리하여 DOM 엘리먼트의 data를 참조한다.

즉 DOM을 신뢰 가능한 출처로 둔다.

useRef는 매 렌더링마다 동일한 객체를 제공하기 때문에 얕은비교 연산에서 항상 true를 반환한다.

장점

<input/>에 text를 제공할 경우를 생각해보면 매 타자마다 리렌더링 되지 않는다. 때문에 불필요한 렌더링을 줄이는데에 도움이 될 수 있다.

이 방식을 사용하는 대표적인 라이브러리는 react-hook-form이다.

Ref

제어 컴포넌트 (Controlled Component)
비제어 컴포넌트
React: 제어 컴포넌트와 비제어 컴포넌트의 차이점

force layout

js에서 요청/호출되는 일부 속성은 브라우저가 스타일과 레이아웃을 동기적으로 계산하도록 한다.

layout trashing

웹 브라우저는 레이아웃 변경을 즉시 처리하지 않고 비슷한 Style의 수정을 모아서 하게 된다. 즉 화면에서 레이아웃에 대한 정보를 실제로 알아야 할 때까지 레이아웃 계산을 지연시킨다.

layout trashing이 발생하는 경우

DOM이 변경되지 않았음을 보장할 수 있는 경우엔 레이아웃 캐시(이전 계산된 값)에서 값을 가져온다.

하지만 offsetHeight과 같은 최신으로 동기화된 레이아웃에 대한 정보에 접근하는 속성같은 경우 강제로 레이아웃 계산(리플로우)를 발생시켜 동기화 후 값을 가져오게 된다.
즉 아래와 같이 코드를 작성하게 되면 불필요한 레이아웃 계산을 과정이 요구된다.

1
2
3
4
elementA.className = "a-style";
var heightA = elementA.offsetHeight; // layout is needed
elementB.className = "b-style"; // invalidates the layout
var heightB = elementB.offsetHeight; // layout is needed again

layout trashing이 발생하지 않는 경우

위 코드는 다음과 같이 수정할 수 있다.

1
2
3
4
elementA.className = "a-style";
elementB.className = "b-style";
var heightA = elementA.offsetHeight; // layout is needed and calculated
var heightB = elementB.offsetHeight; // layout is up-to-date (no work)

똑같은 작업을 하는 코드지만 위의 코드에 비해 레이아웃 계산의 수가 줄어들게 된다.
레이아웃 계산을 강제로 발생시키는 속성은 여기에서 확인할 수 있다.

연속된 layout trashing 최적화하기

paragraph의 너비를 box의 너비와 같도록 하는 코드를 작성한다고 생각해보자

1
2
3
4
5
6
function resizeAllParagraphsToMatchBlockWidth() {
// Puts the browser into a read-write-read-write cycle.
for (var i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + "px";
}
}

위와같이 작성한다면 box의 offsetWidth에 접근한 뒤 paragraphs[i]의 너비로 설정하게 된다.

offsetWidth 속성에 접근할 때마다 강제로 레이아웃 계산을 시도하게 되므로 단일 paragraph마다 layout 계산이 일어나게 된다.

이는 다음과 같이 수정할 수 있다.

1
2
3
4
5
6
7
function resizeAllParagraphsToMatchBlockWidth() {
let width = box.offsetWidth;
for (var i = 0; i < paragraphs.length; i++) {
// Now write.
paragraphs[i].style.width = width + "px";
}
}

reflow를 강제로 발생시켜 애니메이션 실행하기

브라우저는 비슷한 style변화를 모아서 반영하기 때문에 원하는대로 애니메이션이 작동하지 않을 때가 있다. 이럴 때는 강제로 reflow를 발생시켜 해결할 수 있다.

1
2
3
4
5
6
7
8
<h2>no force reflow</h2>
<div class="wrapper">
<div class="item"></div>
</div>
<h2>force reflow</h2>
<div class="wrapper">
<div class="item2"></div>
</div>

아래는 css이다. 애니메이션을 해제하는 클래스를 정의하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.wrapper {
width: 500px;
height: 50px;
position: relative;
border: 1px solid black;
}
.item,
.item2 {
height: 100%;
width: 50px;
background: red;
position: absolute;
left: 0;
-webkit-transition: left 500ms linear;
transition: left 500ms linear;
}
.notransition {
-webkit-transition: none !important;
-moz-transition: none !important;
-o-transition: none !important;
transition: none !important;
}

제대로 작동되지 않는 경우

브라우저의 작동원리로 인해 애니메이션이 제대로 작동하지 않는 경우이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
const $wrapper = document.querySelector(".wrapper");
const $item = document.querySelector(".item");
const $item2 = document.querySelector(".item2");

$item.addEventListener("click", function () {
const itemWidth = $item.clientWidth;
const wrapperWidth = $wrapper.clientWidth;
const itemRightLimit = wrapperWidth - itemWidth;
$item.classList.add("notransition");
$item.style.left = `${itemRightLimit}px`;
$item.classList.remove("notransition");
$item.style.left = "50px";
});

코드만 보았을 때 item의 애니메이션 동작은 다음과 같은 순서로 일어나야 한다.

  1. 애니메이션 해제(notransition 추가)
  2. 박스가 부모박스 오른쪽 끝에 붙도록 left 수정
  3. 애니메이션 설정(notransition 제거)
  4. left50px로 수정

실제로는 다음과 같이 동작한다.

  1. Reflow(속성에 접근했기 때문)
  2. style 수정을 통합함
  3. 애니메이션 설정(notransition 제거)
  4. left50px로 수정(통합된 변경사항)
  5. Repaint

제대로 작동하는 경우

reflow를 강제로 발생시켜 제대로 작동하는 코드이다.

1
2
3
4
5
6
7
8
9
10
11
//...
$item2.addEventListener("click", function () {
const item2Width = $item2.clientWidth;
const wrapperWidth = $wrapper.clientWidth;
const itemRightLimit = wrapperWidth - item2Width;
$item2.classList.add("notransition");
$item2.style.left = `${itemRightLimit}px`;
$item2.clientLeft;
$item2.classList.remove("notransition");
$item2.style.left = "50px";
});

의도한 애니메이션은 item과 같다.

그리고 의도한대로 작동한다. item2의 동작 순서는 다음과 같다.

  1. Reflow
  2. 애니메이션 해제(notransition 추가)
  3. 빨간 박스가 부모박스 오른쪽 끝에 붙도록 left 수정
  4. Repaint
  5. Reflow
  6. 애니메이션 설정(notransition 제거)
  7. left50px로 수정
  8. Repaint

4와 5의 Repaint와 Reflow는 clientLeft속성을 조회하여 브라우저를 강제로 동기화했기 때문에 일어난다.

실제 작동하는 예시는 여기에서 예제를 확인할 수 있다.

Ref

What forces layout / reflow

WHAT IS A FORCED REFLOW AND HOW TO SOLVE IT?

Javascript로 애니메이션 제어하기 (Force Reflow)

FOUC와 해결방안

FOUC는 외부의 css를 불러오기 전에 스타일이 적용되지 않은 웹페이지가 나타나는 현상이다.
즉 브라우저의 CRP에서 Render Tree가 노출된 후 css와 js로 인해 DOM이 변경되면 변경사항을 적용하기 전 화면이 노출되는 현상이다.
IE에서 주로 발생한다.(IE11에서도 여전히 발생한다고 한다.)

@Import를 사용한 css

IE를 제외한 브라우저는 @import된 스타일이 적용될 때까지 화면에 표시하지 않는다.
하지만 IE는 화면에 노출된 상태로 스타일을 적용하여 FOUC를 유발한다.

다음과 같은 해결방법이 있다.

Preload 사용하기

preload 속성을 사용하여 중요한 css를 렌더링 이전에 사용할 수 있도록 한다.

css 링크 옮기기

head 요소안에 css를 링크하는 방식으로도 해결할 수 있다.

렌더링 지연시키기

render block 요소인 <link rel="stylesheet">을 사용하여 렌더링을 고의로 지연시켜 해결할 수 있다.

Proper caching 적용하기

  • 브라우저 캐시 혹은 cdn을 사용하여 정적 리소스를 받아오는 시간을 줄인다.

웹 폰트의 사용

@import를 사용하여 스타일링을 할 때와 같은 원리로 FOUC가 발생한다. IE는 웹폰트를 사용할 경우 기본 폰트를 불러들여 화면에 노출시키고 이후 사용된 웹 폰트로 다시 변경하기 때문에 발생한다.

다음과 같은 해결방법이 있다.

물론 위에서 언급한 preload와 같은 해결방법도 유효하다.

Font Loading API 사용하기

Font Face Observer 라이브러리는 웹 폰트의 로딩 상태를 추적할 수 있는 폰트 로더로, 파일 크기가 작고 실행속도가 빠르다는 장점이 있다.

다음과 같이 사용할 수 있다.

웹 폰트가 적용되지 않은 상태와 적용한 상태의 css를 적어둔다. 그 후 적용되지 않은 상태의 css가 먼저 적용되도록 한다.

1
2
3
4
5
6
7
body {
font-family: "Apple SD Gothic Neo", sans-serif;
}

body.fonts-loaded {
font-family: "Roboto", "Apple SD Gothic Neo" sans-serif;
}

그 후 사용할 웹 폰트로 FontFaceObserver의 인스턴스를 만들고 load 이벤트를 등록한다.

1
2
3
4
5
let font = newFontFaceObserver("Roboto");

font.load().then(function () {
document.body.classList.add("fonts-loaded");
});

font-display 속성

font-display 속성을 사용하여 웹 폰트의 로딩 상태에 따른 동작을 설정할 수 있다.
swap 속성을 통해 fallback font를 사용할 수 있다.

fallback font로 글자를 렌더링하고 로딩이 완료되면 웹 폰트를 적용한다.

1
font-display: swap;

SSR

SSR의 경우 SSR 단계에서 스타일을 해주어 해결할 수 있다.

공식문서나 바벨 참조하기,,^^

요약

웹폰트 - preload하여 사전에 받아와라, 폴백 폰트를 준비해라
css - 폰트보단 우선순위가 떨어지지만 preload해라. 안 될경우 head에서 받아와라.
최후의 방법으로는 렌더링을 지연시켜라..

Ref

FOUC(Flash of Unstyled Content)
웹 폰트 사용과 최적화의 최근 동향