반응형 rem 미세팁

웹 접근성과 반응형을 위해 마진, 패딩등의 단위로 rem을 많이 사용한다.

rem은 html의 font-size 기준으로 기본 font-size는 16px이다.

그래서 계산이 조금 어려운데, 다음과 같이 수정해주면 계산을 쉽게 할 수 있다.

1
2
3
html {
font-size: 10px;
}

html의 font-size가 10px이 되기 때문에 계산을 좀 더 편하게 할 수 있다.

하지만 이렇게 고정값을 줄 경우 브라우저에서 font-size 관련 설정을 larger 등으로 바꾸어뒀을 경우에도 고정되는 문제가 생긴다.

1
2
3
4
html {
font-size: 62.5%;
}
/* 16*0.625===10 */

다음과 같이 작성해주면 브라우저의 larger 설정으로 font-size가 20이 되더라도 그걸 반영하여 적용되게 된다.

바닐라 js로 browser router 만들기

SPA를 만들기 위해선 router 구현이 필요하다.

vanillaJS로 browser router를 구현해보았다.

기본적으로 SPA의 동작원리는 다음과 같다.

key를 url path로 value를 component로 하는 객체 리터럴을 만든다.

페이지 이동을 할 때에 이 객체 리터럴에서 페이지에서 파싱한 값을 통해 component를 조회하여 렌더링한다

페이지 이동을 정의하는 방법엔 두 가지가 있다. 함수를 통해 pushState로 바꿔주고 innerHTML을 바꾸어주거나 혹은 이 작업을 커스텀 이벤트로 정의하는 방식이다.

두 번째( 커스텀 이벤트를 정의하는 방식 )으로 browser router를 구현해보았다.

Router 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Router {
constructor() {}
// 생성자
initEvent() {}
// 이벤트 초기화
onRouterChangeHandler() {}
// route가 변경되었을 때의 이벤트 핸들러
hasRoute() {}
// 올바른 라우트인지 검증
getRoute() {}
// 해당 라우트 가져오기
renderPage() {}
// 페이지 렌더링
push() {}
// 라우터 push
}

Router 초기화

1
2
3
4
5
6
7
8
9
10
11
12
13
constructor({
$app,routes,fallback='/'
}){
this.$app=$app;
this.fallback=fallback;
this.routes=[];

routes.forEach(route=>{
this.routes=[route.path]=route.page;
})

this.initEvent();
}

$app,fallback,route를 초기화하고 initEvent를 실행한다.

커스텀 이벤트 추가

browser router는 history API를 사용하여 주소를 변경한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
initEvent() {
document.addEventListener(
"moveRoutes",
this.moveRoutesHandler.bind(this) as EventListener
);
}

moveRoutesHandler(event: CustomEvent) {
const path: string = event.detail.path;
history.pushState(event.detail, "", path);

this.renderPage(path);
}

moveRoutes라는 이벤트를 추가해주어야 한다.

1
2
3
4
5
6
7
export const customEventEmitter = (eventType: string, detail?: object) => {
document.dispatchEvent(
new CustomEvent(eventType, {
detail,
})
);
};

커스텀 이벤트를 정의하는 함수를 만든다.

페이지 렌더링

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
hasRoute(path: string) {
return typeof this.routes[path] !== "undefined";
}

getRoute(path: string) {
return this.routes[path];
}

renderPage(path: string) {
let route;
const regex = /\w{1,}$/;

if (this.hasRoute(path)) {
route = this.getRoute(path);
// 경로가 존재할경우
} else if (regex.test(path)) {
route = this.getRoute(path.replace(regex, ":id"));
// 경로가 존재하지 않을 경우 동적 라우팅(id)
} else {
route = this.getRoute(this.fallback);
// 그 외의 주소에 대해서는 fallback 실행
}
new route(this.$app, {});
// route 객체의 인스턴스를 만듬
}

push(path: string) {
customEventEmitter("moveRoutes", {
...history.state,
path,
});
}

라우터 export

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export let router;

export function initRouter($app, routes, fallback) {
const routerObj = new Router($app, routes, fallback);

router = {
push: (path) => routerObj.push(path),
};

customEventEmitter(
"moveRoutes",
history.state ?? {
path: "/",
}
);
}

모듈패턴을 이용하여 push를 제외한 나머지는 private로 두었다.

그리고 초기에 customEventEmitter를 통해 루트 페이지가 렌더링 될 수 있도록 하였다.

사용하기

1
2
3
4
5
6
7
const routes = [
{ path: "/", page: App },
{ path: "/detail/:id", page: Detail },
];

const target = document.querySelector(".App");
initRouter(target, routes);

Ref

바닐라JS(TS)로 리액트 SPA 구현하기 | (4) 클래스로 BrowserRouter 구현
Build a custom SPA Router using VanillaJS

input 태그의 width

input에 width:100%을 주어도 overflow가 일어난다.

1
2
3
input {
width: 100%;
}

input태그는 기본적으로 border가 있기 때문에 box-sizing을 바꾸어주어야 한다.

1
2
3
input {
box-sizing: border-box;
}

클릭 이벤트의 발생 순서

최근 진행한 프로젝트에서 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 와 상태관리

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)