나무 흔들기: 참조 가이드
게시 됨: 2022-03-10트리 셰이킹이 무엇인지, 이를 통해 성공하기 위한 준비를 하기 위한 여정을 시작하기 전에 JavaScript 에코시스템에 어떤 모듈이 있는지 이해해야 합니다.
초창기부터 JavaScript 프로그램은 복잡성과 수행하는 작업의 수가 증가했습니다. 이러한 작업을 닫힌 실행 범위로 구분해야 할 필요성이 분명해졌습니다. 이러한 작업 또는 값의 구획은 우리가 모듈 이라고 부르는 것입니다. 주요 목적은 반복을 방지하고 재사용성을 활용하는 것입니다. 그래서 아키텍처는 이러한 특별한 종류의 범위를 허용하고, 그 가치와 작업을 노출하고, 외부 가치와 작업을 소비하도록 고안되었습니다.
모듈이 무엇이고 어떻게 작동하는지 자세히 알아보려면 "ES Module: A Cartoon Deep-Dive"를 추천합니다. 그러나 트리 흔들기와 모듈 소비의 뉘앙스를 이해하려면 위의 정의로 충분해야 합니다.
나무를 흔드는 것은 실제로 무엇을 의미합니까?
간단히 말해서, 트리 쉐이킹은 번들에서 도달할 수 없는 코드(데드 코드라고도 함)를 제거하는 것을 의미합니다. Webpack 버전 3의 설명서에는 다음과 같이 나와 있습니다.
“응용 프로그램을 나무로 상상할 수 있습니다. 실제로 사용하는 소스 코드와 라이브러리는 나무의 살아있는 녹색 잎을 나타냅니다. 데드 코드는 가을에 소모되는 나무의 갈색, 죽은 잎을 나타냅니다. 죽은 잎사귀를 제거하려면 나무를 흔들어 떨어뜨려야 합니다.”
이 용어는 Rollup 팀에 의해 프론트 엔드 커뮤니티에서 처음 대중화되었습니다. 그러나 모든 동적 언어의 작성자는 훨씬 더 일찍부터 이 문제에 어려움을 겪고 있습니다. 나무 흔들기 알고리즘의 아이디어는 적어도 1990년대 초반으로 거슬러 올라갈 수 있습니다.
자바스크립트 랜드에서는 이전에 ES6으로 알려졌던 ES2015의 ECMAScript 모듈(ESM) 사양부터 트리 쉐이킹이 가능했습니다. 그 이후로 트리 쉐이킹은 프로그램의 동작을 변경하지 않고 출력 크기를 줄이기 때문에 대부분의 번들러에서 기본적으로 활성화되었습니다.
그 주된 이유는 ESM이 본질적으로 정적이기 때문입니다. 그것이 무엇을 의미하는지 분석해 봅시다.
ES 모듈 대 CommonJS
CommonJS는 ESM 사양보다 몇 년 앞서 있습니다. JavaScript 생태계에서 재사용 가능한 모듈에 대한 지원 부족을 해결하기 위해 나왔습니다. CommonJS에는 제공된 경로를 기반으로 외부 모듈을 가져오는 require()
함수가 있으며 런타임 중에 범위에 추가합니다.
그 require
는 프로그램의 다른 function
와 마찬가지로 컴파일 타임에 호출 결과를 평가하는 것을 어렵게 만듭니다. 그 위에는 다른 함수 호출, if/else 문, switch 문 등으로 래핑된 코드의 모든 위치에 require
호출을 추가할 수 있다는 사실이 있습니다.
CommonJS 아키텍처의 광범위한 채택으로 인한 학습과 투쟁으로 ESM 사양은 모듈이 import 및 export
각각의 키워드로 import
내보내지는 이 새로운 아키텍처에 정착했습니다. 따라서 더 이상 기능 호출이 없습니다. ESM은 또한 최상위 선언으로만 허용됩니다. 정적 이므로 다른 구조에 중첩할 수 없습니다. ESM은 런타임 실행에 의존하지 않습니다.
범위 및 부작용
그러나 부풀림을 피하기 위해 나무를 흔드는 것이 극복해야 하는 또 다른 장애물이 있습니다. 바로 부작용입니다. 함수는 실행 범위 외부의 요인을 변경하거나 의존할 때 부작용이 있는 것으로 간주됩니다. 부작용이 있는 함수는 불순한 것으로 간주됩니다. 순수 함수는 컨텍스트나 실행된 환경에 관계없이 항상 동일한 결과를 산출합니다.
const pure = (a:number, b:number) => a + b const impure = (c:number) => window.foo.number + c
번들러는 모듈이 순수한지 여부를 결정하기 위해 가능한 한 많이 제공된 코드를 평가하여 목적에 부합합니다. 그러나 컴파일 시간이나 번들링 시간 동안의 코드 평가는 여기까지만 가능합니다. 따라서 완전히 도달할 수 없는 경우에도 부작용이 있는 패키지를 제대로 제거할 수 없다고 가정합니다.
이 때문에 번들러는 이제 개발자가 모듈에 부작용이 없는지 여부를 선언할 수 있도록 하는 모듈의 package.json
파일 내부 키를 허용합니다. 이런 식으로 개발자는 코드 평가를 옵트아웃하고 번들러에 힌트를 줄 수 있습니다. 특정 패키지 내의 코드는 연결 가능한 import 또는 require
문이 없는 경우 제거될 수 있습니다. 이것은 더 얇은 번들을 만들 뿐만 아니라 컴파일 시간을 단축할 수 있습니다.
{ "name": "my-package", "sideEffects": false }
따라서 패키지 개발자인 경우 게시하기 전에 sideEffects
를 양심적으로 사용하고, 물론 예기치 않은 주요 변경 사항을 방지하기 위해 릴리스할 때마다 이를 수정하십시오.
루트 sideEffects
키 외에도 메서드 호출에 인라인 주석 /*@__PURE__*/
주석을 추가하여 파일별로 순도를 결정할 수도 있습니다.
const x = */@__PURE__*/eliminated_if_not_called()
이 인라인 주석은 패키지가 sideEffects: false
를 선언하지 않았거나 라이브러리가 실제로 특정 메서드에 부작용을 나타내는 경우 소비자 개발자를 위한 이스케이프 해치라고 생각합니다.
웹팩 최적화
버전 4부터 Webpack은 모범 사례가 작동하도록 하기 위해 필요한 구성이 점차 줄어들었습니다. 몇 가지 플러그인의 기능이 코어에 통합되었습니다. 그리고 개발 팀은 번들 크기를 매우 중요하게 생각하기 때문에 트리 쉐이킹을 쉽게 만들었습니다.
당신이 땜장이가 아니거나 애플리케이션에 특별한 경우가 없다면 종속성을 트리 셰이킹하는 것은 단 한 줄의 문제입니다.
webpack.config.js
파일에는 mode
라는 루트 속성이 있습니다. 이 속성의 값이 production
일 때마다 트리를 흔들고 모듈을 완전히 최적화합니다. TerserPlugin
으로 데드 코드를 제거하는 것 외에도 mode: 'production'
은 모듈 및 청크에 대해 결정론적 맹글링된 이름을 활성화하고 다음 플러그인을 활성화합니다.
- 플래그 종속성 사용,
- 플래그가 포함된 청크,
- 모듈 연결,
- 오류 발생 시 방출 없음.
트리거 값이 production
인 것은 우연이 아닙니다. 문제를 디버깅하기가 훨씬 더 어렵게 만들기 때문에 개발 환경에서 종속성을 완전히 최적화하지 않으려고 합니다. 따라서 두 가지 접근 방식 중 하나를 사용하는 것이 좋습니다.
한편으로는 mode
플래그를 Webpack 명령줄 인터페이스에 전달할 수 있습니다.
# This will override the setting in your webpack.config.js webpack --mode=production
또는 webpack.config.js
에서 process.env.NODE_ENV
변수를 사용할 수 있습니다.
mode: process.env.NODE_ENV === 'production' ? 'production' : development
이 경우 배포 파이프라인에서 --NODE_ENV=production
을 전달하는 것을 기억해야 합니다.
두 접근 방식 모두 Webpack 버전 3 이하에서 많이 알려진 definePlugin
위에 추상화되어 있습니다. 어떤 옵션을 선택하든 전혀 차이가 없습니다.
Webpack 버전 3 이하
이 섹션의 시나리오와 예제는 Webpack 및 기타 번들러의 최신 버전에는 적용되지 않을 수 있습니다. 이 섹션에서는 Terser 대신 UglifyJS 버전 2의 사용을 고려합니다. UglifyJS는 Terser가 분기된 패키지이므로 코드 평가가 서로 다를 수 있습니다.
Webpack 버전 3 이하에서는 package.json
의 sideEffects
속성을 지원하지 않으므로 코드가 제거되기 전에 모든 패키지를 완전히 평가해야 합니다. 이것만으로는 접근 방식이 덜 효과적이지만 몇 가지 주의 사항도 고려해야 합니다.
위에서 언급했듯이 컴파일러는 패키지가 전역 범위를 변조할 때 자체적으로 알아낼 방법이 없습니다. 그러나 나무 흔들기를 건너 뛰는 유일한 상황은 아닙니다. 더 모호한 시나리오가 있습니다.
Webpack의 문서에서 이 패키지 예제를 가져오세요:
// transform.js import * as mylib from 'mylib'; export const someVar = mylib.transform({ // ... }); export const someOtherVar = mylib.transform({ // ... });
다음은 소비자 번들의 진입점입니다.
// index.js import { someVar } from './transforms.js'; // Use `someVar`...
mylib.transform
이 부작용을 유발하는지 여부를 결정할 방법이 없습니다. 따라서 코드가 제거되지 않습니다.
다음은 유사한 결과를 보이는 다른 상황입니다.
- 컴파일러가 검사할 수 없는 타사 모듈에서 함수 호출,
- 타사 모듈에서 가져온 기능 다시 내보내기.
컴파일러가 트리 흔들기를 작동시키는 데 도움이 될 수 있는 도구는 babel-plugin-transform-imports입니다. 모든 멤버 및 명명된 내보내기를 기본 내보내기로 분할하여 모듈을 개별적으로 평가할 수 있습니다.
// before transformation import { Row, Grid as MyGrid } from 'react-bootstrap'; import { merge } from 'lodash'; // after transformation import Row from 'react-bootstrap/lib/Row'; import MyGrid from 'react-bootstrap/lib/Grid'; import merge from 'lodash/merge';
또한 개발자에게 번거로운 import 문을 피하도록 경고하는 구성 속성이 있습니다. Webpack 버전 3 이상을 사용 중이고 기본 구성으로 실사를 완료하고 권장 플러그인을 추가했지만 번들이 여전히 부풀려진 것처럼 보인다면 이 패키지를 사용해 보는 것이 좋습니다.
스코프 호이스팅 및 컴파일 시간
CommonJS 시대에 대부분의 번들러는 단순히 각 모듈을 다른 함수 선언으로 래핑하고 객체 내부에 매핑했습니다. 이는 다른 지도 개체와 다르지 않습니다.
(function (modulesMap, entry) { // provided CommonJS runtime })({ "index.js": function (require, module, exports) { let { foo } = require('./foo.js') foo.doStuff() }, "foo.js": function(require, module, exports) { module.exports.foo = { doStuff: () => { console.log('I am foo') } } } }, "index.js")
정적으로 분석하기 어렵다는 점 외에는 기본적으로 ESM과 호환되지 않습니다. import
및 export
문을 래핑할 수 없다는 것을 보았기 때문입니다. 따라서 요즘 번들러는 모든 모듈을 최상위 수준으로 끌어 올립니다.
// moduleA.js let $moduleA$export$doStuff = () => ({ doStuff: () => {} }) // index.js $moduleA$export$doStuff()
이 접근 방식은 ESM과 완전히 호환됩니다. 또한 코드 평가에서 호출되지 않는 모듈을 쉽게 찾아 삭제할 수 있습니다. 이 접근 방식의 주의 사항은 컴파일하는 동안 모든 명령문을 터치하고 프로세스 중에 번들을 메모리에 저장하기 때문에 훨씬 더 많은 시간이 걸린다는 것입니다. 이것이 번들링 성능이 모든 사람에게 훨씬 더 큰 관심사가 되고 컴파일된 언어가 웹 개발 도구에 활용되는 큰 이유입니다. 예를 들어, esbuild는 Go로 작성된 번들러이고 SWC는 Rust로 작성된 번들러인 Spark와 통합되는 Rust로 작성된 TypeScript 컴파일러입니다.
범위 호이스팅을 더 잘 이해하려면 Parcel 버전 2의 문서를 적극 권장합니다.
성급한 트랜스파일 방지
불행히도 다소 일반적이고 나무를 흔드는 데 치명적일 수 있는 한 가지 특정 문제가 있습니다. 간단히 말해서, 이는 특수 로더로 작업할 때 다른 컴파일러를 번들러에 통합할 때 발생합니다. 일반적인 조합은 가능한 모든 순열에서 TypeScript, Babel 및 Webpack입니다.
Babel과 TypeScript에는 모두 자체 컴파일러가 있으며 해당 로더를 사용하면 개발자가 쉽게 통합할 수 있습니다. 그리고 그 안에 숨겨진 위협이 있습니다.
이러한 컴파일러는 코드 최적화 전에 코드에 도달합니다. 그리고 기본 설정이든 잘못된 구성이든 이러한 컴파일러는 종종 ESM 대신 CommonJS 모듈을 출력합니다. 이전 섹션에서 언급했듯이 CommonJS 모듈은 동적이므로 데드 코드 제거에 대해 적절하게 평가할 수 없습니다.
이 시나리오는 "동형" 앱(즉, 서버 측과 클라이언트 측 모두에서 동일한 코드를 실행하는 앱)의 성장과 함께 오늘날 더욱 일반화되고 있습니다. Node.js는 아직 ESM에 대한 표준 지원이 없기 때문에 컴파일러가 node
환경을 대상으로 하면 CommonJS를 출력합니다.
따라서 최적화 알고리즘이 수신하는 코드를 확인하십시오 .
나무 흔들기 체크리스트
이제 번들링과 트리 쉐이킹이 작동하는 방식에 대해 자세히 알아보았으므로 현재 구현 및 코드 기반을 다시 방문할 때 편리한 위치에 인쇄할 수 있는 체크리스트를 직접 그려 보겠습니다. 바라건대, 이것은 시간을 절약하고 코드의 인지된 성능뿐만 아니라 파이프라인의 빌드 시간까지 최적화할 수 있게 해줍니다!
- ESM을 사용하고 고유한 코드 기반뿐만 아니라 ESM을 소모품으로 출력하는 패키지도 선호합니다.
- 종속성 중 어느 것이(있는 경우)
sideEffects
를 선언하지 않았는지 또는true
로 설정되었는지 정확히 알고 있는지 확인하십시오. - 인라인 주석을 사용하여 부작용이 있는 패키지를 사용할 때 순수한 메서드 호출을 선언합니다.
- CommonJS 모듈을 출력하는 경우 import 및 export 문을 변환 하기 전에 번들을 최적화해야 합니다.
패키지 저작
바라건대, 이 시점에서 우리 모두는 ESM이 JavaScript 생태계에서 앞으로 나아갈 길이라는 데 동의합니다. 그러나 소프트웨어 개발에서 항상 그렇듯이 전환은 까다로울 수 있습니다. 운 좋게도 패키지 작성자는 사용자를 위한 빠르고 원활한 마이그레이션을 용이하게 하기 위해 비중단 조치를 채택할 수 있습니다.
package.json
에 약간의 추가 기능을 사용하면 패키지가 패키지가 지원하는 환경과 가장 잘 지원되는 방법을 번들러에게 알릴 수 있습니다. 다음은 Skypack의 체크리스트입니다.
- ESM 내보내기를 포함합니다.
-
"type": "module"
을 추가합니다. -
"module": "./path/entry.js"
(커뮤니티 규약)를 통해 진입점을 나타냅니다.
다음은 모든 모범 사례를 따르고 웹 및 Node.js 환경을 모두 지원하려는 경우에 나타나는 예입니다.
{ // ... "main": "./index-cjs.js", "module": "./index-esm.js", "exports": { "require": "./index-cjs.js", "import": "./index-esm.js" } // ... }
이 외에도 Skypack 팀은 패키지 품질 점수 를 벤치마크로 도입하여 주어진 패키지가 수명과 모범 사례를 위해 설정되었는지 여부를 결정했습니다. 이 도구는 GitHub에서 오픈 소스로 제공되며 각 릴리스 전에 쉽게 검사를 수행하기 위해 패키지에 devDependency
로 추가할 수 있습니다.
마무리
이 기사가 도움이 되었기를 바랍니다. 그렇다면 네트워크와 공유하는 것이 좋습니다. 댓글이나 트위터에서 여러분과 소통할 수 있기를 기대합니다.
유용한 리소스
기사 및 문서
- "ES 모듈: 만화 심층 분석", Lin Clark, Mozilla Hacks
- "나무 흔들기", 웹팩
- "구성", 웹팩
- "최적화", 웹팩
- "스코프 호이스팅", Parcel 버전 2 문서
프로젝트 및 도구
- 터서
- babel-plugin-transform-imports
- 스카이팩
- 웹팩
- 소포
- 롤업
- 에스빌드
- SWC
- 패키지 확인