前言

最近业余时间写 React 项目比较多,并且都用的 create-react-app 创建项目,每次创建完后,总要增删改一些配置和文件,显得有点麻烦。

回想起前几年在公司也做过类似的脚手架工具,所以今天打算自己写一个简单的,方便日常使用。

创建 cli

如果为了省事,可以直接通过 oclif 这个项目来创建 cli 项目,非常好用。

1
npx oclif generate mynewcli

如果想自己实现的话,原理大体如下:

首先在 package.json 中的 bin 字段中增加配置。如:

1
2
3
4
5
6
{
"name": "@my-space/react-dev-kit",
"bin": {
"react-dev-kit": "./lib/index.js"
}
}

然后在 ./lib/index.js 文件的第一行写上 #!/usr/bin/env node

我的 ./lib/index.js 文件是 src/index.ts 文件生成的。

剩下的主要就是接受命令行参数,可以自己解析参数,但一般使用三方库,如: commander

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/index.ts
#!/usr/bin/env node
import { Command } from "commander";
import { createAction } from "./actions/create";
import { startAction } from "./actions/start";
import { buildAction } from "./actions/build";

const program = new Command();

program
.name("@my-space/react-dev-kit")
.description("The React Development Toolkit for MySpace")
.version("1.0.0");

program.command("create").description("").action(createAction);
program.command("start").description("").action(startAction);
program.command("build").description("").action(buildAction);

program.parse();

create command

create 命令相对简单,主要是提供一些模版供用户选择,并通过网络下载到本地。不过也还有一些细节是可以做的。

  • 模版列表和内容可以存储在类似 GitHubGitLab 的远程仓库中,并通过网络请求获取,这样维护起来更加方便。
  • 模版内容提供一个类似 define.js 的文件,先拉取到此文件,再根据配置让用户填写更多信息,配置完后需要执行的代码命令。
  • 下载模版文件时,可下载 zip 文件,再通过 7-zip 进行解压。

build command

考虑到可扩展性,除了引入外部配置,还支持了部分远程配置,同时为了后面使用方便还需要把常用的路径给事先获取到。

路径

这部分可以根据实际情况进行调整。我这里基本上是按照约定,固定了大部分路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/core/paths.ts
import fs from "fs";
import path from "path";
import { IExternalConfig, IPaths } from "../types";

export const appDirectory = fs.realpathSync(process.cwd());

export const resolveApp = (relativePath: string) =>
path.resolve(appDirectory, relativePath);

export const moduleFileExtensions = [".ts", ".tsx", ".js", ".jsx"];

export const getPaths = (config: IExternalConfig): IPaths => {
const path: IPaths = {
appPath: resolveApp("."),
appBuild: resolveApp(config.outputDir),
appPublic: resolveApp("public"),
appHtml: resolveApp("public/index.html"),
appSrc: resolveApp("src"),
appIndexJs: resolveApp("src/index.tsx"),
appTsConfig: resolveApp("tsconfig.json"),
// ...
};
return path;
};

外部配置

通过 IO 读取约定的配置文件,比如项目根目录下的 react-dev-kit.config.jsonreact-dev-kit.config.js 文件,这部分没任何难度。

远程配置

把部分配置设计为远程配置,比如 externals 配置,这样在有些项目部希望打包一些三方库( reactreact-domreact-router-dom 等)时就非常方便了。

远程服务器上可以存储一个类似下面的 JSON 配置文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// remote-externals.json
[
{
"moduleName": "react",
"globalName": "React",
"version": "xx.xx.x",
"js": [
"https://cdn.jsdelivr.net/npm/react@xx.xx.x/umd/react.production.min.js"
]
},
{
"moduleName": "react-dom",
"globalName": "ReactDOM",
"version": "xx.xx.x",
"js": [
"https://cdn.jsdelivr.net/npm/react-dom@xx.xx.x/umd/react-dom.production.min.js"
]
}
// ...
]

通过 httpAxios 等之类的库获取到上面的 json 内容,然后配置到 Webpackexternals 中。

1
2
3
4
const webpackConfig: Webpack.Configuration = {
//...
externals: getWebpackExternals(),
};

只配置 externals 还不够的,还需要在 index.html 引入对应的 jscss 文件,我们可以通过 Webpackplugin 来实现。

可参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/plugins/app-html-plugin.ts
import { Compiler } from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";
import { IRemoteConfig } from "../types";

export class AppHtmlPlugin {
private remoteConfig: IRemoteConfig;

constructor(remoteConfig: IRemoteConfig) {
this.remoteConfig = remoteConfig;
}

apply(compiler: Compiler) {
compiler.hooks.compilation.tap("AppHtmlPlugin", (compilation) => {
HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
"AppHtmlPlugin",
(data, cb) => {
const js: string[] = [];
const css: string[] = [];
this.remoteConfig.configContent.externals.forEach((item) => {
if (Array.isArray(item.js) && item.js.length > 0) {
item.js.forEach((n) => js.push(n));
}
if (Array.isArray(item.css) && item.css.length > 0) {
item.css.forEach((n) => css.push(n));
}
});
data.assets.js = [...js, ...data.assets.js];
data.assets.css = [...css, ...data.assets.css];
cb(null, data);
}
);
});
}
}

上下文

为了方便后续代码的使用,我们可以设计一个 Context 对象,在一开始把需要的数据配置都放在里面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/core/context.ts
import { Env, IContext, Mode } from "../types";
import { getRemoteConfig } from "./remote-config";
import { getExternalConfig } from "./external-config";
import { getPaths } from "./paths";

export const createContext = async (mode: Mode): Promise<IContext> => {
const env: Env = {
NODE_ENV: mode,
BABEL_ENV: mode,
};
for (const key in env) {
process.env[key] = env[key];
}
const remoteConfig = await getRemoteConfig();
const externalConfig = getExternalConfig(env);
const paths = getPaths(externalConfig);

return { mode, env, paths, externalConfig, remoteConfig };
};

整合配置

这部分相对灵活,可以根据需求进行配置。最终,通过 webpack-merge 合并 内置配置远程配置外部配置,生成最终的 Webpack 配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/core/webpack-config.ts
import Webpack, { DefinePlugin, ProgressPlugin } from "webpack";
import merge from "webpack-merge";
import { IContext } from "../types";
import { moduleFileExtensions } from "./paths";
import { AppHtmlPlugin } from "../plugins/app-html-plugin";

export const getWebpackConfig = ({
mode,
env,
paths,
externalConfig,
remoteConfig,
}: IContext) => {
const isEnvDevelopment = mode === "development";
const isEnvProduction = mode === "production";

const webpackConfig: Webpack.Configuration = {
mode: isEnvDevelopment ? "development" : "production",
entry: [paths.appIndexJs],
output: {
publicPath: externalConfig.publicPath,
path: paths.appBuild,
// ...
},
devtool: isEnvProduction ? "source-map" : "cheap-module-source-map",
stats: "errors-warnings",
resolve: {
extensions: moduleFileExtensions,
},
optimization: {
// ...
},
module: {
rules: [
// ...
],
},
plugins: [
// ...
new AppHtmlPlugin(remoteConfig),
new DefinePlugin({
"process.env": Object.keys(env).reduce((obj, key) => {
obj[key] = JSON.stringify(env[key]);
return obj;
}),
}),
// ...
],
externals: remoteConfig.getWebpackExternals(),
};
return merge(webpackConfig, externalConfig.configureWebpack);
};

编译

获取最终配置后,直接使用 Webpack.Compiler 进行编译即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/actions/build.ts
import fs from "fs-extra";
import Webpack from "webpack";
import { createContext } from "../core/context";
import { getWebpackConfig } from "../core/webpack-config";

export const buildAction = async () => {
const context = await createContext("production");

const config = getWebpackConfig(context);
const compiler = Webpack(config);

fs.emptyDirSync(context.paths.appBuild);
fs.copySync(context.paths.appPublic, context.paths.appBuild, {
dereference: true,
filter: (file) => file !== context.paths.appHtml,
});
compiler.run((err, stats) => {
console.log("-".repeat(100));
if (stats) {
console.log(stats.toString());
console.log("-".repeat(100));
}
if (!err) {
console.log("build success");
console.log("-".repeat(100));
}
});
};

本文主要提供思路,并未详细列出各类 loader 和具体配置,建议读者自行查阅最新的相关文档。

start command

start 命令和 build 基本是差不多的,主要就是增加 dev-server 相关配置,可参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
// src/actions/start.ts
export const startAction = async () => {
const context = await createContext("development");

const config = getWebpackConfig(context);
const compiler = Webpack(config);

const devServerConfig = getWebpackConfigDevServer(context);
const server = new WebpackDevServer(devServerConfig, compiler);

server.start();
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/core/dev-server-config.ts
import webpackDevServer from "webpack-dev-server";
import { merge } from "webpack-merge";
import { IContext } from "../types";

export const getWebpackConfigDevServer = (context: IContext) => {
const defaultConfig: webpackDevServer.Configuration = {
port: "auto",
hot: true,
open: true,
client: {
progress: true,
},
};
return merge(defaultConfig, context.externalConfig.devServer);
};
  • 第 1 页 共 1 页
作者的图片

YaoWorld

热爱编程,热爱生活。

前端工程师

中国-成都