스크립트 로딩의 희미한 물결을 파헤치다

Jake Archibald
Jake Archibald

소개

이 도움말에서는 브라우저에서 일부 JavaScript를 로드하고 실행하는 방법을 알려 드리겠습니다.

아니, 잠깐만, 다시 와! 평범하고 단순하게 들릴 수도 있지만, 이론적으로 단순한 것이 기존에 기반을 둔 특이한 구멍이 되는 브라우저에서는 이러한 상황이 발생한다는 점을 기억하세요. 이러한 문제를 알면 스크립트를 로드하는 가장 빠르고 중단이 가장 적은 방법을 선택할 수 있습니다. 일정이 빠듯하다면 빠른 참조로 건너뛰세요.

먼저 사양에서 스크립트를 다운로드하고 실행할 수 있는 다양한 방법을 정의하는 방법은 다음과 같습니다.

스크립트 로드에 관한 WhatWG
스크립트 로드에 관한 WhatWG

모든 WhatWG 사양과 마찬가지로 처음에는 스크래블 공장에서 발생한 클러스터 폭탄의 여파처럼 보이지만, 다섯 번이나 읽고 눈에서 피를 닦아낸 후에는 실제로 매우 흥미롭습니다.

첫 번째 스크립트에는

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

아, 행복한 단순함이네요. 브라우저에서 두 스크립트를 동시에 다운로드하고 가능한 한 빨리 실행하여 순서를 유지합니다. '2.js'는 '1.js'가 실행되거나 실행될 때까지 실행되지 않으며, '1.js'는 이전 스크립트 또는 스타일시트가 실행될 때까지 실행되지 않습니다.

안타깝게도 브라우저는 이 모든 작업이 진행되는 동안 페이지의 추가 렌더링을 차단합니다. 이는 파서가 훑어보는 콘텐츠에 문자열을 추가할 수 있는 '웹의 첫 시대'의 DOM API(예: document.write) 때문입니다. 최신 브라우저는 백그라운드에서 문서를 계속 스캔하거나 파싱하며 필요할 수 있는 외부 콘텐츠 (js, 이미지, css 등)의 다운로드를 트리거하지만 렌더링은 여전히 차단됩니다.

따라서 성능 분야에서는 우수한 성능을 보이는 업체라면 가능한 한 적은 양의 콘텐츠를 차단하는 스크립트 요소를 문서의 끝에 배치하는 것이 좋습니다. 안타깝게도 이는 스크립트가 모든 HTML을 다운로드할 때까지 브라우저에 표시되지 않으며, 그 시점부터는 CSS, 이미지, iframe과 같은 다른 콘텐츠의 다운로드를 시작합니다. 최신 브라우저는 이미지보다 JavaScript에 우선순위를 부여할 만큼 지능적이지만, 이보다 더 잘할 수 있습니다.

감사합니다. (아니요, 냉소적이 아닙니다.)

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

Microsoft는 이러한 성능 문제를 인식하고 Internet Explorer 4에 '지연'을 도입했습니다. 기본적으로 'document.write 같은 것을 사용하여 파서에 내용을 삽입하지 않겠다고 약속합니다. 제가 이 약속을 어기는다면 어떤 방식으로든 저를 처벌할 수 있습니다.' 이 속성은 HTML4로 변환되어 다른 브라우저에 표시되었습니다.

위의 예에서 브라우저는 두 스크립트를 동시에 다운로드하고 DOMContentLoaded가 실행되기 직전에 실행하면서 순서를 유지합니다.

양 공장의 클러스터 폭탄처럼 '지연'은 엉망진창이 되었습니다. 'src'와 'defer' 속성과 스크립트 태그와 동적으로 추가된 스크립트 사이에는 6가지 패턴으로 스크립트를 추가합니다. 물론, 브라우저는 실행 순서에 동의하지 않았습니다. 2009년에는 모질라가 이 문제에 대한 훌륭한 글을 작성했습니다.

WHEREWG는 동적으로 추가된 스크립트나 'src'가 없는 스크립트에는 영향을 미치지 않는다고 'defer'를 선언함으로써 동작을 명시적으로 만들었습니다. 그렇지 않으면 지연된 스크립트는 문서가 파싱된 후에 추가된 순서대로 실행되어야 합니다.

감사합니다. (좋아, 지금은 냉소적이야)

물건을 사냥하면 주는 효과가 사라지죠. IE4-9에는 스크립트가 예기치 않은 순서로 실행되는 심각한 버그가 있습니다. 그 과정은 다음과 같습니다.

1.js

console.log('1');
document.getElementsByTagName('p')[0].innerHTML = 'Changing some content';
console.log('2');

2.js 코드

console.log('3');

페이지에 단락이 있다고 가정하면 로그의 예상 순서는 [1, 2, 3]이지만 IE9 이하에서는 [1, 3, 2]가 표시됩니다. 특정 DOM 연산이 발생하면 IE가 현재 스크립트 실행을 일시 중지하고 계속하기 전에 다른 대기 중인 스크립트를 실행합니다.

하지만 IE10 및 기타 브라우저와 같이 버그가 발생하지 않은 구현의 경우에도 전체 문서가 다운로드되고 파싱될 때까지 스크립트 실행이 지연됩니다. 어쨌든 DOMContentLoaded를 기다리려는 경우에는 이 방법이 편리할 수 있지만 성능을 매우 적극적으로 하고 싶다면 리스너를 더 빨리 추가하고 부트스트랩을 시작하면 됩니다.

HTML5 활용

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

HTML5는 document.write를 사용하지 않을 것으로 가정하지만 문서가 파싱되어 실행될 때까지 기다리지 않는 새로운 속성인 'async'를 제공합니다. 브라우저는 두 스크립트를 동시에 다운로드하여 가능한 한 빨리 실행합니다.

유감스럽게도 '2.js'는 가능한 한 빨리 실행되므로 '2.js'가 '1.js'보다 먼저 실행될 수 있습니다. '1.js'가 '1.js'보다 먼저 실행될 수 있습니다. '1.js'는 '2.js'와는 관련이 없는 추적 스크립트일 수 있습니다. 하지만 '1.js'가 '2.js'에 의존하는 jQuery의 CDN 사본인 경우, 이 클러스터의 오류도...

JavaScript 라이브러리가 필요한 것은 알고 있습니다.

가장 중요한 것은 렌더링을 차단하지 않고 일련의 스크립트를 즉시 다운로드하고 추가된 순서대로 가능한 한 빨리 실행하는 것입니다. HTML은 사용자를 싫어하므로 그렇게 하도록 허용하지 않습니다.

JavaScript를 통해 몇 가지 측면에서 이 문제가 해결되었습니다. 일부는 JavaScript를 변경하여 라이브러리가 올바른 순서로 호출하는 콜백에 래핑해야 했습니다 (예: RequireJS). 다른 도메인에서는 XHR을 사용하여 병렬로 다운로드한 다음 올바른 순서로 eval()를 다운로드합니다. 하지만 CORS 헤더가 있고 브라우저에서 이를 지원하지 않는 한 다른 도메인의 스크립트에서는 작동하지 않았습니다. 일부 개발자들은 LabJS와 같은 엄청난 꿀팁을 사용하기도 했습니다.

이 해킹에는 완료 시 이벤트를 트리거하지만 실행을 피하는 방식으로 브라우저를 속여 리소스를 다운로드하게 하는 방법이 포함되어 있습니다. LabJS에서 스크립트가 잘못된 MIME 유형(예: <script type="script/cache" src="...">)과 함께 추가됩니다. 모든 스크립트가 다운로드되고 나면, 브라우저가 캐시에서 스크립트를 바로 가져와서 순서대로 실행하기를 바라면서 스크립트는 올바른 유형으로 다시 추가되었을 것입니다. 이는 편리하지만 지정되지 않은 동작에 따라 달라지며, HTML5 선언 브라우저가 인식할 수 없는 유형의 스크립트를 다운로드해서는 안 되는 경우 중단되었습니다. LabJS는 이러한 변화에 적응했으며 이제 이 도움말에 나온 방법을 조합하여 사용하고 있습니다.

그러나 스크립트 로더에는 나름의 성능 문제가 있습니다. 라이브러리의 JavaScript가 다운로드 및 파싱될 때까지 기다린 후에 관리하는 스크립트가 다운로드되기 시작합니다. 또한 스크립트 로더를 어떻게 로드할까요? 로드할 내용을 스크립트 로더에 지시하는 스크립트를 어떻게 로드할까요? 누가 감시자를 보나요? 나는 왜 나체인거죠? 모두 어려운 질문입니다.

기본적으로 다른 스크립트 다운로드를 고려하기 전에 추가 스크립트 파일을 다운로드해야 한다면 성능 싸움에서 졌습니다.

구조할 DOM

그 해답은 HTML5 사양에 나와 있습니다. 하지만 이 내용은 스크립트 로드 섹션의 하단에 숨겨져 있습니다.

이를 '지구'로 번역해 보겠습니다.

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  document.head.appendChild(script);
});

동적으로 생성되어 문서에 추가되는 스크립트는 기본적으로 비동기식이며 렌더링을 차단하지 않으며 다운로드되는 즉시 실행됩니다. 즉, 잘못된 순서로 표시될 수 있습니다. 그러나 명시적으로 비동기가 아닌 것으로 표시할 수 있습니다.

[
  '//other-domain.com/1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

이렇게 하면 일반 HTML로는 실행할 수 없는 동작이 스크립트에 혼합됩니다. 스크립트는 명시적으로 비동기식이 아니기 때문에 실행 대기열에 추가됩니다. 이는 첫 번째 일반 HTML 예에서 추가한 것과 동일한 대기열에 추가됩니다. 그러나 동적으로 생성되므로 문서 파싱 외부에서 실행되므로 다운로드 중에 렌더링이 차단되지 않습니다. 비동기가 아닌 스크립트 로딩을 동기 XHR과 혼동하지 마세요. 이는 결코 좋은 일이 아닙니다.

위의 스크립트는 프로그레시브 렌더링을 중단하지 않고 가능한 한 빨리 스크립트 다운로드를 대기열에 넣고 페이지 헤드에 인라인으로 포함해야 하며 가능한 한 빨리 지정한 순서에 따라 실행됩니다. '2.js'는 '1.js' 이전에 무료로 다운로드할 수 있지만, '1.js'가 성공적으로 다운로드되어 실행되거나 다운로드에 실패할 때까지 실행되지 않습니다. 만세! 비동기 다운로드이지만 순서대로 실행되네요!

이 방식으로 스크립트를 로드하는 것은 Safari 5.0 (5.1 권장)을 제외하고 비동기 속성을 지원하는 모든 기능에서 지원됩니다. 또한 모든 버전의 Firefox와 Opera는 async 속성을 지원하지 않는 버전에서 문서에 추가된 순서대로 동적으로 추가된 스크립트를 편리하게 실행할 수 있습니다.

그것이 스크립트를 로드하는 가장 빠른 방법일까요? 그렇죠?

로드할 스크립트를 동적으로 결정하는 경우라면 그렇지 않을 수도 있습니다. 위의 예에서 브라우저는 스크립트를 파싱하고 실행하여 다운로드할 스크립트를 찾아야 합니다. 이렇게 하면 미리 로드 스캐너에서 스크립트가 숨겨집니다. 브라우저는 이러한 스캐너를 사용하여 다음에 방문할 가능성이 높은 페이지의 리소스를 검색하거나 파서가 다른 리소스에 의해 차단된 동안 페이지 리소스를 검색합니다.

이를 문서의 헤드에 배치하여 검색 가능성을 다시 추가할 수 있습니다.

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

이렇게 하면 페이지에 1.js와 2.js가 필요하다는 것을 브라우저에 알립니다. link[rel=subresource]link[rel=prefetch]와 비슷하지만 시맨틱스가 다릅니다. 안타깝게도 현재 Chrome에서만 지원되므로 두 번 로드할 스크립트를 한 번, 링크 요소를 통해 한 번, 스크립트에서 다시 선언해야 합니다.

수정: 원래 이 유형은 미리 로드 스캐너에서 선택했다고 밝혔지만, 그렇지 않고 일반 파서에서 선택했다고 설명했습니다. 하지만 미리 로드 스캐너가 이를 선택할 수는 있지만 아직은 그렇지 않은 반면, 실행 코드에 포함된 스크립트는 미리 로드할 수 없습니다. 댓글에서 수정해 주신 요아브 바이스님께 감사드립니다.

이 기사가 우울함

상황이 우울하고 우울증을 느낄 것입니다. 실행 순서를 제어하면서 스크립트를 빠르고 비동기식으로 다운로드할 수 있는 반복적이지 않은 선언적 방법은 없습니다. HTTP2/SPDY를 사용하면 개별적으로 캐시할 수 있는 여러 개의 작은 파일로 스크립트를 전달하는 것이 가장 빠른 방법까지 요청 오버헤드를 줄일 수 있습니다. 상상해 보세요.

<script src="dependencies.js"></script>
<script src="enhancement-1.js"></script>
<script src="enhancement-2.js"></script>
<script src="enhancement-3.js"></script>
…
<script src="enhancement-10.js"></script>

각 개선 스크립트는 특정 페이지 구성요소를 다루지만 extensions.js의 유틸리티 함수가 필요합니다. 모든 비동기식을 비동기식으로 다운로드한 다음, 최대한 빨리, 순서에 관계없이, extensions.js 다음에 개선 스크립트를 실행하는 것이 가장 좋습니다. 그것은 점진적인 개선입니다. 안타깝게도 종속 항목 로드 상태를 추적하도록 스크립트 자체가 수정되지 않는 한 이 작업을 할 수 있는 선언적 방법은 없습니다. async=false로도 이 문제가 해결되지 않습니다. Enhancedment-10.js의 실행이 1~9에서 차단되기 때문입니다. 사실 해킹 없이도 이러한 작업을 수행할 수 있는 브라우저는 단 하나뿐입니다.

IE에 아이디어가 있어!

IE는 스크립트를 다른 브라우저와 다르게 로드합니다.

var script = document.createElement('script');
script.src = 'whatever.js';

이제 IE에서 'whatever.js' 다운로드를 시작하며 스크립트가 문서에 추가될 때까지 다른 브라우저에서 다운로드를 시작하지 않습니다. IE에는 로드 진행률을 알려주는 'readystatechange' 이벤트 및 'readystate' 속성도 있습니다. 이는 스크립트의 로드와 실행을 독립적으로 제어할 수 있게 해주기 때문에 실제로 매우 유용합니다.

var script = document.createElement('script');

script.onreadystatechange = function() {
  if (script.readyState == 'loaded') {
    // Our script has download, but hasn't executed.
    // It won't execute until we do:
    document.body.appendChild(script);
  }
};

script.src = 'whatever.js';

문서에 스크립트를 추가할 시기를 선택하여 복잡한 종속 항목 모델을 빌드할 수 있습니다. IE는 버전 6부터 이 모델을 지원했습니다. 꽤 흥미롭지만 여전히 async=false와 동일한 프리로더 검색 가능성 문제가 있습니다.

됐어요! 스크립트는 어떻게 로드해야 하나요?

알았어. 알았어. 렌더링을 차단하지 않고 반복을 거치지 않으며 브라우저 지원이 뛰어난 방식으로 스크립트를 로드하려는 경우 다음을 제안합니다.

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

저것입니다. 본문 요소의 끝 웹 개발자가 되는 것은 시시푸스 왕이 되는 것과 매우 유사합니다. 그리스 신화에 참고할 수 있는 힙스터 포인트 100점). HTML과 브라우저의 한계로 인해 더 나은 성과를 얻을 수 없습니다.

저는 스크립트를 모듈로 작성해야 하지만 JavaScript 모듈을 통해 스크립트를 로드하고 실행 순서를 제어할 수 있는 선언적 비차단 방식을 제공함으로써 우리를 구할 수 있기를 바랍니다.

지금 뭔가 더 나은 방법이 있는 것 같아?

당연히 보너스 점수를 얻을 수 있습니다. 실적을 매우 공격적으로 끌어올리고 복잡도와 반복에 신경 쓰지 않고 싶다면 위의 몇 가지 방법을 조합할 수 있습니다.

먼저 프리로더의 경우 하위 리소스 선언을 추가합니다.

<link rel="subresource" href="//other-domain.com/1.js">
<link rel="subresource" href="2.js">

그런 다음 문서의 헤드라인에 async=false를 사용하여 JavaScript로 스크립트를 로드하고 IE의 Readystate 기반 스크립트 로딩으로 대체하고 지연으로 대체합니다.

var scripts = [
  '1.js',
  '2.js'
];
var src;
var script;
var pendingScripts = [];
var firstScript = document.scripts[0];

// Watch scripts load in IE
function stateChange() {
  // Execute as many scripts in order as we can
  var pendingScript;
  while (pendingScripts[0] && pendingScripts[0].readyState == 'loaded') {
    pendingScript = pendingScripts.shift();
    // avoid future loading events from this script (eg, if src changes)
    pendingScript.onreadystatechange = null;
    // can't just appendChild, old IE bug if element isn't closed
    firstScript.parentNode.insertBefore(pendingScript, firstScript);
  }
}

// loop through our script urls
while (src = scripts.shift()) {
  if ('async' in firstScript) { // modern browsers
    script = document.createElement('script');
    script.async = false;
    script.src = src;
    document.head.appendChild(script);
  }
  else if (firstScript.readyState) { // IE<10
    // create a script and add it to our todo pile
    script = document.createElement('script');
    pendingScripts.push(script);
    // listen for state changes
    script.onreadystatechange = stateChange;
    // must set src AFTER adding onreadystatechange listener
    // else we'll miss the loaded event for cached scripts
    script.src = src;
  }
  else { // fall back to defer
    document.write('<script src="' + src + '" defer></'+'script>');
  }
}

몇 가지 요령과 축소를 거치면 362바이트 + 스크립트 URL이 됩니다.

!function(e,t,r){function n(){for(;d[0]&&"loaded"==d[0][f];)c=d.shift(),c[o]=!i.parentNode.insertBefore(c,i)}for(var s,a,c,d=[],i=e.scripts[0],o="onreadystatechange",f="readyState";s=r.shift();)a=e.createElement(t),"async"in i?(a.async=!1,e.head.appendChild(a)):i[f]?(d.push(a),a[o]=n):e.write("<"+t+' src="'+s+'" defer></'+t+">"),a.src=s}(document,"script",[
  "//other-domain.com/1.js",
  "2.js"
])

간단한 스크립트 포함에 비해 추가 바이트의 가치가 있나요? 이미 자바스크립트를 사용하여 스크립트를 조건부로 로드하고 있다면 BBC에서 하듯이 이러한 다운로드를 더 일찍 트리거하는 것이 좋습니다. 그렇지 않은 경우 간단한 신체 끝 방법을 고수합니다.

휴, 이제 WhatWG 스크립트 로드 섹션이 왜 이렇게 방대한지 알겠어요. 나는 한 잔이 필요합니다.

빠른 참조

일반 스크립트 요소

<script src="//other-domain.com/1.js"></script>
<script src="2.js"></script>

사양: 함께 다운로드하고 대기 중인 CSS 다음에 순서대로 실행하며 완료될 때까지 렌더링을 차단합니다. 브라우저의 반응: 네, 알겠습니다.

연기

<script src="//other-domain.com/1.js" defer></script>
<script src="2.js" defer></script>

사양 정보: 함께 다운로드하고 DOMContentLoaded 직전에 순서대로 실행합니다. 'src'가 없는 스크립트에서 'defer'를 무시하세요. IE < 10 이러한 경우: 1.js 실행 중간에 2.js를 실행할 수 있습니다. 재미있지 않나요? 빨간색 브라우저에 다음과 같이 표시됩니다. 이 '지연'이 무엇인지 전혀 모릅니다. 없는 것처럼 스크립트를 로드하겠습니다. 다른 브라우저에서는 다음과 같이 표시됩니다. 하지만 'src'가 없는 스크립트에서 'defer'를 무시하면 안 됩니다.

Async

<script src="//other-domain.com/1.js" async></script>
<script src="2.js" async></script>

사양 정보: 함께 다운로드하고 다운로드 순서대로 실행하세요. 빨간색 브라우저에 다음과 같이 표시됩니다. '비동기'란 무엇인가요? 존재하지 않는 것처럼 스크립트를 로드하겠습니다. 다른 브라우저에서는 다음과 같이 표시됩니다. 네, 알겠습니다.

Async false

[
  '1.js',
  '2.js'
].forEach(function(src) {
  var script = document.createElement('script');
  script.src = src;
  script.async = false;
  document.head.appendChild(script);
});

사양 정보: 함께 다운로드, 다운로드되는 즉시 순서대로 실행합니다. Firefox < 3.6, Opera의 메시지: 이 '비동기'가 무엇인지는 모르겠지만 JS를 통해 추가된 스크립트를 추가된 순서대로 실행합니다. Safari 5.0에 표시되는 메시지: 'async'는 이해하지만 JS에서 'false'로 설정하는 것을 이해할 수 없습니다. 어떤 순서로든 스크립트가 도착하는 즉시 실행합니다. IE < 10의 경우: 'async'에 대해서는 모르겠지만 'onreadystatechange'를 사용하는 해결 방법이 있습니다. 다른 빨간색으로 표시된 다른 브라우저의 경우: 이 'async'를 이해하지 못함. 어떤 순서로든 스크립트가 도착하는 즉시 스크립트를 실행합니다. 기타: 저는 당신의 친구입니다. 우리가 직접 이야기를 나눌 것입니다.