面试官:要实现一个前端脚手架,说一下思路

哈喽,大家好,我是团队的 Fine 老师。

今天有个参加辅导的同学向我吐槽,因为简历中提到在公司做过“前端脚手架”的封装,面试官问了实现思路,但没有提前准备,答的很差。

还是那句话,我们简历中提到的内容,都可能成为面试官提问的切入点,大家要认真对待自己的简历。

正好看到了一篇文章,详细介绍了前端脚手架的知识点,给大家分享一下。

PS:文章的内容比较长,但还是推荐大家耐心学习一下。


前端脚手架是一种用于快速搭建和初始化项目结构的工具。它通过提供一个预定义的项目模板和一些常用的配置,帮助开发人员快速启动项目并减少重复性的工作。

前端脚手架的优点包括:

  • 快速初始化: 脚手架可以自动生成项目所需的基本文件结构、配置文件和示例代码,使项目的初始化过程变得简单和快速。
  • 规范项目结构: 脚手架定义了一套标准的项目结构和文件组织方式,使团队成员能够在不同项目中保持一致的开发风格和规范,提高团队协作效率。
  • 依赖管理和构建流程: 脚手架通常集成了依赖管理工具(如npm、yarn)和构建工具(如Webpack、Rollup),简化了依赖安装和打包构建的过程,让开发人员能够更专注于业务逻辑的实现。
  • 可扩展性: 脚手架提供了一些自定义配置的选项,允许开发人员根据项目需求进行灵活的配置和扩展,满足特定的需求。

我们常用的Vue Cli就是一个前端脚手架。它能够帮助开发人员快速搭建和配置Vue.js项目,提供了基本的项目结构、构建流程和开发工具链。

通过Vue CLI,开发人员可以选择不同的预设配置(如默认配置、手动配置、TypeScript配置等),自动生成相应的项目模板和配置文件。它还支持插件系统,允许开发人员根据需求引入其他功能和工具(如路由管理、状态管理、CSS预处理器等)。这样,开发人员可以更专注于Vue组件的开发和业务逻辑的实现,同时享受到构建优化和开发辅助工具的好处。

前端脚手架通过提供快速初始化、规范项目结构、简化依赖管理和构建流程等优点,提高了前端开发的效率和质量。它在实际应用中能够减少重复性劳动,加速项目的开发和部署过程。

今天为大家分享一篇如何去实现一个自己的前端脚手架,篇幅限制,本篇会教大家去实现一个最简单基础的脚手架。以下是正文:

引言

脚手架是什么,相信各位已经熟悉得不能再熟悉了,毕竟无论是vue开发者(vue-cli)还是react(create-react-app)开发者,他们都有各自的脚手架,个人虽是用react更多,但不得不说是更喜欢vue-cli的,它的插件机制非常有意思,虽不如webpack的plugin那么方便,但也很强大。不过再讲这强大的功能之前,原谅我先水一篇脚手架的基础。

正文

概念与优点

相信很多开发者都有这么一段经历,那就是在开始新项目之前,先把旧项目拉下来,删删减减,只留下初始化项目时的配置,一切业务代码都删了,然后再开始新项目的开发。一次两次如此做还好,但再多了就很厌烦,特别是删代码还很难保证项目的纯净,会出现漏删或者删多了的问题。而这时候,你就需要一个脚手架。

脚手架是什么,他就是一个纯净的项目,可以完全不包含业务代码,每次开始新项目之前,跑一下脚手架的命令,那么一个纯净的项目就初始化出来了,可以直接在这之上进行开发。

无论是公司还是个人私底下做项目练手,都极其建议写一个脚手架,就算是像本文这样做一个最简单的也是好的。

那么,脚手架该如何做搭建呢,请移步到下文~

实现

前提:所使用到的第三方库

  • Commander[1] 完整的node命令行解决方。当然也可以使用yargs[2],yargs功能更多一些。
  • Chalk[3] 能给shell命令行的文字添加样式,简单来说就是拿来画画的,可要可不要。
  • fs-extra[4] 操作文件的,比之node自带的fs,这个会更加强大与完善些。
  • inquirer[5] 在shell命令行中提供交互的库,具体效果看下文的演示。
  • ora[6] 在shell命令行中展示loading效果
  • download-git-repo[7] 下载git仓库。

步骤一:指定执行的文件

  • 先创建一个项目 执行npm init -y
  • 创建一个bin文件夹,添加index.js文件,在这个文件中写下#! /usr/bin/env node此时目录结构如下:
image.png
  • 在package.json中指定执行命令和执行的文件
image.png
  • 执行 npm link 命令,链接到本地环境中 npm link (只有本地开发需要执行这一步,正常脚手架全局安装无需执行此步骤)Link 相当于将当前本地模块链接到npm目录下,这个目录可以直接访问,所以当前包就能直接访问了。默认package.json的name为基准,也可以通过bin配置别名。link完后,npm会自动帮忙生成命令,之后可以直接执行cli xxx。

步骤二:配置可执行命令

  • 直接在bin/index.js下配置create命令。直接贴代码了,里面涉及到的都是第三方库的api,不了解的先查下文档较好。

ps:以下代码都是mjs,所以需要在package.json中添加一行 "type": "module"

// 1 配置可执行的命令 commander
import { Command } from 'commander';
import chalk from 'chalk';
import config from '../package.json' assert { type'json' };

const program = new Command();

program
  .command('create <app-name>')  // 创建命令
  .description('create a new project'// 命令描述
  .action((name, options, cmd) => {
    console.log('执行 create 命令');
  });

program.on('--help', () => {
  console.log();
  console.log(`Run ${chalk.cyan('rippi <command> --help')} to show detail of this command`);
  console.log();
});

program
  // 说明版本
  .version(`rippi-cli@${config.version}`)
  // 说明使用方式
  .usage('<command [option]');

// 解析用户执行命令传入的参数
program.parse(process.argv);

将上面提到的第三方库都安装一下,然后随便打开一个cmd,执行 cli create project

步骤三:完善核心命令---create命令

上面的步骤都只是一个脚手架最基本的铺垫,而create命令才是最关键的,而这最核心的create命令都应该做些什么事情呢?

这里就要聊聊脚手架的本质了,脚手架的本质无非就是我们先在一个仓库里写好一个模板项目,然后脚手架每次运行的时候都把这个模板项目拉到目标项目中,脚手架不过是省去了我们拉代码,初始化项目的操作而已。那么现在,create命令的基本流程就是这样了。

image.png

ps: 如果要使用gitee的话,就不能使用download-git-repo这个库了,这个库只支持下载github,要另外找一个支持下载gitee的库

  • 创建一个lib文件夹,任何工具方法或者抽象类都放到这个文件夹中。以下是代码,注释解释的都比较清楚了。
// lib/creator.js 编写一个creator类,整个找模板到下载模板的主要逻辑都抽象到了这个类中。
import { fetchRepoList } from './request.js';
import { loading } from './utils.js';
import downloadGitRepo from 'download-git-repo';
import inquirer from 'inquirer';
import chalk from 'chalk';
import util from 'util';

class Creator {
  constructor(projectName, targetDir) {
    this.name = projectName;
    this.dir = targetDir;
    // 将downloadGitRepo转成promise
    this.downloadGitRepo = util.promisify(downloadGitRepo);
  }

  fetchRepo = async () => {
    const branches = await loading(fetchRepoList, 'waiting for fetch resources');
    return branches;
  }

  fetchTag = () => {}

  download = async (branch) => {
    // 1 拼接下载路径 这里放自己的模板仓库url
    const requestUrl = `rippi-cli-template/react/#${branch}`;
    // 2 把资源下载到某个路径上
    await this.downloadGitRepo(requestUrl, this.dir);
    console.log(chalk.green('done!'));
  }

  create = async () => {
    // 1 先去拉取当前仓库下的所有分支
    const branches = await this.fetchRepo();
    // 这里会在shell命令行弹出选择项,选项为choices中的内容
    const { curBranch } = await inquirer.prompt([
      {
        name'curBranch',
        type'list',
        // 提示信息
        message'please choose current version:',
        // 选项
        choices: branches
          .filter((branch) => branch.name !== 'main')
          .map((branch) => ({
            name: branch.name,
            value: branch.name,
          })),
      },
    ]);
    // 2 下载
    await this.download(curBranch);
  }
};

export default Creator;

// lib/utils.js 给异步方法加loading效果,只是一个好看点的交互效果
import ora from 'ora';

export const loading = async (fn, msg, ...args) => {
  // 计数器,失败自动重试最大次数为3,超过3次就直接返回失败
  let counter = 0;
  const run = async () => {
    const spinner = ora(msg);
    spinner.start();
    try {
      const result = await fn(...args);
      spinner.succeed();
      return result;
    } catch (error) {
      spinner.fail('something go wrong, refetching...');
      if (++counter < 3) {
        return run();
      } else {
        return Promise.reject();
      }
    }
  };
  return run();
};

// lib/request.js 下载仓库

import axios from 'axios';

axios.interceptors.response.use((res) => {
  return res.data;
});

// 这里是获取模板仓库的所有分支,url写自己的模板仓库url
export const fetchRepoList = () => {
  return axios.get('https://api.github.com/repos/rippi-cli-template/react/branches');
};

写完上述代码,接下来我们实例化下creator,然后调用它的create方法就好了。

// lib/create.js
import path from 'path';
import Creator from './creator.js';

/**
 * 执行create时的处理
 * @param {any} name // 创建的项目名
 * @param {any} options // 配置项 必须是上面option配置的选项之一,否则就报错  这里取的起始就是cmd里面的options的各个option的long属性
 * @param {any} cmd // 执行的命令本身 一个大对象,里面很多属性
 */

const create = async (projectName, options, cmd) => {
  // 获取工作目录
  const cwd = process.cwd();
  // 目标目录也就是要创建的目录
  const targetDir = path.join(cwd, projectName);
  // 创建项目
  const creator = new Creator(projectName, targetDir);
  creator.create();
};

export default create;

// bin/index.js 将上文中的action改掉
program
  .command('create <app-name>')  // 创建命令
  .description('create a new project'// 命令描述
  .action((name, options, cmd) => {
    console.log('执行 create 命令');
  });

那么好,完成上述动作,我们来看看效果。

在一个空文件夹中打开shell命令行,然后执行cli create project project是项目名,随便改。

image.png

效果已经出来了,我的这个仓库有两个分支,分别是react和react+ts的模板分支,这里任意选一个。

image.png

选择完毕之后,就会开始下载,看到done就说明下载完了。

image.png

此时我们的文件夹中多了这么一个文件夹,打开进去看。

image.png

就是我们模板仓库里面的那些文件内容。

其实到这里,最基本的一个脚手架就写完了,不过对于尝试了多次的朋友来说会发现一个问题,那就是当当前文件夹中存在相同名称的文件时,文件就直接被覆盖,而很多时候这个行为是不好的,会导致用户丢失不想丢失的内容,为了优化这个体验我们加个--force的配置。

优化:增加--force配置

force,就当遇到同名文件,直接覆盖继续我们的创建项目的流程。

// bin/index.js  新增一个option
program
  .command('create <app-name>')  // 创建命令
  .description('create a new project'// 命令描述
  .option('-f, --force''overwrite target directory if it is existed'// 命令选项(选项名,描述) 这里就是解决下重名的情况
  .action((name, options, cmd) => {
    import('../lib/create.js').then((default: create }) => {
      create(name, options, cmd);
    });
  });

在create方法中,我们接受的第二参数就会包含这个option。

// lib/create.js
import path from 'path';
import fs from 'fs-extra';
import inquirer from 'inquirer';
import Creator from './creator.js';

/**
 * 执行create时的处理
 * @param {any} name // 创建的项目名
 * @param {any} options // 配置项 必须是上面option配置的选项之一,否则就报错  这里取的起始就是cmd里面的options的各个option的long属性
 * @param {any} cmd // 执行的命令本身 一个大对象,里面很多属性
 */

const create = async (projectName, options, cmd) => {
  // 先判断是否重名,如果重名,若选择了force则直接覆盖之前的目录,否则报错
  // 获取工作目录
  const cwd = process.cwd();
  // 目标目录也就是要创建的目录
  const targetDir = path.join(cwd, projectName);
  if (fs.existsSync(targetDir)) {
    // 选择了强制创建,先删除旧的目录,然后创建新的目录
    if (options.force) {
      await fs.remove(targetDir);
    } else {
      const { action } = await inquirer.prompt([
        {
          name'action',
          type'list',
          // 提示信息
          message`${projectName} is existed, are you want to overwrite this directory`,
          // 选项
          choices: [
            { name'overwrite'valuetrue },
            { name'cancel'valuefalse },
          ],
        },
      ]);
      if (!action) {
        return;
      } else {
        console.log('\r\noverwriting...');
        await fs.remove(targetDir);
        console.log('overwrite done');
      }
    }
  }

  // 创建项目
  const creator = new Creator(projectName, targetDir);
  creator.create();
};

export default create;

整个create方法增加多了一个判断是否存在同名文件的情况。

ps:node其实已经不推荐使用exists相关的方法了,但为了好理解这里仍然使用这个方法。node更推荐的是access方法,想了解更多可以查阅node官方文档。

增加完这段逻辑之后,我们这个脚手架的完整流程如下:

simple-cli.png

最后,我们来看下效果吧,

  • 情况一:本身存在project文件,我们再创建一个project文件但没带上--force选项。
动画.gif

这种情况下,检测文件重名了,给了用户选择是否直接覆盖。

  • 情况二:本身存在project文件,我们带上--force选项来创建project
动画.gif

这种情况,就直接覆盖了。

  • 情况三:本身存在project文件,我们不带上--force选项来创建project,且后续选择cancel
动画.gif

选择cancel后,直接退出了。

结尾

本文是脚手架搭建的一个入门,这个脚手架只拥有最简单的功能,而下一篇脚手架的搭建将会是复杂版的,拥有者插件机制,能通过配置插件动态生成项目,比如是初始化各种lint、是否使用mobx/redux,亦或者是是否初始化路由等,这都能通过配置插件完成,敬请期待吧😀。

那么好,本文到此就结束了,希望没接触过脚手架的朋友能通过这篇文章了解到脚手架并且实现自己的脚手架。

作者:猪头切图仔

链接:https://juejin.cn/post/7260893255189758010

来源:掘金

最后

觉得本文有用的小伙伴,可以帮忙点个“在看”,让更多的朋友看到咱们的文章。