虽然目前有很多vue组件库,比如Element-UI,iview等等。每个组件库都有各自的特点,但是每个公司需要的业务组件是不尽相同的,没有哪一个组件库能够非常完美的符合各种需求。比如我现在使用的是Element-UI,我需要使用到Select组件,但是现在有个需求需要进行分页加载,这个时候Element-UI的Select组件就不能适应这种业务场景了。所以我就想着开发一套属于自己的组件库,把平时常用的一些组件封装起来,集成到自己的组件库中,同时也能学会造轮子,提高自己的技术能力。
这里记录一下我从零开始搭建起来的组件库的过程。目前已经有了四十多个组件,后面会一直更新维护的。
github地址:github
演示地址:演示
首先使用vue-cli3新建一个项目,在新建项目的时候把babel
、jest
这些该选上的都选上。
项目新建完成之后,我们需要考虑的是改造项目的目录结构。我们需要一个目录存放组件,一个目录存放文档,一个目录进行开发测试。所以我们要对vue-cli3脚手架生成项目结构改造成如下:
我们需要在package.json添加一些字段
description
项目描述main
通过npm安装然后引用的文件入口module
ES Module 文件入口,rollup 打包需要的入口文件unpkg
npm 上所有的文件都开启 cdn 服务地址license
开源协议keywords
关键字,在npm上搜索包的时候会用上homepage
项目的主页repository
仓库地址files
发布npm包的时候需要进行上传的文件peerDependencies
使用这个包的时候需要预安装依赖dependencies
和devDependencies
的使用方式。当你在项目中使用npm进行开发,并且安装了这个依赖库。当你运行npm install
这个命令的时候,依赖包中dependencies
这个字段的依赖包进被安装下来,devDependencies
这个字段中的依赖包不会被安装下来。所依这2个字段需要慎重使用不能把所有的依赖包都安装进dependencies
这个字段中,否则用户在使用的时候会造成不必要下载和浪费时间。按照我们开发组件库的流程来说,一般如果有多个组件使用到同一个依赖包,比如lodash,这个就需要安装到dependencies
中。我们在实现按需加载的时候,把这个依赖包给排除掉,等用户使用的时候在进行打包加载。
{
"name": "lin-view-ui",
"version": "1.0.26",
"description": "vue components library",
"author": "c10342",
"private": false,
"main": "lib/index.js",
"module": "lib/index.js",
"unpkg": "lib/index.js",
"license": "MIT",
"keywords": [
"vue",
"UI",
"Component"
],
"homepage": "https://github.com/c10342/lin-view-ui",
"repository": {
"type": "git",
"url": "https://github.com/c10342/lin-view-ui"
},
"files": [
"lib",
"packages",
"utils",
"src"
],
"dependencies": {
"async-validator": "^3.4.0",
"deepmerge": "^4.2.2",
"flv.js": "^1.5.0",
"hls.js": "^0.14.12",
"lodash": "^4.17.20",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"vue": "^2.6.11"
},
"devDependencies": {
"@babel/runtime-corejs3": "^7.11.2",
...
}
}
"scripts": {
"serve": "vue-cli-service serve",
"build": "rimraf lib && npm run build:index && npm run build:components && npm run build:assets",
"build:assets": "parallel-webpack --config ./build/webpack.assets.js",
"build:index": "cross-env NODE_ENV=index vue-cli-service build --no-clean",
"build:components": "cross-env NODE_ENV=components vue-cli-service build --no-clean && node ./build/write.js",
"build:docs": "cross-env NODE_ENV=docs vue-cli-service build",
"test:unit": "vue-cli-service test:unit --watch",
"test": "vue-cli-service test:unit",
"upload:docs": "npm run build:docs && node ./build/qiniu.js",
"prepublishOnly": "npm run build && npm run upload:docs",
"pub": "node ./build/publish.js"
}
这里可以看见含有build命令的script脚本都添加了 --no-clean
这个参数,目的就是为了让vue-cli3脚手架在打包的时候不删除原有的目录。这里是因为组件,公共资源这些都是打包到同一个目录中,后面打包的会删除目录在进行打包。同时这里借助了rimraf
这个包在打包前进行统一的删除。
由于vue-cli3项目的配置文件只能写在vue.config.js中,所以需要借助cross-env这个包来添加不同的环境变量,在vue.config.js配置文件中读取这个环境变量,然后引用不同的配置来打包组件,文档,公共资源。vue.config.js文件如下:
const devConfig = require("./build/webpack.dev");
const docsConfig = require("./build/webpack.docs");
const indexConfig = require("./build/webpack.index");
const componentsConfig = require("./build/webpack.components");
const env = process.env.NODE_ENV;
let config = devConfig;
if (env === "development") {
config = devConfig;
} else if (env === "docs") {
config = docsConfig;
} else if (env === "index") {
config = indexConfig;
} else if (env === "components") {
config = componentsConfig;
}
module.exports = config;
通用配置主要需要考虑一下四点:
1、添加编译
把packages、examples、docs等目录添加进编译,因为vue-cli3中src外的文件默认是不被webpack处理的。
chainWebpack: (config) => {
config.module
.rule("js")
.include.add(utils.resolve("packages"))
.end()
.include.add(utils.resolve("examples"))
.end()
.include.add(utils.resolve("docs"))
.end()
.include.add(utils.resolve("src"))
.end()
.use("babel")
.loader("babel-loader")
.tap((options) => {
// 修改它的选项...
return options;
});
},
2、删除无关配置
chainWebpack: (config) => {
config.optimization.delete("splitChunks");
config.plugins.delete("copy");
config.plugins.delete("preload");
config.plugins.delete("prefetch");
config.plugins.delete("html");
config.plugins.delete("hmr");
config.entryPoints.delete("app");
},
3、配置路径别名
配置路径别名是为了方面后面公共资源的引入和按需加载的实现。这里需要做一个约定就是:凡是需要引用到组件或者公共资源的文件,都需要使用路径别名进行引用,否则在后面的按需加载实现会带来一定的麻烦。
configureWebpack:{
resolve: {
alias: {
examples: utils.resolve("examples"),
packages: utils.resolve("packages"),
"lin-view-ui": utils.resolve("src/index.js"),
src: utils.resolve("src"),
},
}
}
这里说的全量包指的就是组件库中的index.js和style.css。其中index.js包含了所有的组件,公共资源文件。style.css包含了所有组件样式以及字体图标。
这里有四点需要注意:
1、打包的时候需要排除Vue这个库,因为我们的组件库是基于Vue的。当你在使用这个组件库的时候,那么说明你这是一个Vue项目,并且已经有了Vue这个库。所以为了减少打包的体积,我们不需要把Vue这库打包进去。
configureWebpack:{
externals: {
vue: {
root: "Vue",
commonjs: "vue",
commonjs2: "vue",
amd: "vue",
},
},
}
2、打包出来的文件模块化规范。首先你要考虑使用的对象。如果你的目标用户只是单纯的面向使用webpack构建项目的用户,那么你可以使用commonjs这个模块化规范。如果你还要考虑其他的使用用户,比如通过script脚本引用。那么你可能需要考虑umd这个模块化规范。因为umd这么模块化规范可以兼容很多模块化规范,比如cmd、amd。
configureWebpack:{
output: {
filename: "index.js",
libraryTarget: "umd",
libraryExport: "default",
library: "LinViewUi",
},
}
3、打包的时候字体图标需要输出到assets/fonts目录下。
chainWebpack: (config) => {
config.module
.rule("fonts")
.use("url-loader")
.tap((option) => {
option.fallback.options.name = "assets/fonts/[name].[hash:8].[ext]";
return option;
});
},
4、打包js和css的时候不需要生成SourceMap文件
{
productionSourceMap: false,
css: {
sourceMap: false,
extract: {
filename: "style.css",
},
},
}
1、babel-plugin-import
按需加载借助的是babel-plugin-import
这个插件。当我们引用如下组件的时候:
import {Button} from 'lin-view-ui'
babel-plugin-import
会帮我们转化成如下的引入方式:
import Button from 'lin-view-ui/lib/Button/index'
import 'lin-view-ui/lib/Button/style'
所以我们在打包单个组件的时候需要生成如下目录结构
{
configureWebpack: {
entry: getComponentEntries("packages"),
output: {
filename: "[name]/index.js",
libraryTarget: "umd",
libraryExport: "default",
library: "[name]",
},
},
css: {
sourceMap: false,
extract: {
filename: "[name]/style.css",
},
},
}
2、排除公共资源文件和依赖包
我们的公共资源文件都是放在src目录下的,所以公共资源我们只需要定位到src目录下即可。依赖包我们可以通过读取packages.json
中的dependencies
字段来获取。其中公共资源需要按照约定使用配置的路径别名进行引用,否则很难匹配到你是用了那些公共资源文件。
const packageJson = require("../package.json");
const dependencies = {};
for (const key in packageJson.dependencies) {
dependencies[key] = {
root: key,
commonjs: key,
commonjs2: key,
amd: key,
};
}
const utilsList = fs.readdirSync(utils.resolve("src/utils"));
const mixinsList = fs.readdirSync(utils.resolve("src/mixins"));
const jsList = fs.readdirSync(utils.resolve("src/js"));
const getExternalsList = () => {
const externals = {
vue: {
root: "Vue",
commonjs: "vue",
commonjs2: "vue",
amd: "vue",
},
...dependencies,
"src/locale/index.js": "lin-view-ui/lib/assets/locale/index.js",
"src/locale/lang/zh-CN.js": "lin-view-ui/lib/assets/locale/lang/zh-CN.js",
"src/locale/lang/en-US.js": "lin-view-ui/lib/assets/locale/lang/en-US.js",
"src/locale/format.js": "lin-view-ui/lib/assets/locale/format.js",
"src/fonts/iconfont.css": "lin-view-ui/src/fonts/iconfont.css",
"flv.js/dist/flv.js": "flv.js/dist/flv.js",
};
utilsList.forEach(function(file) {
file = path.basename(file);
externals[`src/utils/${file}`] = `lin-view-ui/lib/assets/utils/${file}`;
});
mixinsList.forEach(function(file) {
file = path.basename(file);
externals[`src/mixins/${file}`] = `lin-view-ui/lib/assets/mixins/${file}`;
});
jsList.forEach(function(file) {
file = path.basename(file);
externals[`src/js/${file}`] = `lin-view-ui/lib/assets/js/${file}`;
});
return externals;
};
module.exports = {
configureWebpack: {
externals: getExternalsList(),
},
};
按需加载的实现主要就是两点。一是需要排除公共资源文件和依赖包,否则会增大打包的体积;二是打包出来的目录结构需要符合babel-plugin-import
插件的要求,即使你是使用babel-plugin-component
插件也是一样的道理
其实打包公共资源跟打包组件是一样的,只需要获取每个资源文件的入口就可以了,详细配置可以查看打包组件的配置,这里不做讲述了。
组件库的演示文档使用的是Markdown。要使用Markdown主要需要考虑的是代码的显示和组件实例的显示。我这里采用的是代码显示跟组件实例显示分离的方式。也就是同一份代码需要写两遍,组件实例代码写一次,组件代码显示写一次。当然大家也可以参考Element-ui的方式,组件代码显示和组件实例不分离。
要使用Markdown,那就需要借助markdown-it-container
这个插件来处理Markdown。这里需要做一个约定,就是通过以下
:::demo
```html
```
:::
代码块包含的内容就是需要显示的代码。
同时在全局注册一个名为demo-block
的组件,把需要显示的代码插入进该组件的指定位置,通过该组件是用来控制代码的显示和隐藏。
chainWebpack: (config) => {
config.module
.rule("md")
.test(/\.md/)
.use("vue-loader")
.loader("vue-loader")
.end()
.use("vue-markdown-loader")
.loader("vue-markdown-loader/lib/markdown-compiler")
.options({
raw: true,
preprocess: (MarkdownIt, source) => {
MarkdownIt.renderer.rules.table_open = function() {
return '<table class="table">';
};
MarkdownIt.renderer.rules.fence = utils.wrapCustomClass(
MarkdownIt.renderer.rules.fence
);
// ```code`` 给这种样式加个class code_inline
const code_inline = MarkdownIt.renderer.rules.code_inline;
MarkdownIt.renderer.rules.code_inline = function(...args) {
args[0][args[1]].attrJoin("class", "code_inline");
return code_inline(...args);
};
return source;
},
use: [
[
MarkdownItContainer,
"demo",
{
validate: (params) => params.trim().match(/^demo\s*(.*)$/),
render: function(tokens, idx) {
var m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
if (tokens[idx].nesting === 1) {
var desc = tokens[idx + 2].content;
const html = utils.convertHtml(
utils.striptags(tokens[idx + 1].content, "script")
);
// 移除描述,防止被添加到代码块
tokens[idx + 2].children = [];
return `<demo-block>
<div slot="desc">${html}</div>
<div slot="highlight">`;
}
return "</div></demo-block>\n";
},
},
],
],
});
},
到此,Markdown的处理已经完成了。这里需要注意一点就是需要配置文档的入口,因为vue-cli3的默认入口文件时src/main.js,但是已经被我们删除了。
pages: {
docs: {
// page 的入口
entry: "docs/main.js",
// 模板来源
template: "public/index.html",
// 在 dist/index.html 的输出
filename: "index.html",
// 当使用 title 选项时,
// template 中的 title 标签需要是 <title><%= htmlWebpackPlugin.options.title %></title>
title: "组件文档",
// 在这个页面中包含的块,默认情况下会包含
// 提取出来的通用 chunk 和 vendor chunk。
chunks: ["chunk-vendors", "chunk-common", "docs"],
},
},
关于文档的部署,我推荐大家使用七牛云,免费的不用钱,又有免费的cdn加速。
index.js文件是一个入口文件,所有组件都是通过这个文件引入,然后导出。这里我们使用webpack提供的require.context函数统一引入各个组件,这样就不用了每次新增组件就引入一次组件
const testComps = require.context(
"../packages",
true,
/^\.(\/\w+)\/index\.js$/
);
const reg = /^\.\/(\w+)\/index\.js$/;
const componentObjs = {};
testComps.keys().forEach((key) => {
const componentEntity = testComps(key).default;
const result = reg.exec(key)[1];
componentObjs[result] = componentEntity;
});
const install = (Vue, opts = {}) => {
Object.keys(componentObjs).forEach((key) => {
Vue.use(componentObjs[key]);
});
};
// 判断是否是直接引入文件,如果是,就不用调用 Vue.use()
if (typeof window !== "undefined" && window.Vue) {
install(window.Vue);
}
export default {
install,
...componentObjs,
};
这里以Button为例子
组件库的搭建可以去参考element-ui等著名的开源组件库,如果看不懂也可以去参考别人造轮子做的一些组件库,一般都是参考其他的组件库,然后简化了其中的流程。