브라우저의 이벤트 루프와 렌더링은 어떤 관계가 있을까요?
원문 링크 : https://xnim.me/blog/javascript-browser-event-loop-layout-paint-composite-call-stack
주 : 요즘 중국인이 쓴 영어 문서, 러시아인이 쓴 영어 문서를 읽고 있는데, 새로운 언어를 공부하는 느낌입니다.
해당 문서는 러시아인이 작성했습니다.
자기 평가를 위한 문제
1. "1"이 콘솔에 찍힐까?
function loop() {
Promise.resolve().then(loop);
}
setTimeout(() => {console.log(1)}, 0);
loop();
정답 : no
promise 태스크는 마이크로 태스크 큐로 들어감.
해당 큐의 resolved된 프로미스의 콜백이 전부 실행되기 전까지 브라우저는 렌더링을 수행하지 않음
2. 현재 화면에 링크가 있고, 해당 링크 요소에는 cursor:pointer, :hover 관련 스타일이 지정되어 있다.
아래 코드를 콘솔에서 실행한 뒤, 해당 태그와 인터랙션 하면 스타일 적용 결과를 볼 수 있을까?
while (true);
정답 : no
콜스택이 비워지기 전 까지, 브라우저는 렌더링을 수행하지 않는다.
3. 콘솔에는 로그가 어떻게 찍힐까?
Promise.resolve(1)
.then((x) => { console.log(x); return x + 1 })
.then((x) => {console.log(x);})
Promise.resolve(10)
.then((x) => { console.log(x); return x * 10})
.then((x) => {console.log(x);})
정답 : 1 10 2 100
resolved 된 순서대로 then 체인의 콜백을 하나씩 처리함
4. 팝업 요소의 높이를 0에서 auto로 애니메이션 하는 방법
https://www.geeksforgeeks.org/how-to-make-transition-height-from-0-to-auto-using-css/
우리의 목표
이벤트 루프
while (true) {
if (execQueue.isNotEmpty()) {
execQueue.pop().exec();
}
}
- 우선 순위 지정
- 태스크(작업) 실행
- 다양한 큐가 있습니다.
- 브라우저가 <script> 태그를 처리합니다.
- 연기된 태스크: setTimeout, setInterval, requestIdleCallback
- XmlHttpRequest, fetch 등을 통한 서버 응답
- 브라우저 API의 이벤트 및 구독자(subscriber) 알림:
- click, blur, input, visibilitychange, message 등
- 그 중 일부는 사용자가 시작합니다(버튼 클릭, Alt-탭 등).
- promise 상태 변경.
- 해당 변경의 원인은 경우에 따라 js 코드 외부에 있을 수 있습니다.
- DOMMutationObserver, IntersectionObserver와 같은 관찰자(observer)
- RequestAnimationFrame
- 다른 무언가? WebAPI(때때로 브라우저 API라고도 함)를 통해 계획된 것들
- 예시:
- setTimeout(function a() {}, 100) 입력
- WebAPI가 100ms 동안 태스크 연기
- 100ms 후 WebAPI는 함수 a()를 대기열(TaskQueue)에 삽입
- EventLoop는 주기에 따라 태크크를 실행합니다.
- 예시:
JS 코드는 어떻게든 DOM과 상호 작용해야 합니다.
요소의 크기 가져오기, 속성 추가, 일부 팝업 그리기 등을 수행하며 인터페이스를 활성화해야 합니다.
이런 특성들은 요소 렌더링에 몇 가지 제한 사항을 추가합니다.
2개의 스레드를 실행하여 그 중 하나에서 JS를 실행하고 다른 스레드에서 CSS로 렌더링하는 것은 복잡합니다.
많은 코드 동기화가 필요하기 때문입니다. 그렇지 않으면 일관성 없는 실행이 발생할 수 있습니다.
JS와 요소 렌더링이 모두 동일한 스레드에서 작동하는 이유입니다.
좋습니다. 스키마에 "렌더링"을 추가해야 한다는 의미입니다.
단일 태스크가 아니므로 별도의 대기열을 사용하는 것이 좋습니다.
그것을 렌더 큐라고 부릅시다.
- 하나는 대부분의 JS 작업용입니다. (SomeJSTasks라고 가칭합시다)
- 다른 하나는 렌더링용입니다. (RenderQueue)
- TaskQueue는 모든 이벤트, 연기된 태스크, 거의 모든 것을 위한 것입니다. 이 큐의 작업은 "태스크"입니다.
- MicroTaskQueue는 promise 콜백(resolve / reject) 및 MutationObserver용입니다.
- 이 대기열의 작업은 "마이크로태스크"입니다.
주 : 문맥 상 마이크로태크스인지, 태크스인지 구분할 필요가 없으면
작업(JS 코드/ 콜백)과 태스크를 마이크로태스크 / 태스크 전부에 해당하는 용어로 사용하겠습니다.
스크린(화면) 업데이트
이벤트 루프는 프레임과 불가분의 관계에 있습니다.
JS 코드를 실행할 뿐만 아니라 새 프레임을 계산합니다.
브라우저는 가능한 한 빨리 페이지의 변경 사항을 표시하려고 합니다.
몇 가지 제한 사항이 있습니다.
- 하드웨어 제한: 화면 재생 빈도
- 소프트웨어 제한: OS, 브라우저, 에너지 절약 설정 등
대부분의 최신 장치(및 응용 프로그램)는 60FPS(초당 프레임 수)를 지원합니다.
대부분의 브라우저는 이 특정 속도로 화면 업데이트를 시도합니다.
따라서 아티클에서는 60FPS를 기준으로 사용하지만 명확한 속도는 다를 수 있음을 염두에 두는 것이 좋습니다.
(주 : 즉, 하나의 태스크를 16.6ms 내에 처리하는 것을 목표로 가정)
태스크 큐란 무엇인가
- 이벤트 루프는 첫 번째 작업 A를 가져와서 실행합니다. 이는 4ms가 걸립니다.
- 그런 다음 이벤트 루프는 다른 대기열(MicroTaskQueue 및 Render Queue)을 확인합니다.
- 두 큐는 지금 비어 있다 가정합시다. (16.6ms안지남, 프로미스 없음)
- 이벤트 루프가 두 번째 작업을 실행하는 이유입니다.
- 그 다음 작업 B 실행에는12ms가 걸립니다.
- 즉, 총 2개의 작업 처리에 우리는 16ms를 사용합니다.
- 브라우저는 렌더링 대기열에 작업을 추가하여 새 프레임을 그립니다.
- 60fps를 위해 약 16ms마다 프레임을 업데이트 해야 합니다.
- 이벤트 루프는 렌더링 대기열을 확인하고 이러한 작업의 실행을 시작합니다. 약1ms가 걸립니다.
- 이러한 작업 후에 이벤트 루프는 다시 TaskQueue로 돌아갑니다.
중요 참고 사항: 14개 프레임 손실은 브라우저가 연속으로 15개 프레임을 렌더링한다는 의미가 아닙니다.
MicroTaskQueie를 검토하기 전에 콜 스택에 대해 이야기해 봅시다.
콜 스택
콜 스택은 현재 호출 중인 함수와 현재 함수 실행이 완료될 때 함수 간 전환이 발생하는 위치를 보여주는 목록입니다.
예를 살펴보겠습니다.
function findJinny() {
debugger;
console.log('It seems you get confused with universe');
}
function goToTheCave() {
findJinny();
}
function becomeAPrince() {
goToTheCave();
}
function findAFriend() {
// ¯\_(ツ)_/¯
}
function startDndGame() {
const friends = [];
while (friends.length < 2) {
friends.push(findAFriend());
}
becomeAPrince();
}
console.log(startDndGame());
브라우저 콘솔에서 이 코드를 실행하고
디버거 명령에서 일시 중지합니다.
콜 스택은 어떻게 표시될까요?
이는 콜 스택이 동작하는 방식입니다.
현재 실행 중인 모든 함수의 큐(스택)이며
콜 스택은 현재 함수가 종료된 후 올바른 위치로 돌아가도록 도와줍니다.
마이크로태스크 큐란 무엇인가?
마이크로태스크는 구체적입니다.
Promise 또는 MutationObserver 콜백일 수 있습니다.
Microtasks는 꼼수의 일종이며, 일반 태스크과 비교할 때 몇 가지 장단점을 부여합니다.
마이크로태스크의 주요 기능은 콜 스택이 비는 즉시 실행된다는 것입니다.
예를 들어 다음과 같은 콜 스택이 있을 수 있습니다.
우리는 RenderQueue에 렌더링 작업이 없는 경우
- 태스크(not 마이크로태스크가)가 연속적으로 처리될 수 있음을 알았습니다.
- 마이크로태스크 큐가 비어있기 전까지는 렌더링 작업을 처리할 수 없음을 알았습니다.
RenderQueue 내에선 어떤 것이 실행되나요?
각 프레임 렌더링은 여러 단계로 나눌 수 있습니다.
또한 각 단계는 하위 단계로 나눌 수 있습니다.
각 단계에 대해 자세히 살펴 보겠습니다.
RequestAnimationFrame (raf)
브라우저는 렌더링을 시작할 준비가 되었습니다.
우리는 해당 단계를 구독하고 애니메이션 단계를 위한 프레임을 계산하거나 준비할 수 있습니다.
이 콜백은 프레임 렌더링 직전의 애니메이션 작업이나 DOM의 일부 변경 계획에 적합합니다.
- Raf의 콜백에는 다음과 같은 인수가 있습니다: DOMHighResTimeStamp
- "time origin"(문서 수명의 시작) 이후 경과된 밀리초 수입니다.
- 따라서 콜백 내에서 performance.now()를 사용할 필요가 없을 수도 있습니다.
- raf는 descriptor (id)를 반환하므로 cancelAnimationFrame을 사용하여 raf를 취소할 수 있습니다. (setTimeout 처럼);
- 사용자가 탭을 변경하거나 브라우저를 최소화하면 다시 렌더링되지 않으므로 raf도 사용할 수 없습니다.
- 요소의 크기를 변경하거나 요소 속성을 읽는 Js 코드는 requestAnimationFrame을 강제할 수 있습니다.
브라우저가 프레임을 렌더링하는 빈도를 확인하는 방법? 다음 코드가 도움이 될 것입니다.
const checkRequestAnimationDiff = () => {
let prev;
function call() {
requestAnimationFrame((timestamp) => {
if (prev) {
console.log(timestamp - prev); // It should be around 16.6 ms for 60FPS
}
prev = timestamp;
call();
});
}
call();
}
checkRequestAnimationDiff();
- Safari는 프레임이 렌더링된 후 raf를 호출합니다. 동작이 다른 유일한 브라우저입니다. https://github.com/whatwg/html/issues/2569#issuecomment-332150901
Style (recalculation)
Layout
- 예를 들어 Chrome에서는 update layer tree / layout shift를 볼 수 있습니다.
- 요소의 크기 및 위치와 관련된 속성 읽기(offsetWidth, offsetLeft, getBoundingClientRect 등)
- 요소 중 일부(transform 및 will-change 등)를 제외한 요소의 크기 및 위치와 관련된 속성을 작성합니다.
- transform은 컴포지션 단계에서 동작합니다.
- will-change는 컴포지션(composition) 단계에서 속성 변경을 계산해야 한다는 신호를 브라우저에 보냅니다.
- 아래 링크에서 해당 신호를 보내는 목록을 확인할 수 있습니다.
- (주 : 434줄의 함수를 읽어보면 C++를 몰라도 어느정도 이해할 수 있다)
- https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/core/paint/compositing/compositing_reason_finder.cc;l=39
- 아래 링크에서 해당 신호를 보내는 목록을 확인할 수 있습니다.
- 레이아웃 계산
- 레이어에 요소 삽입
- 레이아웃을 강제하는 속성의 전체 목록: https://gist.github.com/paulirish/5d52fb081b3570c81e3a.
- 중요 참고 사항: 레이아웃이 강제 설정되면 콜 스택이 비어 있지 않아도 브라우저가 기본 스레드에서 JS 실행을 일시 중지합니다.
예제에서 확인해 봅시다:
div1.style.height = "200px"; // Change element size
var height1 = div1.clientHeight; // Read property
div1.style.height = "200px";
var height1 = div1.clientHeight; // <-- layout 1
div2.style.margin = "300px";
var height2 = div2.clientHeight; // <-- layout 2
div1.style.height = "200px";
div2.style.margin = "300px";
var height1 = div1.clientHeight; // <-- layout 1
var height2 = div2.clientHeight;
요소를 그룹화하면, 브라우저가 4번째 줄에 도달할 때 이미 필요한 레이아웃이 수행되었으므로 두번째 레이아웃을 수행할 필요가 없습니다.
따라서 이벤트 루프는 태스크 처리 및 마이크로 태스크 처리 단계 모두에서 레이아웃을 강제할 수 있으므로
최종 화면 렌더링을 위한 루프는 한 번의 싸이클이 아니라 여러 번의 부분 싸이클이 될 수도 있습니다.
- 태스크 처리 도중 강제 레이아웃 때문에 렌더 큐 처리로 갔다가 다시 태스크 큐 처리로 돌아올 수 있다.
- raf 콜백으로 강제 레이아웃을 유발하는 오퍼레이션을 배치 처리할 수 있다.
- 해당 콜백에서 읽기와 쓰기를 배치 처리하는 경우, 스타일 재계산과 레이아웃을 한번씩만 수행하는 것이 가능하다.
- DOM 노드 수 줄이기
- 불필요한 레이아웃을 제거하기 위한 dom 속성 읽기 작업 / 쓰기 작업 별 그룹화
- 레이아웃을 강제하는 작업을 합성(composition)을 강제하는 작업으로 교체
Paint
이 작업은 일반적으로 많은 시간을 소비하지 않지만 첫 번째 렌더링 시 클 수 있습니다.
이 단계 후에 프레임을 "물리적으로" 그려야 합니다.
마지막 작업은 "합성(composition)"입니다.
Composition
composition은 GPU에서 실행되는 유일한 단계입니다.
이 단계에서 브라우저는 "transform"과 같은 특정 CSS 스타일만 실행합니다.
중요 사항: transform: translate는 GPU 렌더링 모드를 on 하는 것이 아닙니다.
컴포지션을 위한 연산을 GPU에 할당하는 것 뿐입니다.
따라서 GPU에 렌더링 작업 처리를 맞기기 위해 코드베이스에 transform: translateZ(0)을 사용하는 경우
이러한 방식으로 동작하지 않습니다.
그것은 잘못된 생각입니다.
- 매 프레임마다 강제 레이아웃을 수행하지 않고 CPU 시간을 절약합니다.
- 이 애니메이션은 웹 사이트에 top, right, bottom, left을 통해 구현한 애니메이션이 있을 때 따를 수 있는 작은 지연을 제거합니다.
어떻게 렌더링을 최적화 하나요?
transition을 사용하고 DOM 요소에서 속성을 읽지 않으면 레이아웃과 페인트가 필요하지 않을 수 있습니다.
요약
- 애니메이션을 JS에서 CSS로 이동합니다. 추가 JS 코드 실행은 "무료"가 아닙니다.
- "움직이는" 객체 애니메이션 : transform 사용
- will-change 속성을 사용합니다. 이를 통해 브라우저는 DOM 요소의 transform 적용을 위한 준비를 수행할 수 있습니다.
- 이 속성은 브라우저가 개발자가 변경하길 원하는 요소를 알 수 있도록 해줍니다.
- https://developer.mozilla.org/en-US/docs/Web/CSS/will-change
- DOM 변경 배치 처리
- requestAnimationFrame을 사용하여 다음 프레임에 반영할 변경 사항을 계산합니다.
- 어차피 해야 하는거 같이 처리?
- css 읽기 연산과 쓰기 연산을 각각 모아 처리합니다. 메모이제이션을 사용합니다.
- 레이아웃을 강제하는 속성에 주의하세요: https://gist.github.com/paulirish/5d52fb081b3570c81e3a
- 사소한 상황이 아닌 경우 프로파일러를 실행하여 렌더링 빈도와 타이밍을 확인하는 것이 좋습니다.
- 단계별로 최적화하고 한 번에 모든 작업을 수행하려고 하지 마십시오.
참고
https://www.kirupa.com/html5/animating_with_requestAnimationFrame.htm
'FrontEnd' 카테고리의 다른 글
[객체지향 설계] 객체지향 설계의 기초와 핵심개념 (0) | 2022.12.30 |
---|---|
프론트엔드 성능 최적화 : layout thrashing 피하기 with requestAnimationFrame (0) | 2022.12.29 |
[번역] JS 번들 분할의 모든 것 (0) | 2022.12.26 |
Vue3 컴포넌트 디자인 패턴 : expose (0) | 2022.12.25 |
Vue3 컴포넌트 디자인 패턴 : v-model (0) | 2022.12.22 |