前言
最近业余时间写 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
| #!/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 命令相对简单,主要是提供一些模版供用户选择,并通过网络下载到本地。不过也还有一些细节是可以做的。
- 模版列表和内容可以存储在类似
GitHub 和 GitLab 的远程仓库中,并通过网络请求获取,这样维护起来更加方便。
- 模版内容提供一个类似
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
| 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.json 或 react-dev-kit.config.js 文件,这部分没任何难度。
远程配置
把部分配置设计为远程配置,比如 externals 配置,这样在有些项目部希望打包一些三方库( react、react-dom、react-router-dom 等)时就非常方便了。
远程服务器上可以存储一个类似下面的 JSON 配置文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| [ { "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" ] } ]
|
通过 http、Axios 等之类的库获取到上面的 json 内容,然后配置到 Webpack 的 externals 中。
1 2 3 4
| const webpackConfig: Webpack.Configuration = { externals: getWebpackExternals(), };
|
只配置 externals 还不够的,还需要在 index.html 引入对应的 js 和 css 文件,我们可以通过 Webpack 的 plugin 来实现。
可参考如下:
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
| 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
| 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
| 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
| 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
| 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
| 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); };
|