简介

本文会从零开始配置一个monorepo类型的组件库,包括规范化配置、打包配置、组件库文档配置及开发一些提升效率的脚本等,monorepo 不熟悉的话这里一句话介绍一下,就是在一个git仓库里包含多个独立发布的模块/包。

ps.本文涉及到的工具配置其实在平时开发中一般都不需要自己配置,我们使用的各种脚手架都帮我们搞定了,但是我们至少得大概知道都是什么意思以及为什么,说来惭愧,笔者作为一个三四年工龄的前端老人,基本没有自己动手配过,甚至没有去了解过,所以以下大部分工具都是笔者第一次使用,除了介绍如何配置也会讲到遇到的一些坑及解决方法,另外也会尽量去搞清楚每一个参数的意思及原理,有兴趣的请继续阅读吧~

使用lerna管理项目

首先每个组件都是一个独立的npm包,但是某个组件可能又依赖了另一个组件,这样如果这个组件有bug修改完后发布了新版本,需要手动到依赖它的组件里挨个进行升级再进行发布,这是一个繁琐且效率不高的过程,所以可以使用leran工具来进行管理,lerna是一个专门用于管理带有多个包的JavaScript项目的工具,可以帮助进行npm发布及git上传。

首先全局安装lerna

npm i -g lerna

然后进入仓库目录执行:

lerna init

这个命令用来创建一个新的lerna仓库或者升级一个现有仓库的lerna版本,lerna有两种使用模式:

1.固定模式,默认固定模式下所有包的主版本号和次版本都会使用lerna.json配置里的version字段定义的版本号,如果某一次只修改了其中一个或几个包,但修改了配置文件里的主版本号或次版本号,那么发布时所有的包都会统一升级到该版本并进行发布,单个的包如果想要发布只能修改修订版本号进行发布;

2.独立模式就是每个包使用独立的版本号。

自动生成的目录如下:

可以看到没有.gitignore文件,所以手动创建一下,目前只需要忽略node_modules目录。

我们所有的包都会放在packages文件夹下,添加新包可以使用lerna create xxx命令(后面会通过脚本来生成),组件库推荐给包名增加一个统一的作用域scope,可以避免命名冲突,比如常见的@vue/xxx@babel/xxx等,npm2.0版本开始支持发布带作用域的包,默认的作用域是你的npm用户名,比如:@username/package-name,也可以使用npm config set @scope-name:registry http://reg.example.com 来给你使用的npm仓库关联一个作用域。

给包添加依赖可以使用lerna add module-1 --scope=module-2命令,表示将module-1安装到module-2的依赖里,learn检查到如果依赖的包是本项目中的会直接链接过去:

可以看到有个链接标志,lerna add默认也会执行lerna bootstrap的操作,即给所有的包安装依赖项。

当修改完成后需要发布时可以使用lerna publish命令,该命令会完成模块的发布及git上传工作,有个需要注意的点是带作用域的包使用npm发布时需要添加--access public参数,但是lerna publish不支持该参数,一个解决方法是在所有包的package.json文件里添加:

{
// ...
"publishConfig": {
"access": "publish"
}
}

规范化配置

eslint

eslint是一个配置化的JavaScript代码检查工具,通过该工具可以约束代码风格,以及检测一些潜在错误,做到在不同的开发者下能有一个统一风格的代码,常见的比如是否允许使用==、语句结尾是否去掉;等等,eslint的规则非常多,可以在这里查看https://eslint.bootcss.com/docs/rules/

eslint的所有规则都可单独配置是否开启,并且默认都是禁用的,所以如果要自己来挨个配置是比较麻烦的,但是它有个继承的配置,可以很方便的使用别人的配置,先来安装一下:

npm i eslint --save-dev

然后在package.json文件里加一个命令:

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

之后在命令行输入npm run lint:init 来创建一个eslint配置文件,根据你的情况回答完一些问题后就会生成一个默认配置,我生成的内容如下:

简单看一下各个字段的意思:

  • env字段用来指定你代码所要运行的环境,比如是在浏览器环境下,还是node环境下,不同的环境下所对应的全局变量不一样,因为后续还要写node脚本,所以把node:true也加上;

  • parserOptions表示所支持的语言选项,比如JavaScript的版本、是否启用JSX等,设置正确的语言选项可以让eslint确定什么是解析错误;

  • plugins顾名思义是插件列表,比如你使用的是react,那么需要使用react的插件来支持react的语法,因为我用的是vue,所以使用了vue的插件,可以用来检测单文件的语法问题,插件的命名规则为eslint-plugin-xxxx,配置时前缀可以省略;

  • rules就是规则配置列表,可以单独配置某个规则启用与否;

  • extends就是上文所说的继承,这里使用了官方推荐的配置以及vue插件顺带提供的配置,配置命名一般为eslint-config-xxx,使用时前缀也可以省略,并且插件也可以顺带提供配置功能,引入规则一般为plugin:plugin-name/xxx,此外也可以选择使用其他一些比较出名的配置如eslint-config-airbnb

.gitignore一样,eslint也可以创建一个忽略配置文件.eslintignore,每一行都是一个glob模式来表示哪些路径要忽略:

node_modules
docs
dist
assets

接下来再去package.json文件里加上运行检查的命令:

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

意思是检查当前目录下的所有文件,--fix表示允许eslint进行修复,但是能修自动复的问题很少,执行npm run lint,结果如下:

husky

目前只能手动去运行eslint检查,就算能约束自己每次提交代码前检查一下,也不一定能约束到其他人,没有强制的规范和没有规范没啥区别,所以最好在git提交前采取强制措施,这可以使用Husky,这个工具可以方便的让我们在执行某个git命令前先执行特定的命令,我们的需求是在git commit之前进行eslint检查,这需要使用pre-commit钩子,git还有很多其他的钩子:https://git-scm.com/docs/githooks

国际惯例,先安装:

npm i husky@4 --save-dev

然后在package.json文件里添加:

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

接着我尝试git commit,但是,没有效果。。。检查了nodenpmgit的版本,均没有问题,然后我打开git的隐藏文件夹.git/hooks

发现目前的这些钩子文件后面还是带着sample后缀,如果想要某个钩子生效,这个后缀要去掉才行,但是这种操作显然不应该让我手动来干,那么只能重装husky试试,经过简单的测试,我发现v5.x版本也是不行的,但是v3.0.0v1.1.1两个版本是生效的(笔者系统是windows10,可能和笔者电脑环境有关):

这样如果检查到有错误就会终止commit操作,不过目前一般还会使用另外一个包lint-staged,这个包顾名思义,只检查staged状态下的文件,其他本次提交没有变动的文件就不用检查了,这是合理的也能提高检查速度,先安装:npm i lint-staged --save-dev,然后去package.json里配置一下:

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

首先git钩子执行的命令改成lint-stagedlint-staged字段的值是个对象,对象的key也是glob匹配模式,value可以是字符串或字符串数组,每个字符串代表一个可执行的命令,如果lint-staged发现当前存在staged状态的文件会进行匹配,如果某个规则匹配到了文件那么就会执行这个规则对应的命令,在执行命令的时候会把匹配到的文件作为参数列表传给此命令,比如:exlint --fix xxx.js xxx.vue ...,所以上面配置的意思就是如果在已暂存的文件里匹配到了jsvue文件就执行eslint --fix xxx.js ... ,为啥命令不直接写npm run lint呢,因为lint命令里我们配置了./路径,那么仍将会检查所有文件。

执行效果如下,在上文的截图中可以看到一共有14个错误,但是本次我只修改了一个文件,所以只检查了这一个文件:

stylelint

stylelinteslint十分相似,只不过是用来检查css语法的,除了css文件,同时也支持scsslesscss预处理语言,stylelint可能没eslint那么流行,不过本着学习的目的,咱们也尝试一下,毕竟组件库肯定少不了写样式,依旧先安装:npm i stylelint stylelint-config-standard --save-devstylelint-config-standard是推荐的配置文件,和eslint-config-xxx一样,也可以拿来继承,不喜欢这个规则也可以换其他的,接着创建一个配置文件.stylelintrc,输入以下内容:

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

创建一个忽略配置文件.stylelintignore,输入:

node_modules

最后在package.json中添加一行命令:

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

检查packages目录下所有以cssless结尾的文件,并且可以的话自动进行修复,执行命令效果如下:

最后的最后和eslint一样,在git commit之前也加上自动进行检查,package.json文件修改如下:

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

commitlint

commit的内容对于了解一次提交做了什么来说是很重要的,git commit内容的标准格式其实是包含三部分的:HeaderBodyFooter,其中Header部分是必填的,但是说实话对于我来说Header部分都懒得认真写,更不用说其他几部分了,所以靠自觉不行还是上工具吧,让我们在gitcommit-msg钩子上加上对commit内容的检查功能,不符合规则就打回重写,安装一下校验工具commitlint

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

同样也是一个工具,一个配置,通过继承的方式来使用,严重怀疑这些工具的开发者都是同一批人,接下来创建一个配置文件commitlint.config.js,输入如下内容:

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

当然你也可以再单独配置你需要的规则,然后去package.jsonhusky部分配置钩子:

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

commitlint命令需要有输入参数,也就是我们输入的commit message-E参数的意思如下:

大意就是从环境变量里给定的文件里获取输入内容,这个环境变量看名字就知道是husky提供的,具体它是啥呢,咱们来简单看一下,首先打开.git/hooks/commit-msg文件,这个就是commit-msg钩子执行的bash脚本:

可以看到最后执行了run.js,参数分别为hookNamegitParamsbaseName "$0"代表当前执行的脚本名称,也就是文件名commit-msg"$*"代表所有的参数,run.js里又辗转反侧的最后调用了一个run方法:

function run([, scriptPath, hookName = '', HUSKY_GIT_PARAMS], getStdinFn = get_stdin_1.default) {
console.log('拦截', scriptPath, hookName, HUSKY_GIT_PARAMS) // ...
}

我们打印看一下参数都是啥:

可以看到HUSKY_GIT_PARAMS就是一个文件路径,这个文件里保存着我们这次输入的commit message的内容,接着husky会把它设置到环境变量里:

const env = {};
if (HUSKY_GIT_PARAMS) {
env.HUSKY_GIT_PARAMS = HUSKY_GIT_PARAMS;
}
if (['pre-push', 'pre-receive', 'post-receive', 'post-rewrite'].includes(hookName)) {
// Wait for stdin
env.HUSKY_GIT_STDIN = yield getStdinFn();
}
if (command) {
console.log(`husky > ${hookName} (node ${process.version})`);
execa_1.default.shellSync(command, { cwd, env, stdio: 'inherit' });
return 0;
}

现在再看commitlint -E HUSKY_GIT_PARAMS就很容易理解了,commitlint会去读取.git/COMMIT_EDITMSG文件内容来检查我们输入的commit message是否符合规范。

可以看到我们只输入了一个1的话就报错了。

commitizen

上面提到一个标准的commit message是包含三部分的,详细看就是这样的:

<type>(<scope>): <subject>
空行
<body>
空行
<footer>

当你输入git commit时,就会出现一个命令行编辑器让你来输入,但是这个编辑器很不好用,没用过的话怎么保存都是个问题,所以可以使用commitizen来进行交互式的输入,依次执行下列命令:

npm install commitizen -g

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

执行完后应该会自动在你的package.json文件里加上下列配置:

{
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

然后你就可以使用git cz命令来代替git commit命令了,它会给你一些选项,以及询问你一些问题,如实输入即可:

但是这样git commit命令仍然是可用的,文档上说可以进行如下配置来将git commit转换为git cz

{
"husky": {
"hooks": {
"prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
}
}
}

但是我尝试了不行,报系统找不到指定的路径。的错误,没找到原因和解决方法,如果你知道如何解决的话评论区见吧~强制不了,那只能加一句卑微的提示了:

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

规范化的暂且就配置这么多,其他的比如代码美化可以使用prettier、生成提交日志的可以使用conventional-changelogstandard-version,有需要的可以自行尝试。

打包配置

目前每个组件的结构都是类似下面这样的:

index.js返回一个带install方法的对象,作为vue的插件,使用这个组件的方式如下:

import ModuleX from 'module-x'
Vue.use(ModuleX)

组件库其实直接这么发布就可以了,如果js文件里使用了最新的语法,那么需要在使用该组件的项目里的vue.config.js里添加一下如下配置:

{
transpileDependencies: [
'module-x'
]
}

因为默认情况下 babel-loader 会忽略所有 node_modules 中的文件,添加这个配置可以让Babel 显式转译这个依赖。

不过如果你硬想要打包后再进行发布也是可以的,我们增加一下打包的配置。

先安装一下相关的工具:

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

因为比较多,就不挨个介绍了,应该还是比较清晰的,分别是用来解析样式文件、vue单文件、js文件及其他文件,可以根据你的实际情况增减。

先说一下打包目标,分别给每个包进行打包,打包结果输出到各自文件夹的dist目录下,我们使用webpacknode API来做:

// ./bin/buildModule.js

const webpack = require('webpack')
const path = require('path')
const fs = require('fs-extra')
const {
CleanWebpackPlugin
} = require('clean-webpack-plugin')
const {
VueLoaderPlugin
} = require('vue-loader') // 获取命令行参数,用来打包指定的包,否则打包packages目录下的所有包
const args = process.argv.slice(2) // 生成webpack配置
const createConfigList = () => {
const pkgPath = path.join(__dirname, '../', 'packages')
// 根据是否传入了参数来判断要打的包
const dirs = args.length > 0 ? args : fs.readdirSync(pkgPath)
// 给每个包生成一个webpack配置
return dirs.map((item) => {
return {
// 入口文件为每个包里的index.js文件
entry: path.join(pkgPath, item, 'index.js'),
output: {
filename: 'index.js',
path: path.resolve(pkgPath, item, 'dist'),// 打包删除到dist文件夹下
library: item,
libraryTarget: 'umd',// 打包成umd模块
libraryExport: 'default'
},
target: ['web', 'es5'],// webpack5默认打包生成的代码是包含const、let、箭头函数等es6语法的,所以需要设置一下生成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// 最新版本的file-loader默认使用es module的方式引入图片,最终生成的链接是个对象,所以如果是通过require方式引入图片就访问不了,可以通过该配置关掉
}
}
]
},
plugins: [
new VueLoaderPlugin(),
new CleanWebpackPlugin()
]
}
})
} // 开始打包
webpack(createConfigList(), (err, stats) => {
// 处理和结果处理...
})

然后运行命令node ./bin/buildModule.js 即可打所有的包,或者node ./bin/buildModule.js xxx xxx2 ...来打你指定的包。

当然,这只是最简单的配置,实际上肯定还会遇到很多特定问题,比如:

  • 如果依赖了其他基础组件库的话会比较麻烦,推荐这种情况就不要打包了,直接源码发布;

  • 寻找文件时缺少vue扩展名,那么需要配置一下webpackresolve.extensions

  • 使用了某些比较新的JavaScript语法或者用到jsx等,那么需要配置一下对应的babel插件或预设;

  • 引用了vuejquery等外部库,不可能直接打包进去,所以需要配置一下webpackexternals

  • 某个包可能有多个入口,换句话说也就是个别的包可能有特定的配置,那么可以在该包下面添加一个配置文件,然后上述生成配置的代码里可以读取该文件进行配置合并;

这些问题解决都不难,看一下报的错然后去搜索一下基本很容易就能解决,有兴趣的话也可以去本文的源码查看。

接下来做个小优化,因为webpack打包不是同时进行的,所以包的数量多了的话总时间就很慢,可以使用parallel-webpack这个插件来让它并行打包:

npm i parallel-webpack -D

因为它的api使用的是配置的文件路径,不能直接传递对象类型,所以需要修改一下上述的代码,改成导出一个配置的方式:

// 文件名改成config.js

// ...

// 删除
// webpack(createConfigList(), (err, stats) => {
// 处理和结果处理...
// }) // 增加导出语句
module.exports = createConfigList()

另外创建一个文件:

// run.js

const run = require('parallel-webpack').run
const configPath = require.resolve('./config.js') run(configPath, {
watch: false,
maxRetries: 1,
stats: true
})

执行node ./bin/run.js即可执行,我简单计时了一下,节省了大约一半的时间。

组件文档配置

组件文档工具使用的是VuePress,如果跟我一样遇到了webpack版本冲突问题,可以选择在./docs目录下单独安装:

cd ./docs
npm init
npm install -D vuepress

vuepress的基本配置很简单,使用默认主题按照教程配置即可,这里就不细说了,只说一下如何在文档里使用packages里的组件,先看一下当前目录结构:

config.js文件是vuepress的默认配置文件,打包选项、导航栏、侧边栏等等都在这里配置,enhanceApp是客户端应用的增强,在这里可以获取到vue实例,可以做一些应用启动的工作,比如注册组件等。

zh/rate是我添加的一个组件的文档,文档及示例内容都在文件夹下的README.md文件里,vuepressmarkdown做了扩展,所以在markdown文件里可以使用像vue单文件一样包含templatescriptstyle三个块,方便在文档里进行示例开发,组件需要先在enhanceApp.js文件里进行导入及注册,那么问题来了,我们是导入开发中的还是打包后的呢,小朋友才做选择,成年人都要,比如开发阶段我们就导入开发中的,开发完成了就导入打包后的,区别只是在于package.json里的main入口字段指向不同而已,比如我们先指向开发中的:

// package.json

{
"main": "index.js"
}

接下来去enhanceApp.js里导入及注册:

import Rate from '@zf/rate'

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

如果直接这样的话默认是会报错的,因为找不到这个包,此时我们的包也还没发布,所以也不能直接安装,那怎么办呢,办法应该有好几个,比如可以使用npm link来将包链接到这里,但是这样太麻烦,所以我选择修改一下vuepresswebpack配置,让它寻找包的时候顺便去找packages目录下找,另外也需要给@zf设置一下别名,显然我们的目录里并没有@zf,修改webpack的配置需要在config.js文件里操作:

const path = require('path')

module.exports = {
chainWebpack: (config) => {
// 我们包存放的位置
const pkgPath = path.resolve(__dirname, '../../../', 'packages')
// 修改webpack的resolve.modules配置,解析模块时应该搜索的目录,先去packages,再去node_modules
config.resolve
.modules
.add(pkgPath)
.add('node_modules')
// 修改别名resolve.alias配置
config.resolve
.alias
.set('@zf', pkgPath)
}
}

这样在vuepress里就可以正常使用我们的组件了,当你开发完成后就可以把这个包package.json的入口字段改成打包后的目录:

// package.json

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

其他基本信息、导航栏、侧边栏等可以根据你的需求进行配置,效果如下:

使用脚本新增组件

现在让我们来看一下新增一个组件都有哪些步骤:

1.给要新增的组件取个名字,然后使用npm search xxx来检查一下是否已存在,存在就换个名字;

2.在packages目录下创建文件夹,新建几个基本文件,通常来说是复制粘贴其他组件然后修改;

3.在docs目录下创建文档文件夹,新建README.md文件,文件内容一般也是通过复制粘贴;

4.修改config.js进行侧边栏配置(如果配置了侧边栏的话)、修改enhanceApp.js导入及注册组件;

这一套步骤下来虽然不难,但是繁琐,很容易漏掉某一步,上述这些事情其实特别适合让脚本来干,接下来就实现一下。

初始化工作

先在./bin目录下新建一个add.js文件,这个就是咱们要执行的脚本,首先它肯定要接收一些参数,简单起见这里只需要输入一个组件名,但是为了后续扩展方便,我们使用inquirer来处理命令行输入,接收到输入的组件名称后自动进行一下是否已存在的校验:

// add.js
const {
exec
} = require('child_process')
const inquirer = require('inquirer')
const ora = require('ora')// ora是一个命令行loading工具
const scope = '@zf/'// 包的作用域,如果你的包没有作用域,那么则不需要 inquirer
.prompt([{
type: 'input',
name: 'name',
message: '请输入组件名称',
validate(input) {
// 异步验证需要调用这个方法来告诉inquirer是否校验完成
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 => {
// 错误处理
});

执行后效果如下:

使用模板创建

接下来在packages目录下自动生成文件夹及文件,在【打包配置】一节中可以看到一个基本的包一共有四个文件:index.jspackage.jsonindex.vue以及style.less,首先在./bin目录下创建一个template文件夹,然后再新建这四个文件,基本内容可以先复制粘贴进去,其中index.jsstyle.less的内容不需要修改,所以直接复制到新组件的目录下即可:

// add.js

const upperCamelCase = require('uppercamelcase')// 字符串-风格的转驼峰
const fs = require('fs-extra') const templateDir = path.join(__dirname, 'template')// 模板路径 // 这个方法在上述inquirer的then方法里调用,参数为命令行输入的信息
const create = ({
name
}) => {
// 组件目录
const destDir = path.join(__dirname, '../', 'packages', name)
const srcDir = path.join(destDir, 'src')
// 创建目录
fs.ensureDirSync(destDir)
fs.ensureDirSync(srcDir)
// 复制index.js和style.less
fs.copySync(path.join(templateDir, 'index.js'), path.join(destDir, 'index.js'))
fs.copySync(path.join(templateDir, 'style.less'), path.join(srcDir, 'style.less'))
}

index.vuepackage.json内容的部分信息需要动态注入,比如index.vue的组件名、package.json的包名,我们可以使用一个很简单的库json-templater来以双大括号插值的方法来注入数据,以package.json为例:

// ./bin/template/package.json
{
"name": "{{name}}",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"author": "",
"license": "ISC"
}

name是我们要注入的数据,接下来读取模板的内容,然后注入并渲染,最后创建文件:

// add.js

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
}) => {
// 组件目录
// ...
// 创建package.json
renderTemplateAndCreate('package.json', {
name: scope + name
}, destDir)
// index.vue
renderTemplateAndCreate('index.vue', {
name: upperCamelCase(name)
}, srcDir)
}

到这里组件的目录及文件就创建完成了,文档的目录及文件也是一样,这里就不贴代码了。

使用AST修改

最后要修改的两个文件是config.jsenhanceApp.js,这两个文件虽然也可以向上面一样使用模板注入的方式,但是考虑到这两个文件修改的频率可能比较频繁,所以每次都得去模板里修改不太方便,所以我们换一种方式,使用AST,这样就不需要模板的占位符了。

先看enhanceApp.js,每增加一个组件,我们都需要在这里导入和注册:

import Rate from '@zf/rate'

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

思路很简单,把这个文件的源代码先转换成AST,然后在最后一个import语句后面插入新组件的导入语句,以及在最后一条Vue.use语句和console.log语句之间插入新组件的注册语句,最后再转换回源码写回到这个文件里,AST相关的操作可以使用babel的工具包:@babel/parser@babel/traverse@babel/generator@babel/types

@babel/parser

把源代码转换成AST很简单:

// add.js
const parse = require('@babel/parser').parse // 更新enhanceApp.js
const updateEnhanceApp = ({
name
}) => {
// 读取文件内容
const filePath = path.join(__dirname, '../', 'docs', 'docs', '.vuepress', 'enhanceApp.js')
const code = fs.readFileSync(filePath, {
encoding: 'utf-8'
})
// 转换成AST
const ast = parse(code, {
sourceType: "module"// 因为用到了`import`语法,所以指明把代码解析成module模式
})
console.log(ast)
}

生成的数据很多,所以命令行一般都显示不下去,可以去https://astexplorer.net/这个网站上查看,选择@babel/parser的解析器即可。

@babel/traverse

得到了AST树之后就需要修改这颗树,@babel/traverse用来遍历和修改树节点,这是整个过程中相对麻烦的一个步骤,如果不熟悉AST的基础知识和操作的话推荐先阅读一下这篇文档babel-handbook

接下来我们对着上面解析的截图来写一下添加import语句的代码:

// add.js
const traverse = require('@babel/traverse').default
const t = require("@babel/types")// 这个包是一个工具包,用来检测某个节点的类型、创建新节点等 const updateEnhanceApp = ({
name
}) => {
// ... // traverse的第一个参数是ast对象,第二个是一个访问器,当遍历到某种类型的节点后会调用对应的函数
traverse(ast, {
// 遍历到了Program节点会执行该函数
// 函数的第一个参数并不是节点本身,而是代表节点的路径,路径上会包含该节点和其他节点之间的关系信息,后续的一些操作也都是在路径上进行,如果要访问节点本身,可以访问path.node
Program(nodePath) {
let bodyNodesList = nodePath.node.body // 通过上图可以看到是个数组
// 遍历节点找到最后一个import节点
let lastImportIndex = -1
for (let i = 0; i < bodyNodesList.length; i++) {
if (t.isImportDeclaration(bodyNodesList[i])) {
lastImportIndex = i
}
}
// 构建即将要插入的import语句的AST节点:import name from @zf/name
// 节点类型及需要的参数可以在这里查看:https://babeljs.io/docs/en/babel-types
// 如果不确定使用哪个类型的话可以在上述的https://astexplorer.net/网站上看一下某个语句对应的是什么
const newImportNode = t.importDeclaration(
[ t.ImportDefaultSpecifier(t.Identifier(upperCamelCase(name))) ], // name
t.StringLiteral(scope + name)
)
// 当前没有import节点,则在第一个节点之前插入import节点
if (lastImportIndex === -1) {
let firstPath = nodePath.get('body.0')// 获取body的第一个节点的path
firstPath.insertBefore(newImportNode)// 在该节点之前插入节点
} else { // 当前存在import节点,则在最后一个import节点之后插入import节点
let lastImportPath = nodePath.get(`body.${lastImportIndex}`)
lastImportPath.insertAfter(newImportNode)
}
}
});
}

接下来看一下添加Vue.use的代码,因为生成的AST树太长了,所以不方便截图,大家可以打开上面的网站输入示例代码后看生成的结构:

// add.js

// ...

traverse(ast, {
Program(nodePath) {}, // 遍历到ExportDefaultDeclaration节点
ExportDefaultDeclaration(nodePath) {
let bodyNodesList = nodePath.node.declaration.body.body // 找到箭头函数节点的body,目前存在两个表达式节点
// 下面的逻辑和添加import语句的逻辑基本一致,遍历节点找到最后一个vue.use类型的节点,然后添加一个新节点
let lastIndex = -1
for (let i = 0; i < bodyNodesList.length; i++) {
let node = bodyNodesList[i]
// 找到vue.use类型的节点
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
}
}
// 构建新节点:Vue.use(name)
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 {// body为空的话需要调用`body`节点的pushContainer方法追加节点
let bodyPath = nodePath.get('declaration.body')
bodyPath.pushContainer('body', newNode)
}
} else {
let lastPath = nodePath.get(`declaration.body.body.${lastIndex}`)
lastPath.insertAfter(newNode)
}
}
});

@babel/generator

AST树修改完成接下来就可以转回源代码了:

//  add.js
const generate = require('@babel/generator').default const updateEnhanceApp = ({
name
}) => {
// ... // 生成源代码
const newCode = generate(ast)
}

效果如下:

可以看到使用AST进行简单的操作并不难,关键是要细心及耐心,找对节点。另外对config.js的修改也是大同小异,有兴趣的可以直接看源码。

到这里我们只要使用npm run add命令就可以自动化的创建一个新组件,可以直接进行组件开发了~

结尾

本文是笔者在改造自己组件库的一些过程记录,因为是第一次实践,难免会有错误或不合理的地方,欢迎指出,感谢阅读,再会~

示例代码仓库:https://github.com/wanglin2/vue_components

【万字长文】从零配置一个vue组件库的更多相关文章

  1. 利用webpack打包自己的第一个Vue组件库

    先说一下这篇文章的诞生原因.我们有一个这样的项目,类似或者说就是一个仪表板-Dashboard,其中的各个部分可能不是一个部门写的……我们需要提供拖拽布局(大小和位置)和展示的能力.要实现这样一个功能 ...

  2. 如何从0开发一个Vue组件库并发布到npm

    1.新建文件夹在终端打开执行 npm init -y 生成package.json如下,注意如果要发布到npm,name不能有下划线,大写字母等 { "name": "v ...

  3. 如何写好一个vue组件,老夫的一年经验全在这了【转】 v-bind="$attrs" 和 v-on="$listeners"

    如何写好一个vue组件,老夫的一年经验全在这了 一个适用性良好的组件,一种是可配置项很多,另一种就是容易覆写,从而扩展功能 Vue 组件的 API 来自三部分——prop.事件和插槽: prop 允许 ...

  4. 写一个vue组件

    写一个vue组件 我下面写的是以.vue结尾的单文件组件的写法,是基于webpack构建的项目.如果还不知道怎么用webpack构建一个vue的工程的,可以移步到vue-cli. 一个完整的vue组件 ...

  5. Laravel 项目中编写第一个 Vue 组件

    和 CSS 框架一样,Laravel 不强制你使用什么 JavaScript 客户端框架,但是开箱对 Vue.js 提供了良好的支持,如果你更熟悉 React 的话,也可以将默认的脚手架代码替换成 R ...

  6. 自己编写并发布一个Vue组件

    自己编写并发布一个Vue组件 1. 几种开源协议的介绍 https://blog.csdn.net/techbirds_bao/article/details/8785413 2.开始编写组件 新建p ...

  7. 一个 VUE 组件:实现子元素 scroll 父元素容器不跟随滚动(兼容PC、移动端)

    介绍 我们经常遇到一种情况.当滑动滚动条区域时,子元素滚动条到底部或顶部时就会触发父级滚动条,父级滚动条同理会继续向上触发,直至body容器.这是浏览器默认的滚动行为. 但是很多情况,我们想要子元素滚 ...

  8. 发布 Vant - 高效的 Vue 组件库,再造一个有赞移动商城也不在话下

    发布 Vant - 高效的 Vue 组件库,再造一个有赞移动商城也不在话下:https://segmentfault.com/a/1190000011377961 vantUI框架在vue项目中的应用 ...

  9. 使用VitePress搭建及部署vue组件库文档

    每个组件库都有它们自己的文档.所以当我们开发完成我们自己的组件库必须也需要一个组件库文档.如果你还不了解如何搭建自己的组件库可以看这里->从零搭建Vue3组件库.看完这篇文章你就会发现原来搭建和 ...

随机推荐

  1. Codeforces Round #133 (Div. 2), A.【据图推公式】 B.【思维+简单dfs】

    Problem - 216A - Codeforces Problem - B - Codeforces A Tiling with Hexagons 题意: 给出a b c ,求里面有多少个六边形 ...

  2. oracle split 以及 简单json解析存储过程

    BEGIN; 由于之前工作上需要在oracle中做split功能以及json格分解.然后经过一番google和优化整合,最后整理到一个存储过程包中,易于管理,代码如下: 1.包定义: CREATE O ...

  3. 条件表达式和switch语句

    一,条件表达式相当于(相当于if-else) 1.条件表达式 ? : 由条件运算符组成条件表达式的一般情况为: 表达式1?表达式2:表达式3: 求值规则:如果表达式的值为真,则以表达式2的值作为条件的 ...

  4. 深度优先搜索 DFS 学习笔记

    深度优先搜索 学习笔记 引入 深度优先搜索 DFS 是图论中最基础,最重要的算法之一.DFS 是一种盲目搜寻法,也就是在每个点 \(u\) 上,任选一条边 DFS,直到回溯到 \(u\) 时才选择别的 ...

  5. 前后端分离,简单JWT登录详解

    前后端分离,简单JWT登录详解 目录 前后端分离,简单JWT登录详解 JWT登录流程 1. 用户认证处理 2. 前端登录 3. 前端请求处理 4. 后端请求处理 5. 前端页面跳转处理 6. 退出登录 ...

  6. 【ACM程序设计】并查集

    并查集 并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题.一些常见的用途有:求连通子图.求最小生成树的Kruskal算法和求最近公共祖先( ...

  7. 关于Spring-JDBC测试类的简单封装

    关于Spring-JDBC测试类的简单封装 1.简单封装 /** * Created with IntelliJ IDEA. * * @Author: Suhai * @Date: 2022/04/0 ...

  8. Golang(go语言)开发环境配置

    VSCode开发环境配置 目录 VSCode开发环境配置 先到VSCode官网去下载适合自己系统的VSCode安装软件 演示在WIndows下 安装使用 演示在Linux(Ubuntu/centos) ...

  9. 忘带U盘了??别急!一行python代码即可搞定文件传输

    近日发现了python一个很有趣的功能,今天在这里给大伙儿做一下分享 需求前提 1.想要拷贝电脑的文件到另一台电脑但是又没有U盘2.手机上想获取到存储在电脑的文件3.忘带U盘- 您也太丢三落四了吧,但 ...

  10. 实战 | Linux根分区扩容

    一个执着于技术的公众号 一个执着于技术的公众号 前言 Linux系统作为服务器操作系统,经常遇到一个问题就是服务器分区磁盘空间不足需要扩容的情况.本文以linux系统最常见的发行版centos7系统为 ...