乐趣区

vue-cli的简单实现

这个 demo 我是模仿 Vue-CLI 2.0 写的一个简单的构建工具,3.0 的源码还没去看,所以会有不同的地方。

已经上传到 github 上了

先安装开发依赖的工具

npm i commander handlebars inquirer metalsmith -D

commander:用来处理命令行参数

handlerbars:一个简单高效的语义化模板构建引擎,比如我们用 vue-cli 构建项目后命令行会有一些交互行为,让你选择要安装的包什么的等等,而 Handlerbars.js 会根据你的这些选择回答去渲染模版。

inquirer:会根据模版里面的 meta.js 或者 meta.json 文件中的设置,与用户进行一些简单的交互以确定项目的一些细节。

metalsmith:一个非常简单的可插拔的静态网站生成器,通过添加一些插件对要构建的模版文件进行处理。

安装完后就能在 package.json 中看到如下的依赖

项目目录结构

其中 template-demo 里面包含了本次要构建的项目模版 templae,和 meta.js 文件

代码编写

1.bin/dg.js之后在命令行下面运行

node bin/dg.js xxx xxx

就可以构建项目了。
两个 xxx 的地方 第一个是项目的模版,第二个是要输入到哪个目录下也就是要构建的项目名称

// dg.js
const program = require('commander')
const path = require('path')
const chalk = require('chalk')  // 终端字体颜色
const inquirer = require('inquirer')
const exists = require('fs').existsSync // 判断 路径是否存在
const generate = require('./lib/generate')

/**
 * 注册一个 help 的命令
 * 当在终端输入 dg --help 或者没有跟参数的话
 * 会输出提示
 */
program.on('--help', () => {{console.log('Examples:')
  console.log()
  console.log(chalk.gray('# create a new project with an template')) // 会以灰色字体显示
  console.log('$ dg dgtemplate my-project')
}})

/**
 * 判断参数是否为空
 * 如果为空调用上面注册的 help 命令
 * 输出提示
 */
function help () {program.parse(process.argv)  //commander 用来处理 命令行里面的参数,这边的 process 是 node 的一个全局变量不明白的可以查一下资料
  if (program.args.length < 1) return program.help()}
help()

/**
 * 获取命令行参数
 */
let template = program.args[0] // 命令行第一个参数 模版的名字
const rawName = program.args[1] // 第二个参数 项目目录

/**
 * 获取项目和模版的完整路径
 */
const to = path.resolve(rawName) // 构建的项目的 绝对路径
const tem = path.join(process.cwd(), template) // 模版的路径  cwd 是当前运行的脚本是在哪个路径下运行

/**
 * 判断这个项目路径是否存在也就是是否存在相同的项目名
 * 如果存在提示 是否继续然后运行 run
 * 如果不存在 则直接运行 run 最后会创建一个项目目录
 */
if (exists(to)) {
  inquirer.prompt([  // 这边就用到了与终端交互的 inquirer 了
    {
      type: 'confirm',
      message: 'Continue?',
      name: 'ok'
    }
  ]).then(answers => {if (answers.ok) {run ()
    }
  })
} else {run ()
}

/**
 * run 函数则是用来调用 generate 来构建项目
 */
function run () {if (exists(tem)) {generate(rawName, tem, to, (err) => {if (err) console.log(err)  // 如果构建失败就调用的回调函数
    })
  }
}

注释说明 都在代码里面了。

2. 接下来就是很重要的 lib/generate.js 文件了

// generate.js
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const path = require('path')
const chalk = require('chalk')
const getOptions = require('./options')
const ask = require('./ask')


/**
 * 把 generate 导出去给 dg.js 使用
 * opts 是通过 getOptions()函数用来获取 meta.js 中的配置
 * metalsmith 是通过 metalsmith.js 获取模版的元数据
 * metalmith 可以让我们编写一些插件来对项目下面的文件进行配置
 * 其中第一个 use 的第一个插件就是用来在终端中输入一些问题一些选项让我们设置一些模版中的细节
 * 而这些问题就是 放在 meta.js 中
 * 第二个 use 的插件这是渲染模版,这里就是用了 handebars.js 来渲染模版
 * 
 */
module.exports = function generate (name, tem, dest, done) {const opts = getOptions(name, tem)
  const metalsmith = Metalsmith(path.join(tem, 'template'))
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd()})
  metalsmith.use(askQuestions(opts.prompts)).use(renderTemplateFiles())  // 这两个插件在下面的代码中
  // 在构建前执行一些函数
  metalsmith.clean(false)
    .source('.') // 默认的 source 路径是 ./src 所以这边要改成整个 template 这个根据自己要输出的需求配置
    .destination(dest)  // 要输出到哪个路径下 这里就是 我们的项目地址
    .build((err, files) => {  // 最后进行构建项目
      done(err) // 执行 回掉函数
      if (typeof opts.complete === 'function') {const helpers = { chalk}
        opts.complete(data, helpers)  // 判断 meta.js 中是否定义了构建完成后要执行的函数 这里是判断是否执行自动安装依赖
      } else {console.log('complete is not a function')
      }
    })
}


/**
 * 这里通过这个函数返回一个 metalsmith 的符合 metalsmith 插件格式的函数
 * 第一个参数 fils 就是 这个模版下面的全部文件
 * 第二个参数 ms 就是元数据这里我们的问题以及回答会已键值对的形式存放在里面用于第二个插件渲染模版
 * 第三个参数就是类似 next 的用法了 调用 done 后才能移交给下一个插件运行
 * ask 函数则在另外一个 js 文件中
 */
function askQuestions (prompts) {return (fils, ms, done) => {ask(prompts, ms.metadata(), done)
  }
}

/**
 * render 函数则是通过我们第一个插件收集这些问题以及回答后
 * 然后渲染我们的模版
 */
function renderTemplateFiles () {return (files, ms, done) => {const keys = Object.keys(files)  // 获取模版下的所有文件名
    keys.forEach(key => {  // 遍历对每个文件使用 handlerbars 渲染
      const str = files[key].contents.toString()
      let t = Handlebars.compile(str)
      let html = t(ms.metadata())
      files[key].contents = new Buffer.from(html)  // 渲染后重新写入到文件中
    })
    done() // 移交给下个插件}
}

其实 generate.js 功能就是用来收集我们在命令行下交互的问题的答案用来渲染模版,只不过我这边只是简单的实现,在 vue-cli 2.0 中还有对文件的过滤,跳过不符合使用 handlebars 渲染文件,添加一些 handlebars 的 helpers 来制定文件渲染的规则等等

  1. lib/options.js
// options.js
const path = require('path')

/**
 * 这里的 options 内容比较简单
 * 就是用于用来获取 meta.js 里面的配置
 */
module.exports = function options (name, dir) {const metaPath = path.join(dir, 'meta.js')
  const req = require(metaPath)
  let opts = {}
  opts = req
  return opts
}

options 我也是简单的实现,有兴趣的话可以查看 vue-cli 的源码

  1. lib/ask.js
// ask.js
const async = require('async')  // 这是 node 下一个异步处理的工具
const inquirer = require('inquirer')

const promptMapping = {string: 'input'}

/**
 * 这个函数就是 根据 meta.js 里面定义的 prompts 来与用户进行交互
 * 然后收集用户的交互信息存放在 metadate 也就是 metalsmith 元数据中
 * 用于渲染模版使用
 */
module.exports = function ask (prompts, metadate, done) {async.eachSeries(Object.keys(prompts), (key, next) => {  // 这里不能简单的使用数组的 foreach 方法 否则只直接跳到最后一个问题
    inquirer.prompt([{type: promptMapping[prompts[key].type] || prompts[key].type,
      name: key,
      message: prompts[key].message,
      choices: prompts[key].choices || [],}]).then(answers => {if (typeof answers[key] === 'string') {metadate[key] = answers[key].replace(/"/g,'\\"')
      } else {metadate[key] = answers[key]
      }
      next()}).catch(done)
  }, done) // 全部回答完 调用 done 移交给下一个插件
}

收集问题的答案用于渲染模版

下面是用于渲染模版的配置中的代码

为了方便 我把要渲染的模版,直接跟 构建工具 项目放到了同个文件夹下面,就是上面我截图的项目结构的 template-demo 里面包含了要渲染的模版 放在 template-demo/template下面了,还包含了渲染模版的配置文件meta.js

// meta.js
const {installDependencies} = require('./utils')
const path = require('path')


/***
 * 要交互的问题都放在 prompts 中 
 * when 是当什么情况下 用来判断是否 显示这个问题
 * type 是提问的类型
 * message 就是要显示的问题
 */
module.exports = {
  prompts: {
    name: {
      when: 'ismeta',
      type: 'string',
      message: '项目名称:'
    },
    description: {
      when: 'ismeta',
      type: 'string',
      message: '项目介绍:'
    },
    author: {
      when: 'ismeta',
      type: 'string',
      message: '项目作者:'
    },
    email: {
      when: 'ismeta',
      type: 'string',
      message: '邮箱:'
    },
    dgtable: {
      when: 'ismeta',
      type: 'confirm',
      message: '是否安装 dg-table(笔者编写的基于 elementui 二次开发的强大的表格)',
    },
    genius: {
      when: 'ismeta',
      type: 'list',
      message: '想看想看?',
      choices: [
        {
          name: '想',
          value: '想',
          short: '想'
        },
        {
          name: '很想',
          value: '很想',
          short: '很想'
        }
      ]
    },
    autoInstall: {
      when: 'ismeta',
      type: 'confirm',
      message: '是否自动执行 npm install 安装依赖?',
    },
  },
  complete: function(data, { chalk}) {
    /**
     * 用于判断是否执行自动安装依赖
     */
    const green = chalk.green // 取绿色
    const cwd = path.join(process.cwd(), data.inPlace ? '' : data.destDirName)
    if (data.autoInstall) {installDependencies(cwd, 'npm', green) // 这里使用 npm 安装
        .then(() => {console.log('依赖安装完成')
        })
        .catch(e => {console.log(chalk.red('Error:'), e)
        })
    } else {// printMessage(data, chalk)
    }
  }
}

主要是用于配置交互的问题,和再项目构建完成后执行的 complete 函数,这里就是 判断用户是否 选择了 自动安装依赖,如果 autoInstall 为 true 就自动安装依赖

const spawn = require('child_process').spawn  // 一个 node 的子线程

/**
 * 安装依赖
 */
exports.installDependencies = function installDependencies(
  cwd,
  executable = 'npm',
  color
) {console.log(`\n\n# ${color('正在安装项目依赖 ...')}`)
  console.log('# ========================\n')
  return runCommand(executable, ['install'], {cwd,})
}


function runCommand(cmd, args, options) {return new Promise((resolve, reject) => {
    /**
     * 如果不清楚 spaw 的话可以上网查一下
     * 这里就是 在项目目录下执行 npm install
     */
    const spwan = spawn(
      cmd,
      args,
      Object.assign(
        {cwd: process.cwd(),
          stdio: 'inherit',
          shell: true, // 在 shell 下执行
        },
        options
      )
    )
    spwan.on('exit', () => {resolve()
    })
  })
}

执行安装的具体实现函数。

最后你就可以在构建工具的根目录下 执行

node bin/dg.js template-demo demo

来构建项目啦。
如果把 dg.js 添加到 $PATH 中 就可以 直接使用 dg template-demo demo 来构建项目。







最后我们可以看到我们在命令行回答的问题被渲染到了这里面来了,根据是否安装 dg-table让这个插件出现在了依赖列表里面,当然包括模版中的 index.html 也被渲染了。这里图片就不贴出来了。这个模版只不过是为了演示没有其他意义了。

主要是我比较懒,挺多功能没实现,还有 vue-cli 可以自动从 github 上面拉取模版,const download = require('download-git-repo') // 用于下载远程仓库至本地 支持 GitHub、GitLab、Bitbucket

如果想更清楚的了解内部实现最好还是看下 Vue-cli2.0 的源码。

已经上传到 github 上了

退出移动版