脚手架大致可以分为两个目的:项目的创建(create)和项目的运行(start & build)。
项目地址 掘金链接:https://juejin.cn/post/7097925334581903396
github:https://github.com/fl427/mycli
创建项目 当我们运行一个脚手架的命令,例如eden create时,脚手架首先会读取我们在命令行中的各种选择(毕竟我们希望创建不同类型的项目),然后根据我们的选择从某个地方拉取模板文件,从脚手架自身的仓库中或者远程仓库中拉取都可以,这一步的目的在于将一个可运行的模板文件拉取到用户自己的文件夹中,模板文件创建成功后,通常脚手架会下载依赖项并让项目运行起来。
如上所说,我们可以将实现’eden create’的步骤分为以下几点:
命令行解析,包括利用chalk, commander, inquirer等模块对命令行进行增强,这一步的目的在于拿到用户的命令,知道下一步应该拉取哪种类型的代码。
复制文件,可以从脚手架中的模板文件或者远程仓库拉取,这一步的目的是为了让用户本身的电脑中存在一个可运行的程序,其中package.json较为特殊,需要单独处理
下载依赖并运行,这一步我们利用which模块找到npm实例,用一个子进程控制npm进行诸如npm install, npm start等操作
初始化配置 我们希望用ts维护这个脚手架项目,因此在这里要做一些准备工作。在创建好项目文件夹后,我们安装依赖:
1 npm i typescript @types/node -D && npx tsc --init
如此一来我们便安装好typescript和nodejs的类型定义包,并指定好这一项目为typescript项目,命令会为我们生成tsconfig.json文件,在文件中填入一下内容完成配置:
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 { "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来帮助我们:
在package.json中添加一下内容,就可以启动开发环境并实时编译
1 2 3 4 5 { "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts" } }
项目的构建使用typescript自己的构建能力,不需要使用第三方的构建工具,在package.json中添加:
1 2 3 4 "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命令:
1 2 3 "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 create
,fl427-cli start
,fl427-cli build
命令,这就是我们最终所需要实现的指令。当然,我们也可以执行npm run dev start
等命令来方便地进行开发。
命令行解析 在这一步,我们的目的是读取用户的输入并执行相应的指令,我们会用到这些npm包:chalk, commander, inquirer等。
首先用chalk增加对命令行彩色输出的支持,在src/utils
中添加chalk.ts:
1 2 3 4 5 6 7 8 9 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
中引入就可使用:
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 import { program } from 'commander' ;import { consoleColors } from './utils/chalk' ;program .command ('create' ) .description ('create a project ' ) .action (function ( ){ consoleColors.green ('创建项目~' ) }) program .command ('start' ) .description ('start a project' ) .action (function ( ){ consoleColors.green ('运行项目~' ) }) program .command ('build' ) .description ('build a project' ) .action (function ( ){ consoleColors.green ('构建项目~' ) }) program.parse (process.argv )
上面所示的commander是一个第三方库,它是一个命令行界面的完整解决方案,通过commander提供的自定义指令的功能,我们就可以解析用户传入的指令然后去执行对应的操作:
复制模板文件 这一步我们会将template模板项目复制到用户的工作目录下,并进行依赖的下载与项目的运行。当前,我们将模板项目存储在脚手架工程中,之后更新从远程拉取模板项目的方式。
先放上这一步的代码:
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 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); 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操作' ) }); }); }; 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; fs.writeFile (targetPath + '/package.json' , JSON .stringify (jsonObj,null ,"\t" ), () => { consoleColors.green ('创建package.json文件:' + targetPath + '/package.json' ); resolve (); }) }); }) }; 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;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import create from './methods/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。
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 import * as which from 'which' ;const { spawn } = require ('child_process' );type findNPMReturnType = { npm : string ; npmPath : string } 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) }) } const npmInstall = (args = ['install' ] ) => { const { npmPath } = findNPM (); return (cb?: () => void ) => { runCMD (npmPath, args, () => { cb && cb (); }); } }; export default npmInstall;
接下来在复制完成之后的回调里执行:
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 import npmInstall from "../methods/npm" ;const create = (res: Answer ) => { console .log ('确认创建项目,用户选择为:' , res); 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 (() => { consoleColors.blue ('npm install已完成,可以进行npm start操作' ); runProject (); }) }); }); }; const runProject = ( ) => { try { npmInstall (['start' ])(); } catch (e) { consoleColors.red ('自动启动失败,请手动启动' ) } }
这部分有些回调地狱了,但总体上做这个东西是为了学习,问题不大。到这一步,我们成功复制了模板代码并运行它,接下来我们希望更好地处理项目的运行和编译,我们用另外的进程处理webpack配置。
项目运行 脚手架通常会内置一些webpack的配置项,我们在实际使用时,脚手架会将我们自己写的配置和内置的配置进行融合,那这一步我们可以通过创建一个npm包来辅助实现,用它建立起我们的脚手架进程和用户创建项目进程之间的联系,进而进行配置融合,webpack-dev-server启动等操作。
devWebpack 这一步我们希望达成的目标是:主要的webpack config存储在cli工程内部,需要开发项目时让我们的cli读取用户本地的config配置,和预置的配置融合,最终启动项目。
我们在index.ts
中加入以下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export type cliCommandType = 'start' | 'build' ;import {devWebpack} from './webpack-build/run' ;program .command ('start' ) .description ('start a project' ) .action (function ( ){ consoleColors.green ('运行项目~' ); start ('start' ).then (() => { devWebpack ('start' ); }); })
在src文件夹下新建webpack-build/run.ts
文件,这个文件的目的在于执行webpack的build和dev操作,所以我们在这里引入WebpackDevServer
和webpack
,代码中的getMergedConfig
方法是为了获得最终的webpack config
,我们稍后来看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 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' ;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 ; }
我们新建一个src/webpack/webpack.dev.config.js
文件,其中存储了我们的内置配置(适用于dev环境):
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 const path = require ('path' );const HtmlWebpackPlugin = require ('html-webpack-plugin' );const { ProgressPlugin } = require ('webpack' );import mergeConfig from '../webpack-build/merge' ;const config = { mode : 'development' , entry : './src/index.tsx' , 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 : [ 'style-loader' , 'css-loader' , '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 : [ new HtmlWebpackPlugin ({ template : "./public/index.html" }), new ProgressPlugin (), ], devtool : "source-map" , } export default async (userConfig) => { return await mergeConfig (config, userConfig); }
回到webpack-build
文件夹,我们新建merge.ts
文件,这一文件中存储辅助方法,帮助我们读取用户自定义的配置(放在模板文件夹中的fl427.config.js
文件),和上文中的内置config
融合成为mergeConfig(config)
并传递出去。
1 // 获取开发者的自定义配置,和脚手架的默认配置合并 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:
1 2 3 "scripts": { "start": "fl427-cli start", },
这样一来,当用户执行npm run fl427-start时,程序就会去执行devWebpack函数,拿到最终的config并启动dev-server。
buildWebpack 我们在index.ts
中加入以下内容:
1 2 3 4 5 import {devWebpack} from './webpack-build/run' ; 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文件类似,用于生产环境编译:
1 2 3 import getMergedProdConfig from '../webpack/webpack.prod.config' ;
接下来在template
文件夹的package.json
加入script
,build
阶段也就完成了:
1 2 3 "scripts" : { "build" : "fl427-cli build" , },
参考 https://juejin.cn/post/6919308174151385096
https://juejin.cn/post/6901552013717438472
https://juejin.cn/post/6983217505846165535
Nodejs 文件(或目录)复制操作完成后 回调_wzq2011的博客-CSDN博客
https://juejin.cn/post/6989028324202938398
https://juejin.cn/post/6982215543017193502