基于VUE+TypeScript 一个快速开发的移动端UI组件库

6,555 阅读8分钟

这是一篇求职文章 年龄21 目前级别p6+ 坐标成都 找一份vue.js移动端H5工作
一份没有任何包装纯真实的简历 简历戳这

求职文章一共有两篇 另外一篇请点这一个nuxt(vue)+mongoose全栈项目聊聊我粗浅的项目架构

项目简介

为什么有这个项目?

之前重构完公司的项目后将项目的组件进行抽离然后构成了这个项目,UI库基于项目之后维护也比较方便

项目地址

学生机服务器ui.qymh.org.cn,阿里云当时提供了一个0.9元的cdn,服务器虽然差了点但我挂了cdn,访问应该不会卡
注意在pc端下查看,请按f12调至移动端视角
同样要注意的是在掘金app中打开这个项目,点我项目中的返回箭头是无效的.我也不知道为什么,需要点掘金app提供的箭头返回路由

github

项目github地址QymhUI

项目截图

项目目录

项目目录仿element-ui,先来看张图片

目录分析

component 打包后的组件js
dist 列子打包后的文件
docs 挂载的静态github page
examples 列子目录
packages 组件目录
src 资源目录
typings 构建的命名空间
webpack webpack目录

组件目录

构造了这么多组件,这个地方的目录是仿的element-ui的架构目录

项目架构

webpack配置

webpack这里是一个大的知识点,叙述起来太麻烦了,这里提一下这个项目的webpack和其他有什么不同

  • 1 webpack打包typescript 我引入了 ForkTsCheckerWebpackPlugin,感觉最大的影响就是打包速度快了,而且这个插件高度适配vue,还提供了tslint,虽然我在这个项目没引用,之后会提到
  • 2 我项目中有一个qymhui.config.js,这个文件是UI的配置项,是暴漏给开发者的,就类似于.babelrc postcss.config.js 一样,我在webpack中读取他,然后通过webpack.definePlugin写入process.env,这个位置有一个大坑 1.暴漏给开发者的js只能用commonjs语法 2.我暴漏的js里面开发者是可以写入函数的,然而JSON.stringify是直接忽略函数,之后我通过了对象深度拷贝解决了这个问题

架构分析

  • 1 第一步 在packages中创建组件目录,下面的步骤会以q-radio这个按钮组件进行举列,我们来看看他的目录结构
    模版引擎我用的pug,vue中写typescript我使用了vue-property-decorator,预处理器用的scss

packages/radio/index.ts

import Radio from './src/main.vue'
export default Radio

packages/radio/src/main.vue

<template lang="pug">
  .q-radio(:style="computedOuterStyle")
    //- 方形选择器
    .q-radio-rect(
      v-if="type==='rect'"
      @click="change(!active)"
      :style="computedStyle")
      span(v-show="active")
        i.q-icon.icon-check(:style="{color:active?activeColor:''}")
    //- 圆形选择器
    .q-radio-circle(
      v-if="type==='circle'"
      @click="change(!active)"
      :style="computedStyle")
      span.q-radio-circle-value(
        v-show="active")
        i.q-icon.icon-check(:style="{color:active?activeColor:''}")
</template>

<script lang="ts">
import { Vue, Component, Prop, Emit } from 'vue-property-decorator'
import Proto from '../../proto/tag/main.vue'
import createStyle from '../../proto/tag'
const config = require('../../../src/qymhui.config').default.qradio

@Component({})
export default class QRadio extends Proto {
  // 激活状态
  private active: boolean = false

  // 类型
  @Prop({ default: config.type })
  private type: radio.type

  // 是否有边框
  @Prop({ default: config.hasBorder })
  private hasBorder: boolean

  // 边框颜色
  @Prop({ default: config.borderColor })
  private borderColor: string

  // 激活下的颜色
  @Prop({ default: config.activeColor })
  private activeColor: string

  // 激活下的背景颜色
  @Prop({ default: config.activeBkColor })
  private activeBkColor: string

  // 激活下的border颜色
  @Prop({ default: config.activeBorderColor })
  private activeBorderColor: string

  private get computedStyle() {
    let style = Object.create(null)
    if (this.hasBorder) {
      style.borderStyle = 'solid'
      style.borderWidth = '1px'
      if (this.active) {
        style.borderColor = this.activeBorderColor
      } else {
        style.borderColor = this.borderColor
      }
    }
    if (this.active && this.activeBkColor && this.type === 'circle') {
      style.backgroundColor = this.activeBkColor
    }
    return style
  }

  private get computedOuterStyle() {
    let style = createStyle(this)
    return style
  }

  @Emit()
  private change(active: boolean) {
    this.active = !this.active
  }
}
</script>

<style lang="scss" scoped>
.q-radio {
  display: inline-block;
  height: 0.5rem;
  width: 0.5rem;
  position: relative;
}
.q-radio-rect {
  position: absolute;
  top: 0;
  left: 0;
  height: 0.5rem;
  width: 0.5rem;
  line-height: 0.5rem;
  border-radius: 0.05rem;
  display: inline-block;
  font-size: 10px;
  text-align: center;
  > span {
    display: inline-block;
    height: 100%;
    width: 100%;
    > i {
      font-size: 14px;
    }
  }
}
.q-radio-circle {
  position: absolute;
  top: 0;
  left: 0;
  height: 0.5rem;
  width: 0.5rem;
  line-height: 0.5rem;
  border-radius: 50%;
  display: inline-block;
  font-size: 10px;
  text-align: center;
  &-value {
    color: #fff;
  }
  > span {
    display: inline-block;
    height: 100%;
    width: 100%;
    > i {
      font-size: 14px;
    }
  }
}
</style>


  • 2 第二步引用并暴漏

我在src/index.ts中引入这个组件,并暴漏注册组件的方法,这个位置的写法也仿的element-ui
不过这个地方有一个坑,element-ui注册组件直接用的component.name就可以拿到组件的名字,但ts打包组件的名字会被压缩,不知道这算不算一个Bug,所以我们得单独把每个组件的名字用数组保存,我们来看看代码

import './fonts/iconfont.css'
import './style/highLight.scss'
import './style/widget.scss'
import './style/animate.scss'
import './style/mescroll.scss'
import 'swiper/dist/css/swiper.min.css'
import 'mobile-select/mobile-select.css'

import Vue from 'vue'
import lazyLoad from 'vue-lazyload'
import CONFIG from './qymhui.config'
Vue.use(lazyLoad, CONFIG.qimage)

import '../packages/widget'

import QRow from '../packages/row'
import QCol from '../packages/col'
import QText from '../packages/text'
import QCell from '../packages/cell'
import QHeadBar from '../packages/headBar'
import QSearchBar from '../packages/searchBar'
import QTabBar from '../packages/tabBar'
import QTag from '../packages/tag'
import QCode from '../packages/code'
import QForm from '../packages/form'
import QInput from '../packages/input'
import QRadio from '../packages/radio'
import QStepper from '../packages/stepper'
import QTable from '../packages/table'
import QOverlay from '../packages/overlay'
import QFiles from '../packages/files'
import QImage from '../packages/image'
import QSwiper from '../packages/swiper'
import QPhoto from '../packages/photo'
import QSelect from '../packages/select'
import QScroll from '../packages/scroll'

const components = [
  QRow,
  QCol,
  QText,
  QCell,
  QHeadBar,
  QSearchBar,
  QTabBar,
  QTag,
  QCode,
  QForm,
  QInput,
  QRadio,
  QStepper,
  QTable,
  QOverlay,
  QFiles,
  QImage,
  QSwiper,
  QPhoto,
  QSelect,
  QScroll
]

const componentsName: string[] = [
  'QRow',
  'QCol',
  'QText',
  'QCell',
  'QHeadBar',
  'QSearchBar',
  'QTabBar',
  'QTag',
  'QCode',
  'QForm',
  'QInput',
  'QRadio',
  'QStepper',
  'QTable',
  'QOverlay',
  'QFiles',
  'QImage',
  'QSwiper',
  'QPhoto',
  'QSelect',
  'QScroll'
]

const install = function(Vue: any, opts: any) {
  components.map((component: any, i) => {
    Vue.component(componentsName[i], component)
  })
}

export default {
  install,
  QRow,
  QCol,
  QText,
  QCell,
  QHeadBar,
  QSearchBar,
  QTabBar,
  QTag,
  QCode,
  QForm,
  QInput,
  QRadio,
  QStepper,
  QTable,
  QOverlay,
  QFiles,
  QImage,
  QSwiper,
  QPhoto,
  QSelect,
  QScroll
}

  • 3 直接在引用其中的install,然后通过Vue.use 注册插件就可以使用了

项目特点

快速开发

思路

与其他UI框架的不同在于,我们在组件的布局上进行了创新
平常我们在项目时,会写html,再写css,html中存在大量复杂的命名,如果采用BEM命名准则,比如 .a_b_c .a-b_c 通过下划线链接命名,刚才的列子还只是测试,在真实的开发环境下长度是可怕的,所以我们在布局layout组件中,直接省去了元素命名,并将css书写成本降到最低

架构

这个地方是用typesrcipt的继承实现的

首先构造属性vuets,下面的列子举了一个q-row的列子,我把常用的css样式直接放在了q-row组建的prop

packages/proto/row/main.vue

<script lang="tsx">
import { Vue, Component, Prop } from 'vue-property-decorator'

@Component
export default class Proto extends Vue {
  // 高
  @Prop({ default: -1 })
  public h: string

  // 行高
  @Prop({ default: -1 })
  public lh: string

  // 宽
  @Prop({ default: -1 })
  public w: string

  // 高度百分比
  @Prop({ default: -1 })
  public row: string

  // 宽度百分比
  @Prop({ default: -1 })
  public col: string

  // margin-top
  @Prop({ default: 0 })
  public mt: string

  // margin-right
  @Prop({ default: 0 })
  public mr: string

  // margin-bottom
  @Prop({ default: 0 })
  public mb: string

  // margin-left
  @Prop({ default: 0 })
  public ml: string

  // padding-top
  @Prop({ default: 0 })
  public pt: string

  // padding-right
  @Prop({ default: 0 })
  public pr: string

  // padding-bottom
  @Prop({ default: 0 })
  public pb: string

  // padding-left
  @Prop({ default: 0 })
  public pl: string

  // 定位
  @Prop({ default: 'static' })
  public position: common.position

  // top
  @Prop({ default: -1 })
  public t: number | string

  // right
  @Prop({ default: -1 })
  public r: number | string

  // bottom
  @Prop({ default: -1 })
  public b: number | string

  // left
  @Prop({ default: -1 })
  public l: number | string

  // 字体大小
  @Prop({ default: -1 })
  public fontSize: string

  // 字体颜色
  @Prop({ default: '' })
  public color: string

  // 背景颜色
  @Prop({ default: '' })
  public bkColor: string

  // text-align
  @Prop({ default: '' })
  public textAlign: common.textAlign

  // z-index
  @Prop({ default: 'auto' })
  public zIndex: string

  // display
  @Prop({ default: '' })
  public display: common.display

  // vertical-align
  @Prop({ default: 'baseline' })
  public vertical: common.vertical

  // overflow
  @Prop({ default: 'visible' })
  public overflow: common.overflow

  // text-decoration
  @Prop({ default: 'none' })
  public decoration: common.decoration

  // border-radius
  @Prop({ default: -1 })
  public radius: number | string

  // word-break
  @Prop({ default: 'normal' })
  public wordBreak: common.wordBreak

  // text-indent
  @Prop({ default: -1 })
  public indent: string

  // border
  @Prop({ default: '' })
  public border: string
  // border-top
  @Prop({ default: '' })
  public borderTop: string
  // border-right
  @Prop({ default: '' })
  public borderRight: string
  // border-bottom
  @Prop({ default: '' })
  public borderBottom: string
  // border-left
  @Prop({ default: '' })
  public borderLeft: string
}
</script>

packages/proto/row/index.ts

// 构造全局样式
export default function createStyle(vm: any) {
  const style: any = {
    // 可选属性为auto

    // 高
    height:
      vm.h === -1 && vm.row === -1
        ? 'auto'
        : vm.h !== -1
          ? `${vm.h / 10}rem`
          : `${vm.row}%`,
    // 行高
    lineHeight: vm.lh === -1 ? 'auto' : `${vm.lh / 10}rem`,
    // 宽
    width:
      vm.w === -1 && vm.col === -1
        ? 'normal'
        : vm.w !== -1
          ? `${vm.w / 10}rem`
          : `${vm.col}%`,
    // 定位
    position: vm.position,
    // top
    top:
      vm.t === -1
        ? 'auto'
        : typeof vm.t === 'number'
          ? `${vm.t / 10}rem`
          : `${vm.t}%`,
    // right
    right:
      vm.r === -1
        ? 'auto'
        : typeof vm.r === 'number'
          ? `${vm.r / 10}rem`
          : `${vm.r}%`,
    // bottom
    bottom:
      vm.b === -1
        ? 'auto'
        : typeof vm.b === 'number'
          ? `${vm.b / 10}rem`
          : `${vm.b}%`,
    // left
    left:
      vm.l === -1
        ? 'auto'
        : typeof vm.l === 'number'
          ? `${vm.l / 10}rem`
          : `${vm.l}%`,
    // 字体
    fontSize: vm.fontSize === -1 ? 'inherit' : `${vm.fontSize}px`,

    // 可选属性为空

    // margin-top
    marginTop: vm.mt === 0 ? '' : `${vm.mt / 10}rem`,
    // margin-right
    marginRight: vm.mr === 0 ? '' : `${vm.mr / 10}rem`,
    // margin-bottom
    marginBottom: vm.mb === 0 ? '' : `${vm.mb / 10}rem`,
    // margin-left
    marginLeft: vm.ml === 0 ? '' : `${vm.ml / 10}rem`,
    // padding-top
    paddingTop: vm.pt === 0 ? '' : `${vm.pt / 10}rem`,
    // padding-right
    paddingRight: vm.pr === 0 ? '' : `${vm.pr / 10}rem`,
    // padding-bottom
    paddingBottom: vm.pb === 0 ? '' : `${vm.pb / 10}rem`,
    // padding-left
    paddingLeft: vm.pl === 0 ? '' : `${vm.pl / 10}rem`,
    // border-radius
    borderRadius:
      vm.radius === -1
        ? ''
        : typeof vm.radius === 'number'
          ? `${vm.radius / 10}rem`
          : `${vm.radius}%`,

    // color
    color: vm.color,
    // 背景颜色
    backgroundColor: vm.bkColor,
    // text-align
    textAlign: vm.textAlign,
    // z-index
    zIndex: vm.zIndex,
    // display
    display: vm.display,
    // vertical-align
    verticalAlign: vm.vertical,
    // overflow
    overflow: vm.overflow,
    // word-break
    wordBreak: vm.wordBreak,
    // text-indent
    textIndent: vm.indent === -1 ? '' : `${vm.indent / 10}rem`,
    // text-decoration
    textDecoration: vm.decoration === 'none' ? '' : vm.decoration,
    // border
    border: vm.border || '',
    // border-top
    borderTop: vm.borderTop || '',
    // border-right
    borderRight: vm.borderRight || '',
    // border-bottom
    borderBottom: vm.borderBottom || '',
    // border-left
    borderLeft: vm.borderLeft || ''
  }

  for (const i in style) {
    if (style.hasOwnProperty(i)) {
      const item: string = style[i]
      if (
        item === '' ||
        (item === 'auto' && i !== 'overflow') ||
        item === 'inherit' ||
        item === 'static' ||
        item === 'normal' ||
        item === 'baseline' ||
        item === 'visible' ||
        (item === 'none' && i === 'textDecoration')
      ) {
        delete style[i]
      }
      // 更符合移动端overflow auto的标准
      if (i === 'overflow' && (item === 'auto' || item === 'scroll')) {
        style['-webkit-overflow-scrolling'] = 'touch'
      }
    }
  }

  return style
}

可扩展

思路

与其他UI框架不同,我们提供了config去改变默认的UI布局.你的项目的组件大小可能和UI库提供的不一样,没关系,我们内置了基础的UI布局,但你可以通过 qymhui.config.js去修改我们的默认配置,打造一个属于自己项目的UI库

架构

我们提供了一个默认配置,然后暴漏给用户一个配置,用户的配置是通过webpacknode环境读取的,最后合并两个配置并传向组件,下面就是qymhui.config.js的默认配置

// q-cell
export const qcell = {
  bkColor: '',
  hasPadding: true,
  borderTop: false,
  borderBottom: false,
  borderColor: '#d6d7dc',
  leftIcon: '',
  leftIconColor: '',
  leftText: '',
  leftTextColor: '#333',
  leftWidth: '',
  title: '',
  titleColor: '',
  rightText: '',
  rightTextColor: '',
  rightArrow: false,
  rightArrowColor: '#a1a1a1',
  baseHeight: 1.2
}

// q-head-bar
export const qheadbar = {
  color: '',
  bkColor: '',
  bothWidth: 1,
  hasPadding: true,
  padding: 0.2,
  borderTop: false,
  borderBottom: false,
  borderColor: '#d6d7dc',
  leftEmpty: false,
  leftArrow: false,
  centerEmpty: false,
  centerText: '',
  centerTextColor: '',
  rightEmpty: false,
  rightArrow: false,
  rightText: '',
  rightTextColor: '',
  baseHeight: 1.2
}

// q-search-bar
export const qsearchbar = {
  color: '',
  bkColor: '',
  hasPadding: true,
  padding: 0.2,
  bothWidth: 1,
  borderTop: false,
  borderBottom: false,
  borderColor: '#d6d7dc',
  value: '',
  leftArrow: false,
  leftText: '',
  leftTextColor: '',
  searchBkColor: 'white',
  placeholder: '请输入...',
  clearable: false,
  rightText: '搜索',
  rightTextColor: '',
  baseHeight: 1.2
}

// q-tabbar
export const qtabbar = {
  bkColor: '',
  borderTop: '',
  borderBottom: '',
  borderColor: '#d6d7dc',
  baseHeight: 1.2
}

// q-text
export const qtext = {
  lines: 0
}

// q-tag
export const qtag = {
  bkColor: '#d6d7dc',
  color: 'white',
  fontSize: 12,
  value: '',
  hasBorder: false,
  hasRadius: true,
  borderColor: '#d6d7dc',
  active: false,
  activeBkColor: '',
  activeColor: 'white'
}

// q-input
export const qinput = {
  hasBorder: false,
  borderBottom: true,
  borderColor: '#d6d7dc',
  bkColor: '',
  color: '',
  type: 'text',
  fix: 4,
  placeholder: ''
}

// q-radio
export const qradio = {
  type: 'rect',
  hasBorder: true,
  borderColor: '#a1a1a1',
  activeColor: '',
  activeBkColor: '',
  activeBorderColor: 'transparent'
}

// q-stepper
export const qstepper = {
  color: '#F65A44',
  min: 0,
  max: '',
  fix: 4
}

// q-overlay
export const qoverlay = {
  position: '',
  opacity: 0.3,
  bkColor: 'white',
  minHeight: 10,
  maxHeight: 13,
  show: false
}

// q-files
export const qfiles = {
  multiple: true,
  maxCount: 3,
  maxSize: 4,
  value: '点击上传',
  hasBorder: true,
  borderColor: '#a1a1a1'
}

// q-image
export const qimage = {
  preLoad: 1.3,
  loading: '',
  attemp: 1,
  bkSize: 'contain',
  bkRepeat: 'no-repeat',
  bkPosition: '50%'
}

// q-scroll
export const qscroll = {
  // 下拉刷新
  down: (vm) => {
    return {
      // 是否启用
      use: true,
      // 是否初次调用
      auto: false,
      // 回调
      callback(mescroll) {
        vm.$emit('refresh')
      }
    }
  },
  // 上拉加载
  up: (vm) => {
    return {
      // 是否启用
      use: true,
      // 是否初次调用
      auto: true,
      // 是否启用滚动条
      scrollbar: {
        use: true
      },
      // 回调
      callback: (page, mescroll) => {
        vm.$emit('load', page)
      },
      // 无数据时的提示
      htmlNodata: '<p class="upwarp-nodata">-- 没有更多的数据 --</p>'
    }
  }
}

// $notice
export const $notice = {
  // 提醒
  toast: {
    position: 'bottom',
    timeout: 1500
  },
  // 弹窗
  confirm: {
    text: '请输入文字',
    btnLeft: '确定',
    btnRight: '取消'
  }
}

// $cookie
export const $cookie = {
  // 过期时间
  enpireDays: 7
}

// $axios
export const $axios = {
  // 是否输入日志
  log: true,
  // 超时
  timeout: 20000,
  // 请求拦截器
  requestFn: (config) => {
    return config
  },
  // 响应拦截器
  responseFn: (response) => {
    return response
  }
}

不止UI组件

Widget

我们在项目中提供了除了UI组件的widget常用方法并将他们直接挂载在vue的原型上,你可以在vue环境中直接引用
比如
$cookie设置 cookie
$storage 设置 storage
$toast 提醒插件
$axios ajax封装
下面贴一下$cookie的封装

packages/widget/cookie/index.ts

import Vue from 'vue'
const Cookie = Object.create(null)
const config = require('../../../src/qymhui.config').default.$notice

Cookie.install = (Vue: any) => {
  Vue.prototype.$cookie = {
    /**
     * 获取cookie
     * @param key 键
     */
    get(key: string): string | number {
      let bool = document.cookie.indexOf(key) > -1
      if (bool) {
        let start: number = document.cookie.indexOf(key) + key.length + 1
        let end: number = document.cookie.indexOf(';', start)
        if (end === -1) {
          end = document.cookie.length
        }
        let value: any = document.cookie.slice(start, end)
        return escape(value)
      }
      return ''
    },

    /**
     * 设置cookie
     * @param key 键
     * @param value 值
     * @param expireDays 保留日期
     */
    set(key: string, value: any, expireDays: number = config.enpireDays) {
      let now = new Date()
      now.setDate(now.getDate() + expireDays)
      document.cookie = `${key}=${escape(value)};expires=${now.toUTCString}`
    },

    /**
     * 删除Cookie
     * @param key 键
     */
    delete(key: string | string[]) {
      let now = new Date()
      now.setDate(now.getDate() - 1)

      if (Array.isArray(key)) {
        for (let i in key) {
          let item: string = key[i]
          let value: any = this.get(item)
          document.cookie = `${item}=${escape(
            value
          )};expires=${now.toUTCString()}`
        }
      } else {
        let value = this.get(key)
        document.cookie = `${key}=${escape(value)};expires=${now.toUTCString()}`
      }
    },

    /**
     * 直接删除所有cookie
     */
    deleteAll() {
      let cookie = document.cookie
      let arr = cookie.split(';')
      let later = ''
      let now = new Date()
      now.setDate(now.getDate() - 1)

      for (let i in arr) {
        let item = arr[i]
        later = item + `;expires=${now.toUTCString()}`
        document.cookie = later
      }
    }
  }
}

Vue.use(Cookie)

我们将要做的

  • 移动端适配,目前仅支持flexible.jsrem布局,这是有问题的,flexible.js官方也提到了,之后会通过vh重写布局

  • UI模块需要增加,目前的UI框架是从我们的项目中抽离出来的常用的模块,但不代表是大家常用的,模块量过少

  • 文档现在只有移动端版,将来会支持到PC端版本

结语

其实项目想在年末的时候开源,我多做一些功能,多做一点测试,多完善文档,多修改接口保证更友好更简单.但没办法,要找工作了,项目现在仅有一个雏形,现在提前把架构思路和项目最主要的特点分享出来,我会尽我的全力争取在年末让这个项目成为一个合格的开源项目