前端脚手架的概要设计

一、引言

      随着前端工程化的理念不断深入,脚手架的出现就是为减少重复性工作而引入的命令行工具,
众所周知, 新建项目是很繁琐的一项工作, 要考虑项目目录结构,基础库的配置, 各种规范等等. 在此过程中如何摆脱ctrl + c, ctrl + v,而通过脚手架从零到一搭建项目的方式变得更加有必要.
另外,对于很多系统,他们的页面相似度非常高,所以就可以基于一套模板来搭建,虽然是不同的人开发,但用脚手架来搭建,相同的项目结构与代码书写规范,是很利于项目的后期维护的;
以上就是为什么脚手架存在的意义, 让项目从"搭建-开发-部署"更加快速以及规范.

      目前前端脚手架市场中, 大家最熟悉的就是create-react-app和vue-cli,它们可以帮助我们初始化配置生成项目结构、自动安装依赖,最后我们通过一行指令就可以运行项目开始开发,或者进行项目构建(build)。
这些脚手架提供的都是普遍意义上的最佳实践,但是实际开发中发现,随着业务的不断发展,必然会出现需要针对业务开发的实际情况来进行调整。例如:
● 通过调整插件与配置实现Webpack打包性能优化
● 项目架构调整
● 编码风格
● 用户权限控制
● 融合公司的基建
● 快速创建带有公共/基础业务的模块
● …
      总而言之,随着业务发展,我们往往会沉淀出一套更“个性化”的业务方案。
这时候我们最直接的做法就是开发出一个该方案的脚手架来,以便今后能复用这些最佳实践与方案。

二、思路

● 解耦:脚手架与模板分离
● 脚手架负责构建流程,通过命令行与用户交互,获取项目信息
● 模板负责统一项目结构、工作流程、依赖项管理
● 脚手架需要检测模板的版本是否有更新,支持模板的删除与新建
● 参考vue-cli

三、架构设计

工程化架构.png

前端工程化一个复杂的概念, 上图中很多功能都可以集成到脚手架中, 根据实际需要选取.

四、基本流程

五、用到的三方库

序号 库名 描述
1 commander 处理控制台命令
2 chalk 命令行输出美化
3 latest-version 获取最新的npm包
4 inquirer 控制台询问
5 download-git-repo git远程仓库拉取(github)
6 figlet 粉笔字
7 glob 匹配指定路径文件
8 ora 命令行环境的loading效果
9 clear 清除控制台输出的信息
10 og-symbols 各种日记级别的彩色符号
11 metalsmith 处理模板内容
12 axios ajax请求处理
13 ejs 模板引擎
14 ncp 递归文件拷贝
15 consolidate 模板类
16 rollup JavaScript 模块打包器
17 download 下载文件库
18 extract-zip 压缩包
19 validate-npm-package-name 校验项目名称



六、功能概要

功能概览
在这里插入图片描述

七、基本规范

version 方法输出版本信息
command 注册命令,参数是命令名称和回传给 action 方法的参数
description 输出该命令的描述
action 订阅了该命令触发时的回调函数
parse 对传入的参数进行解析并执行相应的回调

八、开发与调试

开发

先设想下要实现的功能: 根据模板初始化项目(类似vue create )

  • 用到的核心库commander用于解析用户命令

获取用户操作命令, 例: gfe-cli create appDemo, 即获取create命令

import {
    
     Command } from 'commander';
import {
    
     actionMap } from './contsant/actions';
import {
    
     pkg } from './contsant/pkg';

const program = new Command(pkg.name);

Reflect.ownKeys(actionMap).forEach((actKey: string) => {
    
    
    const action = actionMap[actKey];
    program
        .command(actKey) // 配置命令的名字
        .alias(action.alias) // 配置命令的别名
        .description(action.description) // 命令对应的描述
        .action(() => {
    
    
            
          // 当输入 gfe create appDemo时, 输出create
          console.log(actKey);
        })
});

当用户输入的命令错误时,需要给与用户提示 比如用户拼错create为craete时,需要告知用户命令不可用

// command not found
if (actKey == "*") {
    
    

  const errorCmd: string = process.argv.pop() as string;
  console.log(chalk.red(errorCmd) + '  ' + chalk.yellow(action.description))
} else {
    
    

  // TODO:这里实现create功能
}
  • 执行用户命令

通常一个脚手架会对应很多种操作,拿vue为例: create add build …很多常用命令
这样可以规划不同的文件实现对应的功能
image.png
创建create.ts用来实现新建项目的功能

export const create = async (argv: string[]) => {
    
    
  // TODO:业务实现  
}

在index.ts中动态调用

import * as ActionFn from './actions';

if (actKey == "*") {
    
    

   const errorCmd: string = process.argv.pop() as string;
   console.log(chalk.red(errorCmd) + '  ' + chalk.yellow(action.description))
 } else {
    
    

   // 当actKey为create时,调用上面create函数
   ActionFn[actKey](process.argv);
 }

cretae.ts的实现-拉取模板项目
先说下实现该功能的整体思路:
1.获取用户输入命令 gfe create appName, appName即用户创建项目的项目名
2.拉取远程模板列表,供用户选择
3.选择好模板后,选择该模板对应的Tag
4.根据模板与Tag拉取对应模板项目
5.简单模板可以直接复制到项目根目录下, 复杂模板需要进行解析渲染相关变量

在实际场景中我们通常会把模板源码放到公司私有npm上,这里我们以github为例, 实现方法都一样, 调用对应api即可
github api文档: https://docs.github.com/en/rest

  • 提供 --help 帮助提示

首先我们定义好提示的内容

export const actionMap = {
    
    
    create: {
    
    
        alias: 'c',
        description: 'create a project',
        examples: [
            'gfe-cli create <project-name>'
        ]
    },
    add: {
    
    
        alias: 'a',
        description: 'add a component',
        examples: [
            'gfe-cli add component <component-name>'
        ]
    },
    config: {
    
    
        alias: 'conf',
        description: 'config project',
        examples: [
            'gfe-cli config set <k> <v>',
            'gfe-cli config get <k>'
        ]
    },
    list: {
    
    
        alias: 'ls',
        description: 'list all projects',
        examples: [
            'gfe-cli list',
            'gfe-cli ls'
        ]
    },
    '*': {
    
    
        alias: '',
        description: 'command not found',
        expmples: []
    }
};

定义提示命令 通常为: --help

// listen help command
program.on('--help', () => {
    
    
    Reflect.ownKeys(actionMap).forEach((actKey: string) => {
    
    
        const action = actionMap[actKey];
        (action.examples || []).forEach(example => {
    
    
            console.log(chalk.cyan(`   ${
      
      example}`));
        });
    });
});

image.png

初始化项目配置

  • 解析模板

这里我们metalsmith库进行渲染, metalsmith可以将<%=name=>解析为对应的name值
在创建项目时我们通常需要将package.json中的name解析为用户自定义的项目名称
在模板中我们可以这样绑定变量

{
    
    
  "name": "<%=projectName%>"
  ...
  ...
}

解析完成后对应的package.json如下

{
    
    
  "name": "app-name"
  ...
  ...
}

在选择模板时,通常的交互是命令行询问有用户, 用户进行选择或输入, 以vue为例:
image.png
这里我们通过inquirer库实现用户的询问交互, 通常我们会把需要询问的文件统一放到一个ask.json文件内
比如在创建项目过程中询问: 项目属性是否私有、作者姓名、项目描述

[
	{
    
    
		"type": "confirm",
		"name": "private",
		"message": "ths resgistery is private?"
	},
	{
    
    
		"type": "input",
		"name": "author",
		"message": "author?"
	},
	{
    
    
		"type": "input",
		"name": "description",
		"message": "description?"
	}
]

这样我们就统一获取到了用户输入信息, 后面通过metalsmith就可以渲染到项目package.json中了

const args = require(path.join(dest, 'ask.json'));
const result = await inquirer.prompt(args);
// result -> 用户输入结果  { private:false,author:'zhang01',description:'test desc' }

本地测试

package.json 中增加 bin 字段,它是一个可执行命令和本地文件名的映射
在项目根目录下执行 npm link,这样会在全局的 node_modules 下生成一个符号链接,此时就可以在全局使用 package.json 中 bin 字段的命令名了

项目中测试

通过nrm注册私有npm源, 发布新版本脚手架
在涉及项目中投入使用当前cli各项功能

九、源码

/**
 * 新建项目
 */
import inquirer from 'inquirer';
import Metalsmith from 'metalsmith';
import ncp from 'ncp';
import path from 'path';
import util from 'util';
import {
    
     askFileName, downloadDir, loadingFn } from '../contsant';
import {
    
     downloadFile, fetchaTags, fetchRepos } from '../service';
const promisify = util.promisify;
const ncpPromise = promisify(ncp);
const {
    
     ejs } = require('consolidate');
const render = promisify(ejs.render);
const fs = require('fs-extra');
import chalk from 'chalk';
import extract from 'extract-zip';
const validateProjectName = require('validate-npm-package-name');

export const create = async (argv: string[]) => {
    
    

    // 用户创建的项目名
    const projectName: string = argv.slice(3)[0];

    const result = validateProjectName(projectName)
    if (!result.validForNewPackages) {
    
    
        console.error(chalk.red(`Invalid project name: "${
      
      projectName}"`))
        result.errors && result.errors.forEach((err: any) => {
    
    
            console.error(chalk.red.dim('Error: ' + err))
        })
        result.warnings && result.warnings.forEach((warn: any) => {
    
    
            console.error(chalk.red.dim('Warning: ' + warn))
        });

        return false;
    }

    // 选择模板
    const repos = await loadingFn(fetchRepos, 'fetching...')(true);
    // console.log(repos);
    const names = repos.map((t: any) => {
    
    

        return {
    
     name: t.name }
    });

    // 获取选择结果
    const {
    
     repo } = await inquirer.prompt({
    
    
        name: 'repo',
        type: 'list',
        message: chalk.cyan('please choose a template to create project'),
        choices: names
    });
    const targetRepo = repos.find((r: any) => r.name == repo);
    const projectId: string = targetRepo.id;

    // 选择tag
    const tags = await loadingFn(fetchaTags, 'fetching tags...')(projectId);
    const tagNames = tags.map((t: any) => t.name)
    const {
    
     tag } = await inquirer.prompt({
    
    
        name: 'tag',
        type: 'list',
        message:  chalk.cyan('please choose a tag'),
        choices: tagNames
    });

    // 下载模板文件
    const repositoryName: string = targetRepo.name;// 用户选择选择模板名称
    const source = `${
      
      downloadDir}/${
      
      repositoryName}.zip`;
    if (fs.existsSync(source)) fs.removeSync(source);
    const data = await downloadFile(projectId, tag);
    await fs.promises.writeFile(source, data);

    // 解压当前版本库- 解压到同级同名文件夹内
    const dest = `${
      
      downloadDir}/${
      
      repositoryName}`;
    if (fs.existsSync(dest)) fs.removeSync(dest);
    if (!fs.existsSync(dest)) fs.mkdirSync(dest);
    await extract(source, {
    
     dir: dest });


    // 是否需要渲染
    const isNeedRender: boolean = fs.existsSync(path.join(dest, askFileName));
    if (!isNeedRender) {
    
    

        // 简单模板 直接复制模板文件到当前目录
        await ncpPromise(dest, path.resolve(projectName));
    } else {
    
    
        // 渲染模板
        await new Promise((resolve, reject) => {
    
    
            Metalsmith(__dirname)
                .source(dest)
                .destination(path.resolve(projectName))
                .use(async (files, metal, done) => {
    
    
                    const args = require(path.join(dest, askFileName));
                    const result = await inquirer.prompt(args);
                    const meta = metal.metadata();
                    Object.assign(meta, result);
                    delete files[askFileName];
                    done();
                })
                .use((files, metal, done) => {
    
    
                    const meta = metal.metadata();
                    const mergeMeta = Object.assign(meta, {
    
    
                        projectName: projectName
                    });
                    Reflect.ownKeys(files).forEach(async (fileKey: any) => {
    
    
                        // 需要处理内容的文件类型: ts,js,json
                        if (fileKey.includes('.js') || fileKey.includes('.ts') || fileKey.includes('.tsx') || fileKey.includes('.json') || fileKey.includes('.yml')) {
    
    
                            // 处理文件内容
                            let content = files[fileKey].contents.toString();
                            if (content.includes('<%')) {
    
    
                                content = await render(content, mergeMeta);
                                // 更新文件内容
                                files[fileKey].contents = Buffer.from(content);
                            }
                        }
                    });
                    done();
                })
                .build(err => {
    
    
                    err ? reject() : resolve();
                })
        });
    }

}

十、部署

gfe-cli本地直接发布到内网npm上

猜你喜欢

转载自blog.csdn.net/gaojinbo0531/article/details/129294688