likes
comments
collection
share

一个可以任意扩展的前端脚手架你爱了吗

作者站长头像
站长
· 阅读数 12

前言

上文是一个简单的脚手架入门,使用场景比较有限,一般只能是下载个固定模板,如果想要多点功能的话,只能直接改模板,下次使用不想要这些功能了,又要改一次模板,这种体验是非常不好的。那么有没什么办法能在每次执行脚手架的时候进行一定程度的自定义模板呢?当然是有的,那就是插件机制

本文是万字以上的超长文,请一定要沉下心来看完,一定会有不小的收获的。

拓展功能

在开始讲插件机制之前,我们先给之前的脚手架添加些功能。

模板缓存

很多时候模板并不会更新,而我们每次都从github上拉模板,这效率显然是非常慢的。所以,我们改变策略,当首次拉模板的时候,就存到本地磁盘的指定目录,然后再将其复制到目标目录下。

这里我们借助第三方库userhome去拼路径。

那么我们的download方法就改成以下这样。

  download = async (branch) => {
    // 拼接下载路径 这里放自己的模板仓库url
    const requestUrl = `rippi-cli-template/react/#${branch}`;
    // 把资源下载到指定的本地磁盘的文件夹
    const localCacheFolder = userhome('.rippiorg_templates');
    // 指定文件夹的模板的路径
    const localTemplateCacheUrl = (this.templateDir = path.join(localCacheFolder, 'react', 'react+js'));
    // 判断是否已经下载过该模板
    const hasDownloaded = fs.existsSync(localTemplateCacheUrl);
    // 如果已经下载过了,就直接跳过。
    if (!hasDownloaded) {
      await this.downloadGitRepo(requestUrl, localTemplateCacheUrl);
    }
    console.log(chalk.green('模板准备完成!'));
  }

插件机制

所谓插件,其实就是我们这个脚手架向外暴露方法,然后npm包通过这些方法对模板进行一定程度的改造,如果是写过webpack插件的,相信是非常熟悉这个流程的。

那么,我们来分析一下,我们一般会在一个基础模板中添加些什么功能。

个人觉得是可以分为两类,一个是规范类,一个项目功能类,这些都统称为项目的特性。

  • 规范类:诸如eslint、commitLint、husky、lintstaged等等。
  • 功能类:诸如基本路由配置、Ts等。

那么我们再往下分析,这些特性都需要怎样改造模板。我们拿较为全面的配置基本路由来讲。

来看未配置路由和已经配置路由的区别。

  • 1、多了两个第三方依赖,react-route-dom和react-route-config。
  • 2、多了一个路由配置的文件routesConfig。
  • 3、入口文件的改变。
// 未添加路由的时的入口文件
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app';

ReactDOM.createRoot(document.getElementById('root')).render(
  <App />
);

// 添加了路由后的入口文件
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-route-dom';
import routesConfig from './routesConfig';
import { renderRoutes } from 'react-router-config';

ReactDOM.createRoot(document.getElementById('root')).render(
  <Router>{renderRoutes(routesConfig)}</Router>
);

比较上面的代码,可以发现他们的不同,一个是添加了些import语句,然后是改造了jsx内容。

那么,分析到这里,我们究竟要让脚手架暴露出怎样的插件方法呢?

  • 1、直接将需要添加的文件复制到模板中的方法 ------ render
  • 2、为入口文件添加import语句的方法 ------ injectImport
  • 3、通过脚本改造入口文件内容的方法 ------ transformScript
  • 4、添加依赖包的方法 ------ extendPackage

那么我们就写下以下代码,一个构造工厂类

// lib/generator-api.js

class GeneratorApi {
  // 把模板进行渲染并输出到项目中
  render() {}
  // 添加依赖包
  extendPackage() {}
  // 插入import
  injectImport() {}
  // 转换脚本
  transformScript() {}
}

export default GeneratorApi;

这样这个工厂类的的基本样子已经有了,具体那些方法如何实现,请继续往下看。

这个工厂类就是插件所能调用的全部方法。

为了足够的扩展性,我们应该将插件分为内部插件和外部插件,所谓内部插件就是我们定死的特性,这些特性在每次生成模板时提供给用户选择,而外部插件就是无法直接选择,只要添加了就必定会被应用。

内部插件

一般内部插件就是一个模板经常需要的特性,诸如是上面提到的eslintcommitLint路由配置等。

但这些特性只是经常会用到,也就是说会有不需要他们的场景,那么我们就要书写一个prompt给用户选择。

我们来写一个通用的prompt的生成类。

// lib/prompt-api.js
class PromptApi {
  constructor(creator) {
    this.creator = creator;
  }
  // 需要添加的特性
  injectFeature(feature) {
    this.creator.featurePrompts.choices.push(feature);
  }
  // 特性的弹窗
  injectPrompt(prompt) {
    this.creator.injectPrompts.push(prompt);
  }
  // 选择特性完成之后的回调
  onPromptComplete(cb) {
    this.creator.promptCompleteCbs.push(cb);
  }
}

export default PromptApi;

接着,我们来写配置好内部插件的弹框内容。这里我就写两个为例,一个是较为复杂的路由,一个是较为简单的eslint

// lib/prompt-features/router.js
// 路由
const routerPrompt = (cli) => {
  // 注入特性
  cli.injectFeature({
    name: 'Router',
    value: 'router',
    description: '是否支持路由',
  });
  // 弹出选项,决定路由模式
  cli.injectPrompt({
    name: 'routerMode',
    when: (answers) => answers.features.includes('router'),
    message: '请选择路由模式',
    type: 'list',
    choices: [
      { name: 'hash', value: 'hash' },
      { name: 'history', value: 'history' },
    ],
    default: 'history',
  });
  // App组件的title
  cli.injectPrompt({
    name: 'appTitle',
    when: (answers) => answers.features.includes('router'),
    message: '请输入App组件的内容',
    type: 'text',
    default: 'AppTitle',
  });
  // 选完路由模式后的回调  projectOptions就是本次生成的项目的特性的结果记录
  cli.onPromptComplete((answers, projectOptions) => {
    // 如果选择了路由这个特性,那么我们就记录下参数(hash还是history)
    if (answers.features.includes('router')) {
      if (!projectOptions.plugins) {
        projectOptions.plugins = {};
      }
      // 内部插件的npm包 下面会讲如何写这个插件
      projectOptions.plugins['@rippiorg/react-router-plugin'] = {
        routerMode: answers.routerMode,
      };
      projectOptions.routerMode = answers.routerMode;
      projectOptions.appTitle = answers.appTitle;
    }
  })
};

export default routerPrompt;

// lib/prompt-features/eslint.js
// eslint
const eslintPrompt = (cli) => {
  // 注入特性
  cli.injectFeature({
    name: 'eslint',
    value: 'eslint',
    description: '是否支持eslint',
  });

  // 选完路由模式后的回调
  cli.onPromptComplete((answers, projectOptions) => {
    if (answers.features.includes('eslint')) {
      if (!projectOptions.plugins) {
        projectOptions.plugins = {};
      }
      projectOptions.plugins['@rippiorg/react-eslint-plugin'] = {};
    }
  })
};

export default eslintPrompt;

create中,先加载好内部插件

// lib/create.js
import getPromptFeatures from './get-prompt-features.js';

const create = async (projectName, options, cmd) => {
  ......
  const promptFeatures = getPromptFeatures();
  // 创建项目
  const creator = new Creator(projectName, targetDir, promptFeatures);
  creator.create();
};

现在我们回到creator中,提前写好一些需要记录的数据。每一个新定义的属性都有一行注释,虽然目前并不是都会用上,但提前熟悉一下。

// lib/creator.js

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';
import PromptModuleApi from './prompt-api.js'

const defaultFeaturePrompt = {
  name: 'features',
  type: 'checkbox',
  message: '请选择项目的特性',
  choices: [],
};

class Creator {
  constructor(projectName, targetDir, promptFeatures) {
    // 项目名称
    this.name = projectName;
    // 模板目录
    this.templateDir = null;
    // 项目目录
    this.dir = targetDir;
    // 将downloadGitRepo转成promise
    this.downloadGitRepo = util.promisify(downloadGitRepo);
    this.promptFeatures = promptFeatures;
    // 特性的选择,之后他的choices会被一个一个插件填充
    this.featurePrompts = defaultFeaturePrompt;
    // 被注入的插件的选择框
    this.injectPrompts = [];
    // 被注入的选择完成的回调
    this.promptCompleteCbs = [];
    // 所选择的答案
    this.projectOptions = null;
    // 启用的插件
    this.plugins = [];
    // package.json的内容
    this.pkg = null;
    // 文件处理的中间件数组
    this.fileMiddleWares = [];
    // 需要插入的import语句
    this.imports = {};
    // key:文件路径 value:文件内容 插件在执行过程中生成的文件都会记录在这,最后统一写入硬盘
    this.files = {};
  }

  // 加载特性
  async loadFeatures() {
    const promptModuleApi = new PromptModuleApi(this);
    const modules = await Promise.all(this.promptFeatures);
    modules.forEach((module) => {
      module.default(promptModuleApi);
    });
  }

  // 特性选择
  async promptAndResolve() {
    const prompts = [this.featurePrompts, ...this.injectPrompts];
    const answers = await inquirer.prompt(prompts);
    const projectOptions = {};
    this.promptCompleteCbs.forEach((cb) => cb(answers, projectOptions));
    return projectOptions;
  }

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

  fetchTag = () => {}

  download = async (branch) => {
    // 拼接下载路径 这里放自己的模板仓库url
    const requestUrl = `rippi-cli-template/react/#${branch}`;
    // 把资源下载到指定的本地磁盘的文件夹
    const localCacheFolder = userhome('.rippiorg_templates');
    // 指定文件夹的模板的路径
    const localTemplateCacheUrl = (this.templateDir = path.join(localCacheFolder, 'react', 'react+js'));
    // 判断是否已经下载过该模板
    const hasDownloaded = fs.existsSync(localTemplateCacheUrl);
    if (!hasDownloaded) {
      await this.downloadGitRepo(requestUrl, this.dir);
      console.log(chalk.green('模板准备完成!'));
    }
  }

  create = async () => {
    await this.loadFeatures();
    const projectOptions = (this.projectOptions = await this.promptAndResolve());
    // 1 先去拉取当前仓库下的所有分支
    const branches = await this.fetchRepo();
    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);
    // 3 将模板复制到目标目录
    await fs.copy(this.templateDir, this.dir);
  }
};

export default Creator;

到这里,我们来看下效果。

一个可以任意扩展的前端脚手架你爱了吗

好了,效果出来了,出现了两个内部插件让用户进行选择。

不过这里有一点很烦人,就是之前在入口文件读取版本号的时候采用的是import语句的 assert { type: 'json' },因为这个语法很可能被废弃掉,所以一直报这个警告。因为esmodule没有__dirname,就偷了这么个懒,这里稍微扩展下吧,来解决这个警告。

  • 在esmodule中获取到__dirname
import { fileURLToPath } from "url";
import { dirname } from "path";

const __dirname = dirname(fileURLToPath(import.meta.url));
console.log(__dirname);

然后我们将读取package.json文件改为以下这样就好了。

const __dirname = dirname(fileURLToPath(import.meta.url));
const config = fs.readJSONSync(path.resolve(dirname(__dirname, '../package.json'));

一个可以任意扩展的前端脚手架你爱了吗

这样就舒服了。

既然现在我们已经出现了插件的选择,那么我们接下来当然就是在目标目录下载插件了,然后应用这些插件,让这些插件改写我们的目标项目里的各种文件,然后当插件的工作完成之后,我们再把插件删了。

  • 记下目标项目下的所有文件,等插件改写完成后,再覆盖掉当前的文件

glob是第三方库glob提供的一个方法,他能根据用户给出的规则收集文件名。

// 把当期项目中的文件全部写入到this.files中,等待被改写或者处理
async initFiles() {
  const projectFiles = await glob('**/*', { cwd: this.dir, nodir: true });
  for (let i = 0; i < projectFiles.length; i++) {
    const projectFile = projectFiles[i];
    const projectFilePath = path.join(this.dir, projectFile);
    let content;
    if (await isBinaryFile(projectFilePath)) {
      content = await fs.readFile(projectFilePath);
    } else {
      content = await fs.readFile(projectFilePath, 'utf8');
    }
    const curFileName = projectFile.split('\\').join('/');
    this.files[curFileName] = content;
  } 
}
create() {
  ......
  // 4 初始化files对象
  await this.initFiles();
}
  • 安装插件

安装插件其实非常简单,那就是将插件和版本都添加到项目的package.json的开发依赖中,然后下载一下依赖就好了

create() {
  ......
  // 读取项目目录的package.json的内容
  const pkgPath = path.join(this.dir, 'package.json');
  const pkg = (this.pkg = await fs.readJSON(pkgPath));
  // 5 下载内部插件 Reflect.ownKeys和Object.keys一样的
  const pluginDeps = Reflect.ownKeys(projectOptions.plugins);
  // 执行命令的参数
  const orderConfig = { cwd: this.dir, stdio: 'inherit' };
  // 安装依赖 这里就把插件都下载到项目目录里去了
  pluginDeps.forEach((dep) => pkg.devDependencies[dep] = 'latest');
  await execa('pnpm', ['install'], orderConfig);
}

  • 解析、收集所有的插件

这一步就涉及到插件的设计和规范了,是一种作者定死的规矩,就像webpack的插件必须要有apply一样,这个脚手架现在的规定是,任何改动项目文件的动作都写到一个方法中且放入到一个叫generator.js文件中。就如下面这个eslint插件。

一个可以任意扩展的前端脚手架你爱了吗

而这一步就是要将所有插件的这个文件都收集起来,然后后续统一执行他们。

// lib/utils.js
export const loadModule = (request, contextDir) => {
  return Module.createRequire(path.resolve(contextDir, 'package.json'))(request);
}

// lib/creator.js
// 解析和收集插件
async resolvedPlugins(rawPlugins) {
  const plugins = [];
  for (const id of Reflect.ownKeys(rawPlugins)) {
    // 插件的generator文件是在项目的node_modules的,所以以项目的package.json为基准来require
    // 这个apply就是genrator中那个改动文件的方法
    const apply = loadModule(`${id}/generator`, this.dir);
    // 插件的配置的选项{ routerMode: 'hash/history' }
    const options = rawPlugins[id];
    plugins.push({ id, apply, options });
  }
  return plugins;
}
create() {
  ......
  // 6 解析和收集插件
  const resolvedPlugins = await this.resolvedPlugins(projectOptions.plugins);
}
  • 应用插件
// 应用插件
async applyPlugins(plugins) {
  for (const plugin of plugins) {
    const { id, apply, options } = plugin;
    const generatorApi = new GeneratorApi(id, this, options);
    await apply(generatorApi, options);
  }
}
create() {
  // 7 执行插件
  // 7.1 执行插件的那些插入import语句等,就是插件的generator文件
  await this.applyPlugins(resolvedPlugins);
  • 删除插件

本身项目是不需要这些插件的,所以插件执行完成之后要删掉,方法非常简单,就是将插件从package.json中去掉,然后再重新执行一下安装依赖就好了。

create() {
  ......
  // 8 删除插件依赖,因为插件依赖只有在生成项目的时候需要,项目本身是不需要的
  // 8.1 从package.json的开发依赖中删掉插件
  pluginDeps.forEach((dep) => delete pkg.devDependencies[dep]);
  // 8.2 直接覆盖旧的package.json
  this.files['package.json'] = JSON.stringify(pkg, null, 2);
  await execa('pnpm', ['install'], orderConfig);
}
  • 将改写完成的文件重新覆盖掉当前项目中文件
// lib/utils.js
export const writeFileTree = (projectDir, files) => {
  Object.keys(files).forEach((file) => {
    const content = files[file];
    if (file.endsWith('.ejs')) file = file.slice(0, -4);
    const filePath = path.join(projectDir, file);
    fs.ensureDirSync(path.dirname(filePath));
    fs.writeFileSync(filePath, content);
  })
}

// lib/creator.js
create() {
  ......
  // 9 把files写入项目目录
  await writeFileTree(this.dir, this.files);
}

那么到这里就是内部插件的所有流程了。

我们来跑一遍试试

一个可以任意扩展的前端脚手架你爱了吗

可以看到,整个流程是ok的,也和我们写的一样,执行了两次依赖的下载,并且成功安装和删除了插件。不过,项目目录中的文件是不会有什么不同的,与仓库中的还是一样的,主要原因是我们的还没完成generatorApi这个工厂类。

内部插件 ------ 路由

我们通过写路由插件来按需完成generatorApi这个类。

上面我们分析了给一个项目配置基本的路由都有哪些改变,那么我们就一步一步实现这些改变。

  • 添加路由配置文件以及改写app.jsx

添加路由配置文件好说,就是完全新增一个文件,但app.jsx原本是存在于目录中的,这里考虑到app.jsx没什么改动,直接采用一个新的app.jsx覆盖掉目录的app.jsx的方式。

这里先说明下最终的路由插件的目录结构

一个可以任意扩展的前端脚手架你爱了吗

其中template里面是包含两个文件的,一个是src/app.jsx.ejs一个是src/routesConfig.js

// generator.js
module.exports = async (api, options) => {
  // 将template里的文件添加到项目目录里去
  api.render('./template');
  // 在项目入口文件index.jsx里添加import语句
  api.injectImport(api.entryFile,
      `import {${options.historyMode === 'hash' ? 'HashRouter' : 'BrowserRouter'} as Router} from 'react-router-dom'`);
  api.injectImport(api.entryFile,
      `import routesConfig from './routesConfig'`);
  api.injectImport(api.entryFile,
      `import { renderRoutes } from 'react-router-config'`);
  // 执行脚本,通过ast树将<App />改成<Router>{renderRoutes(routesConfig)}</Router>
  api.transformScript(api.entryFile, require('./injectRouter'))
  // 在项目的package.json中添加依赖
  api.extendPackage({
    dependencies: {
      'react-router-dom': 'latest',
      'react-router-config': 'latest'
    }
  });
}

接下来我们依据上面这个插件来完成generatorApi这个类。

  • generatorApi ------ render方法以及injectImport方法

render方法的目的就是为添加文件或者覆盖文件,那我们只要将目标目录下的文件都依次添加到项目的目录中就好了。

主体思路如上,为了少调用几次读写文件等api,这里采用的是先收集要添加的文件,然后后续统一进行添加。

同理injectImport方法,也同样是收集起来, 然后统一进行添加import语句到目标文件。

runTransformation是第三方库vue-codemod提供的方法,主要就是帮我们跑方法,具体可以查阅官方文档。

// lib/generator-api.js

class GeneratorApi {
  constructor(id, creator, options) {
    this.id = id;
    this.creator = creator;
    this.options = options;
  }
  get entryFile() {
    return 'src/index.jsx';
  }
  // 将添加文件和诸注入import语句的方法都收集起来,在creator的create方法统一添加
  async _injectFileMiddleWare(middleWare) {
    this.creator.fileMiddleWares.push(middleWare);
  }
  // 把模板进行渲染并输出到项目中
  render() {
    const execDir = extractCallDir();
    if (isString(templateDir)) {
      templateDir = path.resolve(execDir, templateDir);
      this._injectFileMiddleWare(async (files, projectOptions) => {
        // 拿到该文件夹下的所有文件
        const templateInnerFiles = await glob('**/*', { cwd: templateDir, nodir: true });
        const templateOutsideFiles = await glob('.*', { cwd: templateDir, nodir: true });
        const templateFiles = [...templateOutsideFiles, ...templateInnerFiles]
        for (let i = 0; i < templateFiles.length; i++) {
          let templateFile = templateFiles[i];
          // 给creator的files赋值
          files[templateFile] = await renderFile(path.resolve(templateDir, templateFile), projectOptions);
        }
      })
    }
  }
  // 插入import
  injectImport(file, newImport) {
    // 将import语句都保存到creator中,执行creator的renderFiles时统一插入
    const imports = (this.creator.imports[file] = this.creator.imports[file] || []);
    imports.push(newImport);
  }
}

export default GeneratorApi;

// lib/utils
export const injectImports = (fileInfo, api, { imports }) => {
  const jscodeshift = api.jscodeshift;
  // 拿到ast树
  const astRoot = jscodeshift(fileInfo.source);
  const declarations = astRoot.find(jscodeshift.ImportDeclaration);
  // 所有的import语句
  const toImportAstNode = (imp) => jscodeshift(`${imp}\n`).nodes()[0].program.body[0];
  const importAstNodes = imports.map(toImportAstNode);
  // import 只能放在最顶端,所以如果当前有import语句就紧随这些import语句,无就放在首行
  if (declarations.length > 0) {
    declarations.at(-1).insertAfter(importAstNodes);
  } else {
    astRoot.get().node.program.body.unshift(...importAstNodes);
  }
  return astRoot.toSource();
}

// lib/creator.js
import { injectImports } from './utils';

class Creator {
  // 执行中间件  上面_injectFileMiddleWare接受的那个函数
  async renderFiles() {
    const { files, projectOptions, fileMiddleWares } = this;
    for (const middleWare of fileMiddleWares) {
      await middleWare(files, projectOptions);
    }
    Reflect.ownKeys(files).forEach((file) => {
      const imports = this.imports[file];
      if (imports && imports.length > 0) {
        files[file] = runTransformation(
          { path: file, source: files[file] },
          injectImports,
          { imports },
        );
      }
    });
  }
  create() {
    ......
    // 7 执行插件
    // 7.1 执行插件的那些插入import语句等,就是插件的generator文件
    await this.applyPlugins(resolvedPlugins);
    // 7.2 开始调用插件的转换脚本 this.files(上面初始化出来的对象) 这里就主要是执行插件的转换脚本来改写入口文件了
    await this.renderFiles();
    ......
  }
}
  • generatorApi ------ transformScript方法

同理render方法,这里主要是将要执行的脚本都显存起来,然后统一执行,执行时机就是create方法的第7步。

这个方法非常简单,它只是通过参数获取到要跑的脚本,然后存起来。

transformScript(file, codemod, options = {}) {
    this._injectFileMiddleWare((files) => {
      files[file] = runTransformation(
        { path: file, source: files[file] },
        codemod,
        options,
      );
    })
  }

讲到这个方法,那么我们就来看看这个所谓的脚本都跑什么,我们目光转到路有插件的injectRouter.js文件。

module.exports = (file, api) => {
  const jscodeshift = api.jscodeshift;
  // 拿到ast树
  const root = jscodeshift(file.source)
  // 找到import App from './app'语句
  const appImportDeclaration = root.find(jscodeshift.ImportDeclaration, (node) => {
    if(node.specifiers[0].local.name === 'App'){
      return true
    }
  })
  // 删掉import App from './app'语句
  if(appImportDeclaration)
    appImportDeclaration.remove();

  // 找到<App />jix元素
  const appJSXElement = root.find(jscodeshift.JSXElement, (node) => {
    if (node.openingElement.name.name === 'App') {
      return true
    }
  })
  if(appJSXElement) {
    // 将<App />替换成<Router>{renderRoutes(routesConfig)}</Router>
    appJSXElement.replaceWith(() => {
      return jscodeshift.jsxElement(
        jscodeshift.jsxOpeningElement(jscodeshift.jsxIdentifier('Router')), jscodeshift.jsxClosingElement(jscodeshift.jsxIdentifier('Router')), [
          jscodeshift.jsxExpressionContainer(
            jscodeshift.callExpression(jscodeshift.identifier('renderRoutes'),[jscodeshift.identifier('routesConfig')])
          )
      ], false
      );
    }
  })
  return root.toSource()

上面这段代码对于操作过ast树的读者肯定不陌生,还是比较简单的替换ast树内容的代码,这里就不展开讲了。

所以,所谓的跑节点,一般就是操作ast树,将文件的内容通过脚本改成目标内容。

  • generatorApi ------ extendPackage方法

添加依赖包的方法,其实前面在给目标项目添加插件时就讲过,所谓的添加依赖就是改一下目标目录的package.json这个文件。

// 添加依赖包
// toMerge就是路由插件中传的这个参数
// {
//   dependencies: {
//     'react-router-dom': 'latest',
//     'react-router-config': 'latest'
//   }
// }
extendPackage(toMerge) {
  const pkg = this.creator.pkg;
  for (const key in toMerge) {
    const value = toMerge[key];
    const exist = pkg[key];
    if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
      pkg[key] = mergeDeps(exist || {}, value);
    } else {
      pkg[key] = value;
    }
  }
}

// lib/utils.js
export const mergeDeps = (sourceDeps, depsToInject) => {
  const result = Object.assign({}, sourceDeps);
  for (const key in depsToInject) {
    result[key] = depsToInject[key];
  }
  return result
}

export const isObject = (val) => typeof val === 'object';

export const isString = (val) => typeof val === 'string';

到这里,generatorApi的所有方法就都实现了。而路由插件剩余部分就是直接添加到项目目录中文件,这部分没什么逻辑在,直接去插件目录中看就好了。

内部插件 ------ eslint

复杂的插件(路由插件)如果会写了的话,那么eslint这个插件就不会有什么问题,eslint插件基本就只需要两步,一个是添加eslint相关的依赖,另一个就是添加.eslintrc文件。基本看一下就知道是怎么回事了,就不多讲了。

eslint插件

写了这么多,我们来看下效果。

一个可以任意扩展的前端脚手架你爱了吗

行,脚手架非常完美的跑了下来,接着我们看下项目是否添加上了router和eslint。

一个可以任意扩展的前端脚手架你爱了吗

添加上了!那么到这里,内部插件就大功告成了。

外部插件

所谓外部插件就是用户自己添加的插件,这部分的插件与内部插件的不同点就在于,内部插件有什么,能干什么等只能由脚手架开发者决定,而外部插件就是开放给用户自己定义插件能做什么,但他不能在每次运行脚手架的时候进行选择是否使用该外部插件,也就是只要配置了某一个外部插件,那么不改配置的话每次都会应用此外部插件。

在写这个外部插件之前,我们增加一个config命令,该命令如其名,就是该脚手架配置的。

所谓配置配置,那当然是因人而异的,所以我们脚手架能做的事就是当用户使用了我们的脚手架,直接在用户本地生成一个配置文件,这个配置文件会带上一些默认配置,在每次运行脚手架的时候,都会读取一下配置文件再继续走后面的流程。

讲解到此,我们来看代码吧。

// bin/index.js
// config命令提供两个可选的输入,如果只有key则为查看配置,如果有key和value则为设置,都没有为查看所有的配置
program
  .command('config [key] [value]')
  .description('check or set configuration item')
  .action((name, option) => {
    import('../lib/config.js').then(({ default: config }) => {
      config(name, option);
    });
  });


// lib/config.js

import fs from 'fs-extra';
import { getConfigurations, configurationsPath } from './utils.js';

async function config(key, value) {
  const configurations = getConfigurations();
  if (key && value) {
    // 校验是否改的是配置文件的路径,此配置不可更改
    if (key === 'configPath') {
      console.log('配置文件路径不可更改');
      return;
    }
    // key和value都存在,说明是写配置
    configurations[key] = value;
    await fs.writeJSON(configurationsPath, configurations, { spaces: 2 });
    console.log(`${key}=${value} 配置已保存`);
  } else if (key) {
    // 没有value说明是查值
    const result = configurations[key];
    console.log(result);
  } else {
    // 都没有就说明是查看所有的配置
    console.log(configurations);
  }
};

export default config;

// lib/utils.js
import fs from 'fs-extra';
import userhome from 'userhome';

// 拼接配置文件的路径
export const configurationsPath =  userhome('.cli.json');

export const getConfigurations = () => {
  // 配置一些默认项
  let config = { configPath: configurationsPath };
  // 判断是否有这个配置文件
  const isExist = fs.existsSync(configurationsPath);
  if (isExist) {
    config = fs.readJSONSync(configurationsPath);
  }
  return config;
};

完成以上这个命令,那么我们就有用了一个专属的配置文件了,这样我们就可以把所有的外部插件都写到这个配置文件中去,不过专人专职,专门写一个命令来执行外部插件的增删改查吧。

// bin/index.js
program
  .command('plugin [action] [plugin]')
  .description('check or add or delete or clear plugins')
  .action((action, plugin) => {
    import('../lib/plugin.js').then(({ default: config }) => {
      config(action, plugin);
    });
  });


// lib/plugin.js

import fs from 'fs-extra';
import { getConfigurations, configurationsPath } from './utils.js';

async function config(action, plugin) {
  const configurations = getConfigurations();
  let curPlugins = configurations.plugins ?? [];
  // 如果不是这三种行为则直接返回
  if (!['check', 'add', 'delete', 'clear'].includes(action)) {
    console.log('please enter the correct operation: check/add/delete/clear');
    return;
  }
  // 查看外部插件
  if (action === 'check') {
    console.log(curPlugins);
    return;
  // 增加外部插件
  } else if (action === 'add') {
    if (!plugin) {
      console.log('please enter the plugin name');
      return;
    }
    curPlugins.push(plugin);
  // 删除外部插件
  } else if (action === 'delete') {
    if (!plugin) {
      console.log('please enter the plugin name');
      return;
    }
    curPlugins = curPlugins.filter(item => item !== plugin);
  // 清空外部插件
  } else {
    curPlugins = [];
  }
  configurations.plugins = curPlugins;
  await fs.writeJSON(configurationsPath, configurations, { spaces: 2 });
  console.log('操作成功');
};

export default config;

完成以上代码,我们就已经可以增删改查外部插件了。

一个可以任意扩展的前端脚手架你爱了吗

那么,最后我只要在原本收集插件那个步骤那里扩展一下,读一下配置项的外部插件就大功告成了。

create() {
  const projectOptions = await this.promptAndResolve();
  (configurations.plugins ?? []).forEach((outerPlugin) => {
    projectOptions.plugins[outerPlugin] = {};
  });
  this.projectOptions = projectOptions;
}

那么,我们来看下效果吧,由于并没有写什么外部插件,所以这里仅是打印一下。

一个可以任意扩展的前端脚手架你爱了吗

成功加进来了,后面下载和执行插件什么的都是没变化的。

结尾

一个拥有插件机制的脚手架就到此结束了,脚手架能写的东西其实还非常多,但个人认为这个插件机制才是最为重要且最为有难点的部分,所以本文就花费了大量的篇幅将插件机制,相信能看到这里的朋友都是非常有耐心的朋友了。虽然读完不一定能完全明白,但是通过看和调试代码,想要弄明白其实并不难,文章末尾会附上仓库,欢迎各位来下载和挑刺。

最后的最后,再说一下这个脚手架其实还能进行很多扩展,诸如初始化git、npm yarn pnpm的选择等,但这些扩展都不难,各位有兴趣就自行添加上了!

爆肝了几天的万字长文,无论是码字还是码代码都不易,点个赞再走吧🌹

脚手架仓库