# 1 引言

说到前端编译方案，也就是如何打包项目，如何编译组件，可选方案有很多，比如：

- 通过 webpack / parcel / gulp 构建项目。
- 通过 parcel / gulp / babel 构建组件。

如果你喜欢零配置的 parcel，那么项目和组件都可以拿它来编译。

如果你业务比较复杂，需要使用 webpack 做深度定制，那么常见组合是：项目 - webpack，组件 - gulp。

但项目与组件的编译存在异同点，不同构建工具支持的生态也存在异同点。

## webpack parcel gulp 生态的区别

- babel 一般不会解析模块，也就是一般仅做代码预处理，而不会改变文件结构，也对 require、import 语句不敏感。
- webpack / parcel 主要就是解决模块化打包问题，因为浏览器还不支持（现在部分支持 `type="module"`）。
- gulp 理论上可以将 babel、webpack、parcel 作为插件，但这是后来的事。历史上由于 gulp 是作为 grunt 的替代品出现，当时要解决的问题是处理浏览器兼容问题，打包 scss 或 less，做一些公共资源替换，雪碧图等，最后可以顺带合并到一个文件，但模块化功能远远比 webpack 弱，基本上只能合并，但不能 “理解模块概念”。

## 项目构建与组件构建的区别

项目构建的目的主要在于发布 CDN，所以大家一般不在乎构建脚本的通用性。换句话说，无论项目使用了怎样的构建方式，怎样理解 `import` 语句，甚至写出 `require.context` 等自定义语法，只要最终编译出符合浏览器规范的代码（考虑到兼容性）就足够。

组件构建的目的主要在于发布 NPM，除了 ESNext 规范会使用 Babel 编译成 ES3，大部分代码写的很收敛，甚至对 SASS 的使用都要与 Typescript 插件一起组合成复杂的 Gulp Task。

**所以往往大家会对项目采取复杂的构建约束策略，而对组件的编译采取相对简单的办法，确保发布代码的通用性。**

所以在大部分项目使用 webpack 支持 worker-loader 时，编写组件时发现这段代码不灵了。或者至少你得付出一些代价，因为组件的调试依然可以利用 webpack-dev-server，这时可以加上 worker-loader，但由于 gulp 没有靠谱的 worker 插件，你的组件可能需要将 Worker 引用部分原样输出，希望由引用它的项目做掉对 worker-loader 的支持。

其实这种心态是很危险的，不仅导致了组件不通用，甚至引发了各构建工具的 Tree Shaking 优化。原因就是构建组件的代码太原始，冗余的代码没有删除，甚至直接引用的 SASS 代码仍然保留，更危险的是带上了一些特殊 webpack loader 才支持的语法。

之所以说 Antd 是一个拥有优秀基因的前端组件库，是因为他遵循了前端组件最基本的代码素养：

1. 编译后的代码全部符合基本 JS 规范，换个角度来说，使用 webpack 内置基本 js loader 就能完全解析。
2. 将 css 代码抽离出来，这样不会强制项目对 node_modules 的代码应用 css-loader。

所以一个 **靠谱的组件库** 的产出文件，应该符合基本 ES 模块化规范，且不包括任何特殊语法。

但是这引发了一个新的问题：组件开发体验比项目差很多。

比如组件想使用雪碧图自动优化、想使用 worker-loader 方便快捷的调用多线程，想用自己的 css modules，甚至想把项目里一堆 PostCSS 快捷语法搬过来时怎么办？难道组件开发就不能获得与项目开发一样的体验吗？

要解决这个问题，笔者介绍一种基于 webpack 的通用构建方案，让本地调试、CDN 打包、ES6 -> ES3 转换 都使用统一套配置代码，同一套 loader。

# 2 精读

核心思想只有一句话：利用 [webpack-node-externals](https://github.com/liady/webpack-node-externals) 忽略 Webpack 对指向 node_modules 的 require 或 import 语句：

1. 进行项目/组件调试时，开启 `development` 模式。
2. 进行项目编译时，开启 `production` 模式。
3. 进行组件编译时，开启 `production` 模式，且利用 [webpack-node-externals](https://github.com/liady/webpack-node-externals) 插件忽略 node_modules。

可以想像，根据第三条，如果所有组件都按照这个模式输出代码，那么 webpack 对 node_modules 编译时，只需要将所有 `require` 代码进行合并，不需要执行任何 loader，也不需要压缩，不需要 TreeShaking，因为这些在组件代码编译时全部已经做好了，这种构建效率几乎达到最大。

## 实际案例

我们拿支持 `typescript`、`sass`、`css-modules`、`worker-loader` 的场景作为案例。

我们创建三个文件 `entry.tsx` `entry.worker.ts` 与 `entry.scss`：

**entry.scss:**

```scss
.container {
  border: 1px solid #ccc;
}

.primary {
  color: blue;
  &:hover {
    color: green;
  }
}
```

**entry.worker.ts:**

```tsx
import hello from "hello";

const ctx: Worker = self as any;

ctx.onmessage = event => {
  ctx.postMessage(hello());
};

export default null as any;
```

**entry.tsx:**

```tsx
import * as React from "react";
import styles from "./entry.scss";
import * as MyWorker from "./parser.worker";

const worker = new MyWorker();

export default () => (
  <div className={styles.container}>
    <button className={styles.primary}>Click Me.</button>
  </div>
);
```

在上面三个文件中，我们分别利用了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析（利用 webpack 自动生成字符串代码并利用 Blob URL 方式载入，这样就不需要创建新文件也可以用 worker 了，也不会存在跨域问题）。

为了支持这几个特性对如上代码做调试、项目发布、组件发布，我们分别看下这三个场景该如何配置编译脚本。

### 本地调试

本地调试是不用区分组件与项目的。因为无论何种情况，都需要进行基本的项目编译，载入所有自定义 loader 并打成一个 bundle 包。

此时我们只要维护一份 `webpack` 配置即可：

```typescript
const webpackConfig = {
  mode: "development",
  module: {
    rules: [
      {
        test: /\.worker\.tsx?$/,
        use: {
          loader: "worker-loader",
          options: {
            inline: true
          }
        },
        include: path.join(projectRootPath, "src")
      },
      {
        test: /\.tsx?$/,
        use: [
          [
            "babel-loader",
            {
              plugins: [
                [
                  "babel-plugin-react-css-modules",
                  {
                    filetypes: {
                      ".scss": {
                        syntax: "postcss-scss"
                      }
                    }
                  }
                ]
              ]
            }
          ],
          "ts-loader"
        ],
        include: path.join(projectRootPath, "src")
      },
      {
        test: /\.scss$/,
        use: [
          "style-loader",
          [
            "css-loader",
            {
              importLoaders: 1,
              modules: true
            }
          ],
          "sass-loader"
        ],
        include: path.join(projectRootPath, "src")
      }
    ]
  }
};

export default webpackConfig;
```

利用这个配置加上 `webpack-dev-server` 即可完成组件与项目的本地调试。

### 项目发布

项目发布时，需要将所有代码打入到一个 bundle 包，此时只需使用 `webpack-cli` 即可，对配置做如下修改：

```tsx
export default {
  ...webpackConfig,
  mode: "production"
};
```

### 组件发布

组件发布时，依然使用 `webpack-cli` 构建，但利用 `webpack-node-externals` 忽略对 `node_modules` 的解析。

```tsx
import * as nodeExternals from "webpack-node-externals";

export default {
  ...webpackConfig,
  mode: "production",
  externals: [nodeExternals()]
};
```

此时编译的组件代码，包含了 Typescript 编译、SCSS 编译、css-modules 解析、worker-loader 解析，但所有 `node_modules` 代码都保持原样，比如下面的代码：

<img width=500 src="https://user-images.githubusercontent.com/7970947/51447746-0b94ab00-1d5c-11e9-84c1-6b7f7313e426.png">

做了代码去重、按需加载、打包、压缩，但因为保持了 `require` 原样，因此大小只有源码体积。

同时上述三个场景都在复用 webpack 一套代码的基础上，利用了 webpack 的生态，因此维护性和拓展性都很强。后续再加入新功能，再也不需要到处找 `babel` 或 `gulp` 的插件了！

# 3 总结

本文从 `webpack` 为切入点，但其实还可以从 `parcel` 或 `gulp` 为切入点，实现前端项目、组件构建体系的统一。

不过从可定制性来看，`webpack` 插件生态更完善，所以笔者选择了 `webpack`。

留下一个思考题：你的项目、组件是如何构建的呢？是用了一套代码，还是两套呢？

> 讨论地址是：[精读《如何编译前端项目与组件》 · Issue #125 · dt-fe/weekly](https://github.com/dt-fe/weekly/issues/125)

**如果你想参与讨论，请[点击这里](https://github.com/dt-fe/weekly)，每周都有新的主题，周末或周一发布。前端精读 - 帮你筛选靠谱的内容。**
