1. 项目简介
芒果 UI 是一个基于 Vue / TypeScript 实现的 UI 框架,提供了Button、Input、Grid、Layout、Toast、Tabs、Popover、Collapse等常用组件,适合移动端和 PC 端使用。
2. 项目实现思路(以 Button 组件为例)
造每个轮子都会经历以下几个阶段
- 1)需求分析(用例图、时序图等)
- 2)UI 设计
- 3)写代码
- 4)测试(单元测试)
- 5)文档撰写(README.md)
- 6)持续集成
2.1 需求分析
通过用例图分析,一个 Button 会有以下几种状态:点击按钮(Loading)、不可点击按钮、hover 按钮、按下按钮
2.2 UI 设计
参照 Element UI /Ant Design 等主流框架的 UI 进行设计
2.3 代码实现
2.3.1 项目搭建
- 新建仓库,编写 README
注意:公司源代码要放在收费版 github 或者 brakets 上面,如果放在免费的 github 上,相当于开源
- 声明 License,可以谷歌“阮一峰 + 软件许可”搜到相关参考,本项目采用 MIT 许可证
- 运行
npm init
初始化仓库 - 运行
npm i vue
选择底层代码,本项目选用Vue - 新建 index.html
2.3.2 写组件
- 运行
npm i -D parcel -bundler
安装parcel,用以打包,实现单文件组件 - 新建 src/app.js,在index.html中引入app.js,在app.js中初始化app,import引入Vue和组件,使用compnenets属性映射组件
- 新建 src/button.vue,在template里写html,在script里写js并export,在style里用scss写样式
- 在index.html中使用映射的组件
- 运行
.node_modules/.bin/parcel
安装 @vue/component-compiler-utils、node-sass等要用到的工具,配置package.json,配置vue的完整版 - 运行
npx parcel
,编译运行代码 - 实现button的文本传值:使用slot插槽
- 引入并使用icon:在iconfont确定合适的icon,在index.html引入js代码使用svg;或者将svg本地化:新建src/svg.js并将iconfont的js代码打开复制进去,在用到的组件中引入svg.js ;
- 实现通过传icon来选择icon图标,通过传iconPosition来选择icon图标相对于文本的位置:在button.vue通过props传参
- 实现属性检查,验证iconPosition为left或right中的一个:通过validator
- 实现loading的icon的加载动画:@keyframe + animation
- 点击按钮时实现不同icon的切换:添加并监听click事件
<template>
<button class="g-button" :class="{ [`icon-${iconPosition}`]: true }" @click="$emit('click')">
<g-icon class="icon" v-if="icon && !loading" :name="icon"></g-icon>
<g-icon class="loading icon" v-if="loading" name="loading"></g-icon>
<div class="content">
<slot />
</div>
</button>
</template>
<script>
import Icon from "./icon";
export default {
name: "MgButton",
components: {
"g-icon": Icon,
},
props: {
icon: {},
iconPosition: {
type: String,
default: "left",
validator(value) {
return value === "left" || value === "right";
},
},
loading: {
type: Boolean,
default: false,
},
},
};
</script>
- 实现button合并:创建buttonGroup组件,其中slot插槽内容为button组件
<template>
<div class="g-button-group">
<slot />
</div>
</template>
<script>
export default {
name: "MgButtonGroup",
mounted() {
for (let node of this.$el.children) {
let name = node.nodeName.toLowerCase();
if (name !== "button") {
console.warn(
`g-button-group 的子元素应该全是 g-button,但你写的是${name}`
);
}
}
},
};
</script>
2.4 单元测试
2.4.1 单元测试与 Mock
- 运行
nmp i -D chai
,安装 chai,使用chai.expect写测试用例、断言 - mock测试:运行
nmp i -D chai spise
,安装并使用 chai.spy 监听回调函数、
2.4.2 使用 Karma + Mocha做单元测试
- 安装各种工具(
npm i -D karma karma-chrome-launcher karma-mocha karma-sinon-chai mocha sinon sinon-chai karma-chai karma-chai-spies
),创建 Karma 配置,创建 test/button.test.js 文件,创建 BDD 风格的测试脚本,运行测试脚本 - 小知识:Karma 是一个测试运行器,它可以呼起浏览器,加载测试脚本,然后运行测试用例;Mocha 是一个单元测试框架/库,它可以用来写测试用例,descibe...it...即为Mocha引入的BDD;Sinon 是一个 spy / stub / mock 库,用以辅助测试
- 小知识:BDD(behavior-driven development)行为驱动开发;TDD(test-driven development)测试驱动开发
2.4.3 使用 Travis CI 持续集成
- 创建并配置 .travis.yml 文件
- 前往 travis-ci 官网添加相应 github 仓库
- 将 .travis.yml 文件 push 到仓库
2.4.4 发布 npm 包
- 确保代码全部测试通过
- 配置 package.json
- 上传代码到 npmjs.org
- 模拟用户使用自己的包
以下为BDD测试代码
const expect = chai.expect;
import Vue from "vue";
import Button from "../src/button";
Vue.config.productionTip = false;
Vue.config.devtools = false;
describe("Button", () => {
it("存在.", () => {
expect(Button).to.be.ok;
});
it("可以设置icon.", () => {
const Constructor = Vue.extend(Button);
const vm = new Constructor({
propsData: {
icon: "settings",
},
}).$mount();
const useElement = vm.$el.querySelector("use");
expect(useElement.getAttribute("xlink:href")).to.equal("#i-settings");
vm.$destroy();
});
it("可以设置loading.", () => {
const Constructor = Vue.extend(Button);
const vm = new Constructor({
propsData: {
icon: "settings",
loading: true,
},
}).$mount();
const useElements = vm.$el.querySelectorAll("use");
expect(useElements.length).to.equal(1);
expect(useElements[0].getAttribute("xlink:href")).to.equal("#i-loading");
vm.$destroy();
});
it("icon 默认的 order 是 1", () => {
const div = document.createElement("div");
document.body.appendChild(div);
const Constructor = Vue.extend(Button);
const vm = new Constructor({
propsData: {
icon: "settings",
},
}).$mount(div);
const icon = vm.$el.querySelector("svg");
expect(getComputedStyle(icon).order).to.eq("1");
vm.$el.remove();
vm.$destroy();
});
it("设置 iconPosition 可以改变 order", () => {
const div = document.createElement("div");
document.body.appendChild(div);
const Constructor = Vue.extend(Button);
const vm = new Constructor({
propsData: {
icon: "settings",
iconPosition: "right",
},
}).$mount(div);
const icon = vm.$el.querySelector("svg");
expect(getComputedStyle(icon).order).to.eq("2");
vm.$el.remove();
vm.$destroy();
});
it("点击 button 触发 click 事件", () => {
const Constructor = Vue.extend(Button);
const vm = new Constructor({
propsData: {
icon: "settings",
},
}).$mount();
const callback = sinon.fake();
vm.$on("click", callback);
vm.$el.click();
expect(callback).to.have.been.called;
});
});
2.5 编写 README
UI 介绍,npm标、持续集成标等,如何使用,浏览器版本适配情况,文档,提问......
3. 其他组件代码部分实现思路
造轮子的完整过程已在第2节体现,本节只讲述每个组件的代码实现部分
3.1 Input 组件
- 新建 src/input.vue,在template里写html,在script里写js并export,在style里用scss写样式
- 实现 input 组价的传参:在 props 中声明变量,在 input 中使用变量,在 scss 中写相应样式
- 实现 error 输入框的提示:用 template 标签包裹提示内容,并使用v-if="error"
- 实现 input 组价的事件:添加 change、input、focus、blur 事件,监听原生 input 的 change 事件 $event ,由此触发我们添加的方法,接下来写测试用例,进行测试驱动开发
@change="$emit('change', $event.target.value)"
- 让 input 支持 v-model
:value="value"
@input="$emit('input', $event.target.value)"
3.2 Grid 组件
- 新建 src/col.vue 和 src/row.vue,在template里写html,在script里写js并export,在style里用scss写样式
- 实现每行不同 col 数量时平均每个 col 的 width 的不同:在 col 组件里使用 scss 的循环语法
- 实现不对称布局,如22:2的宽度布局:在 col 组件里设置 span 参数并将该参数绑到 div 上
<template>
<div class="col" :class="[col-${span}]">
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.col {
$class-prefix: col-;
@for $n from 1 through 24 {
&.#{$class-prefix}#{$n} {
width: ($n/24) * 100%;
}
}
</style>
- 实现不均衡布局,如6+4+14(板块+留白+板块):在 col 组件里设置 offset 参数并将该参数绑到 div 上,针对 col 的 margin-left 使用 scss 的循环语法
- 解决整体布局在页面中存在左右padding的bug:在 row 组件里的 props 接受 gutter 参数,在 mounted 钩子中为每个 children 添加 gutter 属性实现传参给 col 组件,在样式中 row 的左右margin设为 -gutter/2 px,col 的左右padding 为 gutter/2 px
- 重构代码,增加代码可读性和简洁性
- 使用 @media 实现响应式
3.3 Layout 组件
- 新建 src/layout.vue、src/header.vue、src/content.vue、src/sider.vue、src/footer.vue,在 template 里写 html,在 script 里写 js 并 export,在 style 里用 scss 写样式
- 实现当页面发现有 silder 元素时使 silder 和 content 左右布局:在 layout 组件的 mounted 钩子上遍历子元素,当发现有子组件 Silder 时,给 layout 的 div 加上 hasSilder 的 class ,再给 hasSilder 加上 flex-direction:column
- 实现点击按钮隐藏silder功能:给silder添加一个visible属性和click事件,click事件改变visible属性的布尔值,配合v-if实现状态切换
- 实现silder隐藏时的过渡动画:用transition标签包裹silder,name="fade",然后给fade进出场动画添加动画
.slide-enter-active,
.slide-leave-active {
transition: all 0.5s;
}
.slide-enter,
.slide-leave-to {
margin-left: -200px;
}
3.4 Toast 组件
- 新建 src/toast.vue,在 template 里写 html,在 script 里写 js 并 export,在 style 里用 scss 写样式
- 新建 src/plugin.js,插件中在Vue.prototype上增加toast属性,创建实例,在app.js引用plugin.js,这样用户可以主动选择是否修改 Vue.prototype,如果冲突了可以选择不使用插件
- 给 toast 加样式
- 实现自动关闭功能:传入autoClose(是否自动关闭)和autoCloseDelay(自动关闭的延时时间),在mounted时setTimeout执行close方法
- 实现点击关闭功能:创建一个close按钮,如果用户传了closeButton,就执行onClickClose事件
- 实现在不同位置弹出的功能:接受position属性,针对不同的position取值计算出不同的class值,采取不同的进场出场动画
3.5 Tab 组件
- 新建 src/tabs.vue、src/tabs-head.vue、src/tabs-item.vue、src/tabs-body.vue、src/tabs-pane.vue,在 template 里写 html,在 script 里写 js 并 export,在 style 里用 scss 写样式
- 实现当用户点击某个item时,触发update事件,并绑定selectedTab:tabs接受一个selected属性并使用 .sync
< tabs :selected.sync="selectedTab"
- 实现tabs的横向/竖向排列:tabs接受一个direction属性,并用validator检验属性只能为横向或者竖向
- 实现tab是否被禁用的功能:tabs-item接受一个disabled属性,类型为布尔,默认false
- 实现tabs切换:tabs-item发布事件,tabs提供new Vue构造出eventbus,tabs的所有后代注入依赖、订阅事件并触发相应方法。
3.6 Popover 组件
- 新建 src/popover.vue,在 template 里写 html,在 script 里写 js 并 export,在 style 里用 scss 写样式
- 接受trigger属性,用户在click或者hover时触发事件,决定popover是否可见
- 接受position属性,用于确定popover出现的位置
3.7 Collapse 组件
- 新建 src/collapse.vue、src/collapse-item.vue,在 template 里写 html,在 script 里写 js 并 export,在 style 里用 scss 写样式
- 确定是否打开面板:collapse接受数组类型的selected属性,被选中则被监听并触发相应函数
- 确定是否只打开一个面板:collapse接受布尔类型的sigle属性,true即为同时只打开一个面板
- 实现sigle:collapse提供new Vue构造出eventbus,collapse-item注入依赖,触发相应方法。数据流:collpase通过eventbus通知item,当item被点击时,item通知eventbus,然后eventbus通知item去进行关闭或打开