阅读 7223
leader要我三天时间搭一套“ react-cli ”出来,我答应了...

leader要我三天时间搭一套“ react-cli ”出来,我答应了...

前言

渐渐的,我们都变成了当初最讨厌的那个人(标题党)。

u=1525319938,700047175&fm=26&fmt=auto.webp

但相比形式,内容明显是更重要的。 希望读者通过本文,能完整的体验到: 从零到一开发一款脚手架的心路历程。

调研&讨论

虽然项目也没啥受重视的,但项目流程该有还是要有的。首先通过安利拉了俩个成员入伙。简单跟大家开会沟通了一下:

1. 市面主流方案调研

image.png

首先,提到react脚手架,必然离不开在react社区占据主流的create-react-app,但出乎意料的被大家第一时间就排除了。主要是因为它配置不够灵活,对webpack的封装太死,这让它在应对复杂项目的时候,有点力不从心,而且自身提供的功能虽然说很通用但是也足够简陋。我猜这时候一定会有人会说,你可以eject一下,把源码暴露出来啊?但这样的话,我使用它的意义就没有了,而且基于它的源码二次开发,成本肯定低不了。

image.png

接下来,我们把目标转向了vue-cli,没想到获得了大家的一致好评。我认为它最优秀的地方是于: 在功能的通用性及灵活性,这两个指标上取得了一个很好的平衡。这点是与vue.js框架的哲学是高度一致的,所以与vue结合起来开发项目,会有一种行云流水的感觉,而且大家一致对它的cli(交互命令行工具)喜爱有加,就是下边这个东东,用过的人一定印象深刻。

image.png

要说它有啥大毛病没有,想了想,不支持react算不算?

image.png

那么,我们的目标也就有了,开发一款使用方式类似于vue-cli的react脚手架。之前对@vue/cli的认识只停留在使用层面,所以需要先对它的实现了解一波。正所谓,知自知彼,才好实现(抄)嘛。

2. @vue/cli源码分析

我clone的是4.5.14的版本,5.0新版本起笔时,而且还处在beta版阶段,略过。

image.png

整体的代码是使用lerna + yarn-workspace维护的,而且与vue正式功能有关的包都包含在了@vue这个npm域(有人也称为npm组织)下。

如上图所示,完整的@vue/cli功能主要是三部分组成的:

  • @vue/cli 负责命令行参数收集
  • @vue/cli-service 这个包是启动vue的引擎及核心,承载webpack配置。
  • @vue/plugin-xxx vue-cli的插件,一个插件对应一个功能,与上边用户自定义feature选项一一对应。

cli与核心功能包分离是一种主流的拆包方式webpack-cli & webpack,@babel/cli & @babel/core皆是如此实现的,属于程序设计上的分层架构,cli(上层)需要依赖核心服务(下层),但核心服务(下层)并不依赖cli(上层),依然可以独立运行。

阅读源码第一步,先从package.json入手。这种通过命令使用的包,都是通过package.jsonbin 字段来实现。

  "bin": {
    "vue": "bin/vue.js"
  },
复制代码

顺藤摸瓜,打开bin/vue.js,这里 这里使用Commander这个命令行解析工具,注册了一些全局命令,这里只注意create这个命令就OK。

在执行全局的vue create xxx命令时,运行的就是这个文件。 image.pngvue create xxx命令触发时,会执行../lib/create这个文件,会把项目名称name传进去,继续追踪../lib/create.js

image.png

create主函数中。首先,拼一个将要创建项目的目标路径targetDir(这里大部分情况是,敲vue create命令时的所在目录 + 传进来的项目name)。

紧接着,是对磁盘已存在同名项目的处理。接下来是重点,new Creator 这个类正式开启了创建项目的主流程。

image.png

找到这个Creator类,可以发现它继承了node的EventEmiter这个事件处理类,这点是为了实现它的整个插件机制,这类似于在编写webpack的插件过程中,需要订阅一些内部派发的一些hook,实现基于发布订阅的事件模式,下边会细细讲解这块。

往下就需要开启断点调试了,帮助将它的整个运行流程看的更清楚些。

image.png

左侧红框中的交互选项是不是时曾相似,没错,它对应的交互界面,在上边出现过。

image.png

vue为了方便用户选择,预设了一些功能集合,Creator类的constuctor里只是初始化一些属性变量,核心的是紧接着调用的create实例方法。

image.png

调用promptAndResolvePreset ,会弹出预设选项界面,✅ 一个默认选项,继续;

image.png

左侧可以看到presets这个表示所选预设的数据里,已经包含了预设里包含的babel、 eslint等插件。

换一个预设,选择手动呢?如下图:得到也是相同的数据结构。

image.png

接下来,就是给presets里的plugins数组里的每一个插件,初始化一些默认选项

image.png

接下来,初始化生成 package.json 需要的数据。

image.png

将刚才预设里包含的插件,插入devDependencies开发依赖中。

image.png

插入成功,调试栈里已经可以看到包含了3个开发依赖

image.png

接下来,将package.json写入磁盘。

image.png

执行npm install,安装依赖。

image.png

接着,又做了些:写入npmrc、yarnrc文件,初始化git仓库等一些无关紧要的事情,快速掠过。

image.png

重点来了,下面就要使用刚安装好的插件,生成项目模版了。

image.png

this.resolvePlugins(preset.plugins, pkg)这个方法很关键,用来引入插件。但在介绍这个方法之前,我想先聊一聊框架的插件机制以及它在vue/cli中的设计与实现。

插件机制:

就是可以将一些可选配或者用户需要自定义的功能封装起来,按需插入使用,这样的设计优势是:可以拆分代码复杂度和功能耦合度,并且极大的增加了框架的可扩展性

插件,首先要定好怎么插。这也是插件机制最重要的一点,也就是框架开发者与插件开发者需要约定好一套插件接入的固定方式。体现在vue/cli中,这种约定如下图所示。

image.png

每一个插件都有一个generator文件夹,下边必须存在一个index.js,然后index.js需要导出一个函数,在函数体里可以调用外部主体注入的一些工具方法,比如,

  • api.injectImport() 在项目里插入一个模块导入。
  • api.extendPackage() 扩展项目的package.json
  • api.render('./template') 使用ejs渲染模版文件(条件编译)

再回来看上边的this.resolvePlugins(preset.plugins, pkg)方法,这里取到的apply,其实就是插件约定导出的那个函数,但这里需要注意的是,只是暂时将apply方法保存起来,并不会调用。

image.png

plugins,经过此方法处理后,就转换为了新的包含具体插件功能的plugins
{ id: options } => [{ id, apply, options }]

image.png

接下来,将整理好的plugins传入Generator这个类中,开始了最后一步,也是最实质的一步:生成项目

可以这样说,之前一大堆的参数收集、整理、引入插件等准备工作都是为了最终的生成这一步。

image.png

new 之后,紧接着调了generate这个实例方法,所以接下来,重点看下这个方法的实现

image.png

方法执行,首先调用了this.initPlugins(),这个方法中最重要的就是遍历执行所有插件默认的apply方法(即前边提到的插件机制中约定默认导出的那个函数),apply第一个参数是api,包含了render渲染ejs模版等这些能力,而这些能力都来自GeneratorAPI这个类,注意第二个参数,会把当前this传入。

image.png

接下来,继续看GeneratorAPI的实现。先排除下@vue/cli-service这个包,因为这个包虽然存在于plugins数组中,但它却很特殊,它属于核心服务,严格意义上并不属于插件的范畴,不需要当插件处理,只需参与安装就行,这里我猜只是让数据结构尽量保持简单,用时候过滤一下也没有很麻烦。

image.png

GeneratorAPI的整体实现采用了一种中间件的设计,其上的 _injectFileMiddleware 方法,会将api上的方法调用以push一个函数的方式先存起来,暂不执行。

拿上边讲到的api.render()渲染模版方法举例,在调用插件导出的apply方法时,确实会触发api.render()调用,但却并不会真正的使用ejs编译模版文件,而是将他们暂存到this.generator.fileMiddlewares这个中间暂存数组中。

image.png

image.png

那么api.render()这些方法真正的起作用是在哪里呢?

image.png

image.png

打开紧接着执行的 this.resolveFiles 方法,真相就在这里。

image.png

for of串行调用每一个middleware函数,得到最终需要生成的文件内容

image.png

此刻,this.files 就保存了完整的文件路径文件内容的映射,如上图左侧所示。

image.png

接下来,是一些常规操作。sortPkg 可以对package.json的依赖整理下顺序,满足下一些强迫症er的感受。

接下来就可以根据this.files,调用 writeFileTree 往磁盘愉快的写文件了。

image.png

最终生成的目录结构如下,流程介绍完毕。

image.png

3. 确定功能及目标

前边的对@vue/cli的详细分析,可以总结为关键的三点

    1. 采用命令行界面的进行功能选择
    1. cli核心服务 @vue/cli-service(webpack配置)采用分层设计,独立发包
    1. 插件机制,按需生成不同的功能模版文件

这样的设计对一个面向所有vue开发者的脚手架而言是必要的,因为它需要具备非常大的灵活性。但对一款只满足特定团队的脚手架来说,是过于复杂的,而且开发成本也会很高,光是将各种功能插件拆分就需要非常大的工作量。其次,团队的项目开发,最重要的就是统一与规范,很多配置及功能都可以是默认内置的,比如eslint、babel、css处理器,这些都是团队项目开发中必备的功能,所以配置的多样性及灵活性并不是首要考虑因素。所以,第三点插件机制可以舍去。

cli与webpack配置分离,这样可以做到两者的独立版本更新,有利于用户项目的持续维护,但这样做的成本在于需要自己定义一套与webpack配置大不一样的配置约定出来,这个在@vue/cli-service 中是使用vue.config.js作为配置文件,其他主流的脚手架比如create-react-app都是走的这个路子。这样的话,就还需要有一份详细的脚手架配置的文档,通过配置脚手架去配置webpack,这就导致我们已掌握的webpack配置知识无用武之地,vue中采用的chainWebpack是个解决办法,但究竟它有多难用,只有用过的人才能体会到。我们也许只需要一款透明的webpack配置,所有的rules、plugins都是可以直接修改的,这样就不需要文档,只需要会webpack配置就能搞定一切,而且,这份webpack配置在我们的团队就是现成的,所以上边提到的第二点也不需要。

所以,我们需要借鉴的地方就主要在于交互命令行工具,还有按需进行ejs模版编译。同时结合我们团队的项目特点进行功能提炼,经过小伙伴的讨论,主要有以下几点选择:

(1) 移动端还是PC端?

PC端会内置基于antd的布局模版,移动端会开启px2rem适配,html里内联 flexible.js脚本。

(2) 生成单页or多页模版?

MPASPA的需求在我们的项目场景中都是存在的,所以在脚手架层面去区分两者还是很有意义的。

(3) 状态管理库、react-router版本按需安装

功能开发

初始化项目

  • monorepo

它相比单仓库的优势是在于有利于多个npm之间的高效联调及node_modules磁盘空间共享 由于lerna的依赖提升对依赖版本号要求过于严格,而且缺少很多yarn-workspace独有的功能(特别是对bin、module的本地软链处理)。目前主流的实践是,利用yarnworkspace做依赖管理,lerna做npm包的自动版本管理及发包。 这里采用monorepo的原因是,后续可能会基于这个脚手架开发组件库或者webpack-plugin等项目,方便他们之间的联调。

npm i -g lerna
lerna init
复制代码

lerna.json

{
  "packages": [
    "packages/*"
  ],
  "version": "0.1.6",
  "npmClient": "yarn",
  "useWorkspaces": true
}
复制代码

package.json

   ...
  "workspaces": [ 
    "packages/*"
  ],
  ...
复制代码

创建cli包

lerna create react-booster-cli
复制代码

初始化下项目

yarn install 
复制代码

image.png

接下来就是实现cli的功能,首先在分析vue/cli源码时提到了,全局包用到的全局命令,是通过package.jsonbin字段实现的,咱们也一样。

booster/packages/react-booster-cli/package.json
  "bin": {
    "booster": "bin/booster.js"
  },
复制代码

接下来创建./bin/booster.js文件

#!/usr/bin/env node
// 命令行解析工具
const program = require('commander');

program
  .version(require("../package").version)
  .usage("<command> [options]");
  
  
  program.command("create <project-name>")
  .description("创建一个新的项目")
  .action((projectName)=>{
    require('../lib/create')(projectName)
  })

  program.parse(process.argv);  

复制代码

#!/usr/bin/env node这句非常重要,声明此文件需要使用node程序来执行。里边用到了commander这个命令行参数解析库,安装一下

yarn workspace react-booster-cli add commander
复制代码

现在就可以测试跑通下咱们的流程了,在booster根目录执行

npx booster
复制代码

出现以下界面就算成功了

image.png

也许有人会好奇运行成功的原因,我这里简单解释下,首先,npx xxx会先去当前目录下的node_modules/.bin/目录下找xxx文件。很显然,是存在的。这是为什么呢? 虽然声明了全局命令,但写的cli包,一没发包,二没安装。

image.png

其实这是yarn install 的一个附带(超好用的)功能,准确说是通过使用workspaceyarn install会自动的帮忙解决安装和link问题,原理是在node_modules/.bin目录下创建软链(类似于win上的文件快捷方式),链接到了packages/react-booster-cli/bin/booster.js

image.png

接下来执行 create 命令会走comander的action,将项目名传到create文件里的create方法中,这与vue/cli是一致的

image.png

功能实现

实现create方法

这里需要很多做cli常用的一些工具包,每个工具的用途下边都有注释说明,很多其实都是用来使命令行更加美观的。也许npm生态里大部分都是前端开发者,像这样装饰命令行的包种类和数量都格外丰富。比如可以使用orachalk很方便实现一些命令行加载效果、颜色字体以及进度条,增加命令行的用户体验。

lib/create.js

const path = require("path");
const fs = require("fs");
// 检测目录是否存在
const exists = fs.existsSync;
// 删除文件
const rm = require("rimraf").sync;
//询问cli输入参数
const ask = require("./ask");
// 命令行交互工具
const inquirer = require("inquirer");
// 命令行loading
const ora = require("ora");
// 输出增色
const chalk = require("chalk");
// 检测版本
const checkVersion = require("./check-version");

const generate = require("./generate");

const { writeFileTree } = require("./util/file");
const runCommand = require("./util/run");

// loading
const spinner = ora();
async function create(projectName) {
  const cwd = process.cwd(); //当前运行node命令的目录
  const projectPath = path.resolve(cwd, projectName);
  // 假如当前已存在同名项目,询问是否覆盖
  if (exists(projectPath)) {
    const answers = await inquirer.prompt([
      {
        type: "confirm",
        message: "Target directory exists. Do you want to replace it?",
        name: "ok",
      },
    ]);
    if (answers.ok) {
      console.log(chalk.yellow("Deleting old project ..."));
      rm(projectPath);
      await create(projectName);
    }
  } else {

    // 收集用户输入选项
    const answers = await ask();
    spinner.start("check version");
    // 检测版本
    await checkVersion();
    spinner.succeed();
    console.log(`✨  Creating project in ${chalk.yellow(projectPath)}.`);
    // console.log(answers);
    // 更新 package.json
    const pkg = require("../template/package.json");

    // 生成项目配置文件,app.config.json
    const appConfig = {};
    const { platform, isMPA, stateLibrary,reactRouterVersion } = answers;
    if (platform === "mobile") {
      pkg.devDependencies["postcss-pxtorem"] = "^6.0.0";
      pkg.dependencies["lib-flexible"] = "^0.3.2";
    } else if (platform === "pc") {
      pkg.dependencies["antd"] = "latest";
    }
    pkg.dependencies[stateLibrary] = "latest";
    if (reactRouterVersion === "v5") {
      pkg.devDependencies["react-router"] = "5.1.2";
    } else if (reactRouterVersion === "v6") {
      pkg.dependencies["react-router"] = "^6.x";
    }

    appConfig.platform = platform;

    spinner.start("rendering template");
    const filesTreeObj = await generate(answers,projectPath);
    spinner.succeed();
    spinner.start("🚀 invoking generators...");
    await writeFileTree(projectPath, {
      ...filesTreeObj,
      "package.json": JSON.stringify(pkg, null, 2),
      "app.config.json": JSON.stringify(appConfig, null, 2),
    });
    spinner.succeed();
    console.log(`🗃  Initializing git repository...`)
    await runCommand('git init')
    
    console.log();
    console.log(
      `🎉  Successfully created project ${chalk.yellow(projectName)}.`
    );
    console.log(
        `👉  Get started with the following commands:\n\n` +
          chalk.cyan(` ${chalk.gray("$")} cd ${projectName}\n`) +
          chalk.cyan(` ${chalk.gray("$")} npm install or yarn\n`) +
          chalk.cyan(` ${chalk.gray("$")} npm run dev`)
      );
    console.log();
     
    
  }
}

module.exports = (...args) => {
  return create(...args).catch((err) => {
    spinner.fail("create error");
    console.error(chalk.red.dim("Error: " + err));
    process.exit(1);
  });
};
复制代码

检测版本

lib/check-version.js

const request = require('request')
const semver = require('semver')
const chalk = require('chalk')
const packageConfig = require('../package.json')


module.exports = function checkVersion() {
    return new Promise((resolve,reject)=>{
        if (!semver.satisfies(process.version, packageConfig.engines.node)) {
            return console.log(chalk.red(
              `  You must upgrade node to >= ${packageConfig.engines.node} .x to use react-booster-cli`
            ))
          }
          request({
            url: 'https://registry.npmjs.org/react-booster-cli',
          }, (err, res, body) => {
            if (!err && res.statusCode === 200) {
              const latestVersion = JSON.parse(body)['dist-tags'].latest
              const localVersion = packageConfig.version
              if (semver.lt(localVersion, latestVersion)) {
                console.log()
                console.log(chalk.yellow('  A newer version of booster-cli is available.'))
                console.log()
                console.log(`  latest:     ${chalk.green(latestVersion)}`)
                console.log(`  installed:  ${chalk.red(localVersion)}`)
                console.log()
              }
              resolve()
            }else{
              reject()
            }
          })
    })
}

复制代码

命令行功能选择

核心是利用inquirer这个包,来实现命令行交互界面,这个包非常强大,提供了单选、多选、输入框等交互方式,活脱脱一个命令行form啊!

lib/ask.js

const { prompt } = require('inquirer');//生成命令行交互界面

const questions = [
  {
    name: 'platform',
    type: 'list',
    message: '您的web应用需要运行在哪个端呢?',
    choices: [{
      name: 'PC端',
      value: 'pc',
    }, {
      name: '移动端',
      value: 'mobile',
    }]
  },
  {
    name: 'isMPA',
    type: 'list',
    message: '生成单页or多页模版?',
    choices: [{
      name: '单页(SPA)',
      value: false,
    }, {
      name: '多页(MPA)',
      value: true,
    }]
  },
  {
    name: 'stateLibrary',
    type: 'list',
    message: '您希望安装的状态管理库是?',
    choices: [{
      name: 'mobx',
      value: 'mobx',
    }, {
      name: 'redux',
      value: 'redux',
    }]
  },
  {
    name: 'reactRouterVersion',
    type: 'list',
    message: '选择react-router版本',
    choices: [{
      name: 'v5(推荐)',
      value: 'v5',
    }, {
      name: 'v6(对hook支持度较好,但api不够稳定)',
      value: 'v6',
    }]
  },
]

module.exports = function ask () {
  return prompt(questions)
}
复制代码

生成项目文件

lib/generate.js

const { isBinaryFileSync } = require("isbinaryfile");
const fs = require("fs");
const ejs = require("ejs");
const path = require("path");

/**
 * @name 渲染文件
 * @param {*} filePath 文件路径
 * @param {*} ejsOptions ejs注入数据对象
 * @returns 文件内容
 */
function renderFile(filePath, ejsOptions = {}) {
  // 二进制文件直接返回
  if (isBinaryFileSync(filePath)) {
    return fs.readFileSync(filePath);
  }
  const content = fs.readFileSync(filePath, "utf-8");

  //src目录下需要经过ejs动态编译
  if (/[\\/]src[\\/].+/.test(filePath)) {
    return ejs.render(content, ejsOptions);
  }
  // 其他文件,比如webpack的配置文件,直接读取返回
  return content;
}

/**
 * @name 生成项目文件
 * @param {*} answers 收集的问题
 * @returns 文件树 eg { '/path/a/b' : 文件内容 }
 */
async function generate(answers, targetDir) {
  const globby = require("globby");

  // 匹配脚手架文件夹所有文件
  const fileList = await globby(["**/*"], {
    cwd: path.resolve(__dirname, "../template"),
    gitignore: true,
    dot: true,
  });
  const { isMPA } = answers;
  // ejs注入的模版变量
  const ejsData = {
    ...answers,
    projectDir: targetDir,
    pageName:'index'
  };
  // 生成文件树对象
  const filesTreeObj = {};
  fileList.forEach((oriPath) => {
    let targetPath = oriPath;
    const absolutePath = path.resolve(__dirname, "../template", oriPath);
  
    if (isMPA && /^src[\\/].+/.test(oriPath)) {
      // 针对多页场景,生成多页面模版
      const [dir, file] = oriPath.split(/[\\/]+/);
      ["index", "pageA", "pageB"].forEach((pageName) => {
        targetPath = `${dir}/pages/${pageName}/${file}`;
        filesTreeObj[targetPath] = renderFile(absolutePath, {
          ...ejsData,
          pageName,
        });
      });
    } else {
      const content = renderFile(absolutePath, ejsData);
      filesTreeObj[targetPath] = content;
    }
  });

  return filesTreeObj;
}

module.exports = generate;

复制代码

将命令行收集到的参数注入到ejs模版中

比如在命令行通过用户交互收集到platform这个代表web平台的参数。

image.png

ejs渲染时, platform = mobile,代表选择是移动端,就在html模版中的head标签中插入flexible.js的脚本,PC的话,就不需要。这样就可以将不同功能的最终生成代码区分开。 image.png

功能开发完毕之后,因为大部分公司都是有自己的私有npm库的,我这里演示下发公开包的步骤,其实都差不多。

发布npm包

npm login
lerna publish
复制代码

发包这一步,还是挺容易踩到坑的,这里总结下我遇到的:

    1. npm公开包的名称需要是唯一的。

      最好提前去npm网站搜索一下,看自己将要发的包名是否已存在。或者花钱买私有域,类似于@vue/xxx这样的。

    1. lerna publish重试不生效。

      运行lerna publish如果中途有包发布失败,再运行lerna publish的时候,因为git的Tag已经打上去了,所以不会再重新发布包到NPM。

      解决方法:运行lerna publish from-git,会把当前标签中涉及的NPM包再发布一次,PS:不会再更新package.json,只是执行npm publish

    1. 全局修改为淘宝npm源导致的问题
    • 淘宝源与官方源的同步存在延时

      npm包已经发布成功,但因为全局设置的是淘宝源,亲测会有一定的同步延时,大概半小时到一个小时左右,所以有可能会更新不到最新的包版本或者直接首次发版成功后的一段时间内找不到包。

    • 全局用淘宝源,会导致发包失败。

      因为淘宝源只能下载包,不能上传包。但是不用淘宝源,安装其他依赖又慢的不行。 解决方法: 安装依赖,统一从淘宝源拉,保证依赖安装速度。发包时借助 npm包的package.json的publishConfig字段指定官方源,确保能发包成功。

      "publishConfig": {
          "registry": "https://registry.npmjs.org/",
          "access": "public"
        },
      复制代码
    1. devDependenciesdependencies的区别

    首先,在普通的业务项目中,这两者是没有任何本质区别的。也就是说在安装时,带不带--dev,只会影响最终在package.json中的归类位置,最终会不会被webpack等的工具打包构建,只决定于是否在项目中被引用。

    但对于发到npm上的项目而言,这点很重要。 当用户安装你的包时,只有生产依赖才会被一起安装,开发依赖则不会。如果使用不当,比如不小心将一个生产依赖装入了开发依赖,安装你npm包的用户会运行报错,找不到xx模块。

最后

npm地址github地址, 欢迎大家试用, 提issues。这个项目是我业余时间开发的,以上标题及故事情节也纯属虚构。代码是完全脱敏公开的,如果你们团队也有类似的需求,可以给大家提供一个参考,这就是我最好的收获。

文章分类
前端
文章标签