如何基于node搭建企业级vue3脚手架

211 阅读6分钟

脚手架的目的

在开展工作之前,我们要先弄清搭建一套脚手架的目的是什么。
肯定当然有同学要说,当然是让使用者快速上手啦,通过脚手架可以快速构建我们需要的前端工程,然后就可以开心的撸代码了。
一个好的脚手架肯定是需要满足自身的业务需求,是将整个团队的业务沉淀整合进去的。所以开发一个脚手架的目的其实就是将团队优秀的公共代码、架构,整合到一个模具中,让新的项目可以一比一去复刻,从而节省大量的搭建时间。

如何去开发一个脚手架

在我进行coding之前,去翻阅了公司老的脚手架,公司老的脚手架是基于vue-cli来进行开发的。其实一个脚手架无非就是做以下几件事

  • 依赖安装:修改package.json文件,安装项目依赖
  • 模板复制: 将脚手架中的模板复制到需要应用的项目中去
  • 文件修改: 新增/修改/删除指定文件代码

以上这些事情,vue-cli都已经做掉了,我们只需要通过调用vue-cli暴露的方法就可以做到以上操作。
但是由于vue3.0的原因,官方从新搭建了一套工程,不再推荐使用vue-cli,而且为了挑战自身不足,我选择了自己用node去手撸上面的功能,然后用自身造的轮子去完成脚手架的开发。
说白了,只要能做到以上3件事,就已经拥有了三把刀,拎着就可以出去狂砍一条街。

前期所依赖的npm包

一个工程的开发不可能全部自己造轮子,挑选合适的依赖包,可以帮助我们事半功倍。
我所挑选的Npm包基本都是从vue-cli那里抄来的,主要是方便😂

  • chalk 主要用于在命令行打印花里胡哨的字
  • commander 主要用于定义命令
  • inquirer 一个好的脚手架怎么可以缺少询问用户操作的步骤呢
  • jecast|@babel/paser 主要用于AST解析
  • zx 用于代码打包
  • semver 用于版本解析
  • javascript-stringify 用于解析文档
  • execa 执行命令时使用

项目目录

|-- root
     |-- .gitignore  
     |-- .npmrc
     |-- outfile.cjs  #打包产物 bin指向此文件   
     |-- package-lock.json
     |-- package.json    
     |-- README.md   #README文档
     |-- bin  #项目入口
     |   |-- index.js
     |-- command #命令集合
     |   |-- add.js
     |   |-- init.js
     |-- generator #具体执行操作集合
     |   |-- plugin-axios.js
     |   |-- plugin-commitlint.js
     |   |-- plugin-stylelint.js
     |   |-- plugin-ui.js
     |-- template #模板集合
     |   |-- plugin-normal
     |   |-- plugin-axios
     |   |-- plugin-commitlint
     |   |-- plugin-ui
     |-- utils #具体工具类
         |-- changePackage.js #修改package.json文件
         |-- checkEnv.js #环境检测
         |-- checkFileExist.js #判断文件是否存在
         |-- ensureEOL.js #换行编码
         |-- extendJSConfig.js #修改文件(ast)
         |-- getEntryFile.js #获取项目入口文件地址(main)
         |-- injectImports.js #在入口文件插入Import语句
         |-- insertStatements.js #在入口文件插入具体语句
         |-- install.js #依赖安装
         |-- loadModule.js #加载文件
         |-- log.js #打印封装
         |-- renderTemplate.js #复制模板文件(异步)
         |-- renderTemplateSync.js #复制模板文件(同步)
         |-- stringifyJS.js #stringify封装

bin/index入口文件

作为一个脚手架,用户肯定是 通过命令行工具来执行,通过执行脚手架具体的命令,来安装相应的依赖。
而作为整个脚手架的入口文件,bin/index主要的功能是定义脚手架命令。
具体就是通过commander来定义的。
**注意: 作为需要node来执行的文件 需要在顶部写清楚 #! /usr/bin/env node**

#! /usr/bin/env node

const semver = require('semver')
// 找到package.json中指定的node版本
const requiredVersion = require('../package.json').engines.node
// 命令行开发工具
const program = require('commander'); 
const { error, log } = require('../utils/log')
// 检测Node版本
function checkNodeVersion(wanted, id) {
    if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
        error(
            'You are using Node ' + process.version + ', but this version of ' + id +
            ' requires Node ' + wanted + '.\nPlease upgrade your Node version.'
        )
        process.exit(1)
    }
}
checkNodeVersion(requiredVersion, 'my-cli')

// 命令定义
program
    .version(`${require('../package').version}`, '-v, --version')
    .helpOption('-h,--help')
    .command('list')
    .description('查看所有可用的项目模板')
    .action(() => {
        log(`
            plugin-axios        axios请求模板
            plugin-commitlint   commitlint提交模板
            plugin-stylelint      stylelint模板
        `)
    })

program
    .command('add')
    .argument('[template]', '输入[template]插件名称去安装')
    .description('添加模板工程')
    .action((templateName) => {
        require('../command/add')(templateName)
    })

program
    .command('init')
    .description('初始化项目')
    .action(() => {
        require('../command/init')()
    })

program.on('--help', function () {
    log('****************');
    log('举例: npm init my-cli add axios');
    log('****************');
});
// 注意 这行不能缺少,这里是用来解析命令行参数
program.parse(process.argv)

command/add.js

这里封装了add命令的操作,add命令用于安装指定的包

const { error, log } = require('../utils/log')
const pluginAxios = require('../generator/plugin-axios')
const pluginCommitlint = require('../generator/plugin-commitlint')
const pluginStylelint = require('../generator/plugin-stylelint')
const switchTemplate = async (templateName) => {
    switch (templateName) {
        // 安装axios模板
        case 'axios':
        case 'plugin-axios': 
            await pluginAxios()
            break;
        // 安装commitlint
        case 'commitlint':
        case 'plugin-commitlint':
            await pluginCommitlint()
            break;
        // 安装stylelint
        case 'stylelint':
        case 'plugin-stylelint':
            await pluginStylelint()
            break;
        default:
            break;
    }
}
async function add (templateName) {
    if (templateName) {//添加指定模板
        switchTemplate(templateName)
    } else {//用户手动选择添加模板
        const inquirer = (await import('inquirer')).default;
        inquirer.prompt([{
            type: 'checkbox',
            message: '请选择您需要安装的插件',
            name: 'templatList',
            choices: [
                "plugin-axios",
                "plugin-commitlint",
                "plugin-sso"
            ],
        }]).then(({ templatList }) => {
            templatList.forEach(templateName => {
                switchTemplate(templateName)
            })
        })
    }
}
module.exports =  (...args) => {
    return add(...args).catch(err => {
        error(err)
        process.exit(1)
    })
}

举例:安装axios

下面以安装axios包举例,axios包需要做4件事情

  • 安装依赖,如axios、vueAxios包
  • 复制模板,将脚手架中的axios封装文件复制到项目中
  • 在main.ts中引入并使用上一步复制的文件
  • 修改vite.config.ts文件,将代理配置写入
module.exports = async (tempType) => {
    return new Promise(async (resolve) => {
        const inquirer = (await import('inquirer')).default;
        const path = require('path')
        const fs = require('fs')
        const { error, success } = require('../utils/log')
        const pkgChange = require('../utils/changePackage')
        const renderTemplate = require('../utils/renderTemplate')
        const injectImports = require('../utils/injectImports')
        const insertStatements = require('../utils/insertStatements')
        const install = require('../utils/install')
        const ensureEOL = require('../utils/ensureEOL')
        const getEntryFile = require('../utils/getEntryFile')
        // 修改package.json
        pkgChange({
            dependencies: {
                'axios': '^1.1.2',
                'vue-axios': '^3.4.1',
            }
        })

        //安装依赖
        await install()
        if (!tempType) {
            const { type } = await inquirer.prompt({
                type: 'list',
                message: '请选择模板类型',
                name: 'type',
                choices: [
                    {
                        name: 'ts(default)',
                        value: 'ts',
                    },
                    {
                        name: 'js',
                        value: 'js',
                    },
                ],
                default: true
            })
            tempType = type
        }

        // 复制template模板文件 分ts/js两个版本
        const templateRoot = path.resolve(__dirname, 'template', tempType, 'plugin-axios')
        renderTemplate(`${templateRoot}`, process.cwd(), err => {
            error(err)
        })

        // 修改vite.config.ts文件
        const configFilePath = `${process.cwd()}/vite.config.ts`
        const extendJSConfig = require('../utils/extendJSConfig')
        const configSource = fs.readFileSync(configFilePath)
        const changeSource = extendJSConfig({
            server: {
                proxy: {
                    '^/api': {
                        target: 'https://api.example.com',
                        ws: true,
                        changeOrigin: true,
                        rewrite: (path) => path.replace(/^\/api/, '')
                    },
                },
            },
            define: {
                'process.env': {
                    'BASE_API': ''
                }
            }
        }, configSource.toString())
        fs.writeFileSync(configFilePath, ensureEOL(changeSource))

        const entryFile = getEntryFile()
        // 入口插入import
        const importedFile = injectImports(entryFile, [
            'import initAxios from \'./plugins/initAxios\';',
        ])
        fs.writeFileSync(entryFile, ensureEOL(importedFile))

        // 入口添加调用
        const insertedFile = insertStatements(entryFile, [
            'initAxios(app)'
        ])
        fs.writeFileSync(entryFile, ensureEOL(insertedFile))
        success('plugin-axios安装成功!')
        resolve()
    })

如何添加依赖

changePackage方法是我定义的添加依赖方法,其实也就是去读取package.json文件,然后导出它暴露出的对象,然后修改这个对象再写回package.json


module.exports = async (extendsData) => {
    const { error } = require('../utils/log')
    const fs = require('fs');
    //根据命令行答询结果修改package.json文件
    fs.readFile(`./package.json`, 'utf8', function (err, data) {
        if (err) {
            error(`读取配置失败, ${err}`);
            return;
        }
        let pkg = JSON.parse(data)
        for (let key in extendsData) {
            if (pkg[key]) {
                pkg[key] = Object.assign(pkg[key], extendsData[key])
            } else {
                pkg[key] = extendsData[key]
            }
        }
        package = JSON.stringify(pkg, null, 4);
        fs.writeFile(`./package.json`, package, 'utf8', (err) => {
            if (err) {
                error(`修改配置失败, ${err}`);
                return;
            }
        });
    })
}

其中extendData是我们需要写入的配置,这里其实只是做了一下对象的简单合并。

如何安装依赖

我们修改了package.json,其实只需要运行install,程序就会自动安装依赖了。但是难点就在于我们的包管理工具有很多,需要去判断当前用户使用了哪种包管理工具,然后再去install
我这里集成了npm、pnpm、yarn三个工具的判断,首先通过尝试读取.lock文件,来判断。如果没有.lock文件,就尝试执行-v命令,来确定用户使用的哪种包管理命令。

const { hasYarn, hasProjectYarn, hasPnpm3OrLater, hasPnpmVersionOrLater, hasProjectPnpm, hasProjectNpm } = require('./checkEnv')
const execa = require('execa')
const chalk = require('chalk')
const readline = require('readline')

const PACKAGE_MANAGER_PNPM4_CONFIG = {
    install: ['install', '--reporter', 'silent', '--shamefully-hoist'],
    add: ['install', '--reporter', 'silent', '--shamefully-hoist'],
    upgrade: ['update', '--reporter', 'silent'],
    remove: ['uninstall', '--reporter', 'silent']
}
const PACKAGE_MANAGER_PNPM3_CONFIG = {
    install: ['install', '--loglevel', 'error', '--shamefully-flatten'],
    add: ['install', '--loglevel', 'error', '--shamefully-flatten'],
    upgrade: ['update', '--loglevel', 'error'],
    remove: ['uninstall', '--loglevel', 'error']
}
const PACKAGE_MANAGER_CONFIG = {
    npm: {
        install: ['install', '--legacy-peer-deps', '--loglevel', 'error'],
        add: ['install', '--loglevel', 'error'],
        upgrade: ['update', '--loglevel', 'error'],
        remove: ['uninstall', '--loglevel', 'error']
    },
    pnpm: hasPnpmVersionOrLater('4.0.0') ? PACKAGE_MANAGER_PNPM4_CONFIG : PACKAGE_MANAGER_PNPM3_CONFIG,
    yarn: {
        install: [],
        add: ['add'],
        upgrade: ['upgrade'],
        remove: ['remove']
    }
}

function toStartOfLine(stream) {
    if (!chalk.supportsColor) {
        stream.write('\r')
        return
    }
    readline.cursorTo(stream, 0)
}

function renderProgressBar(curr, total) {
    const ratio = Math.min(Math.max(curr / total, 0), 1)
    const bar = ` ${curr}/${total}`
    const availableSpace = Math.max(0, process.stderr.columns - bar.length - 3)
    const width = Math.min(total, availableSpace)
    const completeLength = Math.round(width * ratio)
    const complete = `#`.repeat(completeLength)
    const incomplete = `-`.repeat(width - completeLength)
    toStartOfLine(process.stderr)
    process.stderr.write(`[${complete}${incomplete}]${bar}`)
}

const executeCommand = (command, args, cwd) => {
    return new Promise((resolve, reject) => {
        const child = execa(command, args, {
            cwd,
            stdio: ['inherit', 'inherit', command === 'yarn' ? 'pipe' : 'inherit']
        })
        if (command === 'yarn') {
            child.stderr.on('data', buf => {
                const str = buf.toString()
                if (/warning/.test(str)) {
                    return
                }

                // progress bar
                const progressBarMatch = str.match(/\[.*\] (\d+)\/(\d+)/)
                if (progressBarMatch) {
                    // since yarn is in a child process, it's unable to get the width of
                    // the terminal. reimplement the progress bar ourselves!
                    renderProgressBar(progressBarMatch[1], progressBarMatch[2])
                    return
                }

                process.stderr.write(buf)
            })
        }
        child.on('close', code => {
            if (code !== 0) {
                reject(new Error(`command failed: ${command} ${args.join(' ')}`))
                return
            }
            resolve()
        })
    })
}

module.exports = async () => {
    let bin;
    const context = process.cwd()
    if (hasProjectYarn(context)) {
        bin = 'yarn'
    } else if (hasProjectPnpm(context)) {
        bin = 'pnpm'
    } else if (hasProjectNpm(context)) {
        bin = 'npm'
    }
    if (!bin) {
        bin = hasYarn() ? 'yarn' : hasPnpm3OrLater() ? 'pnpm' : 'npm'
    }

    const runCommand = async (command, args) => {
        await executeCommand(
            bin,
            [
                ...PACKAGE_MANAGER_CONFIG[bin][command],
                ...(args || [])
            ],
            context
        )
    }

    await runCommand('install')
}

模板的复制

这个操作其实就是去脚手架读取目录/文件,然后复制到新项目中去。这里有个路径的概念,在命令行执行的上下文环境,要访问的路径有两个

  • 脚手架路径,也就是__dirname
  • 项目路径,也就是process.cwd()
const templateRoot = path.resolve(__dirname, 'template', tempType, 'plugin-axios')
renderTemplate(`${templateRoot}`, process.cwd(), err => {
    error(err)
})

上面代码的意思是,你去脚手架找到plugin-axios这个目录,然后给我复制到新项目中相同路径的文件夹去,没有就创建。
而且我们知道,复制目录是一个递归的操作,因为我们不知道目录有多少层,所以代码如下:

/*
 * 复制目录、子目录,及其中的文件
 * @param src {String} 要复制的目录
 * @param dist {String} 复制到目标目录
 */
// 异步
const fs = require('fs');
module.exports = renderTemplate = (src, dist, callback) => {
    fs.access(dist, function (err) {
        if (err) {
            // 目录不存在时创建目录
            fs.mkdirSync(dist);
        }
        _copy(null, src, dist);
    });

    function _copy(err, src, dist) {
        if (err) {
            callback(err);
        } else {
            fs.readdir(src, function (err, paths) {
                if (err) {
                    callback(err)
                } else {
                    paths.forEach(function (path) {
                        var _src = src + '/' + path;
                        var _dist = dist + '/' + path;
                        fs.stat(_src, function (err, stat) {
                            if (err) {
                                callback(err);
                            } else {
                                // 判断是文件还是目录
                                if (stat.isFile()) {
                                    fs.writeFileSync(_dist, fs.readFileSync(_src));
                                } else if (stat.isDirectory()) {
                                    // 当是目录是,递归复制
                                    renderTemplate(_src, _dist, callback)
                                }
                            }
                        })
                    })
                }
            })
        }
    }
}

这是个异步的操作,这里图省事,没有用Promise去封装,而且callback其实只是错误的捕捉,没有支持真正的reslove。如果需要支持真正的reslove,需要做一个cache缓存,递归Push(),结束Pop()。

如何修改vite.config.ts

修改vite.config.ts文件,要用到ast语法解析。与package.json文件的读写不同。vite.config.ts文件有import,也有export。我们需要的是将export导出的对象取出,然后修改这个对象,再写回去。

module.exports = function extendJSConfig (value, source) {
  const recast = require('recast')
  const parser = require("@babel/parser");
  const stringifyJS = require('./stringifyJS')
  let exportsIdentifier = null
  // const ast = recast.parse(source)
  const ast = parser.parse(source, {
    sourceType:'module'
  })
  recast.types.visit(ast, {
    visitExportDefaultDeclaration (path) {
      const { node } = path
      if (
        node.type === 'ExportDefaultDeclaration'
      ) {
        let theExports = node.declaration
        if (
          theExports.type === 'CallExpression' &&
          theExports.callee.type === 'Identifier' &&
          theExports.callee.name === 'defineConfig'
        ) {
          theExports = theExports.arguments[0]
        }
        if (theExports.type === 'ObjectExpression') {
          augmentExports(theExports)
        } else if (theExports.type === 'Identifier') {
          // do a second pass
          exportsIdentifier = theExports.name
        }
        return false
      }

      this.traverse(path)
    }
  })

  if (exportsIdentifier) {
    recast.types.visit(ast, {
      visitVariableDeclarator ({ node }) {
        if (
          node.id.name === exportsIdentifier &&
          node.init.type === 'ObjectExpression'
        ) {
          augmentExports(node.init)
        }
        return false
      }
    })
  }

  function augmentExports (node) {
    const valueAST = recast.parse(`(${stringifyJS(value, null, 2)})`)
    const props = valueAST.program.body[0].expression.properties
    const existingProps = node.properties
    for (const prop of props) {
      const isUndefinedProp =
        prop.value.type === 'Identifier' && prop.value.name === 'undefined'

      const existing = existingProps.findIndex(p => {
        return !p.computed && p.key.name === prop.key.name
      })
      if (existing > -1) {
        // replace
        existingProps[existing].value = prop.value

        // remove `undefined` props
        if (isUndefinedProp) {
          existingProps.splice(existing, 1)
        }
      } else if (!isUndefinedProp) {
        // append
        existingProps.push(prop)
      }
    }
  }

  return recast.print(ast).code
}

对于ast我也没咋研究,上面的代码是我从vue-cli里抄来的,具体操作就是ast解析文件后,找到export default语句,然后拿到对象。通过recast.parse将我们传入的配置也转为ast语句,最后写入解析后的ast对象。最后再转回来。

如何修改main.ts

与上面修改vite.config.ts差不多,也是通过ast来进行。这里vue-cli只是在文件顶部插入import语句。而我还需要在app.mount('#app')之前插入app.use(xxx)这些操作。所以我封装了两个方法,一个用来插入import,一个用来插入app.use(xxx)

const { runTransformation } = require('vue-codemod')

function injectImports ({source}, api, { imports }) {
    const j = api.jscodeshift
    // 1. 源代码转ast
    const root = j(source)
  
    const toImportAST = i => j(`${i}\n`).nodes()[0].program.body[0]
    const toImportHash = node => JSON.stringify({
        // 导入后指定的变量名
        specifiers: node.specifiers.map(s => s.local.name),
        // 路径
        sourceValue:  node.source.value,
        // 引入的包名
        source: node.source.raw
    })
    // 2. 查找Import语句
    const declarations = root.find(j.ImportDeclaration)
    // 3. 将语句中 变量/导入的包名 解析出来
    const importSet = new Set(declarations.nodes().map(toImportHash))
    // 4. 去重
    const nonDuplicates = node => !importSet.has(toImportHash(node))
    // 5. 指定需要插入的Import语句
    const importASTNodes = imports.map(toImportAST).filter(nonDuplicates)
    // 判断 如果之前源文件就存在import语句 则插入到最后一个import语句后
    if (declarations.length) {
      declarations
        .at(-1)
        // a tricky way to avoid blank line after the previous import
        .forEach(({ node }) => delete node.loc)
        .insertAfter(importASTNodes)
    } else { // 否则插入到文件开头
      // no pre-existing import declarations
      root.get().node.program.body.unshift(...importASTNodes)
    }
    // 6. ast转回代码
    return root.toSource()
  }

  module.exports = (entryFile,imports) => {
    const fs = require('fs')
    const source = fs.readFileSync(entryFile, 'utf-8')
    return runTransformation(
      { path:entryFile, source },
      injectImports,
      { imports }
    )
  }
  
const { runTransformation } = require('vue-codemod')

function insertStatements ({source}, api, { statements }) {
    const j = api.jscodeshift
    // 源代码转ast
    const root = j(source)
  
    const toStatementsAST = i => j(`${i}\n`).nodes()[0].program.body[0]
    // 查找Statement语句
    const declarations = root.find(j.ExpressionStatement)
    //  指定需要插入语句
    const statementASTNodes = statements.map(toStatementsAST)
    // 判断 如果之前源文件就存在import语句 则插入到最后一个import语句后
    if (declarations.length) {
      declarations
        .filter(({ node }) => {
          return node.expression.arguments[0].value === '#app'
        })
        // a tricky way to avoid blank line after the previous import
        .forEach(({ node }) => delete node.loc)
        .insertBefore(statementASTNodes)
    }
    //  ast转回代码
    return root.toSource()
  }

  module.exports = (entryFile,statements) => {
    const fs = require('fs')
    const source = fs.readFileSync(entryFile, 'utf-8')
    return runTransformation(
      { path:entryFile, source },
      insertStatements,
      { statements }
    )
  }
  

上面两个函数的整体逻辑都是差不多的,具体区别只是visit的语句不同,以及相关的If判断条件的区别。

项目模板

image.png
项目模板就是放在脚手架的文件,它里面的内容其实就和我们平时开发的文件一样。以initAxios为例,这里就是简单的初始化操作

import axios from './axios'
import VueAxios from 'vue-axios';
import api from '../api'
import type { App } from 'vue'
export default (app: App<Element>): void => {
    // 通过app.config.globalProperties.axios访问
    app.use(VueAxios, axios)
    // 通过inject('$api')访问api
    app.provide('$api', api)
    // 通过inject('$axios')访问axios
    app.provide('$axios', axios)
    // 暴露为全局方法,可通过this.$api访问
    app.config.globalProperties.$api = api;
}

总结

上面就是如何简单的封装一个脚手架,作为一个大型的前端团队,肯定不能各自为战。各个小组用各个小组的一套去搭建自己的项目,五花八门,这是极度不利于后期维护的。所以封装一个企业级的脚手架是很有必要的。
目前vue3.0官方使用了vite,不再推荐使用vue cli,因为vue-cli是基于webpack来集成的。但是目前官方也没有推出一套标准的脚手架解决方案,因此搭建自己的脚手架也是迫在眉睫。
脚手架其实没有听起来这么唬人,其实也就是做了简单的文件复制/修改等操作,让我们搭建的项目结构都是一样的,这有利于团队间的合作开发。