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

Jake Archibald
Jake Archibald

소개

이 도움말에서는 브라우저에 JavaScript를 로드하고 실행하는 방법을 설명합니다.

아니, 잠깐만, 돌아와! 지루하고 단순해 보이지만, 이 작업은 이론적으로 간단한 작업이 기존 방식으로 인해 버그가 되는 브라우저에서 이루어진다는 점을 기억하세요. 이러한 특성을 알면 가장 빠르고 방해가 되지 않는 스크립트 로드 방법을 선택할 수 있습니다. 일정이 촉박한 경우 빠른 참조로 건너뛰세요.

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

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

모든 WHATWG 사양과 마찬가지로 처음에는 스크래블 공장에서 클러스터 폭탄이 터진 후의 모습처럼 보이지만 5번째 읽고 눈에서 피를 닦아내면 실제로 꽤 흥미롭습니다.

첫 번째 스크립트 포함

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

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

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

따라서 성능 전문가들은 스크립트 요소를 문서 끝에 배치하는 것이 좋습니다. 이렇게 하면 콘텐츠가 최대한 적게 차단되기 때문입니다. 안타깝게도 브라우저가 모든 HTML을 다운로드할 때까지 스크립트가 표시되지 않으며, 그때쯤에는 CSS, 이미지, iframe과 같은 다른 콘텐츠가 다운로드되기 시작합니다. 최신 브라우저는 이미지보다 JavaScript를 우선시할 만큼 똑똑하지만 더 나은 성능을 제공할 수 있습니다.

IE님, 감사합니다. (아니요, 비꼬는 게 아닙니다.)

<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년 당시 Mozilla에서 이 문제에 관한 훌륭한 글을 작성했습니다.

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

IE님, 감사합니다. (이제 비꼬는 거예요)

주는 것도 있고, 앗아가는 것도 있습니다. 안타깝게도 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'가 '1.js'보다 먼저 실행될 수 있습니다. 독립적이라면 문제가 없습니다. '1.js'는 '2.js'와 아무 관련이 없는 추적 스크립트일 수 있습니다. 하지만 '1.js'가 '2.js'가 의존하는 jQuery의 CDN 사본인 경우 페이지가 '1.js'보다 더 빠르게 코팅되지 않습니다.

필요한 게 뭐지? 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);
});

동적으로 생성되어 문서에 추가된 스크립트는 기본적으로 비동기입니다. 렌더링을 차단하지 않으며 다운로드되는 즉시 실행되므로 잘못된 순서로 나올 수 있습니다. 하지만 다음과 같이 not async로 명시적으로 표시할 수 있습니다.

[
  '//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로는 불가능한 동작이 혼합됩니다. async가 명시적으로 false이므로 스크립트는 첫 번째 일반 HTML 예에서 추가된 것과 동일한 실행 대기열에 추가됩니다. 그러나 동적으로 생성되므로 문서 파싱 외부에서 실행되므로 다운로드하는 동안 렌더링이 차단되지 않습니다. 비동기 스크립트 로드를 동기 XHR과 혼동하지 마세요. 이는 좋지 않습니다.

위의 스크립트는 페이지의 헤더에 인라인으로 포함되어야 하며, 점진적 렌더링을 중단하지 않고 최대한 빨리 스크립트 다운로드를 큐에 추가하고 지정된 순서대로 최대한 빨리 실행합니다. '2.js'는 '1.js'보다 먼저 다운로드할 수 있지만 '1.js'가 다운로드 및 실행에 성공하거나 실패할 때까지 실행되지 않습니다. 좋습니다. async-download이지만 ordered-execution입니다.

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

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

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

문서 헤더에 다음을 추가하여 검색 가능성을 다시 추가할 수 있습니다.

<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에서만 지원되며 로드할 스크립트를 링크 요소를 통해 한 번, 스크립트에서 한 번 더 선언해야 합니다.

수정: 원래 미리 로드 스캐너에서 선택했다고 말씀드렸습니다. 일반 파서가 인식할 수 있습니다. 그러나 프리로드 스캐너는 이러한 스크립트를 선택할 있지만 아직 선택하지는 않습니다. 반면 실행 파일 코드에 포함된 스크립트는 프리로드할 수 없습니다. 댓글에서 오류를 지적해 주신 Yoav Weiss님께 감사드립니다.

이 도움말이 우울하게 느껴집니다.

우울한 상황이며 우울한 기분이 들 수 있습니다. 실행 순서를 제어하면서 스크립트를 빠르고 비동기식으로 다운로드하는 반복적이지 않으면서 선언적인 방법은 없습니다. 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>

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

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>

그러니까. body 요소의 끝 예, 웹 개발자는 시시포스 왕과 매우 흡사합니다. 그리스 신화 언급으로 힙스터 포인트 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"
])

간단한 스크립트 포함과 비교하여 추가 바이트가 가치가 있나요? 이미 JavaScript를 사용하여 조건부로 스크립트를 로드하고 있다면(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'를 무시하지 않을 수도 있습니다.

비동기식

<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: 이 'async'가 무엇인지 모르겠지만, JS를 통해 추가된 스크립트를 추가된 순서대로 실행합니다. Safari 5.0: 'async'는 이해하지만 JS로 'false'로 설정하는 것은 이해하지 못합니다. 스크립트가 도착하는 즉시 순서와 관계없이 실행합니다. IE 10 미만: 'async'에 관해 잘 모르지만 'onreadystatechange'를 사용하는 해결 방법이 있습니다. 기타 빨간색 브라우저: 이 'async'를 이해하지 못합니다. 스크립트가 도착하는 즉시 어떤 순서든지 실행합니다. 기타 내용: 제가 친구예요. 책에서 설명하겠습니다.