动手搭一个React脚手架

1,120 阅读13分钟

脚手架大致可以分为两个目的:项目的创建(create)和项目的运行(start & build)。

项目地址

github.com/fl427/mycli

创建项目

当我们运行一个脚手架的命令,例如eden create时,脚手架首先会读取我们在命令行中的各种选择(毕竟我们希望创建不同类型的项目),然后根据我们的选择从某个地方拉取模板文件,从脚手架自身的仓库中或者远程仓库中拉取都可以,这一步的目的在于将一个可运行的模板文件拉取到用户自己的文件夹中,模板文件创建成功后,通常脚手架会下载依赖项并让项目运行起来。

如上所说,我们可以将实现'eden create'的步骤分为以下几点:

  1. 命令行解析,包括利用chalk, commander, inquirer等模块对命令行进行增强,这一步的目的在于拿到用户的命令,知道下一步应该拉取哪种类型的代码。
  1. 复制文件,可以从脚手架中的模板文件或者远程仓库拉取,这一步的目的是为了让用户本身的电脑中存在一个可运行的程序,其中package.json较为特殊,需要单独处理
  1. 下载依赖并运行,这一步我们利用which模块找到npm实例,用一个子进程控制npm进行诸如npm install, npm start等操作

初始化配置

我们希望用ts维护这个脚手架项目,因此在这里要做一些准备工作。在创建好项目文件夹后,我们安装依赖:

npm i typescript @types/node -D && npx tsc --init

如此一来我们便安装好typescript和nodejs的类型定义包,并指定好这一项目为typescript项目,命令会为我们生成tsconfig.json文件,在文件中填入一下内容完成配置:

{
  "compileOnSave": true,
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "moduleResolution": "node",
    "allowJs": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "inlineSourceMap":true,
    "noImplicitThis": true,
    "noUnusedLocals": true,
    "stripInternal": true,
    "pretty": true,
    "declaration": true,
    "outDir": "lib",
    "baseUrl": "./",
    "paths": {
      "*": ["src/*"]
    }
  },
  "exclude": [
    "lib",
    "node_modules",
    "template",
    "target"
  ]
}

接下来在开发环境中我们需要实时编译,利用ts-node-dev来帮助我们:

npm i ts-node-dev -D

在package.json中添加一下内容,就可以启动开发环境并实时编译

{
  "scripts": {
    "dev": "ts-node-dev --respawn --transpile-only src/index.ts"
  }
}

项目的构建使用typescript自己的构建能力,不需要使用第三方的构建工具,在package.json中添加:

"scripts": {
    "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
    "build": "rm -rf lib && tsc --build",
 },

这样一来当我们运行npm run build之后项目就会被打包到lib文件夹下。

在这里说明一下项目初始阶段的文件目录:

如上所示,src文件夹为我们的主要开发目录,其中的index.ts就是我们的项目入口。bin文件夹比较重要,它所对应的是package.json文件中的bin命令:

"bin": {
    "fl427-cli": "./bin/fl427-cli.js"
  },

bin 表示命令(fl427-cli)的可执行文件的位置,在项目根目录执行 npm link,将 package.json 中的属性 bin 的值路径添加全局链接,在命令行中执行 fl427-cli 就会执行 ./bin/fl427-cli.js 文件。这里的../bin/src/index指向的就是我们打包编译之后的入口文件。

至此,项目的初始化完成。我们运行npm run dev之后就可以实时更改项目内容并编译,我们运行npm run build之后就可以再用fl427-cli运行bin目录下的入口文件。在之后,我们会运行fl427-cli createfl427-cli startfl427-cli build命令,这就是我们最终所需要实现的指令。当然,我们也可以执行npm run dev start等命令来方便地进行开发。

命令行解析

在这一步,我们的目的是读取用户的输入并执行相应的指令,我们会用到这些npm包:chalk, commander, inquirer等。

首先用chalk增加对命令行彩色输出的支持,在src/utils中添加chalk.ts:

// chalk.ts
import * as chalk from 'chalk';

export const consoleColors = {
    green: (text: string) => console.log(chalk.green(text)),
    blue: (text: string) => console.log(chalk.blue(text)),
    yellow: (text: string) => console.log(chalk.yellow(text)),
    red: (text: string) => console.log(chalk.red(text)),
}

这里的chalk使用的是"chalk": "4.1.2"版本,新的5.0.1版本在模块导出方面报错,推测可能是这个包自身的问题。

接下来在src/index.ts中引入就可使用:

// 第三方
import { program } from 'commander';
// 第一方
import { consoleColors } from './utils/chalk';

/* create 创建项目 */
program
    .command('create')
    .description('create a project ')
    .action(function(){
        consoleColors.green('创建项目~')
    })

/* start 运行项目 */
program
.command('start')
 .description('start a project')
 .action(function(){
    consoleColors.green('运行项目~')
 })

/* build 打包项目 */
program
.command('build')
.description('build a project')
.action(function(){
    consoleColors.green('构建项目~')
})

program.parse(process.argv)

上面所示的commander是一个第三方库,它是一个命令行界面的完整解决方案,通过commander提供的自定义指令的功能,我们就可以解析用户传入的指令然后去执行对应的操作:

复制模板文件

这一步我们会将template模板项目复制到用户的工作目录下,并进行依赖的下载与项目的运行。当前,我们将模板项目存储在脚手架工程中,之后更新从远程拉取模板项目的方式。

先放上这一步的代码:

// src/methods/create.ts

// 第三方
import * as fs from 'fs';

// 第一方
import { Answer } from "..";
import {consoleColors} from "../utils/chalk";
import * as path from "path";

// 核心方法,在用户本地目录复制模板文件
const create = (res: Answer) => {
    console.log('确认创建项目,用户选择为:', res);
    // 找到本地template文件夹,使用__dirname拼接,找到脚手架目录中的template文件夹,加上replace的原因是我们的编译产物放在lib文件夹下,所以要继续向上回溯,消除lib/这一层级
const templateFilePath = __dirname.slice(0, -11).replace('lib/', '') + 'template';
    // 我们需要知道用户当前的运行目录,以便将文件夹复制过去
const targetPath = process.cwd() + '/target';
    console.log('template目录和target目录', templateFilePath, targetPath);

    handleCopyTemplate(templateFilePath, targetPath).then(() => {
        handleRevisePackageJson(res, targetPath).then(() => {
            consoleColors.blue('复制template文件夹已完成,可以进行npm install操作')
        });
    });
};

// 读取template中的package.json,将其中信息修改为用户输入,并写入到用户工作目录,写入完成后,在回调函数中复制整个template项目
const handleRevisePackageJson = (res: Answer, targetPath: string): Promise<void> => {
    return new Promise<void>((resolve) => {
        consoleColors.green('template文件夹复制成功,接下来修改package.json中的元数据');
        fs.readFile(targetPath + '/package.json', 'utf8', (err, data) => {
            if (err) throw err;
            const { name, author } = res;

            const jsonObj = JSON.parse(data);
            if (name) jsonObj['name'] = name;
            if (author) jsonObj['author'] = author;

            // 写入文件(本地调试防止和脚手架的package.json冲突,加上/target/,之后删除 | stringify的后两个参数是为了换行格式化
fs.writeFile(targetPath + '/package.json', JSON.stringify(jsonObj,null,"\t"), () => {
                consoleColors.green('创建package.json文件:' + targetPath + '/package.json');
                resolve();
            })
        });
    })
};

// 复制template文件夹下的文件 使用fs的同步方法+promise.all,同步读取目录,异步处理文件复制,promise.all回调来执行复制文件完成的回调
const handleCopyTemplate = (templateFilePath: string, targetPath: string) => {
    console.log('开始对template文件夹进行复制', templateFilePath, targetPath);
    if (!templateFilePath || !targetPath || templateFilePath === targetPath) {
        return Promise.reject(new Error('参数无效'));
    }
    return new Promise((resolve, reject) => {
        // 不存在目标目录则同步创建
if (!fs.existsSync(targetPath)) {
            console.log('目标文件夹不存在', targetPath);
            fs.mkdirSync(targetPath, { recursive: true });
        }
        const tasks: {fromPath: string; toPath: string; stat: fs.Stats}[] = [];
        handleReadFileSync(templateFilePath, targetPath, (fromPath: string, toPath: string, stat: fs.Stats) => {
            tasks.push({
                fromPath,
                toPath,
                stat
});
        });
        Promise.all(tasks.map(task => handleCopyFileAsync(task.fromPath, task.toPath, task.stat))).then(resolve).catch(e => reject(e))
    })
}

// 读取目录的操作我们同步执行
const handleReadFileSync = (fromDir: string, toDir: string, cb: (fromPath: string, toPath: string, stat: fs.Stats) => void) => {
    const fileList = fs.readdirSync(fromDir);
    fileList.forEach(name => {
        const fromPath = path.join(fromDir, name);
        const toPath = path.join(toDir, name);
        const stat = fs.statSync(fromPath);
        if (stat.isDirectory()) {
            // 文件夹则递归处理
 // 不存在目标目录则同步创建
if (!fs.existsSync(toPath)) {
                console.log('目标文件夹不存在', toPath);
                fs.mkdirSync(toPath, { recursive: true });
            }
            handleReadFileSync(fromPath, toPath, cb);
        } else if (stat.isFile()) {
            cb(fromPath, toPath, stat);
        }
    })
}

// 复制文件耗时,我们异步处理
const handleCopyFileAsync = (fromPath: string, toPath: string, stat: fs.Stats) => {
    return new Promise((resolve, reject) => {
        const readStream = fs.createReadStream(fromPath);
        const writeStream = fs.createWriteStream(toPath);
        readStream.pipe(writeStream);
        writeStream.on('finish', resolve);
        writeStream.on('error', reject);
    });
}

export default create;
// src/index.ts
import create from './methods/create';
/* create 创建项目 */
program
    .command('create')
    .description('create a project ')
    .action(function(){
        consoleColors.green('创建项目~');
        inquirer.prompt(questions).then((answer: Answer) => {
            if (answer.conf) {
                create(answer);
            }
        })
    })

对template文件夹内容的复制可以分为两部分:复制整个template文件夹+修改复制后的package.json文件。我们的需求是在文件复制完成之后要修改package.json文件,再之后要进行依赖的下载,所以我们就需要判断什么时候复制完成。这里用的方式是使用fs的同步方法+promise.all,同步读取目录,异步处理文件复制。使用计数器的方式也可以实现这一需求,但我认为计数器的写法不够优雅,而全部用同步的方法处理显然耗时不可接受。

我们在handleCopyTemplate方法中维护一个tasks队列,在这里调用handleReadFileSync方法,首先遍历我们的template文件列表,如果是文件夹的话就递归调用handleReadFileSync方法,如果是文件的话我们就调用回调函数,将当前信息添加到tasks队列。之后,我们就对tasks里的任务进行执行,利用handleCopyFileAsync方法异步复制文件,并在promise.all指向完毕之后再修改package.json文件。

至此,我们就完成了对template文件夹的复制,同时对其中的package.json进行修改,下一步开始下载依赖并运行我们的模板项目。

下载依赖

先来看主要代码,由于我们已经成功复制了模板文件,接下来要做的事情分为两步。第一步是找到用户本地电脑里的npm包,用which模块能够完成这一步,找到npm之后我们就可以执行npm install命令来安装依赖,第二步就是在依赖安装完成后的回调中执行项目启动命令npm start。

// src/methods/npm.ts
// 下载依赖,启动项目
// 第三方
import * as which from 'which';
const { spawn } = require('child_process');

type findNPMReturnType = {
    npm: string;
    npmPath: string
}

// 找到用户可用的 npm
const findNPM = (): findNPMReturnType => {
    const npms = process.platform === 'win32' ? ['npm.cmd'] : ['npm'];
    for (let i = 0; i < npms.length; i++) {
        try {
            which.sync(npms[i]);
            console.log('use npm', npms[i], which.sync(npms[i]));
            return {
                npm: npms[i],
                npmPath: which.sync(npms[i])
            };
        } catch (e) {
            console.error(e, '寻找npm失败');
        }
    }
    throw new Error('请安装npm')
}

// 创建子进程运行终端
const runCMD = (cmd: string, args: string[] = [], fn) => {
    const runner = spawn(cmd, args, {
        stdio: 'inherit'
    });
    runner.on('close', code => {
        fn(code)
    })
}

// 主要方法,找到用户的npm并下载依赖
const npmInstall = (args = ['install']) => {
    const { npmPath } = findNPM();
    return (cb?: () => void) => {
        runCMD(npmPath, args, () => {
            cb && cb();
        });
    }
};

export default npmInstall;

接下来在复制完成之后的回调里执行:

// src/methods/create.ts
import npmInstall from "../methods/npm";

// 核心方法,在用户本地目录复制模板文件
const create = (res: Answer) => {
    console.log('确认创建项目,用户选择为:', res);
    // 找到本地template文件夹,使用__dirname拼接,找到脚手架目录中的template文件夹,加上replace的原因是我们的编译产物放在lib文件夹下,所以要继续向上回溯,消除lib/这一层级
const templateFilePath = __dirname.slice(0, -11).replace('lib/', '') + 'template';
    // 我们需要知道用户当前的运行目录,以便将文件夹复制过去
let targetPath = process.cwd();
    
console.log('template目录和target目录', templateFilePath, targetPath);

    handleCopyTemplate(templateFilePath, targetPath).then(() => {
        handleRevisePackageJson(res, targetPath).then(() => {
            consoleColors.blue('复制template文件夹已完成,可以进行npm install操作');
            const t = npmInstall();
            t(() => {
                // npm install完成,启动项目
consoleColors.blue('npm install已完成,可以进行npm start操作');
                runProject();
            })
        });
    });
};

// 运行npm start启动项目
const runProject = () => {
    try {
        npmInstall(['start'])();
    } catch (e) {
        consoleColors.red('自动启动失败,请手动启动')
    }
}

这部分有些回调地狱了,但总体上做这个东西是为了学习,问题不大。到这一步,我们成功复制了模板代码并运行它,接下来我们希望更好地处理项目的运行和编译,我们用另外的进程处理webpack配置。

项目运行

脚手架通常会内置一些webpack的配置项,我们在实际使用时,脚手架会将我们自己写的配置和内置的配置进行融合,那这一步我们可以通过创建一个npm包来辅助实现,用它建立起我们的脚手架进程和用户创建项目进程之间的联系,进而进行配置融合,webpack-dev-server启动等操作。

devWebpack

这一步我们希望达成的目标是:主要的webpack config存储在cli工程内部,需要开发项目时让我们的cli读取用户本地的config配置,和预置的配置融合,最终启动项目。

我们在index.ts中加入以下内容:

// src/index.ts

// 用于后续读取不同的本地配置文件
export type cliCommandType = 'start' | 'build';

import {devWebpack} from './webpack-build/run';

/* start 运行项目 */
program
.command('start')
 .description('start a project')
 .action(function(){
    consoleColors.green('运行项目~');
    start('start').then(() => {
        devWebpack('start');
});
 })

在src文件夹下新建webpack-build/run.ts文件,这个文件的目的在于执行webpack的build和dev操作,所以我们在这里引入WebpackDevServerwebpack,代码中的getMergedConfig方法是为了获得最终的webpack config,我们稍后来看。

 // 执行 webpack 的dev与build
import * as WebpackDevServer from 'webpack-dev-server/lib/Server';
import * as webpack from 'webpack';

import getMergedDevConfig from '../webpack/webpack.dev.config';
import getMergedProdConfig from '../webpack/webpack.prod.config';

// 取得用户自定义的devServer内容
const getUserDevServerConfig = async (type: cliCommandType) => {
    const targetPath = process.cwd() + (type === 'start' ? '/build/webpack.dev.js' : '/build/webpack.prod.js');
    const isExist = fs.existsSync(targetPath);
    if (isExist) {
        // 用户的配置
const userConfig = await import(targetPath) || {};
        console.log('取得用户自定义的devServer配置', userConfig);
        return userConfig;
    }
    return null;
}

// 开发环境构建 export const devWebpack = async (type: cliCommandType) => {
    // 获取用户配置,传递给getMergedDevConfig函数进行融合 const userConfig = await getUserDevServerConfig(type);
    // 提取出用户配置中的devServer字段  const { devServer: userDevServerConfig } = userConfig; const config = await getMergedDevConfig(userConfig);  console.log('dev的config', config);  const compiler = webpack(config);  const defaultDevServerConfig = { host: 'localhost', port: 8001, open: true, hot: true, historyApiFallback: true, headers: { 'Access-Control-Allow-Origin': '*', }, proxy: { '/api': { target: "http://localhost:4000", changeOrigin: true, } }, devMiddleware: { // 简化Server输出,我们只希望看到error信息 stats: 'errors-only', } }  // 用户自定义了devServer就使用用户自己的内容,否则使用默认配置 const devServerOptions = userDevServerConfig ? userDevServerConfig : defaultDevServerConfig;  const server = new WebpackDevServer(devServerOptions, compiler); const runServer = async () => { console.log('Starting server...'); await server.start(); }; runServer().then(); } 

我们新建一个src/webpack/webpack.dev.config.js文件,其中存储了我们的内置配置(适用于dev环境):

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ProgressPlugin } = require('webpack');
import mergeConfig from '../webpack-build/merge';

const config = {
    mode: 'development',
    // 让Webpack知道以哪个模块为入口,进行依赖收集
entry: './src/index.tsx',
    // 告诉Webpack打包好的文件放在哪里,以及如何命名
output: {
        path: path.resolve(process.cwd(), './dist'),
        filename: '[name].[contenthash:8].js'
    },
    resolve: {
        extensions: [".js", ".json", ".jsx", ".ts", ".tsx"],
        alias: {
            '@': path.resolve(process.cwd(), 'src/')
        }
    },
    optimization: {
        runtimeChunk: true,
        splitChunks: {
            chunks: "all"
        }
    },
    module: {
        rules: [
            {
                test: /.(js|jsx|ts|tsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: "babel-loader",
                }
            },
            {
                test: /.(css)$/,
                use: [
                    'style-loader',
                    'css-loader',
                ]
            },
            {
                test: /.s[ac]ss$/i,
                use: [
                    // 将 JS 字符串生成为 style 节点
'style-loader',
                    // 将 CSS 转化成 CommonJS 模块
'css-loader',
                    // 将 Sass 编译成 CSS
'sass-loader',
                ],
            },
            {
                test: /.(less)$/,
                use: [
                    'style-loader',
                    'css-loader',
                    {
                        loader: 'less-loader',
                        options: {
                            lessOptions: {
                                modifyVars: {
                                    'parmary-color': '#006AFF',
                                    'border-radius-base': '4px',
                                    'btn-border-radius': '4px',
                                    'btn-font-weight': '500',
                                }
                            }
                        }
                    }
                ]
            },
            {
                test: /.(png|jpe?g|gif|svg)(?.*)?$/,
                use: [{
                    loader: 'url-loader',
                    options: {
                        limit: 50000,
                        name: 'img/[name].[ext]'
                    }
                }]
            },
            {
                test: /.woff|woff2|eot|ttf|otf$/,
                use: [{
                    loader: "url-loader",
                    options: {
                        name: '[name].[hash:6].[ext]',
                        limit: 50000,
                        esModule: false
                    }
                }]
            }
        ]
    },
    plugins: [
        // 这里我们指定自己的html文件模板,也可以指定生成的html文件名
// 如果不传参数,会有一个默认的模板文件
new HtmlWebpackPlugin({
            template: "./public/index.html"
        }),
        // 添加构建信息输出,监控各个 hook 执行的进度 percentage,输出各个 hook 的名称和描述
new ProgressPlugin(),
    ],
    devtool: "source-map",
}

export default async (userConfig) => {
    return await mergeConfig(config, userConfig);
}

回到webpack-build文件夹,我们新建merge.ts文件,这一文件中存储辅助方法,帮助我们读取用户自定义的配置(放在模板文件夹中的fl427.config.js文件),和上文中的内置config融合成为mergeConfig(config)并传递出去。

 // 获取开发者的自定义配置,和脚手架的默认配置合并 import {merge} from 'webpack-merge';  // 返回最终打包的webpack配置 const mergeConfig = async (config, userConfig) => { if (!userConfig) { return config; } return merge(config, userConfig); }  export default mergeConfig; 

最终,src/index.ts中的'start'部分就通了,我们在template文件夹中的package.json新增script:

"scripts": {
    "start": "fl427-cli start",
},

这样一来,当用户执行npm run fl427-start时,程序就会去执行devWebpack函数,拿到最终的config并启动dev-server。

buildWebpack

我们在index.ts中加入以下内容:

// src/index.ts

import {devWebpack} from './webpack-build/run';

/* build 打包项目 */ program .command('build') .description('build a project') .action(function(){ consoleColors.green('构建项目~'); start('build').then(() => { consoleColors.green('+++构建完成+++') buildWebpack('build'); }) }) 

回到webpack-build/run.ts文件,在这里我们新建一个buildWebpack函数,里面引入的webpack.prod.config文件和webpack.dev.config文件类似,用于生产环境编译:

import getMergedProdConfig from '../webpack/webpack.prod.config';

// 生产环境构建 export const buildWebpack = async (type: cliCommandType) => { const userConfig = await getUserDevServerConfig(type); // Final Config; const config = await getMergedProdConfig(userConfig);  console.log('build的config', config);  const compiler = webpack(config); compiler.run((err, stat) => { console.log('构建时', err); }) } 

接下来在template文件夹的package.json加入scriptbuild阶段也就完成了:

"scripts": {
    "build": "fl427-cli build",
},

参考

juejin.cn/post/691930…

juejin.cn/post/690155…

juejin.cn/post/698321…

Nodejs 文件(或目录)复制操作完成后 回调_wzq2011的博客-CSDN博客

juejin.cn/post/698902…

juejin.cn/post/698221…