前言 最近业余时间写 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 commandcreate 命令相对简单,主要是提供一些模版供用户选择,并通过网络下载到本地。不过也还有一些细节是可以做的。
模版列表和内容可以存储在类似 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 commandstart 命令和 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 ); };