【开源心路历程】创建一个属于你自己的脚手架

1,052 阅读11分钟

前言

  大家好,我是阿江,知道我的朋友可能知道我之前在搞一个叫 vue3-vite-cli 的脚手架,现在算是总结出来一些经验,想分享给有需求的小伙伴,也希望各位大佬如果有更好的方法可以不吝赐教。

  其实最开始的 vite-vue-cli 的项目建立实际上是抱着学习 vue3 + vite + ts 的项目demo,后续随着观看vite的源码以及内置的项目模板复制流程转念产生改造成一个vue3 可以直接上手的项目模板。

   从而做到既可以练手又可以搭建一套以后可通用的项目模板通过 npm 安装一下即可(即为脚手架的模式)。这次记录下来实际上是以技术点为目的的记录,并且给也希望搭建自己一套脚手架的小伙伴使用!

最后达到类似vite指令创建模板项目的形式,直接搭建好一套空的项目模板上传到项目中 上传npm即可随时随地安装使用减少个人习惯的不必要的配置。我大抵不会从我之前先从vite上创建开始讲,因为后面涉及到项目层级的大改,所以直接从项目层级调整过后的项目开始记录此脚手架创建过程。

开始

创建项目

npm init

读者可以参照这种形式自行填写。 image99.png 这样我们就把包管理器引入了! 接下来,我是依次创建 .gitgnoreREADME.md这俩标配文件,分别用于git上传忽略说明文档

配置.gitgnore 和 README.md

.gitgnore文件可以参照以下配置,大体如此,忽略依赖等,不再赘述。

node_modules
dist
*.local
.idea
.vscode

README.md这个是说明文件,尽可能简单的去描述项目层级、特点、使用步骤。这里支持makedownhtml的写法

这里推荐一个读者可能会用到的生成vue 说明内部依赖第三方库的徽章的传送门: shields

npm的脚手架指令

package.json 同级文件创建一个 bin 文件夹

 mkdir bin

这一步是为npm下创建软连接,以便快捷调用指令做准备!

接下来需要在刚创建的bin文件夹中创建一个npmrc.js这个是npm的配置文件,类似vue.config.js 用于配置一些npm cli的所有配置或者 npm config的配置项、proxy代理

本篇只是简单的对于我们配置好的项目模板进行复制,暂时用不到这些详细配置,如需保证脚手架的依赖下载都一致可以通过npmrc.jsconfig配置源等,这里不过多描述。

npmrc.js

#!/usr/bin/env node
require('../packages/create/index')

#!/usr/bin/env node, 则为了解决了不同的用户node路径不同的问题,可以让系统动态的去查找node来执行你的脚本文件。
require('../packages/create/index')这里是为了项目层级清晰所以将创建的js放在更能表明意思的层级,读者可根据自己的层级去修改,但是一定要指到用于创建脚手架项目的js

添加创建指令

接着我们在package.json中添加如下代码

"bin": {
  "create-cli": "./bin/npmrc.js"
}

这里的bin就是我们用于创建 npm 能识别指令的文件夹,而 create-cli则是我们用于运行 npmrc.js的指令,也就是运行的是创建脚手架项目的js

创建脚手架模板

这是的脚手架模板也就是通过vue-cli 自己加入部分常用模块如后台管理系统的,封装好自定义组件常用验证指令自定义工具函数库webpack多模块配置权限验证axios封装等,抽离出来封装成一个项目模板并放到脚手架中。
读者可以按照如下命名: template-xxx-xxx-xx 模板名称,创建在create文件夹中,方便下面我们拷贝复制👇

image.png

创建脚手架项目的js

package.json的同级目录创建packages文件夹

mkdir packages

在刚创建的packages文件夹下创建create文件夹

mkdir create

用于表明创建项目内容,读者也可根据自己自定义指令来调整。

接下来创建index.js也就是刚刚提到的创建脚手架项目的js 下面会index.js中相对核心的方法注释并在最后贴上全部代码方便读者理解。

引入对应依赖

#!/usr/bin/env node
//引入询问式操作库 prompt
const { prompt } = require('enquirer');
//node 处理文件操作模块
const fs =require('fs');
//node 处理文件路径模块
const path = require('path');
//调用指令的终端路径
const cwd=process.cwd();
//通过创建子进程的形式执行终端指令的库
const execa=require('execa');
//用于终端展示的进度条库
let progressBar = require('progress');
//通过控制流做到的单行日志
let log=require('single-line-log').stdout;
//引入终端多彩颜色
require('colors');

这里的依赖切记要下到最完成package.json中,因为npm上传后只会默认下载最外层的package.json中的依赖,否则上传完成后调用指令时会报找不到依赖!

编写步骤并选择项目模板

let projectname = await prompt({
    type: 'input',
    name: 'projectName',
    message:'projectName/项目名',
    initial:"vue3-vite-cli",
})
//包含验证名称安全性校验和指令调用层级是否拥有同名文件覆盖询问。
const projectName=await checkProjectName(projectname.projectName)
let selectTemplate =  await prompt({
    type: 'select',
    name: 'ProjectTemplate',
    message: 'Project-template/选择项目模板',
    initial:"vue3-vite-cli",
    choices: [
        { name: 'xxx-模板'},//这里的模板用是给用户看的模板名
        { name: 'xxx-模板'},
    ]
});

关于模板名这里需要注意的是,尽量字明其意,并且创建完成后需要和 create所创建的template-xxx模板名的xxx一致。然后用户做到选择后直接同名拼接copy对应项目模板文件夹。

const templateDir = path.join(__dirname, `template-${selectTemplate.ProjectTemplate}`)
let root =path.join(cwd,projectName);//获取调用指令和传入项目名的拼接地址
console.log(`\nScaffolding project in ${projectName}.../创建${projectName}项目中...`)
emptyDir(root); //清空传入地址的文件夹及文件

拷贝进度条实现

calculateCount(path.join(templateDir)); //统计数量
bar =new progressBar('Current creation progress/当前创建进度: :bar :percent ', { total: copyCount ,
    complete: "█",
    incomplete:"░",
    width: 30,
});

copy模板文件

await copy(path.join(templateDir), root);

判断用户当前下载依赖使用的包管理器

const pkg = require(path.join(templateDir, `package.json`))
pkg.name = projectName
const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm'
console.log(`\nDone. Now run/完毕。现在运行:`)
console.log(`\nDownloading dependencies.../正在下载依赖...`)
let downShell=pkgManager === 'yarn' ? '' : 'install';
console.log(`\nrunning/正在运行:${pkgManager+" "+downShell}` );

通过execl打印依赖下载过程结果

const downResult = await execa(`${pkgManager}`, [downShell],{cwd:path.relative(cwd, root),stdio:'inherit'});
if(downResult.failed){
    console.error('\nFailed to download dependencies/下载依赖失败 ');
    console.log(`${pkgManager === 'yarn' ? `yarn` : `npm install`}\n`)
}else{
    console.log(`Depend on the download is complete!/依赖下载完成!🥳`)
}

通过execl 父子线程通信 将执行的下载依赖的子线程日志同步到父线程

完整代码

#!/usr/bin/env node
const { prompt } = require('enquirer');
const fs =require('fs');
const path = require('path');
const cwd=process.cwd();
const execa=require('execa');
let progressBar = require('progress');
let log=require('single-line-log').stdout;
require('colors');

let copyCount=0; //需要拷贝的文件数量
let copySchedule=0;//拷贝进度
let bar ;//进度条

async function init() {
    try{
    const renameFiles = {
        _gitignore: '.gitignore'
    }

    let projectname = await prompt({
        type: 'input',
        name: 'projectName',
        message:'projectName/项目名',
        initial:"vue3-vite-cli",
    })
    const projectName=await checkProjectName(projectname.projectName)
    let selectTemplate =  await prompt({
        type: 'select',
        name: 'ProjectTemplate',
        message: 'Project-template/选择项目模板',
        initial:"vue3-vite-cli",
        choices: [
            { name: 'vue3-ts-initial'},
            { name: 'webpack-protist-js'},
        ]
    });
    const templateDir = path.join(__dirname, `template-${selectTemplate.ProjectTemplate}`)
    let root =path.join(cwd,projectName);
    console.log(`\nScaffolding project in ${projectName}.../创建${projectName}项目中...`)

    emptyDir(root);

    calculateCount(path.join(templateDir));

    bar =new progressBar('Current creation progress/当前创建进度: :bar :percent ', { total: copyCount ,
        complete: "█",
        incomplete:"░",
        width: 30,
    });

    await copy(path.join(templateDir), root);

    const pkg = require(path.join(templateDir, `package.json`))

    pkg.name = projectName

    const pkgManager = /yarn/.test(process.env.npm_execpath) ? 'yarn' : 'npm'

    console.log(`\nDone. Now run/完毕。现在运行:`)

    console.log(`\nDownloading dependencies.../正在下载依赖...`)

    let downShell=pkgManager === 'yarn' ? '' : 'install';
    console.log(`\nrunning/正在运行:${pkgManager+" "+downShell}` );
    const downResult = await execa(`${pkgManager}`, [downShell],{cwd:path.relative(cwd, root),stdio:'inherit'});
    if(downResult.failed){
        console.error('\nFailed to download dependencies/下载依赖失败 ');
        console.log(`${pkgManager === 'yarn' ? `yarn` : `npm install`}\n`)
    }else{
        console.log(`Depend on the download is complete!/依赖下载完成!🥳`)
    }

    if (root !== cwd) {
        console.log(`\ncd ${path.relative(cwd, root)}`.green)
    }
    console.log(`${pkgManager === 'yarn' ? `yarn dev` : `npm run dev`}\n`.green)
    }catch (e) {
        
    }
}

async function copy(src, dest) {
    const stat = fs.statSync(src)
    if (stat.isDirectory()) { //是否是一个目录 而不是文件。
      copyDir(src, dest)
    } else {
      copySchedule++;
      await fs.copyFileSync(src, dest)
    }
    if (bar.complete) {
        log('\nCreated/创建完成\n'.green);
    }else{
        bar.tick(copySchedule/(copyCount/100));
    }
}

async function checkProjectName(projectName) {
    const packageNameRegExp = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*/)?[a-z0-9-~][a-z0-9-._~]*$/
    if (packageNameRegExp.test(projectName)) {
        console.log(path.join(cwd, projectName));
        if(fs.existsSync(path.join(cwd,projectName))){
            let coverQuerySelect =  await prompt({
                type: 'select',
                name: 'coverQuery',
                message: 'The current file name already exists, do you want to overwrite it?/当前文件名已存在,是否覆盖?',
                initial:"yes/确认",
                choices: [
                    { name: 'yes'},
                    { name: 'no'},
                ]
            });
            if (coverQuerySelect.coverQuery == 'no') {
                prompt.stop();
            }
        }
        return projectName
    } else {
        const suggestedPackageName = projectName
            .trim()
            .toLowerCase()
            .replace(/\s+/g, '-')
            .replace(/^[._]/, '')
            .replace(/[^a-z0-9-~]+/g, '-')

        /**
           console.log(`@type {{ inputPackageName: string }}
         */
        const { inputPackageName } = await prompt({
            type: 'input',
            name: 'inputPackageName',
            message: `Package name:`,
            initial: suggestedPackageName,
            validate: (input) =>
                packageNameRegExp.test(input) ? true : 'Invalid package.json name'
        })
        return inputPackageName
    }
}

function copyDir(srcDir, destDir) {
    fs.mkdirSync(destDir, { recursive: true })
    for (const file of fs.readdirSync(srcDir)) {
        const srcFile = path.resolve(srcDir, file)
        const destFile = path.resolve(destDir, file)
        copy(srcFile, destFile)
    }
}

/**
 * 计算当前模板文件数量
 */
function calculateCount(srcDir){
    if (fs.statSync(srcDir).isDirectory()) { //是否是一个目录 而不是文件。
        dirCount(srcDir)
    }
}

/***
 * 目录内部数量
 */
function dirCount(srcDir) {
    copyCount+=fs.readdirSync(srcDir).length;
    for (const file of fs.readdirSync(srcDir)) {
        const srcFile = path.resolve(srcDir, file)
        calculateCount(srcFile)
    }
}

function emptyDir(dir) {
    if (!fs.existsSync(dir)) {
        return
    }
    for (const file of fs.readdirSync(dir)) {
        const abs = path.resolve(dir, file)
        if (fs.lstatSync(abs).isDirectory()) {
            emptyDir(abs)
            fs.rmdirSync(abs)
        } else {
            fs.unlinkSync(abs)
        }
    }
}

init();

自此后续读者就可以参照上述形式 创建模板并运行node index.js 尝试拷贝项目模板了。当然这肯定不是我们最后的效果,我们期望的是直接create-cli 即可调用我们的脚手架创建指令,接下来重头戏来了!

上传npm

上传之前先修改一下npm源

npm config set registry https://registry.npmjs.org

如果是新创建的npm账号请先验证自己的邮箱否则在shell登陆时会500

接着npm上传忽略文件有一个自己自带的.npmignore 文件 优先级会高于.gitignore 但是如果没有.npmignore 会使用.gitignore的忽略

这里需要注意的是,每次上传需要 更新packages.json的版本号,不能与上次一致

npm 登陆

没有npm账号的读者可以先去官网注册一个账号

已经拥有账号的读者进行下一步登陆

npm login

会需要输入账号密码邮箱

如果还登陆不上可能需要vpn的帮助

npm 包本地测试

建议在上传之前可以先在本地测试一下即将上传的npm 包

//进入需要测试的项目目录 
cd vue3-vite-cli //这里就是项目名
npm link vue3-vite-cli //部署到本地全局 //进入到其他项目目录层级
cd test //进入一个测试目录进行测试
npm link //由于我是本地全局所有直接调用 我的 create-cli 就行

npm 上传

npm publish

使用脚手架

上传npm包成功之后,首先可以登陆npm官网查看是否上传成功

image.png 然后即可通过

    npm i vite-vue3-cli -g //读者需要更换成自己包名

下载脚手架并安装到全局之后就是调用

 create-cli 

脚手架中创建项目的指令

拓展知识点

接下来如果读者想要开源自己的脚手架的话,势必需要配置的一下如开源协议脚手架文档官网github的issues规范等,下面会对这些东西进行说明

MIT开源协议

package.json 同级目录中创建一个LICENSEMIT证书,可以使用下面的范本,替换一下时间和账户名称即可。这里附带MIT官网

Begin license text.


Copyright <YEAR> <COPYRIGHT HOLDER>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


End license text.

issues模板规范

如果读者的脚手架想开源到github又想保证issues问题能精确的表达出意思,可以在项目的根目录上创建一个.github的文件夹

image.png

接着在.github文件夹中创建ISSUE_TEMPLATE的文件夹,在下面创建xxx_issues.md文件用于将固定好的issues提问格式写好,方便使用者快速填写使用。
例子如下:

---
name: report_issues  
about: 请注意请按照模板规则提交你的问题
---
# 提交issues 模板
请严格按照如下模板提交问题,

<!-你的环境->
  
  例如: mac xxx 
 
<!-你的问题->  

   例如: 我的页面切换会白屏
 
<!-重现步骤->  
   例如:  
   1.打开项目  
   2.点击 '我的' tab 切换 会导致一秒钟的白屏  
   而我的代码如下:
   ```js
     66666666666
    ```
<!-希望得到的结果->  
    例如:切屏没有任何问题~。  
   

接下来就可以在github 创建issues 中看到你刚刚上传的模板了

image.png

使用vitePress 搭建官网

后续会单独分出一篇文章去讲关于vitePress 搭建官网哦!

结语

自此关于脚手架整体的搭建过程全部描述完成,其实这次脚手架的搭建对于第三方库的使用和平时业务项目中大不相同,就像尤哥说的,开源需要点满另一套技能树。另外整体项目有参考vite项目源码分层,也算是部分vite的源码手写实现吧。
最后附上 我自己脚手架的git地址:vue3-vite-cli 文档地址
希望小伙伴们能点点star支持一下笔者。之后会给大家带来更好的文章!
一起努力一起进步,我是阿江!

下期预告:vitePress官网搭建