如何华丽的实现一套脚手架 - 以umicli和转转zzcli为例

928 阅读5分钟

文章背景

  对前端开发者而言,脚手架是快速开发不可或缺的一环。脚手架方便开发者从基于已有的代码库,去下载符合项目所使用的模板,并根据需求修改配置并生成项目文件。从而减少新建项目时重复的配置工作,统一团队开发中各项目的配置和规范。

  本文作为本次umi系列的最后一篇,既然umi的源码在前面的文章已经分析完了,那么探索下umi3的脚手架的运行内容也是不错的~在此基础上,也分享一下现阶段转转内部的脚手架zz-cli的一些实现与构建内容。

umi脚手架

如何运作

  从官方文档可以看到,执行以下命令就可以让脚手架运行起来

yarn create @umijs/umi-app
# 或 npx @umijs/create-umi-app

create-umi-app.js中包含

require('../lib/cli')

cli.ts

  cli文件内默认引入执行index.ts

require('./')
  .default({
    cwd: process.cwd(),
    args,
  })

  代码中,先实例化AppGenerator类,然后执行run方法;

import AppGenerator from './AppGenerator/AppGenerator';
const generator = new AppGenerator({
  cwd,
  args,
});
generator.run();

  紧接着我们来看看AppGenerator类做了什么事情。

AppGenerator.ts

  AppGenerator类继承Generator类,并定义了writing方法,该方法执行copyDirectory复制目录,方法参数包含版本号、路由模式、模板文件地址、目标产生文件的地址等参数。

import { Generator } from '@umijs/utils';

export default class AppGenerator extends Generator {
  async writing() {
    this.copyDirectory({
      context: {
        version, //根据配置文件获取版本号
        conventionRoutes, // 常规路由?约定路由
      },
      path, // 找到对应templates文件
      targetthis.cwd, 
    });
  }
}

Generator.ts

  介绍Generator类之前,还是要简单提一下umi2的基础上umi3的脚手架做了哪些更改。

  umi2的脚手架项目独立于umi项目,在生成模板文件前,会在模板询问处提供3套项目模板供用户选择,选择完成后,直接去远程拉取内容下载;Generator类的构造则是通过yeoman这个脚手架工具去完成。(yeoman是一个通用型脚手架工具,容易拓展。我们在搭建脚手架时也可以把它作为一个方案备选,毕竟构建Generator类的方式多样,使用方式参考这里

  umi3则将脚手架的创建功能收拢到umi项目的/packages/create-umi-app文件夹下,项目只有一套默认模板供使用,并且极大程度上的去除了脚手架创建时用户配置能力。

  言归正传,我们先来看看umi3的Generator类包含了哪些内容。

  最主要的是引用nodejs fs模块的copyFileSyncreadFileSyncstatSyncwriteFileSync等方法,去获取文件的读写能力;引入nodejs path模块获取处理文件路径的能力。

  其次引用了chalk(优化命令行显示), glob(文件名称匹配), mkdirp(创建文件夹), Mustache(模板引擎), prompts(询问交互), yargs(获取命令行参数)等工具插件去获取各种能力。

  Generator类主要定义了run,prompting,writing,copyTpl,copyDirectory等方法。

  • run是外部调用时执行的入口,如果有其他配置问题需要额外写入的话,也可以在外面手动传入prompting的内容,执行完prompting的内容后才会开始执行writing
  • writing主要是在执行文件模板拷贝的一个入口方法(在外部的AppGenerator中,writing执行了copyDirectory复制目录)
  • copyTpl解析.tpl格式的模板文件并写入
  • copyDirectory遍历模板文件,根据文件名称进行区分,对.tpl执行copyTpl写入,对普通文件直接进行复制

  定义完Generator类、在项目中进行引用之后,我们就可以对模板文件进行解析和操作。

class Generator {
  ...
  constructor({ cwd, args }: IOpts) {
    this.cwd = cwd;
    this.args = args;
    this.prompts = {};
  }
  
  /**
   * run函数执行,当前没有写入prompting内容,需要可以根据需求自行定义
   */
  run() {
    this.writing();
  }

  prompting() {
    return [] as any;
  }
  
  // 写入动作
  async writing() {}

  // 复制模板
  copyTpl(opts: { templatePath: string; target: string; context: object }) {
    const tpl = readFileSync(opts.templatePath'utf-8');
    // 解析模板替换变量,得到新的模板内容
    const content = Mustache.render(tpl, opts.context);
    mkdirp.sync(dirname(opts.target));
    writeFileSync(opts.target, content, 'utf-8');
  }

  // 复制文件目录 这个是脚手架执行的主要内容
  copyDirectory(opts: { path: string; context: object; target: string }) {
    const files = glob.sync('**/*', {
      cwd: opts.path,
      dottrue,
      ignore: ['**/node_modules/**'],
    });
    
    files.forEach((file: any) => {
      const absFile = join(opts.path, file);
      // 判断文件是否是相关的模板引擎文件,如果是的话需要解析模板再输出文件
      if (file.endsWith('.tpl')) {
        this.copyTpl();
      } else {
        // 直接复制文件
        const absTarget = join(opts.target, file);
        mkdirp.sync(dirname(absTarget));
        copyFileSync(absFile, absTarget);
      }
    });
    
  }
}

总结

  umi3脚手架总体上满足了脚手架的完整功能,既能够创建初始模板项目,也能够预置配置。当前需要用户直接参与进行配置的内容很少,因此手写定义Generator类的方法也是合适的。

  讲完了umi3脚手架,再来看看咱们转转的zz-cli:一样是脚手架,却有点不一样。

zz-cli

  zz-cli是转转前端脚手架工具,内置了转转fe各团队在日常开发中的各项代码模板,除了创建项目代码,同时还提供了与公司内部其他项目交互的能力,如统跳平台、zz-ui区块组件文件、上传基建项目文档等等,通过一个脚手架将一份项目代码与fe业务的各个节点衔接了起来。

zz-cli的内容揭秘

  zz-cli引用了commander做命令管理工具,安装完脚手架后,在命令行输入 zz,就会展示下面的内容   可以看到,zz 定义了5个Commands,就是脚手架可执行的命令。我们主要要看的是create,这个命令主要执行的是脚手架最基本也是最重要的功能:创建标准模板项目。完成只需要四步,咱们往下看吧

第一步:模板选择询问

  我们先输入命令行让脚手架的create命令执行起来:

zz create my-app
node bin/zz create my-app  // 源码调试可以执行这个~原理一样的

  执行完之后,找到program.command('create <app-name>')定义的操作内容action,调用inquirer这个模板询问工具,生成前端框架模板选择列表,供用户进行选择。

program
  .usage('<command> [options]')
  .command('create <app-name>')
  .action(async (name, cmd) => {
    const options = cleanArgs(cmd)
    if (process.argv.includes('-g') || process.argv.includes('--git')) {
      options.forceGit = true
    }
    count++
    if (count > 1return
    const answer = await inquirer.prompt([{
      type'list',
      message'请选择需要的前端框架模版:',
      name'projecteChoice',
      choices: Localconfig.create
    }])
    // 进入lib/zz-create文件
    require('../lib/zz-create')(answer.projecteChoice)
  })

  选择完需要的模板类型后,进入lib/zz-create/index.js文件

第二步:获取验证信息

   进来后首先验证一下git登录信息

exec('git config --get user.name')

   并且存储用户定义的项目名称、当前目录位置、文件名、文件的绝对路径等信息

   当读取到用户执行 create 命令,并且定义了项目文件名时,会去进行拉取代码模板操作

第三步:获取代码模板

   请求接口获取模板,根据用户选择的模板类型匹配出对应模板,然后执行generate

   这里做了一个特殊处理:判断当前的网络状态,当处于网络离线状态会寻找使用已被缓存的模版。

function getTpl() {
    rp().then(res => {
      if (res.list && res.list.length) {
        const data = res.list;
        let answers = [];
        answers = data.filter(item => `${item.type}[${item.desc}]` === tplname)[0]
        tmp = path.join(home, '.zz-templates', `${answers.project}-${answers.branch}`)
        // 网络离线状态使用已被缓存的模版
        if (program.offline) {
          template = tmp
          if (isLocalPath(template)) {
            const templatePath = getTemplatePath(template)
            if (exists(templatePath)) {
              generate(name, templatePath, to, err => {
              })
            }
          }
        } else {
          let officialTemplate = answers.clone_url;
          downloadTpl(officialTemplate, tmp, () => {
            generate(name, tmp, to, err => {
            })
          });
        }
      }
    })
  }
}

   downloadTpl的方法中,引用download-git-repo工具,找到模板信息中对应项目文件的git地址并拉取内容,引入ora提供终端加载中的效果。下载完成后的回调中执行generate

function downloadAndGenerate(template, tmp, callback) {
  const spinner = ora('downloading template')
  spinner.start()
  if (exists(tmp)) rm(tmp)
  download(template, tmp, {
    clone: true
  }, err => {
    spinner.stop()
  })
}

   接下来我们看看zz-cli构建的 generate 有什么内容。

第四步:构建执行generate

   构建generate总共有三块内容:获取配置项、过滤文件、渲染文件。

1.获取预设的配置项

   执行create指令时,脚手架会询问一些配置,如安装依赖、业务线、项目名、项目描述、路由模式等。在generate执行时,首先执行askQuestions获取到之前配置的信息。

const getOptions require('./options')
// getOptions获取用户git信息、业务线选择等
const opts getOptions(name, src)

metalsmith.use(askQuestions(opts.prompts))
...

  opts配置信息的数据格式如下:

{
  helpers: {},
  prompts: {
    group: { type: 'list', message: '请选择业务线', choices: [Array] },
    name: {
      type: 'string',
      required: true,
      message: '填写项目名称(与线上保持一致)',
      default: 'test-project',
      validate: [Function (anonymous)]
    },
    description: {
      type: 'string',
      required: false,
      message: '填写项目描述',
      default: '转转前端vue项目'
    },
    author: {
      type: 'string',
      message: 'xxx',
      default: 'xxxx'
    },
    sentryKey: { type: 'confirm', message: '是否接入sentry(异常上报系统)?' }
  },
  complete: [AsyncFunction: complete]
}
2.根据配置内容过滤文件

  根据获取到的配置信息,执行filterFiles,根据条件对文件进行过滤并返回

const filter = require('./filter')

metalsmith.use(askQuestions(opts.prompts))
    .use(filterFiles(opts.filters))
    
function filterFiles (filters) {
  return (files, metalsmith, done) => {
    filter(files, filters, metalsmith.metadata(), done)
  }
}
3.渲染模板文件

  获取到过滤完成的文件后,执行renderTemplateFiles函数去遍历每个文件,异步处理其中的内容;使用模板引擎工具,在所有可配置的文件内容中写入配置项,最后生成渲染文件。

metalsmith.use(askQuestions(opts.prompts))
  .use(filterFiles(opts.filters))
  .use(renderTemplateFiles(opts.skipInterpolation, opts))
function renderTemplateFiles (skipInterpolation, opts) { 
  return (files, metalsmith, done) => {
    const keys = Object.keys(files)
    const metalsmithMetadata = metalsmith.metadata()
    metalsmithMetadata.date = new Date().toLocaleDateString()
    async.each(keys, (file, next) => {
    
      //读取文件内容
      const str = files[file].contents.toString()
      
      //渲染文件
      render(str, metalsmithMetadata, (err, res) => {
        files[file].contents = Buffer.from(res)
        next()
      })
    }, done)
  }
}

  至此,模板文件已准备就绪,万事俱备,只欠文件输出。

4.输出文件

  这里也很简单,只需要引用metalsmith这个静态网站生成器,读取并操纵文件,把生成好的模板文件打包到dest目录下。

metalsmith.clean(false//以确保Metalsmith在其他任务处于活动状态时从不擦除build文件夹。
  .source('.'// 源目录
  .destination(dest) // 生成目录
  .build((err, files) => { // 构建文件
    done(err)
    // 执行完成回调
    if (typeof opts.complete === 'function') {
      const helpers = { chalk, logger, files }
      opts.complete(data, helpers)
    }
  })
...

return data

  至此,创建标准模板项目的工作圆满完成。

总结

  zz-cli在生成模板代码、生成配置的基础上,极大地完善了脚手架的配置能力,让用户能够更大程度上的把脚手架与自身的业务配置需求紧密关联起来,跟umi不一样的点在于,能够保证配置按需引入。

思考umi3-cli与zz-cli的区别

  从前面两个脚手架的讲解,能很明显感觉到两个脚手架的区别。umi属于提供给你一个通用模板,zzcli则是给我们一些选项,让我们选择最适合的模板。为什么会这样?因为这两个脚手架产生的目的不同,可以理解为,一个注重深度,一个注重广度。

  • umi3cli生成的是一套基于react框架的的代码模板,主要特点是实现代码插件化、让开发者无需关注webpack等配置工具的配置项,上手就能工作,基本完全把配置的内容交给umi去做,只管更新,偏向于通用脚手架。
  • zzcli支持多套业务代码模板,与当前转转前端业务高度粘合,支持各种技术栈。涵盖了小程序uniapp、SSR、vue、sdk项目、node项目等等,与转转fe的技术架构紧密的结合起来,属于企业级的脚手架。
  • 在脚手架的实现上,umi把脚手架的运行配置封装到了脚手架项目之中,而zzcli则直接写在各个模板中。umi的做法无疑是能够达成统一项目配置规范的效果,这点值得我们去学习,在业务的发展中不断的统一各业务项目的配置规范。

结尾

  总体来说,构建脚手架的方式多种多样,我们可以根据具体需求,选择直接用市面上提供的工具去实现各种功能。殊途同归,脚手架的目的就是节约团队不必要的开发时间,一个脚手架,能够同步团队所有项目的规范、目录结构和文件生成、集成工程。赶紧学习,非常有用,入股不亏。