基於VUE+TypeScript 一個快速開發的移動端UI元件庫

Qymh發表於2018-08-09

這是一篇求職文章 年齡21 目前級別p6+ 座標成都 找一份vue.js移動端H5工作
一份沒有任何包裝純真實的簡歷 簡歷戳這

求職文章一共有兩篇 另外一篇請點這一個nuxt(vue)+mongoose全棧專案聊聊我粗淺的專案架構

專案簡介

為什麼有這個專案?

之前重構完公司的專案後將專案的元件進行抽離然後構成了這個專案,UI庫基於專案之後維護也比較方便

專案地址

學生機伺服器ui.qymh.org.cn,阿里雲當時提供了一個0.9元的cdn,伺服器雖然差了點但我掛了cdn,訪問應該不會卡
注意在pc端下檢視,請按f12調至移動端視角
同樣要注意的是在掘金app中開啟這個專案,點我專案中的返回箭頭是無效的.我也不知道為什麼,需要點掘金app提供的箭頭返回路由

github

專案github地址QymhUI

專案截圖

基於VUE+TypeScript 一個快速開發的移動端UI元件庫

專案目錄

專案目錄仿element-ui,先來看張圖片

基於VUE+TypeScript 一個快速開發的移動端UI元件庫

目錄分析

component 打包後的元件js
dist 列子打包後的檔案
docs 掛載的靜態github page
examples 列子目錄
packages 元件目錄
src 資源目錄
typings 構建的名稱空間
webpack webpack目錄

元件目錄

構造了這麼多元件,這個地方的目錄是仿的element-ui的架構目錄

基於VUE+TypeScript 一個快速開發的移動端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端版本

結語

其實專案想在年末的時候開源,我多做一些功能,多做一點測試,多完善文件,多修改介面保證更友好更簡單.但沒辦法,要找工作了,專案現在僅有一個雛形,現在提前把架構思路和專案最主要的特點分享出來,我會盡我的全力爭取在年末讓這個專案成為一個合格的開源專案

相關文章