Vite로 만나는 라이브러리 세계
2024년 08월 22일 8일 전- Vite
- Rollup
- React
목차
Intro
이번 글에서는 vite
를 번들러로 활용하여 프론트엔드 라이브러리를 만드는 방법에 대해서 알아본다. 또한 라이브러리를 더 높은 수준으로 만들어내려면 필수적으로 알아야 할 tree-shake
에 대해 이해 해보고 이를 vite
에도 적용해 볼 것이다.
Vite는 무엇인가
vite
는 최근에 리액트를 개발에서 CRA(create-react-app)
를 대신하는 필수적인 도구로 자리잡았다. 이는 vite
가 리액트의 개발도구로서 의존성 사전 번들링을 통해서 CRA
비하여 압도적으로 빠른 개발서버 구성 및 HMR
기능을 제공하고 있기 때문이다. 이러한 훌륭한 개발경험 덕분에 최근 vite
는 2023년 프론트엔드 라이브러리 설문조사에서 98%의 retention
을 보여주는 기염을 토했다.
이러한 개발 환경의 이점은 단순히 개발 서버 구성 뿐만 아니라 라이브러리 번들링에서도 엿볼 수 있다. vite
는 스스로 번들링을 진행하는게 아니라 이 역할을 rollup
에게 맡기고 있다.
그럼 rollup은 무엇인가
rollup
은 자바스크립트 기반의 번들러 로서 라이브러리 번들링에 특화되어있다. 특히나, 다른 번들링 도구에 비하여 esm
모듈의 코드를 번들링을 지원해주고 그리고 사용되는 코드만 필수적으로 포함시키는 tree-shaking
을 지원해주는데에 특화되어 있어 라이브러리의 번들 사이즈를 줄이는데 굉장히 탁월하다.
💡
rollup
이 막 등장했던 시기에webpack
은 기본적으로ESM
모듈 구조에 대한 번들링을 지원해주지 않았으나,webpack 5
에서 부터는 공식적으로 ESM 번들링이 기본 지원이 되고 있다.
이 뿐만 아니라 rollup
출시된 이후로 다양한 플러그인들이 오픈소스로 출시되면서 굉장히 깊이있는 번들링 생태계를 갖추고 있다. 이 때문에 번들링이 이뤄지는 빌드 타임에서는 안정적인 빌드 환경 및 생태계를 제공해주고자 성능이 굉장히 좋은 esbuild
대신해서 rollup
을 사용하고 있다. 이에 대한 더 자세한 내용은 vite
의 공식문서를 참고하면 좋다.
Vite로 라이브러리 만들기
이제 본격적으로 앞서 살펴본 vite
를 기반으로 하여 라이브러리를 만들어 볼 것이다. 리액트 관련 라이브러리를 만드는 상황을 가정해보자.
vite config
// vite.config.ts
import { resolve } from 'node:path'
import { defineConfig, type PluginOption } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'
import dts from 'vite-plugin-dts'
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'index',
formats: ['es', 'cjs'],
},
rollupOptions: {
external: ['react', 'react-dom'],
},
},
plugins: [
// parse tsconfig paths
tsconfigPaths(),
// declaration type concerned plugin
dts({
copyDtsFiles: true,
entryRoot: 'src',
include: ['src'],
exclude: ['node_modules', 'dist', '**/*.test.ts*', './src/vite-env.d.ts'],
})
],
})
위의 config
파일에서 설정들의 의미를 이야기 해보자면 다음과 같다.
build config
vite
에 설정하는 config 값으로 라이브러리 형태로 설정하도록 설정하는 부분이다. 각 config
별로 설명해보자면
build.lib
: 라이브러리 형태로vite
를 사용하고자 할때 사용해야하는 부분이다.build.lib.entry
: 라이브러리의 엔트리포인트가 되는 부분이다.build.lib.fileName
: 라이브러리 모드로 번들링 된 자바스크립트 파일의 이름이 되는 부분이다.fileName: 'index'
라고 설정하였으니index.js
라는 파일이 아웃풋으로 생성될 것이다build.lib.formats
: 빌드 결과물이 도출할 자바스크립트 포맷을 결정하는 부분이다.ESM
모듈과CommonJS
모듈 모두에 지원되는 라이브러리를 만들고자[’es’, ‘cjs’]
값을 기입했다.
다음은 vite
가 아닌 내부 rollup
에 적용되는 옵션이다.
build.rollupOptions.external
: 라이브러리에 포함되지 않아야할 의존성들을 명시하는 부분이다. 가령, 리액트를 기반으로 만들어지는 라이브러리의 경우 이 값을 설정해주어야 한다. 이는 라이브러리 사용자는 어차피react
의존성을 설치하기 마련이기에react
패키지 내용이 번들링에 포함되면 불필요한 중복이 될 뿐더러 라이브러리의 사이즈만 키우는 꼴이 되기 때문에 꼭 설정해주어야 한다.
plugins config
다음은 vite
에서 사용되는 플러그인을 기입하는 부분이다. 필수적인 부분과 그렇지 않은 부분이 있으니 참고해서 보길 바란다.
-
vite-plugin-dts
: 라이브러리에서 타입을 지원해야할 때 필수적으로 사용해야 하는 플러그인이다. 이는 타입스크립트로 작성된 소스코드를 통해 라이브러리 사용자가 필요한 타입을 만들어주는 플러그인이다.copyDtsFile
은 소스코드에 선언 타입 파일(d.ts
)이 있는 경우 이를 번들링에 포함시키는 역할을 한다.include
,exclude
는 이름에서 쉽게 유추할 수 있듯이 타입을 생성할 대상에 포함시킬지 아닐지를 결정하는 부분이다. 나의 경우는 소스코드 부분 (src
하위 모든 폴더)은 포함시키되, 테스트 파일이나 불필요한 파일들은 제외 시켰다.entryRoot
는src
경로를 생략한 구조로 나오도록 하는 역할을 한다. 가령 예를 들어 소스코드에서 빌드에 포함되는 경로가src/components/index.ts
라면, 빌드 후 번들된 파일의 경로는components/index.js
가 된다
-
vite-tsconfig-paths
: 타입스크립트로 소스코드를 작성할때import
경로에alias
가 포함되어 있는 경우에 사용하면 편리한 플러그인이다. 만약 이 플러그인 없이alias
를vite
환경에서 사용한다면, 이중으로 관련 설정을 해주어야 한다// tsconfig.json "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] }, }, // vite.config.ts resolve: { alias: [ { find: "@", replacement: path.resolve(__dirname, "src") }, ], },
이렇게 처리하는 대신에 이 플러그인을 사용하게 된다면
vite.config.ts
의alias
설정은 생략하고,tsconfig
에 설정한alias
로import
경로를 파싱해준다. 기본적으로는 추가적인 설정이 없다면tsconfig.json
을 기준으로 경로를 파싱한다.
package.json 설정
다음은 빌드된 결과물을 패키지로 내보내고자 할때 사용자가 참고해야되는 부분을 기입하는 작업이다.
peerDependency
이 라이브러리를 설정할때 같이 설치해줘야할 의존성을 명시하는 부분이다. 앞서 vite
설정 부분 중 external
에 기입된 의존성과 같은 의존성을 기입해주면 된다.
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
}
해당하는 패키지의 이름과 함께 설치되어야할 버전을 명시해주면 된다.
exports와 기타 field들
다음은 사용자가 라이브버리를 import
할때 알맞은 빌드의 결과물을 찾아갈 수 있도록 하는 부분이다. 앞서 vite 설정 값으로 도출된 번들 결과물에 대한 일종의 가이드를 작성하는 것이다.
pacakge/
└── dist/
├── index.css // 스타일시트 파일
├── index.js
├── index.cjs
└── index.d.ts
가령 위와 같은 결과물이 번들링 결과로 도출 되었다면 아래와 같은 설정이 필요하다.
"files": [
"dist"
],
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./styles": {
"import": "./dist/index.css",
"require": "./dist/index.css"
}
}
이 설정에 대해 이야기 해보자면 다음과 같다.
files
: 배포시 포함되어야할 파일들을 명시하는 부분이다.main
: 이 라이브러리를commonjs
형태로 사용할 때 진입점이 되는 경로이다.module
: 이 라이브러리를esmodule
형태로 사용할 때 진입점이 되는 경로이다.types
: 이 라이브러리의 타입 파일을 사용하고자 할때 진입점이 되는 경로이다.exports
: 패키지를import
할때 세부적인 경로를 명시한다. 여기서.
은 패키지 자체를 의미 한다. 만약 패키지 이름이mypackage
이고import * form 'mypackage'
를 통해 import 한다면,dist/index.*js
파일을 참조한다는 것을 의미한다. 반면,import 'mypackage/styles'
라는 경로로 import 하면dist/index.css
파일을 참조하게 된다.require
:cjs
형태로 라이브러리를 사용할때 참조하는 경로이다.import
:esm
형태로 라이브러리를 사용할때 참조하는 경로이다.types
: 타입을 참조하는 경로이다.
이렇게 간단한 설정으로 라이브러리 하나가 완성되었다.
라이브러리 코드 점검하기
위에서 설명한 방식대로 빌드한 라이브러리는 어떠한 형태로 사용자 앱에 전달될까? 실제 빌드 결과물을 갖고 그 차이를 확인해보고자 한다.
예시로 들고자 하는 나의 라이브러리는 UI 컴포넌트 키트이다. 이 패키지의 엔트리 파일은 다음과 같다.
/* atoms */
export * from './atoms/box'
export * from './atoms/icon'
export * from './atoms/text'
export * from './atoms/flex'
export * from './atoms/grid'
/* molecules */
export * from './molecules/icon-button'
export * from './molecules/theme-switch'
export * from './molecules/chip'
export * from './molecules/progressive-hover-circle'
/* organisms */
export * from './organism/carousel'
각 폴더 하위에 box
,icon
, text
와 같은 파일들로부터 모든 컴포넌트들을 export
하는 방식이다.
// atoms/box.tsx
import { createElement, type ElementType, forwardRef, type ReactElement } from 'react'
import { type BoxProps } from './box.types'
import { useBoxProps } from './hooks/use-box-props'
import { type PolymorphicRef } from '@/types/polymorphic'
import { Slot } from '@radix-ui/react-slot'
export type BoxComponent = <C extends ElementType = 'div'>(
props: BoxProps<C>,
) => ReactElement<BoxProps<C>>
export const Box = forwardRef(
<C extends ElementType = 'div'>(props: BoxProps<C>, ref: PolymorphicRef<C>) => {
const { as, asChild, ...otherBoxProps } = useBoxProps(props)
console.log('box component') // make console log
return createElement(asChild ? Slot : as ?? 'div', { ref, ...otherBoxProps })
},
) as BoxComponent
그리고 컴포넌트 Box
의 코드는 다음과 같이 생겼으며 확인을 위해 간단히 로그를 남기는 코드를 작성해봤다.
// organism/carousel.tsx
...
export const CarouselPagination = ({
className,
size = 'sm',
...rest
}: CarouselPaginationProps) => {
...
console.log('carousel component') // make console log
return (
<Box className={clsx(styles.knobList, className)} {...rest}>
...
</Box>
)
}
...
그리고 Carousel
컴포넌트는 다음과 같이 생겼으며 확인을 위해 로그가 찍히는 코드가 들어가 있다. 이러한 라이브러리를 앞서 소개한 방식대로 번들링화면 아래와 같은 코드가 도출된다
...
const mr = jr(
(r, i) => {
const { as: s, asChild: t, ...n } = Is(r);
return console.log("box component"), si(t ? rs : s ?? "div", { ref: i, ...n });
}
);
...
), l = (u) => (c) => {
c.preventDefault(), c.stopPropagation(), d(u);
};
return console.log("carousel component"), /* @__PURE__ */ T.jsx(mr, { className: pr(Xt, r), ...s, children: a.map((u, c) => /* @__PURE__ */ T.jsx(
정확한 형태는 알아볼 수 없으나, 번들링 된 코드에 두 컴포넌트가 담겨있는 것이 제대로 확인된다.
방금 번들링한 라이브러리의 대략적인 구조는 다음과 같다. 이때 이상적으로 라이브러리 사용자가 하나의 컴포넌트만 사용한다면 그 컴포넌트와 관련된 코드들만 사용하는 것이 당연한 이치일 것 이다. 예를 들어, Box
컴포넌트만 사용한다면 아래와 같이 색칠된 코드들만 사용되는 것이 이상적일 것이다.
이를 직접 확인해보고자 간단한 Vite
프로젝트를 이용해 Box
컴포넌트 만을 import
해오는 다음의 코드를 작성하였다.
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import { Box } from '@sxungchxn/dev-ui'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
function App() {
return (
<div>
<Box />
</div>
)
}
이러한 코드를 번들링 해보면 다음과 같다.
t)=>{const{as:r,asChild:n,...i}=am(e);return console.log("box component"),w.createElement(n?l1:r??"div",{ref:t,...i})});var
...
.stopPropagation(),s(u)};return console.log("carousel component"),V.jsx(Be,{className:et(x_,e),...r,children:o.map((u,f)=>V.jsx
...
작성한 Vite
코드 내에서는 Box
컴포넌트만을 import 해 사용하고 있음에도 불구하고, 번들링 결과물에는 이와 관련 없는 Carousel
컴포넌트 코드가 담겨져 있다. 나중에 이야기할 treeshake
기법을 적용한 번들 결과물과 비교해보면 차이가 많이 느껴진다.
// 기존 방식 대로 번들된 라이브러리를 사용하는 코드 번들
vite v5.0.11 building for production...
✓ 328 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-BPvgi06w.css 0.92 kB │ gzip: 0.50 kB
dist/assets/index-C6XOIYZp.js 257.03 kB │ gzip: 75.43 kB
// treeshake 기법을 적용해 번들된 라이브러리를 사용하는 코드 번들
vite v5.0.11 building for production...
✓ 388 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-BPvgi06w.css 0.92 kB │ gzip: 0.50 kB
dist/assets/index-DQVu27KS.js 204.42 kB │ gzip: 55.69 kB
사용할 코드만 가져오는 Treeshake
그렇다면 treeshake
기법은 무엇일까? webpack
공식 문서에는 다음과 같이 정의되어 있다.
Tree shaking
is a term commonly used in the JavaScript context for dead-code elimination. It relies on the static structure of ES2015 module syntax, i.e.import
andexport
.
ES2015
(ES6
) 에서부터 도입된 import
, export
문을 이용해 모듈의 정적인 구조를 파악하여 죽은 코드(사용되지 않는)코드를 제거하는 기법으로 정의를 내리고 있다.
아까 예시로 들었던 이 사진과 같이 사용되지 않는 코드(하얀색)들은 번들 대상에서 제외하는 것이다.
treeshake의 성립조건
treeshake
의 의미와 영향력을 확인했으니 이를 사용하기 위한 조건들을 확인해보자.
1️⃣ ESM 모듈
정의에서도 보았듯이 esm
모듈에서 사용하는 export
, import
문을 기반으로 하여 treeshake
를 적용할 수 있다고 하고 있다. 이는 기존의 다른 모듈과는 다르게 모듈 구조를 정적으로 분석이 가능한 문법이기 때문이다. 이는 ESM
고유의 특징들이 때문인데 이는 이 글에서 정말 잘 다뤄주고 있으니 한 번 읽어볼 것을 추천한다. 주요 특징들만 짧게 이야기 하자면 다음과 같다.
import
하는 변수는 값을 가져오지 않고 참조값을 가져온다.import
문은 항상 최상단에 위치하기에 조건부import
가 불가하고 경로 변수로 할당이 불가능하다.import
,export
문을 통해 연결된 모듈 구조가 모두 파악되어야 그 모듈들의 실행이 가능하다.
이러한 특성들 덕분에, ES 모듈은 정적인 모듈 구조 분석을 가능하게 한다.
2️⃣ sideEffect 없는 코드
여기서 sideEffect
란 모듈 관점에서의 sideEffect
를 의미하는데, 이는 실행이 되지 않았음에도 모듈을 로드하면서 실행되는 코드 부분을 의미한다.
// module.ts
export const code = 'code'
console.log(code) // code with side-effect
export const removeTrailingSlash = (input: string) => {
...
}
// index.ts
import { removeTrailingSlash } from './module'
removeTrailingSlash() // 'code' 가 출력된다.
다음과 같이 module.ts
파일에 있는 console.log
부분은 해당 파일이 import
된다는 이유만으로 실행이 이루어진다. 이것이 sideEffect
이다. 또다른 대표적인 예가 css
파일을 임포트 하는 것이다. 이는 모듈 로드만으로 실행없이도 관련 내용들이 모두 불러와져야 하는 부분이다.
import 'style.css'
한편, 번들러 입장에서는 이러한 sideEffect
가 있는 코드 없이 모듈관계가 명확한 코드를 제공 받게 될때 불필요한 코드를 제거하는 작업을 더 탁월하게 해낼 수 있다. 빌드 도구가 이를 자동으로 감지할 수 있다면 더할나위 없이 좋겠지만 아직까지는 개발자가 이를 판별해주는게 보다 더 효율적이다. 이때 개발자가 효과적으로 사용할 수 있는 것이 package.json
의 sideEffects
필드이다.
{
"name": "my-package",
"sideEffects": false
}
sideEffects
가 없다고 개발자 단에서 선언해줌으로써, 번들러 입장에서는 더욱 과감한 최적화가 가능해지는 것이다.
{
"name": "my-package",
"sideEffects": [
"*.css",
]
}
반면, 디자인 시스템 내에 포함된 css
파일과 같이 sideEffect
가 동반될 수 밖에 없는 코드에 대해서는 매칭되는 파일 이름을 명시함으로써 sideEffect
가 있는 코드에 대한 힌트를 번들러에게 제공해 줄 수 있다.
3️⃣ 모듈 구조의 유지
사실 앞 문단에서 언급한 sideEffect
의 이점을 제대로 누리기 위해서는 모듈 구조를 번들 결과물에서도 유지할 수 있어야 한다. 왜냐하면, 단일 파일로 번들링 할 경우, 사용되지 않은 코드를 없애는 최적화 과정이 제대로 이뤄지지 않은 경우가 발생할 수 있기 때문이다. 대표적인 예로 CommonJS
만을 지원하는 라이브러리를 import
하는 경우가 대표적이다.
// dist/index.js
import { isNil } from "lodash"; // lodash module import
const checkExistance = (variable) => !isNil(variable);
const userAccount = {
name: "user account",
};
const getUserAccount = () => {
return userAccount;
};
const getUserPhoneNumber = () => "***********";
const getUserName = () => "John Doe";
export { checkExistance, getUserName, getUserPhoneNumber, getUserAccount };
commonjs
만을 지원하는 lodash
라이브러리로부터 isNil
임포트 하는 결과물들을 비롯해 여러 코드들이 하나의 파일로 번들링 되었다고 하자. 이러한 라이브러리를 사용하는 코드(첫번째)를 webpack
으로 번들링 하게 되면 다음의 결과물(두번째)이 도출된다.
import { getUserName } from "user-library";
console.log(getUserName());
/***/ "./node_modules/user-library/dist/index.js":
/*!*************************************************!*\
!*** ./node_modules/user-library/dist/index.js ***!
\*************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
"use strict";
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "getUserName": () => (/* binding */ getUserName)
/* harmony export */ });
/* unused harmony exports checkExistance, userAccount, getUserPhoneNumber, getUserAccount */
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash */ "./node_modules/user-library/node_modules/lodash/lodash.js");
/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_0__);
const checkExistance = (variable) => !isNil(variable);
const userAccount = {
name: "user account",
};
const getUserPhoneNumber = {
number: '***********'
};
const getUserAccount = () => {
return userAccount
};
const getUserName = () => 'John Doe';
/***/ }),
/***/ "./node_modules/user-library/node_modules/lodash/lodash.js":
/*!*****************************************************************!*\
!*** ./node_modules/user-library/node_modules/lodash/lodash.js ***!
\*****************************************************************/
/***/ (function(module, exports, __webpack_require__) {
/* module decorator */ module = __webpack_require__.nmd(module);
var __WEBPACK_AMD_DEFINE_RESULT__;/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
// ...
lodash
코드를 사용하지 않는 getUserName
만 사용하는 코드만을 import
하였음에도 lodash
관련 코드가 번들되어 있는 것을 볼 수 있다. 단일 파일로 번들내에서는 commonjs
모듈인 lodash
불러와 무조건 번들링에 포함되기 때문이다.
💡
CommonJS
는 왜 무조건 번들링에 포함될까?
CommonJS
는 조건부import
,dynamic path
등ES Module
보다 융통성있는 기능들이 제공되지만 이는 정적인 방식으로 모듈 구조를 파악하지 못하고 동적인 방식으로(런타임에 실행을 통해)만 모듈구조를 파악할 수 있게 만들기 때문이다. 관련한 자세한 내용은 ESM, CJS를 잘 비교한 이 자료를 참고해보면 좋다.
이 때문에 모듈구조를 유지하는 것이 sideEffects
의 최적화를 보다 원활하게 해주기 때문에 보다 나은 tree-shaking
을 위해선 꼭 필요한 부분이다.
Vite로 Treeshake 적용하기
모듈 구조 잘 유지되게 만들기
라이브러리 빌드 결과물이 모듈 구조를 잘 유지할 수 있도록 rollup
의 preserveModules
옵션을 활용해준다. 이렇게 하면 우리가 소스코드에서 import
하는 구조와 거의 동일하게 빌드 결과물이 도출되게 해준다.
export default defineConfig({
plugins: [
// 이전 config와 동일
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'index',
fileName: 'index',
},
rollupOptions: {
external: ['react', 'react-dom'],
// 아까와 달라지는 부분들
input: ['src/index.ts'],
output: [
{
format: 'esm',
dir: 'dist',
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: ({ name: fileName }) => {
return `${fileName}.js`
},
},
{
format: 'cjs',
dir: 'dist',
entryFileNames: ({ name: fileName }) => {
return `${fileName}.cjs`
},
},
],
},
},
})
이제는 아까와는 다르게 빌드의 주된 업무를 rollup
에 맡겨주도록 해야 한다. 이를 위해 rollupOptions
에 빌드와 관련된 설정을 해준다. 처음에 명시하는 input
필드는 엔트리포인트가 될 파일을 의미한다. output
부터는 빌드 결과물에 대한 설정을 적는 필드이다. 각 필드들을 설명해보자면 다음과 같다.
output.format
: 빌드 결과물의 포맷.esm
,cjs
등을 명시하여 원하는 포맷의 결과물을 만들어낼 수 있다.output.dir
: 결과물이 위치할 폴더명output.preserverModues
: 모듈구조 유지 여부와 관련된 옵션output.preserveModulesRoot
: 빌드 결과물이 어느 폴더 기준점부터 생략하여 구조를 유지할지 결정한다. 이를 명시하지 않으면src/**/*.ts
와 같은 경로의 파일들은dist/src/**/*
와 같이src
폴더 경로가 최상단에 위치하게 된다. 이를 방지하기 위해서src
폴더 계층은 걷어내게 해주는 옵션이다.output.entryFileNames
:chunk
파일들의 파일명을 결정하는 함수이다. 이는esm
으로 빌드되는 결과물은.js
확장자로,cjs
로 빌드되는 결과물은.cjs
확장자로 파일을 만들어내도록 구분짓는 역할을 한다.
💡 CJS 포맷에는 모듈 구조를 유지해주더라도
treeshake
가 제대로 동작하지는 않기 때문에 단일 파일로 번들되게 설정해주었다.
사이드 이펙트 명시하기
다음은 번들러가 보다 잘 트리셰이킹 할 수 있게 sideEffects
를 명시해야 한다. 앞서 언급한 대로 만약 sideEffects
를 유발하는 코드가 없다면, 다음과 같이 false
를 명시해 주어야 한다.
// package.json
{
"sideEffects": false,
}
반면 다음과 같이 사이드 이펙트를 유발하는 코드가 어쩔 수 없이 포함되는 경우가 있다면 다음과 같이 이를 유발하는 파일의 경로명을 명시해줄 필요가 있다. 가령, vanilla-extract
라이브러리를 사용하는 ui
라이브러리에서 아래 코드와 같이
import "./reset.css" // reset.css.ts를 import
사이드 이펙트를 유발하는 코드가 있는 경우라면 아래와 같이 해당하는 파일의 경로를 명시해주어야 한다. 경로는 glob pattern
과 같은 형식도 허용한다.
// package.json
{
"sideEffects": [
"*.css.ts"
],
}
완성된 코드 결과물은 이 레포지토리에서 확인해 볼 수 있다.
결과물 비교하기
# non-tresshaking build
dist/index.js 305.72 kB │ gzip: 78.04 kB
# treeshaking build
dist/components/atoms/text/text.js 1.15 kB │ gzip: 0.50 kB
dist/components/molecules/chip/chip.js 0.88 kB │ gzip: 0.43 kB
...
dist/index.js 2.27 kB │ gzip: 0.73 kB
트리셰이킹이 적용되기 전에는 빌드 결과물이 하나의 파일로만 나오고 그 용량은 305.72kB
이나 된다. 이 때문에 특정 컴포넌트만 사용하게 되더라도 다른 컴포넌트의 코드까지 같이 사용자앱에 번들되버릴 수 있다.
반면, 트리셰이킹이 적용된 라이브러리에서는 모듈 구조에 따라 코드들이 잘게 쪼개어져있으며, 특정 컴포넌트만 사용한다면 그 컴포넌트 코드와 연관된 코드들만 불러올 수 있게 된다.
정리
이번 글을 통해 vite
가 번들러로서 어떠한 의미를 가지는지, 이를 바탕으로 라이브러리는 어떻게 만들어볼 수 있는지 그리고 보다 고도화된 라이브러리를 만들어내기 위해 필요한 treeshaking
에 대해서까지 알아봤다. 이번 글을 통해 vite
그리고 rollup
을 이용한 라이브러리 세계로 진입할 수 있는 시야가 열렸으면 좋겠다.