组件封装参考

大家好,今天我们来谈谈前端每个人都在写的东西,组件。

在当下react和vue这么流行的大环境下,相信你一定写过组件。如果你感觉你是职场小白,只从github或者npm下载过一些组件使用,自己没有写过,那么你就错了,你一定写过。因为往大一点说,一个页面可以是一个组件,往小了说,一个按钮也是一个组件,你写的区别只在于它的扩展性是不是好,通用性是不是强。所以,首先不要畏惧,我们每个人其实都在写组件,今天的分享,就是如何让我们更好的写组件

一、需求分析-构思组件

从接手项目,带你开启组件封装第一步:

1. 需求分析

拿到项目之后,这里以一个新项目为例,首先,全局概览。看一下这个页面总共分几部分。各个部分之间有什么相同之处,这里举例(边距:考虑封装一个共有的盒子、色系:考虑用父元素继承的方式获取、title、排列方式、图文规划、功能区块等);初步划分功能,列出几个可能要封装的组件。
示例

2. 综合考虑

联系其他页面,再次分类,调整组件的分类,尽可能做到组件公用,扩张组件的可配置项(例如,title文案前可能有icon图标,尾部可能有更多箭头,文案等,尽可能完善这个组件)。

3. 归置组件

综合页面需求及过往经验,将组件划分为基础组件业务组件

针对不同类型组件,有不同的封装要求。公共类组件,可配置项一定要多,即要活,放哪里都可以用。业务类组件,可以适当地写死一些东西,因为确定这个组件是轻易不会变动的,例如(流程说明、提示楼层、价格楼层等)
示例

4. 使用说明

封装好的组件,最好有使用说明,有使用demo最好,使小白只要粘贴代码,就可以跑起来,首先增加使用者的自信心,可以轻易看到组件(默认样式及功能可以展示与使用)。

二、组件封装要求

组件封装,需要有一些强制的要求,才能保证我们的组件库在日益壮大的同时,不会因为各种不统一和零散化,导致组件库越来越难维护。

1. 关于组件规模

  • 基础组件:被拆解的很零散的组件;例如一个按钮、一个input框、一个title、一个盒子

一般而言,一些基础的、使用频率比较高的组件,适合做成‘零散型’组件,即一个小的单位,例如,一个toast,一个input,一个btn一个置顶按钮等。但是越是‘零散’的组件,其可配置项越要灵活,功能性越要完善;包括样式、入参等。
优点:可使用范围广,通用性、扩展性强。
缺点:组件很小,只能局部使用,需要和页面其他元素联动使用。

  • 模块组件:归整的很完善的组件;例如一个完整的轮播图、一个完整的筛选项、一个分页list。

相对于基础组件,为了更高效的开发,我们有时候会把业务性比较强,功能比较统一的一些‘大型模块’封装为一个组件。例如整个筛选功能,整个分页加载。整个图文混排等‘模块’,封装为一个组件。

优点:组件功能完善,不需要组装配合,直接可以构建成完整的页面,开发效率高。
缺点:业务定制化程度高,相对来说,可配置项少。

2. 关于class命名

  1. 语义化
    命名一定要语义化,让使用的人看到类名,就知道这个是组件的那个元素。(例如title_icon title_more_btn)
    禁止使用中文拼写,要求英文翻译。

  2. 风格统一
    同一个组件,命名一定要风格一致。(下划线、短横杠、驼峰式)

  3. 业务分类
    业务性强的组件,class命名的时候paimai_button_small

  4. 功能分类
    组件公用性强的,使用公用class命名,例如confim_submit_button

3. 关于样式书写

  1. 百分比
    内部样式用100%来处理,外部套一个父盒子,父盒子的宽高使用传入样式

  2. 作用域
    样式书写最好使用父套子的方式,给类名加权重和作用域,以保证绝对的样式‘内部性’。

  3. 容错
    样式也要有容错,不能差几像素,就乱了,这里指用flex布局,不要写死宽高。

  4. 禁忌写死
    样式避免使用!important写死样式。

  5. 范围
    必要时使用最大限制max或者min,防止外部没有定义,宽高超出组件内部。

  6. 托底
    书写托底的默认样式,且风格保持一致。
    我们定义页面一级父盒子,padding的留白统一为25像素。

  7. 语言
    建议统一使用scss书写,虽然目前组件库内部webpack配置会处理好几种css语言,但是,为统一代码,目前建议使用scss。

4. 关于mock数据结构

  1. 大众化
    数据结构大众化。即数据类型符合一般渲染逻辑;例如:轮播图使用数组结构、基本信息使用对象、图片地址使用字符串等

  2. 容错处理
    内部数据结构要紧凑、稳固,接收外部传入参数时,做各种格式化处理,此处包括(托底,容错,类型判断,防止因外部输入格式不对导致组件内部报错)。
    例如:入参是数组的要求,就需要判断来源数据类型,不是数组的,格式化为数组,不可以格式化的,直接走托底。

  3. 可扩展
    为后续组件扩展留余地,不把数据结构写的太死。例如二位数组处理,数据分组格式化等。

5. 关于变量参数

  1. 语义化
    例如:size,表示组件的大小)
    示例

  2. 多参
    只供组件内部使用的变量,可以内部定义。尽可能多的由外部传入参数,因为这样可以尽可能的做到可配置。

  3. 托底值
    不管是内部定义参数,还是外部传参都必须有默认值,即托底逻辑处理,不要存在必传参数,必要时做提示处理。

6. 关于方法暴露

  1. 内部方法暴露
    尽可能多的暴露方法,关于内部的一些方法定义,如果可以使组件更具灵活性,可以考虑将内部方法暴露出来。例如:功能强大的swiper组件,就暴露了很多内部定义的方法,供用户灵活的定制自己的组件。
    示例

  2. 业务逻辑不要有
    在方法中,一切业务逻辑不要有,尽可能的、可配置的透传参数,以便父级元素做数据处理。

  3. 命名
    方法名称命名标准:
    点击事件 **Click
    格式化方法 **Format
    初始化方法 **Init
    方法名称不怕长,就怕看不懂功能,分不清你我

三、示例解析

1.放大镜组件(vue)

组件示例

  • 组件内部代码
<template>
  <div class="magnifier-box" :class="vertical?'vertical':''" :ref="id" @mousemove="mousemove" @mouseover="mouseover" @mouseleave="mouseleave">
    <img v-show="showImg" :src="imgUrl" alt="">
    <div class="mouse-cover"></div>
  </div>
</template>
  • 组件变量定义
export default {
  props: {
    scale: {
      type: Number,
      default: 2
    },
    scalebox: {
      type: Number,
      default: 1
    },
    url: {
      type: String,
      required: true
    },
    bigUrl: {
      type: String,
      default: null
    },
    scroll: {
      type: Boolean,
      default: false
    }
  },
  data () {
    return {
      id: null,
      cover: null,
      imgbox: null,
      imgwrap: null,
      orginUrl: null,
      bigImgUrl: null,
      bigOrginUrl: null,
      imgUrl: null,
      img: null,
      canvas: null,
      ctx: null,
      rectTimesX: 0,
      rectTimesY: 0,
      imgTimesX: 0,
      imgTimesY: 0,
      init: false,
      step: 0,
      bigStep: 0,
      vertical: false,
      showImg: true
    }
  },
  created () {
    var $chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678';    /****默认去掉了容易混淆的字符oOLl,9gq,Vv,Uu,I1****/
    var maxPos = $chars.length
    var str = ''
    for (let i = 0; i < 10; i++) {
      str += $chars.charAt(Math.floor(Math.random() * maxPos));
    }
    this.id = str
    this.imgUrl = this.url
    this.orginUrl = this.url
    this.bigImgUrl = this.bigUrl
    this.bigOrginUrl = this.bigUrl
  },
  watch: {
    url: function (val) {
      this.imgUrl = val
      this.orginUrl = val
      this.initTime()
    },
    bigUrl: function () {
      this.bigImgUrl = bigUrl
      this.bigOrginUrl = bigUrl
      this.initTime()
    }
  },
  mounted () {
    this.$nextTick(() => {
      this.initTime()
    })
  },
  methods: {
    initTime () {
      this.init = false
      let box = this.$refs[this.id]
      this.imgbox = box
      this.cover = box.querySelector('.mouse-cover')
      this.cover.style.width = (this.imgbox.offsetWidth / this.scale) + 'px'
      this.cover.style.height = (this.imgbox.offsetHeight / this.scale) + 'px'
      this.cover.style.left = '-100%'
      this.cover.style.top = '-100%'
      this.imgwrap = box.querySelector('img')
      let imgsrc;
      if (this.bigImgUrl) {
        imgsrc = this.bigImgUrl
      } else {
        imgsrc = this.imgUrl
      }
      this.img = new Image()
      this.img.src = imgsrc
      this.img.onload = () => {
        this.vertical = this.img.width < this.img.height
        this.init = true
      }
      if (this.canvas) {
        this.canvas.parentNode.removeChild(this.canvas)
        this.canvas = null
      }
      this.canvas = document.createElement('canvas')
      this.canvas.className = 'mouse-cover-canvas'
      this.canvas.style.position = 'absolute'
      this.canvas.style.left = this.imgbox.offsetLeft + this.imgbox.offsetWidth + 10 + 'px'
      this.canvas.style.top = this.imgbox.offsetTop + 'px'
      this.canvas.style.border = '1px solid #eee'
      this.canvas.style.zIndex = '99999'
      this.canvas.height = this.imgbox.offsetHeight * this.scalebox
      this.canvas.width = this.imgbox.offsetWidth * this.scalebox
      this.canvas.style.display = 'none'
      this.imgbox.parentNode.append(this.canvas)
      this.ctx = this.canvas.getContext("2d")
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    },
    mousemove (e) {
      if (!this.init) {
        return false
      }
      let _this = this
      //获取实际的offset
      function offset (curEle) {
        var totalLeft = null, totalTop = null, par = curEle.offsetParent;
        //首先加自己本身的左偏移和上偏移
        totalLeft += curEle.offsetLeft;
        totalTop += curEle.offsetTop
        //只要没有找到body,我们就把父级参照物的边框和偏移也进行累加
        while (par) {
          if (navigator.userAgent.indexOf("MSIE 8.0")===-1) {
            //累加父级参照物的边框
            totalLeft += par.clientLeft;
            totalTop += par.clientTop
          }

          //累加父级参照物本身的偏移
          totalLeft += par.offsetLeft;
          totalTop += par.offsetTop
          par = par.offsetParent;
        }

        return {
          left: totalLeft,
          top: totalTop
        }
      }

      function getXY (eve) {
        return {
          x: eve.clientX - (_this.cover.offsetWidth / 2),
          y: eve.clientY - (_this.cover.offsetHeight / 2)
        };
      }
      let oEvent = e || event;
      let pos = getXY(oEvent);
      let imgwrap = offset(this.imgwrap)
      let range = {
        minX: imgwrap.left,
        maxX: imgwrap.left + this.imgwrap.offsetWidth - this.cover.offsetWidth,
        minY: imgwrap.top - document.documentElement.scrollTop,
        maxY: imgwrap.top - document.documentElement.scrollTop + this.imgwrap.offsetHeight - this.cover.offsetHeight,
      }
      if (pos.x > range.maxX) {
        pos.x = range.maxX
      }
      if (pos.x < range.minX) {
        pos.x = range.minX
      }
      if (pos.y > range.maxY) {
        pos.y = range.maxY
      }
      if (pos.y < range.minY) {
        pos.y = range.minY
      }
      this.cover.style.left = pos.x + 'px'
      this.cover.style.top = pos.y + 'px'
      this.ctx.clearRect(0, 0, this.imgwrap.offsetWidth, this.imgwrap.offsetHeight);
      let startX = pos.x - (imgwrap.left - document.documentElement.scrollLeft),
        startY = pos.y - (imgwrap.top - document.documentElement.scrollTop)
      // 重新初始化图片的宽高
      this.rectTimesX = (this.imgbox.offsetWidth / this.scale) / this.imgwrap.offsetWidth,
      this.rectTimesY = (this.imgbox.offsetHeight / this.scale) / this.imgwrap.offsetHeight
      this.imgTimesX = this.img.width / this.imgwrap.offsetWidth,
      this.imgTimesY = this.img.height / this.imgwrap.offsetHeight
      this.ctx.drawImage(
        this.img,
        startX * this.imgTimesX,
        startY * this.imgTimesY,
        this.img.width * this.rectTimesX,
        this.img.height * this.rectTimesY,
        0,
        0,
        this.canvas.width,
        this.canvas.height
      );
    },
    mouseover (e) {
      if (!this.init) {
        return false
      }
      e = e || event
      if (!this.scroll) {
        e.currentTarget.addEventListener('mousewheel', function (ev) {
          ev.preventDefault()
        }, false)

        e.currentTarget.addEventListener('DOMMouseScroll', function (ev) {
          ev.preventDefault()
        }, false)
      }

      this.cover.style.display = 'block'
      this.canvas.style.display = 'block'
    },
    mouseleave () {
      if (!this.init) {
        return false
      }
      this.cover.style.display = 'none'
      this.canvas.style.display = 'none'
    },

  }
}
</script>
  • 组件样式书写

<style lang="scss" scoped>
  .magnifier-box{
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
    overflow: hidden;
    position: relative;
    img{
      width: 100%;
      /*height: 100%;*/
    };
    .mouse-cover{
      position: fixed;
      background-color: rgba(255,255,255,0.5);
      cursor:move
    };
    .mouse-cover-canvas{
      position:fixed;
      left:100%;
      top:0;
      width:100%;
      height:100%;
    }
    &.vertical{
      img{
        height: 100%;
        width: auto
      }
    }
  }
</style>

2.消息框组件(eleemnt-ui)

  • 组件Dom
<template>
  <transition name="msgbox-fade">
    <div
      class="el-message-box__wrapper"
      tabindex="-1"
      v-show="visible"
      @click.self="handleWrapperClick"
      role="dialog"
      aria-modal="true"
      :aria-label="title || 'dialog'">
      <div class="el-message-box" :class="[customClass, center && 'el-message-box--center']">
        <div class="el-message-box__header" v-if="title !== null &&title">
          <div class="el-message-box__title">
            <div
              :class="['el-message-box__status', icon]"
              v-if="icon && center">
            </div>
            <span>{{ title }}</span>
          </div>
        </div>
        <div class="el-message-box__content">
          <div
            :class="['el-message-box__status', icon]"
            v-if="icon && !center && message !== ''">
          </div>
          <div class="el-message-box__message" v-if="message !== ''">
            <slot>
              <div v-if="!dangerouslyUseHTMLString">
                <em>{{ mainMessage }}</em>
                <p>{{ message }}</p>
              </div>
              <p v-else v-html="message"></p>
            </slot>
          </div>
          <div class="el-message-box__input" v-show="showInput">
            <el-input
              v-model="inputValue"
              :type="inputType"
              @keydown.enter.native="handleInputEnter"
              :placeholder="inputPlaceholder"
              ref="input"></el-input>
            <div class="el-message-box__errormsg" :style="{ visibility: !!editorErrorMessage ? 'visible' : 'hidden' }">{{ editorErrorMessage }}</div>
          </div>
        </div>
        <div class="el-message-box__btns">
          <el-button
            :loading="cancelButtonLoading"
            :class="[ cancelButtonClasses ]"
            v-if="showCancelButton"
            :round="roundButton"
            size="small"
            @click.native="handleAction('cancel')"
            @keydown.enter="handleAction('cancel')">
            {{ cancelButtonText || t('el.messagebox.cancel') }}
          </el-button>
          <el-button
            :loading="confirmButtonLoading"
            ref="confirm"
            :class="[ confirmButtonClasses ]"
            v-show="showConfirmButton"
            :round="roundButton"
            size="small"
            @click.native="handleAction('confirm')"
            @keydown.enter="handleAction('confirm')">
            {{ confirmButtonText || t('el.messagebox.confirm') }}
          </el-button>
        </div>
      </div>
    </div>
  </transition>
</template>
  • 组件变量定义
 props: {
      modal: {
        default: true
      },
      lockScroll: {
        default: true
      },
      showClose: {
        type: Boolean,
        default: true
      },
      closeOnClickModal: {
        default: true
      },
      closeOnPressEscape: {
        default: true
      },
      closeOnHashChange: {
        default: true
      },
      center: {
        default: false,
        type: Boolean
      },
      roundButton: {
        default: false,
        type: Boolean
      }
    },

    components: {
      ElInput
    },

    computed: {
      icon() {
        const { type, iconClass } = this;
        return iconClass || (type && typeMap[type] ? `el-icon-${ typeMap[type] }` : '');
      },

      confirmButtonClasses() {
        return `el-button--primary ${ this.confirmButtonClass }`;
      },
      cancelButtonClasses() {
        return `${ this.cancelButtonClass }`;
      }
    },

    methods: {
      doClose() {
        if (!this.visible) return;
        this.visible = false;
        this._closing = true;

        this.onClose && this.onClose();
        messageBox.closeDialog(); // 解绑

        this.opened = false;
        this.doAfterClose();
        setTimeout(() => {
          if (this.action) this.callback(this.action, this);
        });
      },

      handleWrapperClick() {
        if (this.closeOnClickModal) {
          this.handleAction(this.distinguishCancelAndClose ? 'close' : 'cancel');
        }
      },

      handleInputEnter() {
        if (this.inputType !== 'textarea') {
          return this.handleAction('confirm');
        }
      },

      handleAction(action) {
        if (this.$type === 'prompt' && action === 'confirm' && !this.validate()) {
          return;
        }
        this.action = action;
        if (typeof this.beforeClose === 'function') {
          this.close = this.getSafeClose();
          this.beforeClose(action, this, this.close);
        } else {
          this.doClose();
        }
      },

      getFirstFocus() {
        const btn = this.$el.querySelector('.el-message-box__btns .el-button');
        const title = this.$el.querySelector('.el-message-box__btns .el-message-box__title');
        return btn || title;
      },
      getInputElement() {
        const inputRefs = this.$refs.input.$refs;
        return inputRefs.input || inputRefs.textarea;
      }
    },

    watch: {
      inputValue: {
        immediate: true,
        handler(val) {
          this.$nextTick(_ => {
            if (this.$type === 'prompt' && val !== null) {
              this.validate();
            }
          });
        }
      }   
    },

    data() {
      return {
        uid: 1,
        title: undefined,
        mainMessage: '',
        message: '',
        type: '',
        iconClass: '',
        customClass: '',
        showInput: false,
        inputValue: null,
        inputPlaceholder: '',
        inputType: 'text',
        inputPattern: null,
        inputValidator: null,
        inputErrorMessage: '',
        showConfirmButton: true,
        showCancelButton: false,
        action: '',
        confirmButtonText: '',
        cancelButtonText: '',
        confirmButtonLoading: false,
        cancelButtonLoading: false,
        confirmButtonClass: '',
        confirmButtonDisabled: false,
        cancelButtonClass: '',
        editorErrorMessage: null,
        callback: null,
        dangerouslyUseHTMLString: false,
        focusAfterClosed: null,
        isOnComposition: false,
        distinguishCancelAndClose: false
      };
    }
  • 组件样式书写参考
.el-message-box{
    border-radius: 2px;
    em{
      font-size: 14px;
      color: $color-title;
      line-height: 15px;
    }
    p{
      font-size: 12px;
      color: $color-default;
      line-height: 15px;
    }
    &__title{
      font-size: 14px;
      color: $color-title;
      line-height: 20px;
      font-weight: bold;
    }
    &__header{
      padding: 9px 15px;
      background: $color-bg;
      border-bottom: 1px solid $color-border;
    }
    &__headerbtn{
      top: 12px;
    }
    &__content{
      padding: 28px 30px;
    }
    &__status{
      font-size: 30px;
      margin-right: 10px;
      &.el-icon-warning{
        color: $color-warning;
      }
      &.el-icon-success{
        color: $color-success;
      }
      &.el-icon-info{
        color: $color-info;
      }
      &.el-icon-error{
        color: $color-error;
      }
      &+.el-message-box__message{
        padding-left: 40px;
      }
    }
    &__btns{
      margin-top: 12px;
      margin-bottom: 5px;
    }
  }

总结

相信任何一个好的组件,都是经过实践历练,调试出来的,开始封装的组件没有那么多功能,没有那么完善不要灰心,坚持用,不好的地方我们可以在使用中改,经过多次使用的组件,一定会慢慢丰富和强健起来。最终可能经过3,5年时间,我们做的的组件库,可以达到项目的70%利用率,同时,我们的开发效率也会大大提高。代码更强健。
最后,本文档作为前端组件基本标准,我们会越来越细化它的要求。希望大家一起献计献策,完善我们的组件封装标准。
原文地址:https://yolkpie.net/2021/01/27/组件封装参考/

posted @ 2021-02-05 14:00  yolkpie  阅读(272)  评论(0编辑  收藏  举报