芒果 UI 项目实现思路

816 阅读7分钟

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去进行关闭或打开