UI组件是对一组相关的交互和样式的封装,提供了简单的调用方式和接口,让开发者能很便捷地使用组件提供的功能来实现业务需求。css
我在一个名为Admin UI的Vue UI组件库(GitHub地址:github.com/BboyAwey/ad…Vue 组件库。不过即便你是React的使用者,也能够参考本文给出的经验,由于若是你打算编写一个React UI组件库,你将不得不面对几乎彻底同样的问题。html
这篇文章也是我在公司的一次技术分享的内容。我在这里主要只探讨思路,尽可能不去涉及具体实现。而且我对这些问题的解决思路也不尽然是彻底合理的,若有错漏请读者斧正。vue
当你开始着手组件库的开发时,第一件事可能就是创建一个项目,由于是Vue 组件库,你极可能会使用其官方推荐的vue-cli工具来生成一个项目。node
当项目生成后,你很快就发现这个项目模版的文件结构用于业务开发很是合适,但并不那么适合组件库的开发。这时你极可能会在其src
文件夹下用你的组件库名称创建一个文件夹来存放你的组件库代码。但这个时候你还并不清楚全部须要作的事情,你并无继续调整文件结构。react
咱们暂且将你的组件库就命名为
admin-ui
,方便后续行文jquery
当你真正开始编写第一个组件时,你确定会首先编写一个用来展现正在开发中的组件的页面,并在其上对其进行测试。因此你又在src
文件夹中新建了一个examples
文件夹用来存放你的示例代码。webpack
这时你的文件结构看起来就像这样:ios
极可能一段时间后,你为每一个组件都编写了一个示例页,甚至其中一些示例页自己已经作的很棒了。若是有一天组件库主要开发完成了,这堆实例页也就没什么用了。因而你可能会对这些示例页进行完善,将每一个组件的特性和接口列表甚至使用示例代码放到它们的示例页上,而后将其部署在一台服务器上方便你的用户随时查看。它们很幸运地没有被浪费,而是都变成了组件库的使用文档了!git
而后你可能意识到一我的的力量之薄弱,你会邀请其它开发者参与到你的项目中,然而尽管你在使用vue-cli生成项目时已经开启了ESlint,但多人协同开发一套完整的UI库仅仅依靠代码风格的统一是远远不够的。大家可能须要创建开发文档,将各类约定和设计,以及须要共享的其它信息发布在其中。因此你又在项目的根目录中新建了一个documentation
目录,并在其中使用GitBook生成了一个文档,同时同步到了GitBook服务器,以便你的伙伴们即便没有同步它们也能在线查看。github
你如今已经能够开始开发你的组件了,在编写第一个组件的时候,你意识到你如今编写的这个项目本质上是你的组件库的使用文档,若是这算是一个业务项目,那么它是直接将你的组件库源码放置到了本身的源码中来使用。但若是其它业务项目组使用了你的组件库,他们目前只能像你如今这样把源码拷贝到他们的项目中使用,而且每次你的组件库升级了,他们都须要经过再次进行拷贝来进行升级,这种状况显然是你所不能接受的。你开始考虑你的用户们怎么安装你的组件库了。
你首先想到的是最流行的安装方式:npm。若是直接将你的组件库发布到npm上,你的用户将能经过它或者yarn很是方便地进行安装和升级。但目前你的组件库刚刚起步,你并不但愿立刻开源。因而你向公司申请了一个大家公司本身搭建的gitlab中的仓库,而后在你的组件库所在的那个文件夹git init
初始化了一个git项目,并将其同步到你申请的那个仓库中。这时,公司的同事们已经能够经过
npm install admin-ui git+ssh://admin-ui-git-location.git --save
复制代码
来安装你的组件库了。
而后你能想到的第二种安装方式就是CDN。你的用户们经过在他们的页面内联
<script src="admin/ui/cdn/location"></script>
复制代码
来使用你的组件库,这时就涉及到如何打包你的组件库了。在这种场景下,你须要将你的组件库打包为一整个admin-ui.js
这样的js文件来使用。关于打包咱们将在下一节继续讨论。
固然,最后一种安装方式就是直接使用源码了,将你的组件库直接放到项目源码中进行引用。
肯定了须要支持的安装方式,你可能已经意识到,如今你的项目中有两部分须要打包和发布:
你首先想到的是使用文档打包起来很方便,由于这个项目目前的打包配置直接就能将你编写的全部示例页面打包好,你惟一要作的就是运行
npm run build
复制代码
但关键问题在于你的组件库admin-ui。它目前是做为你这个项目的一部分源码存在的。因此你不得不开始思考如何对这部分代码进行单独打包。
固然,你也能够不对你的组件库进行打包,而是直接将其源码发布为一个npm包,但那样的话,用户在使用它的时候就须要依赖打包工具来对你的代码进行打包。而相似vue-cli这类工具生成的项目,默认状况下是不会打包来自node_modules文件夹下的代码,用户必须修改构建配置手动指定须要打包的代码位置,这很不方便
因而你在对照着build.js
、webpack.prod.conf.js
和prod.env.js
,在项目根目录下的build
和config
文件夹中分别新增了publish.js
、webpack.publish.conf.js
和publish.env.js
文件,并查阅了webpack文档,去掉了不须要的一些功能配置,设置好了对你的组件库进行打包的配置。
你指望将打包后的代码就放在你的组件库文件夹内,并命名为dist
,这时你的组件库的源文件就须要移动到src
目录下。
webpack在对代码进行打包时须要指定入口文件,这时你发现你的组件库自己尚未出口文件。
你在组件库的src
文件夹下新建了一个index.js
文件,它引入并输出了全部的组件。
import Button from './components/button'
import Icon from './components/icon'
// ...省略的代码...
export {
Button,
Icon
// ...省略的代码...
}
复制代码
到这里,你或许会干脆将组件库自己的文件结构也一并规划好:
在这种输出格式下,你的用户能够经过
import { Button } from 'admin-ui'
复制代码
来从组件库中得到Button组件。然而这仅仅是对这种格式的支持(这并非按需加载),用户还须要可以进行全量加载,也就是一次引入全部组件并所有自动注册好。因此你在index.js
中将全部的组件都挂载到adminUi
对象上,而后再在该对象上挂载install()
方法用于支持Vue.use()
,最后直接输出这个对象。如今你的index.js
看起来像这样:
import Button from './components/button'
import Icon from './components/icon'
// ...省略的代码...
export {
Button,
Icon
// ...省略的代码...
}
const adminUi = {
Button,
Icon,
// ...省略的代码...
}
adminUi.install = function (Vue, options = {}) {
Vue.component('cu-button', Button)
Vue.component('cu-icon', Icon)
// ...省略的代码,你也能够用循环来写...
}
export default adminUi
复制代码
install()
方法中能够作不少事情,除了注册组件,极可能你也会在其中进行一些实例方法的挂载
这时你的用户能够经过
import adminUi from 'adminUi'
Vue.use(adminUi)
复制代码
来进行全量加载。
接下来就是按需加载。你发现若是仅仅是经过你的index.js
入口文件去加载某个组件,其它组件虽然没有被用户引入,但仍旧被编译到了用户的代码中去了。因此你不得不考虑新的方式。既然不能从单一入口加载,是否能够为每一个组件指定一个加载点呢?你但愿你的用户可以经过相似
import Button from 'admin-ui/button'
复制代码
这样的方式来加载单个组件,这样就不存在多余的组件了。因此你意识到,每一个组件还须要单独进行打包。以每一个组件的出口文件(可能也是个index.js
,这里你应该意识到每一个组件的文件结构保持一致能带来好处)为打包入口,将每一个组件都打包为一个单独的模块放置到dist
中的lib
文件夹下。这时,按需加载就被支持了。
我并未讨论具体的webpack配置,一是由于本文主要讨论思路而不是具体实现,二是这个话题若是要深刻讨论须要更多篇幅,三是webpack自己配置很是复杂而我并不算熟练。
而后你愉快地尝试了一下打包,但沮丧地发现,无论是以组件库自己的出口文件为入口,仍是在对每一个组件进行单独打包的时候,结果除了一个.js
文件,还会有一个.css
文件。你的用户无论是全量加载,仍是按需加载,在引入.js
文件时还要引入对应的.css
文件。在全量加载时,因为只加载一次,这彷佛不是什么大问题。但若是是按需加载,由于要引入屡次,这就有些麻烦了。
分离的css文件是出于性能考虑,css文件能够被浏览器缓存,同时组件自己渲染时不须要再生成css了
解决方案有两种,一种是推荐用户使用babel-plugin-component,另外一种是打包后的组件自己再也不提供css文件,而是全局引入全量加载的那个css文件。两种作法均可以,但我使用的是后者。
有两个缘由,首先组件们的样式集合起来体积并不大,压缩打包后控制在60KB之内(这其中绝大部分都是font-awesome的样式代码,组件的全部样式不超过5kb);其次因为使用了font-awesome,若是每一个组件单独引入本身的样式,依赖了font-awesome的组件们就会出现重复的样式。
当你的组件库被用于不一样的项目中,或者某个项目须要换肤功能时,不可避免地,你须要在你的组件库中设计一个主题系统。
首先须要明确,你的主题系统功能的边界。在我看来,影响一个管理类后台系统风格的因素主要有三种:
因此不妨先将你的主题系统边界就设定为这三种因素。
而后你开始思考可行的主题系统实现思路:
特殊格式的字符串替换无疑是最简便的,开发时当遇到须要被主题系统控制的样式时,在css中直接使用特使格式的字符串,在运行时进行替换便可。好比:
div {
color: $$primary$$;
}
复制代码
运行时被你的脚本替换成:
div {
color: #00f;
}
复制代码
这种方案的优势是开发时很是便捷,基本不影响开发体验,甚至还有提高。在传统的jquery时代问题不大,但就Vue项目而言,存在“替换时机”问题。你大能够在项目初始化后将页面中全部<style>
标签中的特殊字符替换掉,但当页面变化,新的组件的style
被插入到head
中时,你还须要再次替换,很难找到合适的时机来作这件事。
主题文件预编译是目前市面上主流的主题实现方案。即UI库自己提供生成不一样主题的css文件的工具,事先编译好几套不一样的主题样式文件。优势是简单直接,方便好用。但缺陷也显而易见:运行时的主题替换变得很是粗暴(粗粒度)——你只能一整套一整套地替换。
样式类则是设计好样式规则,在须要的元素上应用样式类便可:
.au-theme-font-color--primary {
color: #00f;
}
复制代码
<p class="au-theme-font-color--primary">主色</p>
复制代码
样式类一样有它的很是明显的缺陷:首先你须要有很是清晰的样式类规则设计,而后对开发的影响也很是重大:全部的主题系统涵盖的样式都只能用样式类来书写,不能直接写在css中。这两点给使用者带来必定认知和使用负担。但优势也一样明显:控制粒度能够很是细,不存在替换时机问题,同时,不只仅能够控制组件库自己的主题,也能够直接用于整个项目。
带有实验性质地,我选择了样式类,因此假定你也作出了一样的选择。
若是你不知道从何下手,不妨试着从你的主题系统的使用者的角度入手。你指望你的使用者可以经过一个简单的函数来控制主题:
adminUi.theme(config)
复制代码
那么很天然地你就会去定义好config
的结构。根据前面界定好的主题系统功能,你会将其作以下定义:
const config = {
colors: {
'base-0': '#000',
'base-1': '#232a29',
'base-2': '#2f3938',
'base-3': '#44514f ',
'base-4': '#616e6c',
'base-5': '#7c8886',
'base-6': '#b1bbba',
'base-7': '#d9dedd',
'base-8': '#eaf0ef',
'base-9': '#f3f7f6',
'base-10': '#f7fcfb',
'base-11': '#fdfdfd',
'base-12': '#fff',
'primary-top': '#01241d',
'primary-up': '#3fd5b8',
'primary': '#19bc9d',
'primary-down': '#169f85',
'primary-bottom': '#e7f7f4',
'info-top': '#011725',
'info-up': '#f0faf8',
'info': '#3498db',
'info-down': '#2d82ba',
'info-bottom': '#e6f3fc',
'warning-top': '#251800',
'warning-up': '#fec564',
'warning': '#ffb433',
'warning-down': '#e99b14',
'warning-bottom': '#fbf3e5',
'danger-top': '#220401',
'danger-up': '#f56354',
'danger': '#e74c3c',
'danger-down': '#c33a2c',
'danger-bottom': '#fae7e5',
'success-top': '#012401',
'success-up': '#7fcb7f',
'success': '#5cb95c',
'success-down': '#3da63d',
'success-bottom': '#e7fae7'
},
shadows: {
'base': '0 1px 4px rgba(0, 0, 0, .2)',
'primary': '0 0 4px rgba(25, 188, 157, .6)',
'info': '0 0 4px rgba(52, 152, 219, .6)',
'warning': '0 0 4px rgba(255, 180, 51, .6)',
'danger': '0 0 4px rgba(231, 76, 60, .6)',
'success': '0 0 4px rgba(92, 185, 92, .6)'
},
radiuses: {
'small': '2px',
'large': '5px'
}
}
复制代码
primary
、warning
、danger
、info
、success
为主要颜色[COLOR]-up
、[COLOR]-down
为明度较接近主要颜色的次要颜色[COLOR]-top
、[COLOR]-bottom
为明度与主要颜色相差较大的辅助颜色base-0
、base-12
为最暗无彩色和最亮无彩色base-[1~11]
为按明度排列的无彩色(灰色)不使用带有颜色信息的词(好比light、dark-primary等)而是使用数字和方向来做为颜色名称的缘由是为了方便用户在某个名称上定义任意的颜色,假如你将纯黑色的名称定义为了dark,但用户配置时却使用的是#fff纯白色,这个名称就会带来误解。在非彩色上,咱们使用数字来做为名称,而在彩色上,使用方向来做为名称,既能契合彩色的层次设计,又能规避歧义。
你的这套配置规则指望用户可以按照明度配置颜色,每一个种类颜色明度排列都是一致的。这是为了方便色彩之间的明暗搭配,好比应该在深色背景上使用浅色文字。但有这个限制的同时,带来的好处即是用户可以配置一些自定义的颜色。
同时,为了进一步精简颜色配置,你决定在阴影、非主要颜色和无彩色缺省的状况下,基于primary
颜色和一些辅助配置来自动计算它们。因而用户的实际配置能够进一步简化:
export default {
theme: {
colors: { // 彩色配置
primary: '#1c86e2',
info: '#68217a',
warning: '#f5ae08',
danger: '#ea3a46',
success: '#0cb470'
},
shadows: { // 阴影配置
// primary: '0 0 4px #1c86e2',
// info: '0 0 4px #68217a',
// warning: '0 0 4px #f5ae08',
// danger: '0 0 4px #ea3a46',
// success: '0 0 4px #0cb470'
},
radiuses: {
small: '3px',
large: '5px'
}
},
lightnessReverse: false, // 反转lightness排序(黑白主题)
colorTopBottom: 5, // top和bottom颜色距离纯黑和纯白的lightness的距离,越小越接近纯黑纯白
colorUpDown: 10, // 彩色上下接近色与正色的lightness距离
baseColorLeve: 12, // 无彩色分级数量
baseColorHue: '20%', // 无彩色饱和度
baseShadowOpacity: 0.2, // 无彩色阴影不透明度
colorShadowOpacity: 0.6 // 彩色阴影不透明度
}
复制代码
主题系统的文件结构以下:
接下来,思考用户配置完了主题系统后,他们如何将其应用到元素上。你的主题系统提供的样式类,须要一个便于记忆的语法,来方便用户使用,这时你可能会设计出相似下面这样的语法规则:
前缀 [-伪类名] -属性名 --属性值 [-权重]
复制代码
!important
后缀在这套语法规则下,用户用起来就像下面这样:
<div class=" au-theme-background-color--base-12 au-theme-border-color--primary au-theme-font-color--base-3 au-theme-box-shadow--base au-theme-radius--small"></div>
复制代码
最后,你的主题系统将用户传入的配置根据你的语法规则转换为具体的样式类代码,并利用<style>
标签将其插入了页面。
任何一个UI组件库,尤为是管理系统的UI组件库,都不可避免地须要提供一套表单组件。缘由很简单,首先各家浏览器提供的默认表单控件不只风格不一还丑到天际;其次表单的排版、验证等等功能都是刚需,没理由不抽象出来。
因此首先你会列举出经常使用的表单组件:文本输入框、多选、单选、开关、下拉、级联、日期、时间、文件上传,这些组件都被你放到TODO LIST中了。
你会发现不少表单组件的行为方式是一致的,好比都须要value
接口,都得支持v-model
,都有input
或者change
事件等等。这些统一的行为最好放置到一块儿去,因此你使用Vue的mixin
功能来提取这些统一的行为到一块儿,一方面便于管理,另外一方面可以使表单组件在功能与行为上尽量保持一致,以此来下降用户的认知成本。
其实验证这部分功能严格来说,也算是统一的表单接口,因此也能够一并放在上面的文件中,但验证部分的逻辑其实相对独立一些,因此你极可能会将其独立出来另作一个minxin
来管理。
若是你常常编写表单,不难发现实际上关于验证,只有两种状况:
要支持交互验证其实很简单,简单利用事件便可实现。而要支持提交验证,则须要每一个表单组件提供具体的验证方法供外部调用,好比:this.$refs.$userNameInput.validate()
,外部调用该函数便可得到验证结果;在提交表单时将全部表单组件的验证方法调用一遍便可。
而你的程序在运行验证代码时,也有两种状况:
支持同步验证很是简单,正常调用外部给定的验证器函数,而后返回其结果便可。但若是是异步验证就会比较麻烦。咱们稍微深刻一点,假设目前用户像下面这样指定了<au-input/>
的验证器:
<au-input :validatiors="[ { validator (v) { return v > 0 }, warning: '必须大于0' } ]"/>
复制代码
当你获取到这个验证器后,你并不能知道其是同步仍是异步验证,因此你可能会要求用户指明是同步仍是异步:
<au-input :validatiors="validators"/>
复制代码
export default {
data () {
return {
validators: [
{
validator (v) { return v && v.length && !/^\s*$/g.test(v) },
warning: '不能为空'
},
{
validator () {
return new Promise(resolve => {
axios.get('is-duplicated-name')
.then(data => resolve(data.result))
})
},
warning: '已经有重复的名字了',
async: true
}
]
}
}
}
复制代码
当用户像上面同样指明了同步仍是异步验证,而且其验证函数返回的是一个promise
后,你就能够事先将全部的验证器分红两类:同步验证和异步验证,而且首先验证同步函数,若是有任意未经过的验证,则能够不验证异步函数来减少开支。下面是我在Admin UI中的验证逻辑,放出来供你们参考:
// the common validation logic of enhanced form components
export default {
// ... 省略的代码 ...
methods: {
validate () {
let vm = this
if (vm.warnings && vm.warnings.length) return false
if (!vm.validators) return false
// separate async and sync
let syncStack = []
let asyncStack = []
vm.validators.forEach((v) => {
if (v.async) {
asyncStack.push(v)
} else {
syncStack.push(v)
}
})
// handler warnings
let handleWarnings = (res, i, warning) => {
if (!res) {
vm.$set(vm.localWarnings, i, warning)
} else {
vm.$delete(vm.localWarnings, i)
}
}
return new Promise((resolve) => {
let asyncCount = asyncStack.length
// execute sync validation first
syncStack.forEach((v, i) => {
handleWarnings(v.validator(vm.value), i, v.warning)
})
// if sync validation passed, execute async validationg
if (!vm.hasLocalWarnings) {
if (asyncCount <= 0) { // no async
resolve(!vm.hasLocalWarnings)
} else {
Promise.all(asyncStack.map((av, i) => {
return av.validator(vm.value).then(res => {
handleWarnings(res, i, av.warning)
})
})).then(results => {
if (results.includes(false)) resolve(!vm.hasLocalWarnings)
else resolve(!vm.hasLocalWarnings)
})
}
} else { // if sync validation failed
resolve(!vm.hasLocalWarnings)
}
})
}
}
}
复制代码
表单组件的验证方法返回的是一个promise
,在其resolve
方法中返回了具体的验证结果,好处是在提交验证时,用户不须要区分同步仍是异步,所有统一对待,简单方便:
export default {
validateAllFormitems () {
Promise.all([
this.$refs.name.validate(),
this.$refs.age.validate(),
this.$refs.gender.validate()
]).then(results => {
if (!results.includes(false)) this.submit()
})
}
}
复制代码
常见的表单排班有两种,一种是label与表单元素上下排列,另外一种是左右排列。你的表单组件们的排版方式应当保持统一,因此你可能会建立一个表单组件的容器组件来作这件事。固然,你的表单组件接口中,也应当有对应的控制排版的接口。
上下排列时,除了常规宽度,像文本输入框、下拉选择框一类的组件还要考虑100%宽度的情形。这时你可能须要另外一个full-width
接口来让用户选择是否全宽度占满。
左右排列时,则要考虑label
的对齐。通常的作法是,规定全部表单元素的label
到一个合适的宽度,以后label
中的文字向右对齐。因为各个组件之间自己是相互独立的,你应该会指望使用者来告诉你label
的合适宽度,因此你每一个表单组件都提供了label-width
接口。
你将这些特性都封装在一个叫form-item
的容器组件中,并在每一个表单组件中使用它。
关于日期、时间及日期时间范围这三个功能对应的组件,我但愿你可以思考的问题是:如何划分组件的功能。
常见的划分是
然而我更推荐的划分是
这么划分的好处是,你的日期选择器和时间选择器可以最大程度被复用,而且三个组件在实现上相对前一种划分中的三个组件要简单不少。这并很差理解,须要你仔细体会。
绝大部分UI库都提供了图标组件。缘由很简单:没有人喜欢那烦人的字体文件路径问题。将字体文件经过一个固定的组件进行引入可以避免你的使用者为其所困。
Admin UI早期版本使用的是一个更漂亮的Ionicons图标库,但其图标种类略少,后续的版本更换成了Font Awesome图标库。
选择何种图标影响并非很大,你甚至能够不使用地第三方的图标库而是仅提供你的组件库所须要的最小图标集,转而将使用何种图标库的选择权交给你的使用者——图标组件应当支持第三方图标的使用。
市面上几乎绝大部分UI库都带着网格系统,来方便开发者快速自适应布局页面。早期的技术,例如Bootstrap等UI库,使用float
与width
等CSS属性及CSS媒体查询来实现网格系统。而现代的UI库则大部分使用flex
来实现网格。
你可能会想要使用现代的技术来实现一个相似Bootstrap中的网格系统。然而在Bootstrap中,网格的使用是依靠样式类来进行的,同时它要求一个父元素及若干子元素来造成要求的结构。你可能对此并不满意。样式类的应用可使用props
来替代,而固定的父级元素,你却可能并不但愿用户关心。为此,你考虑只用一个grid
组件来实现整个网格系统,因此在初始化的时候,你须要处理好父元素,尤为是须要处理父元素涉及使用display
属性的状况,由于你须要老是在父元素上使用flex
属性。
在不考虑格子之间间距的状况下,彻底可使用媒体查询和flex
属性来完成网格系统。但若是涉及到间距,要使用CSS来实现就会比较麻烦。flex
属性可以实现网格横向排列的特性,但网格自己宽度,由于涉及到间距的计算,你可能会使用JavaScript来作。
使用者经过props
传入一个相似widthLg
的属性,告知组件在大屏下所占的网格数量,经过space
属性告知组件与下一组件的间距,这时候须要引入行的概念,你须要计算哪些组件处在一行中,由于行末尾的最后一个grid
右侧不能有间距,不然会由于超出一行总宽度而被挤到下一行。
网格的实现并不困难,主要面对的需求点就是网格本身的特性:不一样屏宽下所占网格数量、偏移距离、间距。你可能看明白了,我并不打算展开讲具体实现,可是你能够去看看源码一探究竟。说实话,这部分的实现并不算有优雅,主要是一些实验性质的作法。欢迎你来重构!
说实话,这一部分我并不打算讲。但你得知道,你的组件库将来若是要开源,这两部分是必不可少的。
若是以具体实现细节做为考量,这篇文章基本上没有什么干货。我仅仅是把编写一个组件库须要面对的问题进行了罗列,并泛泛地谈了谈解决方案而已。无论怎样,但愿可以对你有所启发。
最后,即便是造轮子,也有它其中的乐趣所在。这里先挖一个坑,将来我可能会从组件中挑选一些比较有意思的,另写文章来分享具体的组件实现细节,敬请期待~
同时,也欢迎你去fork这个充满了私货的UI库,提Issues或者Pull Request都是很是欢迎的~