Packaging a component library
Introduction
Let’s explore how to approach packaging a typical component library (e.g. written in TypeScript, with React and CSS modules). Our goal will be to reach a production-ready output and support code splitting by a consumer for both JavaScript and CSS, allowing a consumer to split imported parts of the library across separate chunks of a bundled application.
Note that the examples shown here will be pseudo-code implementations that are straightforward to translate for Rollup, Webpack, or any other bundler.
Initial packaging
Before jumping into solutions, let’s start with a naive approach. We can imagine enumerating each component in the library and bundling it individually:
const componentPaths = glob.sync('src/components/*.tsx');
await Promise.all(
componentPaths.map(path => {
await bundle(path, {
outDir: 'dist/components',
});
}),
);
This works for code splitting, in that we package a component entrypoint per component, but we have some problems to solve:
- Any component that depends on another will duplicate a copy of it.
- A naive implementation with either combine all of the CSS assets into a single extracted CSS file — bad for code splitting — or require use of a JavaScript-based stylesheet loader to inject script tags.
Can we address this duplication, and find a better solution to handle stylesheets?
Addressing duplication
The duplication problem is straightforward. Any bundler will support some form of external
option to indicate modules that are loaded at runtime or by a consuming bundler. Typically, this option is used for peer dependencies (e.g. excluding React from our library’s bundle). However, it turns out that because our component library is intended to be re-bundled by a consuming application author, every component is simply an external of every other component. That is, each component’s imports are left as-is for the consumer’s bundler to resolve, rather than bundled together and producing duplication when components are reused. By expressing this to our bundler, we can package our components such that each packaged component preserves any relative import statements where necessary.
const componentPaths = glob.sync('src/components/*.tsx');
await Promise.all(
componentPaths.map(path => {
await bundle(path, {
outDir: 'dist/components',
external: p => path !== p,
});
}),
);
Handling stylesheets
A component library should not bundle its own CSS loader for consumers. Rather, it should expose its stylesheets directly as CSS files for a consumer’s bundler to re-package. The solution here is to: 1) extract and emit the CSS file for each component, and 2) add a literal import './${componentName}.css'
back to the top of each component’s packaged content. This will serve as a header to be referenced by an application’s own bundler to resolve the stylesheet for its own stylesheet loader.
const componentPaths = glob.sync('src/components/*.tsx');
await Promise.all(
componentPaths.map(path => {
const componentName = parseComponentName(path);
await bundle(path, {
outDir: 'dist/components',
external: p => path !== p,
plugins: [
css({
extract: `${componentName}.css`,
}),
],
});
const jsPath = join('./dist/components', `${componentName}.js`);
const cssPath = join('./dist/components', `${componentName}.css`);
if (existsSync(jsPath) && existsSync(cssPath)) {
const js = await readFile(jsPath, 'utf8');
await writeFile(jsPath, `import './${componentName}.css';\n${js}`);
}
}),
);
Conclusion
By packaging each component individually, marking other components as external, and emitting separate CSS files for each component, we avoid duplication of components and enable application authors to handle styles with whatever stylesheet loader works best for their application. Together, these packaging approaches provide a foundation for building production-ready component libraries.