简介

本文将从零开始配置一个 monorepo 类型的组件库,涵盖规范化配置、打包配置、组件库文档配置以及开发效率提升脚本等内容。对于不熟悉 monorepo 的读者,这里简单介绍一下:monorepo 是指在一个 git 仓库中包含多个独立发布的模块/包。

PS:本文涉及到的工具配置在平时开发中通常不需要手动配置,各种脚手架已经帮我们完成了这些工作。但了解这些配置的含义和原理仍然很有价值。作为有多年开发经验的前端开发者,笔者也是第一次动手配置这些工具,因此除了介绍配置方法,还会分享遇到的问题及解决方案,并尽量解释每个参数的含义和原理。

使用 Lerna 管理项目

在组件库中,每个组件都是一个独立的 npm 包,但组件之间可能存在依赖关系。当某个组件修复 bug 并发布新版本后,需要手动升级依赖该组件的其他组件,这个过程既繁琐又低效。Lerna 就是专门用于管理多包 JavaScript 项目的工具,可以自动化处理 npm 发布和 git 上传。

初始化 Lerna

首先全局安装 Lerna:

bash

npm i -g lerna

然后进入仓库目录执行:

bash

lerna init

这个命令用于创建新的 lerna 仓库或升级现有仓库的 lerna 版本。Lerna 有两种使用模式:

  1. 固定模式(默认):所有包的主版本号和次版本号都使用 lerna.json 配置中的 version 字段

  2. 独立模式:每个包使用独立的版本号

初始化后生成的目录结构如下:

text

.
├── lerna.json
├── package.json
└── packages/

需要手动创建 .gitignore 文件,目前只需忽略 node_modules 目录。

包管理

所有包都放在 packages 文件夹下,可以使用 lerna create xxx 命令添加新包。对于组件库,推荐给包名添加统一的作用域(scope),避免命名冲突。

添加依赖可以使用 lerna add module-1 --scope=module-2 命令,这会将 module-1 安装到 module-2 的依赖中。如果依赖的包是项目内的,Lerna 会直接创建链接。

发布配置

发布时使用 lerna publish 命令,该命令会完成模块发布和 git 上传工作。需要注意的是,带作用域的包在 npm 发布时需要添加 --access public 参数,但 lerna publish 不支持该参数。解决方法是在所有包的 package.json 中添加:

json

{
  "publishConfig": {
    "access": "publish"
  }
}

规范化配置

ESLint

ESLint 是配置化的 JavaScript 代码检查工具,可以约束代码风格并检测潜在错误。

安装 ESLint:

bash

npm i eslint --save-dev

在 package.json 中添加初始化命令:

json

{
  "scripts": {
    "lint:init": "eslint --init"
  }
}

执行 npm run lint:init 创建 ESLint 配置文件,根据提示回答问题后生成配置。

创建 .eslintignore 文件:

text

node_modules
docs
dist
assets

在 package.json 中添加检查命令:

json

{
  "scripts": {
    "lint": "eslint ./ --fix"
  }
}

Husky

为了在 git 提交前强制进行代码检查,可以使用 Husky 工具。

安装 Husky:

bash

npm i husky@4 --save-dev

在 package.json 中配置:

json

{
  "husky": {
    "hooks": {
      "pre-commit": "npm run lint"
    }
  }
}

lint-staged

为了提高检查效率,只检查暂存区的文件,可以使用 lint-staged:

bash

npm i lint-staged --save-dev

配置 package.json

json

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{js,vue}": [
      "eslint --fix"
    ]
  }
}

Stylelint

Stylelint 是用于检查 CSS 语法的工具,支持 CSS 预处理语言。

安装 Stylelint:

bash

npm i stylelint stylelint-config-standard --save-dev

创建配置文件 .stylelintrc

json

{
  "extends": "stylelint-config-standard"
}

创建忽略文件 .stylelintignore

text

node_modules

添加检查命令:

json

{
  "scripts": {
    "style:lint": "stylelint packages/**/*.{css,less} --fix"
  }
}

在 lint-staged 中配置:

json

{
  "lint-staged": {
    "*.{css,less}": [
      "stylelint --fix"
    ]
  }
}

Commitlint

为了规范 commit 信息,可以使用 Commitlint 进行检查。

安装 Commitlint:

bash

npm i --save-dev @commitlint/config-conventional @commitlint/cli

创建配置文件 commitlint.config.js

javascript

module.exports = {
  extends: ['@commitlint/config-conventional']
}

配置 Husky:

json

{
  "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
}

Commitizen

Commitizen 提供交互式的 commit 信息输入。

全局安装 Commitizen:

bash

npm install commitizen -g

初始化:

bash

commitizen init cz-conventional-changelog --save-dev --save-exact

在 package.json 中添加提示:

json

{
  "husky": {
    "hooks": {
      "prepare-commit-msg": "echo ----------------please use [git cz] command instead of [git commit]----------------"
    }
  }
}

打包配置

组件库可以直接发布源码,但如果需要在项目中使用,需要在项目的 vue.config.js 中添加转译配置:

javascript

{
  transpileDependencies: [
    'module-x'
  ]
}

Webpack 配置

安装相关工具:

bash

npm i webpack less less-loader css-loader style-loader vue-loader vue-template-compiler babel-loader @babel/core @babel/cli @babel/preset-env url-loader clean-webpack-plugin -D

创建打包脚本 ./bin/buildModule.js

javascript

const webpack = require('webpack')
const path = require('path')
const fs = require('fs-extra')
const { CleanWebpackPlugin } = require('clean-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')

// 获取命令行参数
const args = process.argv.slice(2)

// 生成 webpack 配置
const createConfigList = () => {
  const pkgPath = path.join(__dirname, '../', 'packages')
  const dirs = args.length > 0 ? args : fs.readdirSync(pkgPath)
  
  return dirs.map((item) => {
    return {
      entry: path.join(pkgPath, item, 'index.js'),
      output: {
        filename: 'index.js',
        path: path.resolve(pkgPath, item, 'dist'),
        library: item,
        libraryTarget: 'umd',
        libraryExport: 'default'
      },
      target: ['web', 'es5'],
      module: {
        rules: [
          {
            test: /\.css$/,
            use: ['style-loader', 'css-loader']
          },
          {
            test: /\.less$/,
            use: ['style-loader', 'css-loader', 'less-loader']
          },
          {
            test: /\.vue$/,
            loader: 'vue-loader'
          },
          {
            test: /\.js$/,
            loader: 'babel-loader'
          },
          {
            test: /\.(png|jpe?g|gif)$/i,
            loader: 'url-loader',
            options: {
              esModule: false
            }
          }
        ]
      },
      plugins: [
        new VueLoaderPlugin(),
        new CleanWebpackPlugin()
      ]
    }
  })
}

// 开始打包
webpack(createConfigList(), (err, stats) => {
  // 处理和结果处理...
})

并行打包优化

使用 parallel-webpack 提高打包速度:

bash

npm i parallel-webpack -D

修改配置为导出模式:

javascript

// config.js
module.exports = createConfigList()

创建运行文件:

javascript

// run.js
const run = require('parallel-webpack').run
const configPath = require.resolve('./config.js')

run(configPath, {
  watch: false,
  maxRetries: 1,
  stats: true
})

组件文档配置

使用 VuePress 搭建组件文档,如果在项目根目录安装遇到 webpack 版本冲突,可以在 ./docs 目录下单独安装:

bash

cd ./docs
npm init
npm install -D vuepress

配置解析路径

在 VuePress 配置文件中修改 webpack 配置,使其能够解析 packages 目录下的组件:

javascript

// config.js
const path = require('path')

module.exports = {
  chainWebpack: (config) => {
    const pkgPath = path.resolve(__dirname, '../../../', 'packages')
    config.resolve
      .modules
      .add(pkgPath)
      .add('node_modules')
    config.resolve
      .alias
      .set('@zf', pkgPath)
  }
}

注册组件

在 enhanceApp.js 中注册组件:

javascript

import Rate from '@zf/rate'

export default ({ Vue }) => {
  Vue.use(Rate)
}

开发阶段使用源码入口:

json

{
  "main": "index.js"
}

发布阶段使用打包后入口:

json

{
  "main": "dist/index.js"
}

使用脚本新增组件

新增组件的传统步骤繁琐且容易遗漏,可以通过脚本自动化完成。

初始化脚本

创建 ./bin/add.js 文件:

javascript

const { exec } = require('child_process')
const inquirer = require('inquirer')
const ora = require('ora')
const scope = '@zf/'

inquirer
  .prompt([{
    type: 'input',
    name: 'name',
    message: '请输入组件名称',
    validate(input) {
      const done = this.async();
      input = String(input).trim()
      if (!input) {
        return done('请输入组件名称')
      }
      const spinner = ora('正在检查包名是否存在').start()
      exec(`npm search ${scope + input}`, (err, stdout) => {
        spinner.stop()
        if (err) {
          done('检查包名是否存在失败,请重试')
        } else {
          if (/No matches/.test(stdout)) {
            done(null, true)
          } else {
            done('该包名已存在,请修改')
          }
        }
      })
    }
  }])
  .then(answers => {
    // 处理输入结果
    console.log(answers)
  })
  .catch(error => {
    // 错误处理
  });

使用模板创建文件

在 ./bin/template 目录下创建模板文件,使用 json-templater 进行动态内容注入:

javascript

const upperCamelCase = require('uppercamelcase')
const render = require('json-templater/string')

const renderTemplateAndCreate = (file, data = {}, dest) => {
  const templateContent = fs.readFileSync(path.join(templateDir, file), {
    encoding: 'utf-8'
  })
  const fileContent = render(templateContent, data)
  fs.writeFileSync(path.join(dest, file), fileContent, {
    encoding: 'utf-8'
  })
}

const create = ({ name }) => {
  const destDir = path.join(__dirname, '../', 'packages', name)
  const srcDir = path.join(destDir, 'src')
  
  fs.ensureDirSync(destDir)
  fs.ensureDirSync(srcDir)
  
  // 复制固定文件
  fs.copySync(path.join(templateDir, 'index.js'), path.join(destDir, 'index.js'))
  fs.copySync(path.join(templateDir, 'style.less'), path.join(srcDir, 'style.less'))
  
  // 渲染模板文件
  renderTemplateAndCreate('package.json', {
    name: scope + name
  }, destDir)
  
  renderTemplateAndCreate('index.vue', {
    name: upperCamelCase(name)
  }, srcDir)
}

使用 AST 修改配置文件

使用 Babel 工具链解析和修改 enhanceApp.js 和 config.js

javascript

const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const t = require("@babel/types")
const generate = require('@babel/generator').default

const updateEnhanceApp = ({ name }) => {
  const filePath = path.join(__dirname, '../', 'docs', 'docs', '.vuepress', 'enhanceApp.js')
  const code = fs.readFileSync(filePath, { encoding: 'utf-8' })
  const ast = parse(code, { sourceType: "module" })
  
  traverse(ast, {
    Program(nodePath) {
      // 添加 import 语句
      let bodyNodesList = nodePath.node.body
      let lastImportIndex = -1
      for (let i = 0; i < bodyNodesList.length; i++) {
        if (t.isImportDeclaration(bodyNodesList[i])) {
          lastImportIndex = i
        }
      }
      
      const newImportNode = t.importDeclaration(
        [t.ImportDefaultSpecifier(t.Identifier(upperCamelCase(name)))],
        t.StringLiteral(scope + name)
      )
      
      if (lastImportIndex === -1) {
        let firstPath = nodePath.get('body.0')
        firstPath.insertBefore(newImportNode)
      } else {
        let lastImportPath = nodePath.get(`body.${lastImportIndex}`)
        lastImportPath.insertAfter(newImportNode)
      }
    },
    
    ExportDefaultDeclaration(nodePath) {
      // 添加 Vue.use 语句
      let bodyNodesList = nodePath.node.declaration.body.body
      let lastIndex = -1
      for (let i = 0; i < bodyNodesList.length; i++) {
        let node = bodyNodesList[i]
        if (
          t.isExpressionStatement(node) &&
          t.isCallExpression(node.expression) &&
          t.isMemberExpression(node.expression.callee) &&
          node.expression.callee.object.name === 'Vue' &&
          node.expression.callee.property.name === 'use'
        ) {
          lastIndex = i
        }
      }
      
      const newNode = t.expressionStatement(
        t.callExpression(
          t.memberExpression(
            t.identifier('Vue'),
            t.identifier('use')
          ),
          [t.identifier(upperCamelCase(name))]
        )
      )
      
      if (lastIndex === -1) {
        if (bodyNodesList.length > 0) {
          let firstPath = nodePath.get('declaration.body.body.0')
          firstPath.insertBefore(newNode)
        } else {
          let bodyPath = nodePath.get('declaration.body')
          bodyPath.pushContainer('body', newNode)
        }
      } else {
        let lastPath = nodePath.get(`declaration.body.body.${lastIndex}`)
        lastPath.insertAfter(newNode)
      }
    }
  });
  
  const newCode = generate(ast)
  fs.writeFileSync(filePath, newCode.code, { encoding: 'utf-8' })
}

通过以上脚本,只需运行 npm run add 命令即可自动化创建新组件,大大提高了开发效率。

总结

本文详细介绍了从零开始配置 monorepo 组件库的完整流程,包括项目结构管理、代码规范化、打包配置、文档搭建以及效率工具开发。通过合理的工具链配置和自动化脚本,可以显著提高组件库的开发效率和代码质量。

这些配置虽然在实际开发中通常由脚手架完成,但了解其原理和实现方式对于深入理解前端工程化具有重要意义。希望本文能为读者在前端工程化方面的学习提供有价值的参考。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐