手把手教你写一个脚手架

男娘i 2023-01-22 12:54 349阅读 0赞

最近在学习 vue-cli[1] 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。

我将这个轮子分成三个版本:

1.尽可能用最少的代码实现一个最简版本的脚手架。2.在 1 的基础上添加一些辅助功能,例如选择包管理器、npm 源等等。3.实现插件化,可以自由的进行扩展。在不影响内部源码的情况下,添加功能。

有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。

建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli[2]。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。

null

第一个版本 v1

第一个版本的功能比较简单,大致为:

1.用户输入命令,准备创建项目。2.脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能。3.用户选择自己需要的功能。4.脚手架根据用户的选择创建 package.json 文件,并添加对应的依赖项。5.脚手架根据用户的选择渲染项目模板,生成文件(例如 index.htmlmain.jsApp.vue 等文件)。6.执行 npm install 命令安装依赖。

项目目录树:

  1. ├─.vscode
  2. ├─bin
  3. ├─mvc.js # mvc 全局命令
  4. ├─lib
  5. ├─generator # 各个功能的模板
  6. ├─babel # babel 模板
  7. ├─linter # eslint 模板
  8. ├─router # vue-router 模板
  9. ├─vue # vue 模板
  10. ├─vuex # vuex 模板
  11. └─webpack # webpack 模板
  12. ├─promptModules # 各个模块的交互提示语
  13. └─utils # 一系列工具函数
  14. ├─create.js # create 命令处理函数
  15. ├─Creator.js # 处理交互提示
  16. ├─Generator.js # 渲染模板
  17. ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator
  18. └─scripts # commit message 验证脚本 和项目无关 不需关注

处理用户命令

脚手架第一个功能就是处理用户的命令,这需要使用 commander.js[3]。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:

  1. #!/usr/bin/env node
  2. const program = require('commander')
  3. const create = require('../lib/create')
  4. program
  5. .version('0.1.0')
  6. .command('create <name>')
  7. .description('create a new project')
  8. .action(name => {
  9. create(name)
  10. })
  11. program.parse()

它使用 commander 注册了一个 create 命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的 bin 目录,并命名为 mvc.js。然后在 package.json 文件添加这段代码:

  1. "bin": {
  2. "mvc": "./bin/mvc.js"
  3. },

再执行 npm link[4],就可以将 mvc 注册成全局命令。这样在电脑上的任何地方都能使用 mvc 命令了。实际上,就是用 mvc 命令来代替执行 node ./bin/mvc.js

假设用户在命令行上输入 mvc create demo(实际上执行的是 node ./bin/mvc.js create demo),commander 解析到命令 create 和参数 demo。然后脚手架可以在 action 回调里取到参数 name(值为 demo)。

和用户交互

取到用户要创建的项目名称 demo 之后,就可以弹出交互选项,询问用户要创建的项目需要哪些功能。这需要用到 Inquirer.js[5]。Inquirer.js 的功能就是弹出一个问题和一些选项,让用户选择。并且选项可以指定是多选、单选等等。

例如下面的代码:

  1. const prompts = [
  2. {
  3. "name": "features", // 选项名称
  4. "message": "Check the features needed for your project:", // 选项提示语
  5. "pageSize": 10,
  6. "type": "checkbox", // 选项类型 另外还有 confirm list 等
  7. "choices": [ // 具体的选项
  8. {
  9. "name": "Babel",
  10. "value": "babel",
  11. "short": "Babel",
  12. "description": "Transpile modern JavaScript to older versions (for compatibility)",
  13. "link": "https://babeljs.io/",
  14. "checked": true
  15. },
  16. {
  17. "name": "Router",
  18. "value": "router",
  19. "description": "Structure the app with dynamic pages",
  20. "link": "https://router.vuejs.org/"
  21. },
  22. ]
  23. }
  24. ]
  25. inquirer.prompt(prompts)

弹出的问题和选项如下:

null

问题的类型 "type": "checkbox"checkbox 说明是多选。如果两个选项都进行选中的话,返回来的值为:

  1. { features: ['babel', 'router'] }

其中 features 是上面问题中的 name 属性。features 数组中的值则是每个选项中的 value

Inquirer.js 还可以提供具有相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例如下面的代码:

  1. {
  2. name: 'Router',
  3. value: 'router',
  4. description: 'Structure the app with dynamic pages',
  5. link: 'https://router.vuejs.org/',
  6. },
  7. {
  8. name: 'historyMode',
  9. when: answers => answers.features.includes('router'),
  10. type: 'confirm',
  11. message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
  12. description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
  13. link: 'https://router.vuejs.org/guide/essentials/history-mode.html',
  14. },

第二个问题中有一个属性 when,它的值是一个函数 answers => answers.features.includes('router')。当函数的执行结果为 true,第二个问题才会显示出来。如果你在上一个问题中选择了 router,它的结果就会变为 true。弹出第二个问题:问你路由模式是否选择 history 模式。

大致了解 Inquirer.js 后,就可以明白这一步我们要做什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展示出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。

有哪些功能

先来看一下第一个版本支持哪些功能:

•vue•vue-router•vuex•babel•webpack•linter(eslint)

由于这是一个 vue 相关的脚手架,所以 vue 是默认提供的,不需要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。所以可供用户选择的功能只有 4 个:

•vue-router•vuex•babel•linter

现在我们先来看一下这 4 个功能对应的交互提示语相关的文件。它们全部放在 lib/promptModules 目录下:

  1. -babel.js
  2. -linter.js
  3. -router.js
  4. -vuex.js

每个文件包含了和它相关的所有交互式问题。例如刚才的示例,说明 router 相关的问题有两个。下面再看一下 babel.js 的代码:

  1. module.exports = (api) => {
  2. api.injectFeature({
  3. name: 'Babel',
  4. value: 'babel',
  5. short: 'Babel',
  6. description: 'Transpile modern JavaScript to older versions (for compatibility)',
  7. link: 'https://babeljs.io/',
  8. checked: true,
  9. })
  10. }

只有一个问题,就是问下用户需不需要 babel 功能,默认为 checked: true,也就是需要。

注入问题

用户使用 create 命令后,脚手架需要将所有功能的交互提示语句聚合在一起:

  1. // craete.js
  2. const creator = new Creator()
  3. // 获取各个模块的交互提示语
  4. const promptModules = getPromptModules()
  5. const promptAPI = new PromptModuleAPI(creator)
  6. promptModules.forEach(m => m(promptAPI))
  7. // 清空控制台
  8. clearConsole()
  9. // 弹出交互提示语并获取用户的选择
  10. const answers = await inquirer.prompt(creator.getFinalPrompts())
  11. function getPromptModules() {
  12. return [
  13. 'babel',
  14. 'router',
  15. 'vuex',
  16. 'linter',
  17. ].map(file => require(`./promptModules/${file}`))
  18. }
  19. // Creator.js
  20. class Creator {
  21. constructor() {
  22. this.featurePrompt = {
  23. name: 'features',
  24. message: 'Check the features needed for your project:',
  25. pageSize: 10,
  26. type: 'checkbox',
  27. choices: [],
  28. }
  29. this.injectedPrompts = []
  30. }
  31. getFinalPrompts() {
  32. this.injectedPrompts.forEach(prompt => {
  33. const originalWhen = prompt.when || (() => true)
  34. prompt.when = answers => originalWhen(answers)
  35. })
  36. const prompts = [
  37. this.featurePrompt,
  38. ...this.injectedPrompts,
  39. ]
  40. return prompts
  41. }
  42. }
  43. module.exports = Creator
  44. // PromptModuleAPI.js
  45. module.exports = class PromptModuleAPI {
  46. constructor(creator) {
  47. this.creator = creator
  48. }
  49. injectFeature(feature) {
  50. this.creator.featurePrompt.choices.push(feature)
  51. }
  52. injectPrompt(prompt) {
  53. this.creator.injectedPrompts.push(prompt)
  54. }
  55. }

以上代码的逻辑如下:

1.创建 creator 对象2.调用 getPromptModules() 获取所有功能的交互提示语3.再调用 PromptModuleAPI 将所有交互提示语注入到 creator 对象4.通过 const answers = await inquirer.prompt(creator.getFinalPrompts()) 在控制台弹出交互语句,并将用户选择结果赋值给 answers 变量。

如果所有功能都选上,answers 的值为:

  1. {
  2. features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 项目具有的功能
  3. historyMode: true, // 路由是否使用 history 模式
  4. eslintConfig: 'airbnb', // esilnt 校验代码的默认规则,可被覆盖
  5. lintOn: [ 'save' ] // 保存代码时进行校验
  6. }

项目模板

获取用户的选项后就该开始渲染模板和生成 package.json 文件了。先来看一下如何生成 package.json 文件:

  1. // package.json 文件内容
  2. const pkg = {
  3. name,
  4. version: '0.1.0',
  5. dependencies: {},
  6. devDependencies: {},
  7. }

先定义一个 pkg 变量来表示 package.json 文件,并设定一些默认值。

所有的项目模板都放在 lib/generator 目录下:

  1. ├─lib
  2. ├─generator # 各个功能的模板
  3. ├─babel # babel 模板
  4. ├─linter # eslint 模板
  5. ├─router # vue-router 模板
  6. ├─vue # vue 模板
  7. ├─vuex # vuex 模板
  8. └─webpack # webpack 模板

每个模板的功能都差不多:

1.向 pkg 变量注入依赖项2.提供模板文件

注入依赖

下面是 babel 相关的代码:

  1. module.exports = (generator) => {
  2. generator.extendPackage({
  3. babel: {
  4. presets: ['@babel/preset-env'],
  5. },
  6. dependencies: {
  7. 'core-js': '^3.8.3',
  8. },
  9. devDependencies: {
  10. '@babel/core': '^7.12.13',
  11. '@babel/preset-env': '^7.12.13',
  12. 'babel-loader': '^8.2.2',
  13. },
  14. })
  15. }

可以看到,模板调用 generator 对象的 extendPackage() 方法向 pkg 变量注入了 babel 相关的所有依赖。

  1. extendPackage(fields) {
  2. const pkg = this.pkg
  3. for (const key in fields) {
  4. const value = fields[key]
  5. const existing = pkg[key]
  6. if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
  7. pkg[key] = Object.assign(existing || {}, value)
  8. } else {
  9. pkg[key] = value
  10. }
  11. }
  12. }

注入依赖的过程就是遍历所有用户已选择的模板,并调用 extendPackage() 注入依赖。

渲染模板

脚手架是怎么渲染模板的呢?用 vuex 举例,先看一下它的代码:

  1. module.exports = (generator) => {
  2. // 向入口文件 `src/main.js` 注入代码 import store from './store'
  3. generator.injectImports(generator.entryFile, `import store from './store'`)
  4. // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
  5. generator.injectRootOptions(generator.entryFile, `store`)
  6. // 注入依赖
  7. generator.extendPackage({
  8. dependencies: {
  9. vuex: '^3.6.2',
  10. },
  11. })
  12. // 渲染模板
  13. generator.render('./template', {})
  14. }

可以看到渲染的代码为 generator.render('./template', {})./template 是模板目录的路径:

null

所有的模板代码都放在 template 目录下,vuex 将会在用户创建的目录下的 src 目录生成 store 文件夹,里面有一个 index.js 文件。它的内容为:

  1. import Vue from 'vue'
  2. import Vuex from 'vuex'
  3. Vue.use(Vuex)
  4. export default new Vuex.Store({
  5. state: {
  6. },
  7. mutations: {
  8. },
  9. actions: {
  10. },
  11. modules: {
  12. },
  13. })

这里简单描述一下 generator.render() 的渲染过程。

第一步, 使用 globby[6] 读取模板目录下的所有文件:

  1. const _files = await globby(['**/*'], { cwd: source, dot: true })

第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs[7] 进行渲染:

  1. // 返回文件内容
  2. const template = fs.readFileSync(name, 'utf-8')
  3. return ejs.render(template, data, ejsOptions)

使用 ejs 的好处,就是可以结合变量来决定是否渲染某些代码。例如 webpack 的模板中有这样一段代码:

  1. module: {
  2. rules: [
  3. <%_ if (hasBabel) { _%>
  4. {
  5. test: /\.js$/,
  6. loader: 'babel-loader',
  7. exclude: /node_modules/,
  8. },
  9. <%_ } _%>
  10. ],
  11. },

ejs 可以根据用户是否选择了 babel 来决定是否渲染这段代码。如果 hasBabelfalse,则这段代码:

  1. {
  2. test: /\.js$/,
  3. loader: 'babel-loader',
  4. exclude: /node_modules/,
  5. },

将不会被渲染出来。hasBabel 的值是调用 render() 时用参数传过去的:

  1. generator.render('./template', {
  2. hasBabel: options.features.includes('babel'),
  3. lintOnSave: options.lintOn.includes('save'),
  4. })

第三步,注入特定代码。回想一下刚才 vuex 中的:

  1. // 向入口文件 `src/main.js` 注入代码 import store from './store'
  2. generator.injectImports(generator.entryFile, `import store from './store'`)
  3. // 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
  4. generator.injectRootOptions(generator.entryFile, `store`)

这两行代码的作用是:在项目入口文件 src/main.js 中注入特定的代码。

vuexvue 的一个状态管理库,属于 vue 全家桶中的一员。如果创建的项目没有选择 vuexvue-router。则 src/main.js 的代码为:

  1. import Vue from 'vue'
  2. import App from './App.vue'
  3. Vue.config.productionTip = false
  4. new Vue({
  5. render: (h) => h(App),
  6. }).$mount('#app')

如果选择了 vuex,它会注入上面所说的两行代码,现在 src/main.js 代码变为:

  1. import Vue from 'vue'
  2. import store from './store' // 注入的代码
  3. import App from './App.vue'
  4. Vue.config.productionTip = false
  5. new Vue({
  6. store, // 注入的代码
  7. render: (h) => h(App),
  8. }).$mount('#app')

这里简单描述一下代码的注入过程:

1.使用 vue-codemod[8] 将代码解析成语法抽象树 AST。2.然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。3.最后将新的 AST 重新渲染成代码。

提取 package.json 的部分选项

一些第三方库的配置项可以放在 package.json 文件,也可以自己独立生成一份文件。例如 babelpackage.json 中注入的配置为:

  1. babel: {
  2. presets: ['@babel/preset-env'],
  3. }

我们可以调用 generator.extractConfigFiles() 将内容提取出来并生成 babel.config.js 文件:

  1. module.exports = {
  2. presets: ['@babel/preset-env'],
  3. }

生成文件

渲染好的模板文件和 package.json 文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用 writeFileTree() 将文件生成:

  1. const fs = require('fs-extra')
  2. const path = require('path')
  3. module.exports = async function writeFileTree(dir, files) {
  4. Object.keys(files).forEach((name) => {
  5. const filePath = path.join(dir, name)
  6. fs.ensureDirSync(path.dirname(filePath))
  7. fs.writeFileSync(filePath, files[name])
  8. })
  9. }

这段代码的逻辑如下:

1.遍历所有渲染好的文件,逐一生成。2.在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。3.写入文件。

例如现在一个文件路径为 src/test.js,第一次写入时,由于还没有 src 目录。所以会先生成 src 目录,再生成 test.js 文件。

webpack

webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。

添加新功能

添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModuleslib/generator。在 lib/promptModules 中添加的是这个功能相关的交互提示语。在 lib/generator 中添加的是这个功能相关的依赖和模板代码。

不过不是所有的功能都需要添加模板代码的,例如 babel 就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts。除了添加 ts 相关的依赖,还得在 webpack vue vue-router vuex linter 等功能中修改原有的模板代码。

举个例子,在 vue-router 中,如果支持 ts,则这段代码:

  1. const routes = [ // ... ]

需要修改为:

  1. <%_ if (hasTypeScript) { _%>
  2. const routes: Array<RouteConfig> = [ // ... ]
  3. <%_ } else { _%>
  4. const routes = [ // ... ]
  5. <%_ } _%>

因为 ts 的值有类型。

总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。

下载依赖

下载依赖需要使用 execa[9],它可以调用子进程执行命令。

  1. const execa = require('execa')
  2. module.exports = function executeCommand(command, cwd) {
  3. return new Promise((resolve, reject) => {
  4. const child = execa(command, [], {
  5. cwd,
  6. stdio: ['inherit', 'pipe', 'inherit'],
  7. })
  8. child.stdout.on('data', buffer => {
  9. process.stdout.write(buffer)
  10. })
  11. child.on('close', code => {
  12. if (code !== 0) {
  13. reject(new Error(`command failed: ${command}`))
  14. return
  15. }
  16. resolve()
  17. })
  18. })
  19. }
  20. // create.js 文件
  21. console.log('\n正在下载依赖...\n')
  22. // 下载依赖
  23. await executeCommand('npm install', path.join(process.cwd(), name))
  24. console.log('\n依赖下载完成! 执行下列命令开始开发:\n')
  25. console.log(`cd ${name}`)
  26. console.log(`npm run dev`)

调用 executeCommand() 开始下载依赖,参数为 npm install 和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:

  1. child.stdout.on('data', buffer => {
  2. process.stdout.write(buffer)
  3. })

下面我用动图演示一下 v1 版本的创建过程:

null

创建成功的项目截图:

null

null

第二个版本 v2

第二个版本在 v1 的基础上添加了一些辅助功能:

1.创建项目时判断该项目是否已存在,支持覆盖和合并创建。2.选择功能时提供默认配置和手动选择两种模式。3.如果用户的环境同时存在 yarn 和 npm,则会提示用户要使用哪个包管理器。4.如果 npm 的默认源速度比较慢,则提示用户是否要切换到淘宝源。5.如果用户是手动选择功能,在结束后会询问用户是否要将这次的选择保存为默认配置。

覆盖和合并

创建项目时,先提前判断一下该项目是否存在:

  1. const targetDir = path.join(process.cwd(), name)
  2. // 如果目标目录已存在,询问是覆盖还是合并
  3. if (fs.existsSync(targetDir)) {
  4. // 清空控制台
  5. clearConsole()
  6. const { action } = await inquirer.prompt([
  7. {
  8. name: 'action',
  9. type: 'list',
  10. message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
  11. choices: [
  12. { name: 'Overwrite', value: 'overwrite' },
  13. { name: 'Merge', value: 'merge' },
  14. ],
  15. },
  16. ])
  17. if (action === 'overwrite') {
  18. console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
  19. await fs.remove(targetDir)
  20. }
  21. }

null

如果选择 overwrite,则进行移除 fs.remove(targetDir)

默认配置和手动模式

先在代码中提前把默认配置的代码写好:

  1. exports.defaultPreset = {
  2. features: ['babel', 'linter'],
  3. historyMode: false,
  4. eslintConfig: 'airbnb',
  5. lintOn: ['save'],
  6. }

这个配置默认使用 babeleslint

然后生成交互提示语时,先调用 getDefaultPrompts() 方法获取默认配置。

  1. getDefaultPrompts() {
  2. const presets = this.getPresets()
  3. const presetChoices = Object.entries(presets).map(([name, preset]) => {
  4. let displayName = name
  5. return {
  6. name: `${displayName} (${preset.features})`,
  7. value: name,
  8. }
  9. })
  10. const presetPrompt = {
  11. name: 'preset',
  12. type: 'list',
  13. message: `Please pick a preset:`,
  14. choices: [
  15. // 默认配置
  16. ...presetChoices,
  17. // 这是手动模式提示语
  18. {
  19. name: 'Manually select features',
  20. value: '__manual__',
  21. },
  22. ],
  23. }
  24. const featurePrompt = {
  25. name: 'features',
  26. when: isManualMode,
  27. type: 'checkbox',
  28. message: 'Check the features needed for your project:',
  29. choices: [],
  30. pageSize: 10,
  31. }
  32. return {
  33. presetPrompt,
  34. featurePrompt,
  35. }
  36. }

这样配置后,在用户选择功能前会先弹出这样的提示语:

null

包管理器

vue-cli 创建项目时,会生成一个 .vuerc 文件,里面会记录一些关于项目的配置信息。例如使用哪个包管理器、npm 源是否使用淘宝源等等。为了避免和 vue-cli 冲突,本脚手架生成的配置文件为 .mvcrc

这个 .mvcrc 文件保存在用户的 home 目录下(不同操作系统目录不同)。我的是 win10 操作系统,保存目录为 C:\Users\bin。获取用户的 home 目录可以通过以下代码获取:

  1. const os = require('os')
  2. os.homedir()

.mvcrc 文件还会保存用户创建项目的配置,这样当用户重新创建项目时,就可以直接选择以前创建过的配置,不用再一步步的选择项目功能。

在第一次创建项目时,.mvcrc 文件是不存在的。如果这时用户还安装了 yarn,脚手架就会提示用户要使用哪个包管理器:

  1. // 读取 `.mvcrc` 文件
  2. const savedOptions = loadOptions()
  3. // 如果没有指定包管理器并且存在 yarn
  4. if (!savedOptions.packageManager && hasYarn) {
  5. const packageManagerChoices = []
  6. if (hasYarn()) {
  7. packageManagerChoices.push({
  8. name: 'Use Yarn',
  9. value: 'yarn',
  10. short: 'Yarn',
  11. })
  12. }
  13. packageManagerChoices.push({
  14. name: 'Use NPM',
  15. value: 'npm',
  16. short: 'NPM',
  17. })
  18. otherPrompts.push({
  19. name: 'packageManager',
  20. type: 'list',
  21. message: 'Pick the package manager to use when installing dependencies:',
  22. choices: packageManagerChoices,
  23. })
  24. }

null

当用户选择 yarn 后,下载依赖的命令就会变为 yarn;如果选择了 npm,下载命令则为 npm install

  1. const PACKAGE_MANAGER_CONFIG = {
  2. npm: {
  3. install: ['install'],
  4. },
  5. yarn: {
  6. install: [],
  7. },
  8. }
  9. await executeCommand(
  10. this.bin, // 'yarn' or 'npm'
  11. [
  12. ...PACKAGE_MANAGER_CONFIG[this.bin][command],
  13. ...(args || []),
  14. ],
  15. this.context,
  16. )

切换 npm 源

当用户选择了项目功能后,会先调用 shouldUseTaobao() 方法判断是否需要切换淘宝源:

  1. const execa = require('execa')
  2. const chalk = require('chalk')
  3. const request = require('./request')
  4. const { hasYarn } = require('./env')
  5. const inquirer = require('inquirer')
  6. const registries = require('./registries')
  7. const { loadOptions, saveOptions } = require('./options')
  8. async function ping(registry) {
  9. await request.get(`${registry}/vue-cli-version-marker/latest`)
  10. return registry
  11. }
  12. function removeSlash(url) {
  13. return url.replace(/\/$/, '')
  14. }
  15. let checked
  16. let result
  17. module.exports = async function shouldUseTaobao(command) {
  18. if (!command) {
  19. command = hasYarn() ? 'yarn' : 'npm'
  20. }
  21. // ensure this only gets called once.
  22. if (checked) return result
  23. checked = true
  24. // previously saved preference
  25. const saved = loadOptions().useTaobaoRegistry
  26. if (typeof saved === 'boolean') {
  27. return (result = saved)
  28. }
  29. const save = val => {
  30. result = val
  31. saveOptions({ useTaobaoRegistry: val })
  32. return val
  33. }
  34. let userCurrent
  35. try {
  36. userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout
  37. } catch (registryError) {
  38. try {
  39. // Yarn 2 uses `npmRegistryServer` instead of `registry`
  40. userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout
  41. } catch (npmRegistryServerError) {
  42. return save(false)
  43. }
  44. }
  45. const defaultRegistry = registries[command]
  46. if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
  47. // user has configured custom registry, respect that
  48. return save(false)
  49. }
  50. let faster
  51. try {
  52. faster = await Promise.race([
  53. ping(defaultRegistry),
  54. ping(registries.taobao),
  55. ])
  56. } catch (e) {
  57. return save(false)
  58. }
  59. if (faster !== registries.taobao) {
  60. // default is already faster
  61. return save(false)
  62. }
  63. if (process.env.VUE_CLI_API_MODE) {
  64. return save(true)
  65. }
  66. // ask and save preference
  67. const { useTaobaoRegistry } = await inquirer.prompt([
  68. {
  69. name: 'useTaobaoRegistry',
  70. type: 'confirm',
  71. message: chalk.yellow(
  72. ` Your connection to the default ${command} registry seems to be slow.\n`
  73. + ` Use ${chalk.cyan(registries.taobao)} for faster installation?`,
  74. ),
  75. },
  76. ])
  77. // 注册淘宝源
  78. if (useTaobaoRegistry) {
  79. await execa(command, ['config', 'set', 'registry', registries.taobao])
  80. }
  81. return save(useTaobaoRegistry)
  82. }

上面代码的逻辑为:

1.先判断默认配置文件 .mvcrc 是否有 useTaobaoRegistry 选项。如果有,直接将结果返回,无需判断。2.向 npm 默认源和淘宝源各发一个 get 请求,通过 Promise.race() 来调用。这样更快的那个请求会先返回,从而知道是默认源还是淘宝源速度更快。3.如果淘宝源速度更快,向用户提示是否切换到淘宝源。4.如果用户选择淘宝源,则调用 await execa(command, ['config', 'set', 'registry', registries.taobao]) 将当前 npm 的源改为淘宝源,即 npm config set registry https://registry.npm.taobao.org。如果是 yarn,则命令为 yarn config set registry https://registry.npm.taobao.org

一点疑问

其实 vue-cli 是没有这段代码的:

  1. // 注册淘宝源
  2. if (useTaobaoRegistry) {
  3. await execa(command, ['config', 'set', 'registry', registries.taobao])
  4. }

这是我自己加的。主要是我没有在 vue-cli 中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件 .npmrc 是可以更改默认源的,如果在 .npmrc 文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 肯定不会去读取 .vuerc 的配置来决定是否使用淘宝源。

对于这一点我没搞明白,所以在用户选择了淘宝源之后,手动调用命令注册一遍。

将项目功能保存为默认配置

如果用户创建项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:

null

询问用户是否将这次的项目选择保存为默认配置,如果用户选择是,则弹出下一个提示语:

null

让用户输入保存配置的名称。

这两句提示语相关的代码为:

  1. const otherPrompts = [
  2. {
  3. name: 'save',
  4. when: isManualMode,
  5. type: 'confirm',
  6. message: 'Save this as a preset for future projects?',
  7. default: false,
  8. },
  9. {
  10. name: 'saveName',
  11. when: answers => answers.save,
  12. type: 'input',
  13. message: 'Save preset as:',
  14. },
  15. ]

保存配置的代码为:

  1. exports.saveOptions = (toSave) => {
  2. const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
  3. for (const key in options) {
  4. if (!(key in exports.defaults)) {
  5. delete options[key]
  6. }
  7. }
  8. cachedOptions = options
  9. try {
  10. fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
  11. return true
  12. } catch (e) {
  13. error(
  14. `Error saving preferences: `
  15. + `make sure you have write access to ${rcPath}.\n`
  16. + `(${e.message})`,
  17. )
  18. }
  19. }
  20. exports.savePreset = (name, preset) => {
  21. const presets = cloneDeep(exports.loadOptions().presets || {})
  22. presets[name] = preset
  23. return exports.saveOptions({ presets })
  24. }

以上代码直接将用户的配置保存到 .mvcrc 文件中。下面是我电脑上的 .mvcrc 的内容:

  1. {
  2. "packageManager": "npm",
  3. "presets": {
  4. "test": {
  5. "features": [
  6. "babel",
  7. "linter"
  8. ],
  9. "eslintConfig": "airbnb",
  10. "lintOn": [
  11. "save"
  12. ]
  13. },
  14. "demo": {
  15. "features": [
  16. "babel",
  17. "linter"
  18. ],
  19. "eslintConfig": "airbnb",
  20. "lintOn": [
  21. "save"
  22. ]
  23. }
  24. },
  25. "useTaobaoRegistry": true
  26. }

下次再创建项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来创建项目。

至此,v2 版本的内容就介绍完了。

小结

由于 vue-cli 关于插件的源码我还没有看完,所以这篇文章只讲解前两个版本的源码。v3 版本等我看完 vue-cli 的源码再回来填坑,预计在 3 月初就可以完成。

如果你想了解更多关于前端工程化的文章,可以看一下我写的《带你入门前端工程》[10]。这里是全文目录:

1.技术选型:如何进行技术选型?2.统一规范:如何制订规范并利用工具保证规范被严格执行?3.前端组件化:什么是模块化、组件化?4.测试:如何写单元测试和 E2E(端到端) 测试?5.构建工具:构建工具有哪些?都有哪些功能和优势?6.自动化部署:如何利用 Jenkins、Github Actions 自动化部署项目?7.前端监控:讲解前端监控原理及如何利用 sentry 对项目实行监控。8.性能优化(一):如何检测网站性能?有哪些实用的性能优化规则?9.性能优化(二):如何检测网站性能?有哪些实用的性能优化规则?10.重构:为什么做重构?重构有哪些手法?11.微服务:微服务是什么?如何搭建微服务项目?12.Severless:Severless 是什么?如何使用 Severless?

参考资料

•vue-cli[11]

References

[1] vue-cli: https://github.com/vuejs/vue-cli
[2] mini-cli: https://github.com/woai3c/mini-cli
[3] commander.js: https://github.com/tj/commander.js/blob/master/Readme\_zh-CN.md
[4] npm link: https://docs.npmjs.com/cli/v7/commands/npm-link
[5] Inquirer.js: https://github.com/SBoudrias/Inquirer.js/
[6] globby: https://github.com/sindresorhus/globby
[7] ejs: https://github.com/mde/ejs
[8] vue-codemod: https://github.com/vuejs/vue-codemod
[9] execa: https://github.com/sindresorhus/execa
[10] 《带你入门前端工程》: https://github.com/woai3c/introduction-to-front-end-engineering
[11] vue-cli: https://github.com/vuejs/vue-cli

c9b98fa65f0b91c8e9b80db0207654f6.png

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

932f3d461ae8dfdb1be6659329ae6925.png

发表评论

表情:
评论列表 (有 0 条评论,349人围观)

还没有评论,来说两句吧...

相关阅读

    相关 手把手一个脚手架

    最近在学习 vue-cli\[1\] 的源码,获益良多。为了让自己理解得更加深刻,我决定模仿它造一个轮子,争取尽可能多的实现原有的功能。 我将这个轮子分成三个版本: 1.尽

    相关 手把手链表

    ![在这里插入图片描述][20201104144843252.png_pic_center] 本文只讲逻辑,源码参考本文之下的五篇博客(关注后可以往下找) ![在这里插入

    相关 手把手vue插件

    组件与插件的区别 组件: 对某功能或某模块的封装(例如 我们写的弹窗,loading) 插件: 对一系列组件的封装(例如 vuex,vue-router) 关系: