从零构建一个前端脚手架

4,276 阅读6分钟

近期公司领导要求造轮子,以提升团队技术沉淀(0_0),所以要求团队内部人员都要去进行相应的技术基础建设,由于本人以前搞过简单的脚手架工具,所以cli这部分就交由我来进行开发。我个人对这部分内容也是比较感兴趣(出去面试能吹一会),本文将会沿着我个人的思路出发,从零搭建一个前端cli工具。

cli功能整理

接到任务,在调研内部外部多个cli工具后(借...鉴...)吸收百家之所长,结合公司当前开发流程,整理了如下优化点:

  • 版本检测
    • 所有命令执行前,统一检测工具版本,提示升级
  • 初始化 - init
    • 统一项目模版,多个项目模版可供选择
    • 整合vue/cli(部门技术栈以vue为主)
    • 统一eslint,部门代码风格统一
    • 统一开发配置,针对部门特点的个性化配置
  • 启动 - dev
    • 检查项目配置
    • 提供数据mock
  • 打包 - build
    • 符合公司打包规范
    • 检查配置文件
    • 静态资源路径替换
  • 发布 - public (公司为什么没用自动构建啊)
    • 检查发布所需配置文件
    • 检查发布所需打包文件
    • 发布到指定cdn

以上是我对cli工具功能的整理,比较符合我们当前的开发流程。功能整理完,开干!

前期准备

在正式开始写代码之前,我们有必要先找一些好用的插件,以提升我们的开发效率,cli相应的开发库百度一下你就知道,以下我列举了我的项目里用到的库。

node工具

  • path:原生的路径工具
  • fs:原生的文件读写工具
  • child_process:node子进程包
  • process: node进程信息
// path
// 生成一个规范化的绝对路径
path.resolve(__dirname, b) 
// /User/Desktop/code/b

//  - 路径连接
path.join(a,b,c)
// a/b/c

// fs
// 读取文件夹
fs.readdirSync
// 读取文件
fs.readFileSync
// 文件路径是否存在
fs.existsSync
// 返回文件/文件夹信息
const f = fs.statSync
f.isDirectory() // 是否是文件夹
f.isFile() // 是否是文件
// 写入数据
fs.writeFileSync

// 子进程
import {execSync, spawnSync} from 'child_process';
// 传参形式不同
execSync('npm run dev') // 返回执行结果 最大200k
spawnSync('npm', ['run', 'dev']) //返回执行结果 大小不限

import { cwd } from 'process';
// 返回当前执行命令目录绝对路径
cwd()

第三方插件:

开始 - 创建项目

项目结构

脚手架的开发使用的是ts语法. 最终转译成js语法。

npm install -g typescript
npm init
tsc --init
npm i path fs ..... fontmin

目录结构:

  • src: 源代码
    • index.ts 入口文件
    • utils 工具函数
    • actions 命令
  • bin: 导出目录
  • package.json
  • tsconfig.json

package.json配置

// package.json
{
    // 脚手架名称
    name: "xxx-cli",
    // 脚手架版本,必须三位,否则发布时会报错
    version: "1.0.0",
    // 脚手架执行名称 以及对应入口
    bin: {
        xxx: './bin/index.js'
    },
    ......
    // npm发布时需要的文件
    files: [
        "bin"
    ]
}

配置tsconfig.json

{
  "compilerOptions": {
    "types": [
      "node"
    ],
    "typeRoots": [
      "node_modules/@types"
    ],
    "outDir": "bin",
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "module": "commonjs",
    "paths": {
      "src/*": [
        "src/*"
      ]
    }
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "node_modules"
  ]
}

版本检测

检测本地工具的版本和npm最新版本,如果本地版本低于npm版本,提示升级,否则不执行动作

// src/actions/checkVersion
// 伪代码
const checkVersion = () => {
    // 获取本地版本号
    const local = new Promise((resolve) => {
        let localStr = '';
        const childProcesslocal = exec('xxx -V');
        if (childProcesslocal.stdout) {
            childProcesslocal.stdout.on('data', chunk => {
                localStr += chunk;
            });
            childProcesslocal.stdout.on('end', () => {
                childProcesslocal.kill();
                resolve(localStr);
            });
        }
    });
    const latest = .....同上 exec('npm views xxx versions');
    
    if (latest > local) {
        // 升级
        npm i -g xxx@latest
        return true;
    }
};

入口文件

入口文件通过识别用户输入的命令,执行对应的操作。我们可以对命令进行优化,添加可选参数,对动作进行修改. 这里我们主要用到的是commender命令行工具,以下是init命令的伪代码,其他命令添加方式基本一致,按需使用。

src/index.js

// 用户执行 xxx init demo
// 执行 xxx init 工具的init命令不会生效
// <project-name> 相当于用户输入的第三个参数
program.command('init <project-name>')
  .description(chalk.yellow('初始化项目'))
  .action((projectName: string) => {
     init(projectName);
  });
  
// 另一种写法
// 当我们添加了option后,执行 xxx init -t
// 那么返回的options参数中template = true
// -t 参数是可选项,所以执行 xxx init 工具的init功能生效,
program.command('init')
  .options('-t, --template', '初始化一个模版')
  .description(chalk.yellow('初始化项目'))
  .action((options) => {
  // options = {template: }
     if (checkVersion()) return;
     init(options);
  });


program.parse(process.argv);

init命令解析

执行 xxx init 命令,我们需要向开发者提供多种模版的选项,我所在的公司部门的项目目前有三大类

  • 小游戏
  • 活动
  • 宣传 我的思路是:
  • xxx init
  • 下载模版代码
  • 提示用户选择模版
  • 其他配置选项
  • 初始化项目
  • 复制模版到项目内
  • 执行npm install
  • done

模版代码库配置一个json文件,每做一个模版,就在json文件内进行配置,拉取模版之后,读取json文件,然后在向用户展示,这样的话我们以后新建模版,就不用对工具进行升级操作了。

项目统一配置也可以抽离出去,维护一个文件,放在代码库内,生成项目后,直接copy到项目内即可。

vue项目预设

关于初始化项目,因为我部门技术栈是vue,所以我们初始化时其实是用了vue create命令,这里有一个问题就是直接vue create 项目的话,会出现vue/cli的配置选项,如果配置错误,可能会影响我们项目统一的config配置,所以我们需要直接跳过vue/cli的配置项。这里vue/cli提供了一个命令

vue create app -p path

我们可以指定一个预设模版(path),这样vue就会按照预设模版进行项目初始化,并且预设模版还可以让我们直接注入项目所需要的npm包。

// preset/preset.js
// vue/cli配置项
{
  "useConfigFiles": true,
  "cssPreprocessor": "sass",
  "plugins": {
    "@vue/cli-plugin-babel": {},
    "@vue/cli-plugin-eslint": {
      "config": "airbnb",
      "lintOn": ["save", "commit"]
    },
    "@vue/cli-plugin-router": {},
    "@vue/cli-plugin-vuex": {}
  }
}
// preset/generator.js
// 初始化项目时在package.json内添加npm包
module.exports = (api) => {
 api.extendPackage({
    dependencies: {
        xxxxxx,
        xxxxx,
    },
    devDependencies: {
        "webpack": "^4.3.2",
    }
});
}

Init模块

init模块实现大概如下:

// src/actions/init.js
    export default function Init() {
        // 下载模版
        spawn('git', ['clone', 'git.xxx'], { cwd: cwd() });
        // 读取模版
        const config = fs.readFileSync('template.json');
        
        // 用户控制台选择模版
        inquirer.prompt([
            {
                type: 'list',
                choices: config,
                name: 'templateName'
            }
        ])
        // vue/cli初始化一个项目,并使用预设配置, 
        // 如果公司有自己的npm镜像,也可以设置一下
        spawn('vue', ['create', projectName, '-p', presetPath, '-r', 'xxx.npm']);
        
        // 复制模版
        spawn('cp', ['-r', templatePath, projectPath]);
        
        console.log('done');
    }

init部分的代码最主要的是对模版的下载以及模版注入到初始化项目,这里涉及git,shell,vue命令,并且各种路径的使用也比较复杂,以及对各种情况的判断,提高容错,基本上init做完后,其他命令就很简单了, 熟能生巧了属于是。

End

虽然是老板的任务,但是在开发过程中是有许多收获的,之前一直想写一些有意思的东西,但是总是被其他事所影响,中途放弃,这次算是在外力的影响下,逼着自己去做了一个对我个人而言有意义的事(有东西可以吹了)。以上代码以及开发思路只是对这次cli开发的一点总结,不够完善,但是对于之前没有开发过的人来说,是一次难忘的体验。后期还会再对其他比较有意思的功能进行一个总结。

下班!!!