简介

本文会从零开始配置一个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. OpenHarmony标准设备应用开发(三)——分布式数据管理

    (以下内容来自开发者分享,不代表 OpenHarmony 项目群工作委员会观点) 邢碌 上一章,我们通过分布式音乐播放器.分布式炸弹.分布式购物车,带大家讲解了 OpenAtom OpenHarmon ...

  2. ES 文档与索引介绍

    在之前的文章中,介绍了 ES 整体的架构和内容,这篇主要针对 ES 最小的存储单位 - 文档以及由文档组成的索引进行详细介绍. 会涉及到如下的内容: 文档的 CURD 操作. Dynamic Mapp ...

  3. C#/VB.NET 将RTF转为HTML

    RTF文档即富文本格式(Rich Text Format)的文档.我们在处理文件时,遇到需要对文档格式进行转换时,可以将RTF转为其他格式,如转为DOCX/DOC.PDF或者HTML,以满足程序设计需 ...

  4. 非关系型数据库Nosql的优缺点分析

    Nosql的全称是Not Only Sql,Nosql指的是非关系型数据库,而我们常用的都是关系型数据库.就像我们常用的mysql,oralce.sqlserver等一样,这些数据库一般用来存储重要信 ...

  5. [笔记] $f(i)$ 为 $k$ 次多项式,$\sum_{i=0}^nf(i)\cdot q^i$ 的 $O(k\log k)$ 求法

    \(f(i)\) 为 \(k\) 次多项式,\(\sum_{i=0}^nf(i)\cdot q^i\) 的 \(O(k\log k)\) 求法 令 \(S(n)=\sum_{i=0}^{n-1}f(i ...

  6. viewport布局

    1.viewport实例 <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <h ...

  7. p2p-tunnel 打洞内网穿透系列(二)TCP转发访问内网共享文件夹

    系列文章 p2p-tunnel 打洞内网穿透系列(一)客户端配置及打洞 p2p-tunnel 打洞内网穿透系列(二)TCP转发访问远程共享文件夹 p2p-tunnel 打洞内网穿透系列(三)TCP转发 ...

  8. crontab和cron表达式详解

    引言 我们在定时任务中经常能接触到cron表达式,但是在写cron表达式的时候我们会遇到各种各样版本的cron表达式,比如我遇到过5位.6位甚至7位的cron表达式,导致我一度搞混这些表达式.更严重的 ...

  9. 实验:Python图形图像处理

    1. 准备一张照片,编写Python程序将该照片进行图像处理,分别输出以下效果的图片:(a)灰度图:(b)轮廓图: (c)变换RGB通道图:(d)旋转45度图. 2. 假设当前文件夹中data.csv ...

  10. logging日志模块详细,日志模块的配置字典,第三方模块的下载与使用

    logging日志模块详细 简介 用Python写代码的时候,在想看的地方写个print xx 就能在控制台上显示打印信息,这样子就能知道它是什么 了,但是当我需要看大量的地方或者在一个文件中查看的时 ...