jatfret / blog

Blog posts in the repository's issues
2 stars 0 forks source link

前端开发者如何拥有一个自定义的前端脚手架工具? #1

Open jatfret opened 6 years ago

jatfret commented 6 years ago

1.什么是前端脚手架?它能解决什么问题?

现在,如果想要创建一个新的前端项目,在写项目代码之前,你可能得先这么做:

做完上面的事情之后,你才能开始去写一个“hello world”。这只是配置了一个开发环境,如果引入测试环境,你还得安装单元测试的相关的依赖包,写对应的配置和运行脚本,发布到生产环境则需要进行代码合并、压缩、混淆,规范化的发布可能还要引入持续集成工具。由此可见,这些复杂的配置对于一个不经常写前端代码,准确说是对于不经常写这些配置的开发者来说是非常不友好的。不过,大部分前端领域在发展过程中面临的问题,往往在其他编程领域早已出现过,并且已经有比较成熟的解决方案,上面所描述的配置繁琐的问题,我们可以引入脚手架工具来解决。

脚手架是建筑行业里辅助施工的一类工具。在编程领域里,它的定义比较宽泛,主要是用来提供项目初始化的一种基本封装,而具体封装到什么程度是不确定的。前端脚手架一般是指通过命令行工具来生成初始化项目模板文件,这些文件包含项目环境的基础配置文件以及部分样板文件,以此来减少开发者的重复性工作的工具。下面图片描述了,脚手架工具在前端项目中的角色。 创建新项目时,你不必花费精力去解决 Babel、React 分别需要哪些安装包,webpack 的配置文件怎么写,sass-loader 怎么安装不成功等烦人的问题,只需要在命令行执行一条命令,起身去倒杯茶,回来就可以写“hello world”。

2.常见的前端脚手架工具

在前端开发领域,使用最广泛的脚手架工具是 yeoman,另外 React、Vue 也各自推出了官方的脚手架工具 create-react-appvue-cli。yeoman 发布于 2012 年,现在看来依然不过时,没有因为框架、工具的迭代而被人弃用,并且它的应用场景不局限于前端项目。它的官网上始终写着一行文字:

THE WEB'S SCAFFOLDING TOOL FOR MODERN WEBAPPS

大概意思是:不管怎么定义“morden”,我都可以是你的脚手架工具。这个表述没毛病,因为你安装的 yeoman 工具本身不提供脚手架模版,它是一个脚手架模版的管家和执行者。脚手架模版对应于 yeoman 定义的 generator,yeoman 鼓励大家开发 generator 发布到它的平台并且通过 npm 可以单独安装,截止到目前已发布的 generator 有 6000 多个。create-react-app 和 vue-cli 则专注于生成各自框架的项目模版封装文件,它们提供了基本的开发、测试、生产环境的基本配置,一定程度上提供了该框架生态的最佳实践,选用对应的前端框架时,用它们官方的脚手架工具是一个不错的选择。

3.如何开发一个前端脚手架工具

如果不满意现有的脚手架工具提供的模版封装,除了在生成的文件里去修改配置外,现在有三种方案来自定义初始化文件,即开发并发布一个:

  1. yeoman 的 generator
  2. create-react-app 或者 vue-cli 的自定义 template
  3. 自定义的脚手架工具

yeoman 提供了详细的文档来说明如何为 yeoman 开发一个 generator ,你可以在 generator 里封装自己的初始化文件,其他 yeoman 的使用者也可以在平台里搜索并使用你所发布的 generator。对于如何发布自定义 template,vue-cli 的文档有说明,create-react-app 文档没有提到,但在源码里我们可以看到如何实现 。

接下来的内容将会重点介绍第三种方法,如何去开发一个自定义的前端脚手架工具,针对模板安装部分会结合 create-react-app 和 vue-cli 的源码来说明它们的实现思路。

3.1 基础条件

开发一个前端脚手架工具,你只需具备以下条件:

得益于 Node.js 的出现,js 能够跳出浏览器的运行环境,在网络编程和命令行工具上做更多的事情。作为前端开发者,使用 Node.js 平台来开发脚手架工具再合适不过了,当然你也可以用 shell 脚本来替代 Node.js 来实现。例子里会用到的 Node.js 核心模块主要包括 File systemPathChild processes,第三方库包括 Commander.jsChalkInquirer.jsnode-fs-extra,另外脚手架工具有怎样的命令行交互模式,生成什么项目文件模版,模版的配置文件怎么写,取决于你对工具的诉求。

3.2 命令行执行文件

首先初始化脚手架工具项目,这里为工具取名为 scaffoldding,初始化命令如下 :

$ mkdir scanffolding
$ cd scanffolding
$ npm init

再依次安装项目所以依赖的第三方库:

$ npm install --save commander chalk inquirer fs-extra

安装成功后,在 package.json 文件新增 bin 字段。通过 npm install -g xxx 命令安装到全局的包,可通过命令行的方式来调用。由 npm 安装的可调用命令由 package.json 文件的 bin 字段指定,bin 字段下的命令需要说明可执行文件的路径,一般放置于包的根目录下的 bin 文件夹中,执行命令时会通过软链接的方式,执行 bin 文件夹下的可执行文件。

"bin": {
    "scanffolding": "./bin/scanffolding"
},

在项目根目录下创建 bin 文件夹,在文件夹里新建 scanffolding 和 scanffolding-init 文件,并添加这两个文件的可执行权限:

$ mkdir bin
$ cd bin
$ touch scanffolding scanffolding-init
$ chmod 755 scanffolding scanffolding-init

编辑 scanffolding 文件。这里用到了 Commmander.js 的提供的类似与 git 子命令的用法,command 方法里的 init 命令所定义的操作,可通过新建单独 scanffolding-init 文件的方式来拆分,若要给脚手架工具增加其他功能,可以通过增加其他命令的方式来实现,如 listadd 等 command,则增加对应的 scanffolding-xxx 文件。本文只介绍初始化命令,更多的命令可根据个人需求来扩展。

#!/usr/bin/env node

const pkg = require('../package.json')
const program = require('commander')
const chalk = require('chalk')

program
  .version(pkg.version)
  .usage('<command> [options]')
  .command('init <project-name>', 'generate a new project from templates')
  .parse(process.argv)

找到创建的 scaffolding 文件夹完整路径,通过 npm 本地安装的方式将工具安装到全局,安装成功后即可执行 $ scanffolding 命令来查看输出的帮助信息。

3.3 模板文件生成

项目模板文件生成是脚手架工具存在的目的,生成模板文件后,webpack 的配置、babel 的编译等开发工作流的建立,就由模板中的配置文件来负责了。对于一个成熟的脚手架工具来说,模板文件是需要发布到线上的,或者说储存在云端,因为脚手架工具可能只会安装一次,若模板文件只存在本地,若想要对编写的文件进行更新将会变得非常麻烦,不过 scanffolding 作为练手的例子,将会使用本地存储的方案。我们知道 yeoman 的模板文件是通过编写 generator 来发布的,而 create-react-app、vue-cli 的模板文件分别是通过 npm/yarn 包和 github 仓库来发布和安装的,除了使用它们提供的模板,也可以自己指定其他 npm/yarn 包和 git 仓库地址。接下来将会介绍 create-react-app 和 vue-cli 的实现方案,以及在 scanffolding 例子中模板文件本地的方案,这样你对所有方案的实现都有所了解。

3.3.1 create-react-app

使用 create-react-app 时,在命令行不指定模板参数时,会默认安装 react-scripts 包,若有指定包路径参数,则安装指定路径的包。react-scripts 包含模版文件以及其他 npm 脚本文件(如常用的 eject)。部分代码如下:

// 该方法用来获取需要安装的包
// 传入的 version 由初始化命令的 “--scripts-version”参数指定,有以下三种情况:
// 1.指定 react-scripts 的版本号
// 2.指定第三方包的路径
// 3.传空,安装最新版本 react-scripts
function getInstallPackage(version) {
  let packageToInstall = 'react-scripts';
  const validSemver = semver.valid(version);
  if (validSemver) {
    packageToInstall += `@${validSemver}`;
  } else if (version) {
    // for tar.gz or alternative paths
    packageToInstall = version;
  }
  return packageToInstall;
}

const packageToInstall = getInstallPackage(version);
const allDependencies = ['react', 'react-dom', packageToInstall];

// 一个 promise 链,首先获取需要安装的包的包名
// useYarn 为全局变量,优先判断本地是否安装 yarn
// 调用 install 方法启动安装
getPackageName(packageToInstall)
  .then(packageName =>
    checkIfOnline(useYarn).then(isOnline => ({
      isOnline: isOnline,
      packageName: packageName,
    }))
  )
  .then(info => {
    const isOnline = info.isOnline;
    const packageName = info.packageName;
    console.log(
      `Installing ${chalk.cyan('react')}, ${chalk.cyan(
        'react-dom'
      )}, and ${chalk.cyan(packageName)}...`
    );
    console.log();

    return install(root, useYarn, allDependencies, verbose, isOnline).then(
      () => packageName
    );
  })
  // ...

// 配置安装包的命令,用 yarn 或者 npm 的方式安装
// 调用 child_process 模块的 spawn 方法,新建一个子进程来执行安装命令
// 默认安装的依赖包为: react、react-dom、react-scripts
function install(root, useYarn, dependencies, verbose, isOnline) {
  return new Promise((resolve, reject) => {
    let command;
    let args;
    if (useYarn) {
      command = 'yarnpkg';
      args = ['add', '--exact'];
      if (!isOnline) {
        args.push('--offline');
      }
      [].push.apply(args, dependencies);

      args.push('--cwd');
      args.push(root);

      if (!isOnline) {
        console.log(chalk.yellow('You appear to be offline.'));
        console.log(chalk.yellow('Falling back to the local Yarn cache.'));
        console.log();
      }
    } else {
      command = 'npm';
      args = [
        'install',
        '--save',
        '--save-exact',
        '--loglevel',
        'error',
      ].concat(dependencies);
    }

    if (verbose) {
      args.push('--verbose');
    }

    const child = spawn(command, args, { stdio: 'inherit' });
    // ...
  });
}

3.3.2 vue-cli

vue-cli 工具将默认提供的模板文件(template)整合到了 vuejs-templates 仓库,里面有 6 种与 vue 开发相关的构建工具配置组合,通过 download-git-repo 下载和提取 git 仓库文件。它的部分源码如下:

// 参数 template 的值为 vue-cli 工具的第一个命令行参数 <project-name>
// 可传入官方模板地址,或者其他仓库地址,也可以本地文件路径
// spinner 是一个“转菊花”的插件,download 为 download-git-repo 插件提供的方法
// 下载成功后的回调函数里,generate 方法用来生成本地文件,删除缓存目录。
function downloadAndGenerate (template) {
  const spinner = ora('downloading template')
  spinner.start()
  // Remove if local template exists
  if (exists(tmp)) rm(tmp)
  download(template, tmp, { clone }, err => {
    spinner.stop()
    if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())
    generate(name, tmp, to, err => {
      if (err) logger.fatal(err)
      console.log()
      logger.success('Generated "%s".', name)
    })
  })
}
// ...

3.3.3 scanffolding 例子

在编辑 scanffoding-init 文件之前,需要在项目根目录创建 templates 文件夹,然后在该文件夹下添加模板文件配置。本例中通过使用在命令行依次出现的 webpack/gulp、react/vue/none、是否单页应用这三个交互 prompt 来得到使用者最终需要生成的 template 是哪个,所以需要在 templates 文件夹确保有以下文件夹: templates ├── gulp ├── gulp-react ├── gulp-react-spa ├── gulp-spa ├── gulp-vue ├── gulp-vue-spa ├── webpack ├── webpack-react ├── webpack-react-spa ├── webpack-spa ├── webpack-vue └── webpack-vue-spa

每个文件夹里的初始化文件以及配置,你可以自由创建。现在开始编辑 scanffolding-init 文件:

#!/usr/bin/env node

const program = require("commander")
const chalk = require("chalk")
const inquirer = require("inquirer")
const fse = require("fs-extra")
const path = require("path")
const { execSync } = require("child_process")

program.usage('<project-name>')

// 定义命令行交互的问题数组
const prompts = [{
  type: 'list',
  name: 'buildTool',
  message: 'please select your project build tool:',
  choices: [{
    name: 'webpack',
    value: 'webpack',
    checked: true
  }, {
    name: 'gulp',
    value: 'gulp',
    checked: false
  }]
}, {
  type: 'list',
  name: 'framework',
  message: 'please select your font-end develop framework',
  choices: [{
    name: 'react',
    value: 'react'
  }, {
    name: 'vue',
    value: 'vue'
  }, {
    name: 'none',
    value: 'none'
  }]
},{
  type: 'confirm',
  name: 'isSPA',
  message: 'Is your webapp a single-page application ?'
}]

const projectName = program.args[0]
validateProjectName(projectName)

// 验证使用者输入的项目名称是否在当前目录存在
// 若存在,询问是否覆盖
function validateProjectName(projectName){
  if(fse.existsSync(projectName)){
    inquirer.prompt([{
      type: 'confirm',
      name: 'isContinue',
      message: 'the projectName is already existed, do you want to rewrite?',
    }]).then((answer)=>{
      if(answer.isContinue){
        fse.removeSync(projectName)
        generateProject()
      }else{
        console.error(chalk.red('Initialize interrupt'))
        process.exit(1)
      }  
    })
  }else{
    generateProject()
  }
}

// 生成模版文件的入口函数
// 依次调用上面定义的问题数组里的选项,由使用者选择,结果保存在对应变量中
function generateProject(){
  inquirer.prompt(prompts).then(answer => {
    const templateName = getTemplateName(answer)
    const templateDir = path.join(getTemplateDir(), templateName)   
    const projectDir = path.join(process.cwd(), projectName)
    copyTemplate(templateDir, projectDir)
  })
}

// 将模版文件 copy 到当前工作目录
function copyTemplate(templateDir, projectDir){
  try {
    console.log(chalk.gray('The project is generating, wait please...'))
    fse.copySync(templateDir, projectDir)
    const packageJsonPath = path.join(projectDir, 'package.json')
    if(fse.existsSync(path.join(packageJsonPath))){
      // 将生成的 package.json 文件的 name 字段修改为使用者输入的项目名称
      let packageJson = require(packageJsonPath)
      packageJson.name = projectName
      fse.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
    }
    console.log(chalk.green('The project was initialized successfully!'))
  }catch(err) {
    console.error(err)
  }
}

// 确定模版文件的名称
function getTemplateName(answer){
  const nameArr = []
  nameArr.push(answer.buildTool)
  if(answer.framework !== 'none'){
    nameArr.push(answer.framework)
  }
  if(answer.isSPA){
    nameArr.push("spa")
  }
  return nameArr.join("-")
}

// 获取 scanffolding 工具的 templates 文件夹的安装路径
function getTemplateDir(){
  let root
  try{
    root = execSync('npm root -g').toString().trim()
  }catch(err){
    console.log(err)
  }
  if(root){
    return path.join(root, './templates')
  }
}

编辑完成后,在命令行输入初始化命令,选择选项,就可以生成一个新的项目:

$ scanffolding init yourProjectName

4.总结

从时间成本来说,使用前端脚手架工具可以极大的减轻开发者花在初始化文件和写配置文件的时间,从学习的角度来说,通过研究脚手架可以很好的学习 Node.js 在命令行工具上的应用,每个前端开发者都值得拥有一个脚手架工具,另外对于项目团队来说,开发一款脚手架工具,可以更好的实现项目各规范的统一。