手把手开发自己的配置生成器

手把手开发自己的配置生成器

本文基于 Plop 开发配置生成器,专注于核心功能的开发,毕竟目标仅是支持个人或小团体的配置器,有现成稳定的命令行工具包当然是首选!

原因

场景一:ESLint 和 Prettier 配置项目的代码检查和代码格式化,但存在配置冲突,如何配置才是最好的解决方式(非 eslint-config-prettier 关闭错误)?
场景二:Vue2.7 和 Vue3 都支持 hooks 开发,在首选 hooks 模式下,如何配置可以让 Vue2.7 开发的同时不丢失 Vue2 旧 api 的错误修复(如 $listeners 等)?
场景三:个人开发一个 npm 包时需要完善 发布、测试、代码检查、代码格式化、提交信息格式、[TypeScript] 等一系列工具配置,是否可以无脑安装依赖且直接生成一套标准配置?

通过上方不同场景我们来针对其中遇到的问题来进行开发本次的配置生成器。

也许有人会觉得上方的问题都不是问题,可能有些场景是可以按照官方或社区方案简单配置即可解决,但架不住每次遇到同样场景但不同问题时都需要去搜寻信息,若是有一次维护终身质保的工具岂不妙哉?

目标

本文将通过维护不同配置模板解决上述场景问题。

功能

  • 针对不同工具或场景生成自己的配置模板
  • 可以自动安装相关依赖

Plop 简介

Plop 官方文档

简单来说就是一个基于 Handlebars 生成特定文件类型的小工具,详细的大家可以尝试自行搜索,不再赘述。

开始

初始化项目

创建一个空目录且初始化 package.json 文件,本人习惯用 pnpm,后续所有命令都基于 pnpm 执行,其他工具也大差不差。

安装依赖

我们将使用 TypeScript 进行开发,优点不必多说,接下来安装将会用到的依赖。

shell 复制代码
pnpm i -D typescript lodash-es @types/lodash-es @types/node node-plop plop @antfu/install-pkg execa local-pkg vite vite-plugin-dts

调整 @antfu/install-pkg、execa、local-pkg 为生产依赖,不参与打包。 调整 plop 为预装依赖,不参与打包。

json 复制代码
{
  // ...
  "peerDependencies": {
    "plop": ">=3"
  },
  "dependencies": {
    "@antfu/install-pkg": "^0.1.1",
    "execa": "^7.2.0",
    "local-pkg": "^0.4.3"
  },
  "devDependencies": {
    "@types/lodash-es": "^4.17.8",
    "@types/node": "^20.4.9",
    "lodash-es": "^4.17.21",
    "node-plop": "^0.31.1",
    "plop": "^3.1.2",
    "typescript": "^5.1.6",
    "vite": "^4.4.9",
    "vite-plugin-dts": "^3.5.1"
  }
}

vite.config.ts

可能有人看到这里就蒙了,这不是构建前端的吗,为何要装这个?

这里简单说下之前开发的过程,原先采用 unbuild 的 mkdist 构建,后来发现配置的别名无效,且项目内部还需要模糊匹配多个文件,难免还需要类似 fast-glob 的工具,想到构建现代 JS 库可能 Rollup 更合适,但又需要自己额外配置各种插件,如此直接使用 vite 的库模式构建更省时省力而且熟悉好用,文档也更加完善,所以就是他了😁。

ts 复制代码
import type { UserConfig } from 'vite'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
import pkg from './package.json'
import { resolve } from 'node:path'
import { uniq, keys } from 'lodash-es'

function genExternals() {
  return uniq([
    /^node(:.+)?$/,
    ...keys(pkg.peerDependencies),
    ...keys(pkg.dependencies),
  ]) as any[]
}

export default defineConfig(() => {
  return {
    build: {
      // 开启库模式
      lib: {
        entry: resolve(__dirname, 'src/index.ts'),
      },
      rollupOptions: {
        // 外化生产环境依赖
        external: genExternals(),
        // 这里必须为数组,否则需填写 build.lib.name,自行选择即可
        output: [{
          // 仅构建 esm 格式,原因是部分依赖库仅有 esm 格式,不设为外化依赖参与构建后会出错,如:execa
          // 若是宿主端需要在 .cjs 文件内使用时推荐安装 @esbuild-kit/cjs-loader 进行处理
          format: 'esm',
          // 保留模块结构,可选
          // 后续将创建 templates 目录存放所有模板,可以保持可以目录结构
          preserveModules: true,
          // 模块结构的根目录
          preserveModulesRoot: resolve(__dirname, 'src'),
          // 保持目录结构时非外化库的依赖更改为同名目录下,否则会出现 node_modules 目录
          entryFileNames: (info) =>
            `${/node_modules/.test(info.name) ? info.name.split('node_modules/').at(-1)! : '[name]'}.js`,
        }]
      },
    },
    define: {
      // esm 格式下没有 __dirname,这里将用 import.meta.url 填充,
      // 然后在 utils/paths.ts 内导出获取真实目录名的函数
      __dirname: 'import.meta.url',
    },
    esbuild: {
      // 构建时移除下列代码
      drop: ['console', 'debugger'],
    },
    resolve: {
      // 定义路径别名
      alias: {
        '@/': resolve(__dirname, 'src') + '/'
      }
    },
    // 构建库的类型文件
    plugins: [dts({ copyDtsFiles: true, outDir: resolve(__dirname, 'dist'), include: ['src'] })],
  } as UserConfig
})

tsconfig.json

初始化 tsconfig.json,可自行配置。

jsonc 复制代码
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ESNext"],
    "types": ["vite/client"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitAny": false,

    "noFallthroughCasesInSwitch": true,

    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts"]
}

编码

  • 预设使用方式是安装此依赖后,创建 plopfile.js 文件并调用我们的安装函数完成安装,所以先创建 src/index.ts 并导出对应函数。
ts 复制代码
// src/index.ts
export function setupConfigGenerators() {
    // code...
}
  • 定义相关类型

使用了 TypeScript 就要体现它的作用,这里我们先定义需要用到的相关接口类型。

ts 复制代码
// src/types.ts
import type { Actions, PromptQuestion } from 'node-plop'

/**
 * 配置生成器的配置项
 */
export interface ConfigGeneratorOptions {
  /**
   * 是否自动安装依赖
   * @default false
   */
  autoInstall?: boolean
  /**
   * 自动安装依赖时的可选参数
   */
  additionalArgs?: string[]
}

/**
 * 配置模板元数据
 */
export interface TemplateMetadata {
  /**
   * 模板名称,用于选择生成哪个模板
   */
  name: string
  /**
   * 模板描述,用于选择时描述当前模板,不存在时显示模板名称
   */
  description?: string
  /**
   * 当前模板需要安装的相关依赖,支持函数形式,根据问题答案返回需要安装的依赖
   */
  deps?: string[] | ((answers: any) => string[])
  /**
   * 安装依赖时的可选参数
   */
  additionalArgs?: string[]
  /**
   * 当前模板的问题
   */
  prompts?: PromptQuestion[]
  /**
   * 当前模板的 `Plop` 操作
   */
  actions?: Actions

  /**
   * 依赖安装完成时的回调
   */
  onInstalled?: (answers: any) => void
}
  • 完善入口文件
ts 复制代码
// src/index.ts
import type { NodePlopAPI, PromptQuestion } from 'node-plop'
import type { ConfigGeneratorOptions, TemplateMetadata } from './types'
import { isBoolean, isFunction, uniq } from 'lodash-es'
import { installPackage } from '@antfu/install-pkg'

export function setupConfigGenerators(plop: NodePlopAPI, options?: ConfigGeneratorOptions) {
  // 获取模板元数据集合
  const templates = Object.values(
    // 借助 vite 的模糊导入模板
    import.meta.glob<TemplateMetadata>('./templates/**/metadata.ts', {
      // 只导入 default 内容
      import: 'default',
      // 同步导入
      eager: true,
    }),
  )

  // 没有模板时发出警告,可自行替换输出方式
  if (templates.length === 0) return console.log('[warn]: 没有找到模板。')

  // 注册生成器,名称叫做 config-generator,可自定义
  plop.setGenerator('config-generator', {
    // 添加描述
    description: '生成各种场景下的配置文件。',
    // 注册问题
    prompts: [
      {
        // name 值会赋给答案,这里最后可通过 answers.type 获取
        name: 'type',
        message: '选择模板配置类型:',
        // 单选列表
        type: 'list',
        // 根据模板元数据生成列表
        choices: templates.map(({ name, description }) => {
          return { name: description || name, value: name }
        }),
      },
      // 注册模板的问题
      ...templates.reduce(
        (arr, item) =>
          arr.concat(
            (item.prompts || []).map((_item) => ({
              ..._item,
              // 这里以模板 name 作为答案第一层级,模板内的问题答案均放于其内部
              name: `${item.name}.${_item.name}`,
              // 是否显示该问题
              when: async (answer) => {
                // 只有选中当前模板时才出现该问题
                let valid = answer.type === item.name

                // 如果选中当前模板且模板问题存在 `when` 时进行验证
                if (valid && _item.when != null) {
                  valid = isBoolean(_item.when)
                    ? _item.when
                    : isFunction(_item.when)
                    ? await _item.when(Object.assign({}, answer[answer.type]))
                    : valid
                }

                return valid
              },
            })),
          ),
        [] as PromptQuestion[],
      ),
      {
        name: 'autoInstall',
        message: '是否自动安装相关依赖?',
        type: 'confirm',
        default: false,
        when: (answers) => {
          // 如果配置项里设置了该属性,则直接跳过
          if (typeof options?.autoInstall !== 'undefined') return false

          // 如果模板没有相关依赖则跳过
          const template = templates.find(({ name }) => name === answers.type)
          if (!template?.deps) return false

          // 获取当前模板答案
          const data = Object.assign({}, answers[answers.type])
          const deps = isFunction(template.deps) ? template.deps(data) : template.deps

          // 如果没有相关依赖则跳过
          return deps.length > 0
        },
      },
    ],
    actions: (answers = {}) => {
      const { type } = answers
      // 获取选中的模板元数据
      const metadata = templates.find(({ name }) => name === type)
      if (!metadata) return []

      // 获取当前模板的问题答案
      const data = Object.assign({}, answers[type])

      // 是否自动安装相关依赖
      const autoInstall = typeof options?.autoInstall !== 'undefined' ? options?.autoInstall : answers.autoInstall
      if (autoInstall) {
        // 获取当前模板的依赖集合
        const deps = isFunction(metadata.deps) ? metadata.deps(data) : metadata.deps!

        // 使用 @antfu/install-pkg 安装开发依赖
        // Actions 无法设置为异步模式,但 installPackage 内部会默认继承当前 stdio
        installPackage(deps, {
          // 开发依赖
          dev: true,
          // 安装时的可选参数
          additionalArgs: uniq([
            ...(metadata.additionalArgs || []),
            ...(options?.additionalArgs || []),
          ]),
        }).then(() => {
          // 安装成功后执行模板回调
          metadata.onInstalled?.(data)
        })
      }

      // 获取模板的操作集合
      const actions = isFunction(metadata.actions) ? metadata.actions(data) : metadata.actions
      return actions || []
    },
  })
}
  • 添加 __dirname 处理函数
ts 复制代码
// src/utils.ts
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

export function $dir(url: string) {
  return dirname(fileURLToPath(url))
}

场景问题解决

场景一、二:添加 ESLint 和 Prettier 模板。

  • ESLint 模板
ts 复制代码
// src/templates/eslint/metadata.ts
import { resolve } from 'node:path'
import { cwd } from 'node:process'
import { getPackageInfoSync, isPackageExists } from 'local-pkg'
import { TemplateMetadata } from '@/types'
import { $dir } from '@/utils'

export default {
  name: 'eslint',
  description: 'ESLint 配置',
  // 相关依赖
  // @antfu/eslint-config 是 antfu 个人针对 eslint 的配置,其中集成了 ts、vue、自定义配置,可以根据个人需求自由搭配
  // @antfu/eslint-config 是不推荐使用 prettier 的,但是某些情况下 prettier 确实很好用,所以可以在模板规则中尝试手动覆盖一些冲突规则和不合理规则,最终搭配 vscode 配置就能进行很好的集成了
  deps: ['eslint', 'eslint-define-config', '@antfu/eslint-config'],
  actions: () => {
    // 尝试获取 vue 信息,如果有则设置 vue 相关配置
    // 如果 vue 版本是 2.x,则关闭弃用的规则检查
    const vueInfo = getPackageInfoSync('vue')
    const data = {
      hasVue: !!vueInfo,
      isVue2: Number.parseInt(vueInfo?.version!) === 2,
      hasTS: isPackageExists('typescript'),
    }
    return [
      {
        type: 'add',
        // 读取当前目录下的 default.hbs 模板,在执行命令的根目录生成 .eslintrc.cjs 文件
        templateFile: resolve($dir(__dirname), 'default.hbs'),
        path: resolve(cwd(), '.eslintrc.cjs'),
        // 传入模板需要的数据
        data,
      },
    ]
  },
} as TemplateMetadata
json 复制代码
const { defineConfig } = require('eslint-define-config')

module.exports = defineConfig({
  root: true,
  extends: ['@antfu'],
  rules: {
    // 个人习惯的规则
    'array-element-newline': ['error', 'consistent'],
    'array-bracket-newline': ['error', 'consistent'],
    'function-paren-newline': ['error', 'consistent'],
    'arrow-parens': ['error', 'always'],
    'quote-props': ['error', 'as-needed'],
    'object-shorthand': ['off'],
    'space-before-blocks': 'error',
    'operator-linebreak': 'off', // prettier 冲突

    // antfu 不习惯的规则
    'antfu/if-newline': 'off', 
    'antfu/generic-spacing': 'off', // ts 泛型换行
    
{{!-- 如果有 vue --}}
{{#if hasVue}}

    // vue
    'vue/no-setup-props-destructure': 'error',
    'vue/component-name-in-template-casing': [
      'error',
      'PascalCase',
      { registeredComponentsOnly: false },
    ],
    'vue/max-attributes-per-line': ['error', { singleline: 1 }],
    'vue/singleline-html-element-content-newline': ['off'], // prettier 冲突
    {{!-- 如果版本是 vue2 --}}
    {{#if isVue2}}

    // vue2
    'vue/no-deprecated-destroyed-lifecycle': ['off'],
    'vue/no-deprecated-dollar-listeners-api': ['off'],
    'vue/no-deprecated-dollar-scopedslots-api': ['off'],
    'vue/no-deprecated-events-api': ['off'],
    'vue/no-deprecated-filter': ['error'],
    'vue/no-deprecated-functional-template': ['error'],
    'vue/no-deprecated-html-element-is': ['error'],
    'vue/no-deprecated-inline-template': ['error'],
    'vue/no-deprecated-props-default-this': ['warn'],
    'vue/no-deprecated-router-link-tag-prop': ['error'],
    'vue/no-deprecated-scope-attribute': ['error'],
    'vue/no-deprecated-slot-attribute': ['off'],
    'vue/no-deprecated-slot-scope-attribute': ['off'],
    'vue/no-deprecated-v-bind-sync': ['off'],
    'vue/no-deprecated-v-is': ['error'],
    'vue/no-deprecated-v-on-native-modifier': ['off'],
    'vue/no-deprecated-v-on-number-modifiers': ['off'],
    'vue/no-deprecated-vue-config-keycodes': ['error'],
    'vue/no-v-for-template-key-on-child': ['off'],
    'vue/require-slots-as-functions': ['off'],
    'vue/custom-event-name-casing': ['off'],
    'vue/no-multiple-template-root': ['error'],
    'vue/no-v-for-template-key': ['error'],
    'vue/no-v-model-argument': ['error'],
    'vue/valid-v-bind-sync': ['error'],
    {{/if}}
{{/if}}
{{!-- 如果有 TypeScript --}}
{{#if hasTS}}

    // ts
    '@typescript-eslint/ban-ts-comment': 'off',
    '@typescript-eslint/brace-style': 'off', // prettier 冲突
  },
{{/if}}
})
  • Prettier 模板
ts 复制代码
src/templates/prettier/metadata.ts
import { resolve } from 'node:path'
import { cwd } from 'node:process'
import { $dir } from '@/utils'
import { TemplateMetadata } from '@/types'

export default {
  name: 'prettier',
  description: 'Prettier 配置。',
  deps: ['prettier'],
  actions: [
    {
      type: 'add',
      templateFile: resolve($dir(__dirname), 'default.hbs'),
      path: resolve(cwd(), '.prettierrc'),
    },
  ],
} as TemplateMetadata

这里的配置项可以尽量完整,包括一些与默认值相匹配的,容易在项目中很明显的进行维护。

json 复制代码
{
  "arrowParens": "always",
  "bracketSameLine": false,
  "bracketSpacing": true,
  "printWidth": 100,
  "semi": false,
  "singleAttributePerLine": true,
  "trailingComma": "all",
  "tabWidth": 2,
  "useTabs": false,
  "singleQuote": true,
  "htmlWhitespaceSensitivity": "css",
  "jsxSingleQuote": false,
  "proseWrap": "preserve",
  "quoteProps": "as-needed",
  "vueIndentScriptAndStyle": false
}

场景三的问题这里就不体现了,有兴趣的朋友可以查看源码仓库,有更完整更多场景的模板,或者尝试安装 @rhao/plop-generators 进行生成。

构建并测试

构建

  • 添加 build 命令,执行 vite build 进行构建。

由于我们安装了 lodash-es 是一个 ESModule 库,而 vite 构建格式是基于 package.json 里的 type 字段,没有配置时认为是 CommonJS,所以构建会出错,这里需要加上 type 为 module。

shell 复制代码
# 运行构建命令
pnpm build

查看构建产物发现缺失静态 .hbs 文件,原因在于我们没有显示的导入,所以不构成依赖拓扑导致被忽略,添加一个简单脚本进行资源即可,也可自行处理,只需达到目的即可。

安装 fast-glob、fs-extra 依赖。

shell 复制代码
# npm-run-all 待会儿用于串联执行脚本
pnpm i -D fast-glob fs-extra @types/fs-extra npm-run-all
js 复制代码
// scripts/copy-static-files.mjs
import { resolve } from 'node:path'
import fs from 'fs-extra'
import fg from 'fast-glob'
import { cwd } from 'node:process'

const srcDir = resolve(cwd(), 'src')
const distDir = resolve(cwd(), 'dist')

const files = fg.globSync([
  '**/*.hbs',
], {
  cwd: srcDir,
  onlyFiles: true,
})

files.forEach((file) => {
  fs.copySync(resolve(srcDir, file), resolve(distDir, file))
})

改造 package.json 的脚本命令。

json 复制代码
{
  "type": "module",
  "scripts": {
    // 完整的构建命令
    "build": "run-s build:lib cp:static",
    // 仅构建库
    "build:lib": "vite build",
    // 复制静态文件
    "cp:static": "node scripts/copy-static-files.mjs",
    // 启动 plop 命令
    "plop": "plop"
  }
  // ...
}

再次运行 build 命令后会发现 dist 目录已经完整。

测试

上面我们也已经配好了 plop 命令,接下来根目录下创建 plopfile.mjs 安装我们开发的配置生成器,然后执行 plop 命令为我们当前的项目安装代码检查和格式化工具,并且生成相应的配置文件。

js 复制代码
// config-geneartors/plopfile.mjs
import { setupConfigGenerators } from './dist/index.js'

export default function (plop) {
  setupConfigGenerators(plop, {
    autoInstall: true
  })
}
shell 复制代码
pnpm plop

依次选择 ESLint 和 Prettier 两个配置项,完成后目录下已经的多了 .eslintrc 和 .prettierrc 文件即大功告成!

之后配合 VSCode 的 ESLint 和 Prettier 插件即可完美适配代码检查和格式化两个功能,且其配置项明显可见,不再有过于冗长的配置链路难以维护。

最后

最后只需要修改 package.json 里的内容即可在 npm 发布自己的配置生成器了,这里就不再赘述。如果有兴趣的朋友想了解其他配置模板(TypeScript、Release-It、Changesets、VSCode等)可查看下列仓库。

最后的最后,走过路过既是有缘,动动手指点个爱心❤️既是对作者最大的肯定,万分感谢🫡!

相关推荐
deli00715 分钟前
32K 上下文塞满后,AI 最先忘掉什么?120 轮对话实测
前端
deli0071 小时前
手机拍灯为什么有一条条黑纹?PWM 240Hz 实测
前端
wangruofeng6 小时前
AI Models 1.1.0 发布,查大模型价格、对比模型和找 API 地址
前端·aigc·ai编程
wangruofeng6 小时前
给前端开发者的 100 个设计参考网站
前端·设计
星栈9 小时前
视频即代码,Remotion 能做什么、不能做什么
前端·音视频开发
广州华水科技9 小时前
单北斗GNSS位移监测系统的应用与技术解析
前端
用户479492835691510 小时前
从一句话或代码仓到可交互 HTML:开源 Agent 画图技能 Archify v3.2 实测与选型边界
前端·后端·面试
风骏时光牛马12 小时前
生成一个AI多模态标题,仅输出一条标题,无额外文字、问句、备选、配图、文案等任何附加内容,不需要其他的任务服务 只要标题。
前端
wangruofeng12 小时前
从 GIF 到 AVIF:网页图片格式演进与 2026 年选型指南
前端·架构
清晨Coding13 小时前
为什么用 React Developer Tools 查看组件树这么乱?
前端·react.js