手把手教你玩转render函数「组件封装-dynamic-checkbox」

1,726 阅读3分钟

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

支持的类型

  • 多选(checkbox)
  • 单选(radio)

Attributes

参数说明类型默认值
value / v-model绑定值[String, Number,Boolean,Array]
type表单类型["checkbox", "radio", 'checkbox-group']checkbox
button是否渲染成按钮Booleanfalse
支持el-radio/checkbox/checkbox-group所有参数

当value值为Array的时候,会被识别成el-checkbox-group/el-radio-group, 此时需要提供一个options选项或者提供一个optionsUrl异步获取数据,两者互斥

参数说明类型默认值
options多选组合框的选项Array
formatter格式化选项(异步获取数据可能用到)Function
optionsUrl异步获取选项String
当传入optionsUrl下面参数可选
method请求方式支持RESTful-APIget
params/data遵循RESTful-API规范Object{}
parseData解析接口获取的数据Function

这是当前组件最初始的形态

dynamic-checkbox.vue

<script>
// 供全局使用
let h
// 支持的类型
const checkBoxType = [
  'checkbox',
  'checkbox-group',
  'radio'
]

export default {
  name: 'DynamicCheckbox',
  // $attrs中的成员不显示在dom上
  inheritAttrs: false,
  props: {
    // 类型
    type: {
      default: 'checkbox',
      validator: typeVal => {
        return checkBoxType.includes(typeVal)
      }
    },
    // 绑定值
    value: {
      type: [String, Number, Boolean, Array],
      default: ''
    },
    // 支持el-radio/checkbox/checkbox-group所有参数
  },
  computed: {
    // 双向绑定
    newValue: {
      get({ value }) {
        return value
      },
      set(value) {
        this.$emit('change', value)
      }
    },
  },
  methods: {
    onChangeHandle(val) {
      this.newValue = val
    }
  },
  render() {
    h = this.$createElement
    const {
      onChangeHandle,
      $attrs,
    } = this

    return h('?', {
      props: {
        value: this.newValue,
        ...this.$attrs
      },
      on: {
        change: onChangeHandle
      },
      ref: $attrs['ref-name'] || 'elCheckbox'
    },)
  }
}
</script>

解决$attrs中的中横线命名不转换

这里需要去考虑一个问题,就是在Vue中我们传递属性一般会用这两种方式去写

<dynamic-checkbox ref-name="radio" />
<dynamic-checkbox refName="radio" />

通过prop识别的属性Vue会帮你自动将中横线转换,但是不被props识别的属性(也就是统一放到$attrs对象里面的),是不会帮你自动转换的,所以我们需要实现自动转换的getAttrsName方法

getAttrsName({inheritAttrs: true}, 'inherit-attrs') // true
getAttrsName({'inherit-attrs': true}, 'inheritAttrs') // true
/**
* @description: 解决$attrs并不会自动将kebab-case转换为camelCase的问题
* @param {Object} $attrs
* @param {String} name
* @return {*} camelCase风格的name
*/
export function getAttrsName($attrs, name) {
  if ($attrs[name]) {
    return $attrs[name]
  }
  // 将中横线转转换为驼峰
  const kebabCase2camelCase = /-+([\w])/g
  const kebabCase2camelCaseFn = (name, reg) => {
    return name.replace(reg, (execStr, $1) => {
      return $1.toUpperCase()
    })
  }

  // 将驼峰转换为中横线
  const cameCase2kebabCase = /(?<=[a-z1-9])([A-Z])/g
  const cameCase2kebabCaseFn = (name, reg) => {
    return name.replace(reg, (execStr, $1) => {
      return `-${$1.toLocaleLowerCase()}`
    })
  }

  let reg
  let fn
  // 中横线转换成驼峰
  if (/-/g.test(name)) {
    reg = kebabCase2camelCase
    fn = kebabCase2camelCaseFn
  } else {
    // 驼峰转换为中横线
    reg = cameCase2kebabCase
    fn = cameCase2kebabCaseFn
  }

  return $attrs[fn(name, reg)]
}

所以最开始上面那块代码需要改一下

ref: $attrs['ref-name'] || 'elCheckbox'
// 替换
ref: getAttrsName($attrs, 'ref-name') || 'elCheckbox'

radio/radio-group

<template>
   <!-- radio -->
    <el-card slot="left" shadow="never" header="radio">
      <el-radio-group v-model="testVal1">
        <template v-for="item of cites">
          <dynamic-checkbox
            :key="item"
            :label="item"
            type="radio"
            ref-name="radio"
            @change="checkboxChang('radio', $event)"
          >
            备选项-{{ item }}
          </dynamic-checkbox>
        </template>
      </el-radio-group>
    </el-card>

    <!-- radio-group -->
    <el-card slot="left" shadow="never" header="radio-group">
      <dynamic-checkbox
        ref="radio-group"
        v-model="testVal2"
        :options="data"
        type="radio"
        :button="true"
        ref-name="radio-group"
        @change="checkboxChang('radio-group', $event)"
      />
    </el-card>
</template>


<script>
import dynamicCheckbox from '@/components/common/dynamic-checkbox'
export default {
  name: 'CkTestCheckbox',
  components: {
    'dynamic-checkbox': dynamicCheckbox
  },
  data() {
    return {
      cites: ['上海', '北京', '广州'],
      data: [
        { 'text': '贡茶', 'label': '上海市长宁区金钟路633号' },
        { 'text': '豪大大香鸡排超级奶爸', 'label': '上海市嘉定区曹安公路曹安路1685号' },
        { 'text': '茶芝兰(奶茶,手抓饼)', 'label': '上海市普陀区同普路1435号' },
        { 'text': '十二泷町', 'label': '上海市北翟路1444弄81号B幢-107' },
        { 'text': '星移浓缩咖啡', 'label': '上海市嘉定区新郁路817号' },
        { 'text': '阿姨奶茶/豪大大', 'label': '嘉定区曹安路1611号' },
        { 'text': '新麦甜四季甜品炸鸡', 'label': '嘉定区曹安公路2383弄55号' },
        { 'text': 'Monica摩托主题咖啡店', 'label': '嘉定区江桥镇曹安公路2409号1F,2383弄62号1F' },
        { 'text': '浮生若茶(凌空soho店)', 'label': '上海长宁区金钟路968号9号楼地下一层' }
      ],
      testVal1: '',
      testVal2: '上海市长宁区金钟路633号',
    }
  },
  methods: {
    checkboxChang(type, $event) {
      console.log(type, $event)
    }
  }
}
</script>

可以Get到的技巧

  • 利用template不被渲染来避免生成太多不必要的dom节点,这一点也算是性能优化的一小部分

效果图💗

dynamic-checkbox.vue

export default {
  computed: {
    // 是否渲染组合
    group({ $attrs: { options, optionsUrl }}) {
      return options || optionsUrl
        ? 'group'
        : ''
    },
    // 最终要渲染的组件名称
    componentTag: {
      get({ type, group, $attrs, isRenderButton }) {
        const tag = `el-${type}`
        if (group) {
          return `${tag}-${group}`
        }
        return `${tag}${isRenderButton($attrs.button)}`
      }
    }
  },
  methods: {
    // 是否渲染按钮类型
    isRenderButton(button) {
      return button
        ? '-button'
        : ''
    },
    // 渲染选项节点
    renderOptionsVNode(tag, newOptions) {
      let item = {}
      return newOptions.map(o => {
        if (!isObject(o)) {
          item.label = o
        } else {
          // 提供给用户formatter方法来格式化选项
          item = this.$attrs.formatter &&
          this.$attrs.formatter(o) ||
          o
        }

        return h(tag, {
          props: {
            ...item
          }
        }, item.text || item.label)
      })
    },
    // 渲染默读插槽内容
    renderSlots($slots) {
      return Object.values($slots).map(s => $slots[s])
    },
  },
  render() {
    h = this.$createElement
    const {
      group,
      type,
      $attrs,
      $slots,
      componentTag,
      onChangeHandle,
      isRenderButton,
      getAsyncOptions
    } = this

    // 子内容
    const childrenContent = []

    // 最终拿的options
    let newOptions = []

    // 组合选项
    if (group) {
      // 这里先不考虑异步options,后面再实现
      newOptions = $attrs.options

      // 组合子选项VNode
      const optionsVNodes = this.renderOptionsVNode(
        `el-${type}${isRenderButton($attrs)}`,
        newOptions
      )
      childrenContent.push(...optionsVNodes)
    } else {
      childrenContent.push(...$slots.default || [])
    }

    return h(componentTag, {
      props: {
        value: this.newValue,
        ...this.$attrs
      },
      on: {
        input: onChangeHandle,
        ...this.$listeners
      },
      ref: getAttrsName($attrs, 'ref-name') || 'elCheckbox'
    }, childrenContent)
  }
}

用render函数去写组件的时候,尽量去用函数式的方式去写相对单一的方法来组合实现单个功能,方便我们集成一个大范围的组件(前提这个组件紧密依赖),比如Form和表单项,我们可以将就实现一个单一功能的方法统一提取到文件夹的utils供当前组件及依赖的子组件使用

checkbox/checkbox-group

上面组件还有一块块需要完善

  • 支持异步获取options
  • 提供parseData方法让用户可以解析异步请求到的数据

所以我们需要去改动一下render函数那块,这里也有一个点需要注意,render函数中不能去写任何异步操作

async render() {
  let h = this.$createElement
  let data = await this.getAsyncData()
  h('el-selct', {
    props: {....}
  }, data.map(i => h('el-options', {....})))
  
    
  // 或者这种
  new Promise(resolve => {
    setTimeout(() => {
      resolve()
    }, 1000 * 3)
  }).then(() => {
    h(....)
  })
}

所以我们一般会在created钩子中去执行异步操作,但是这里有会出现一个问题,created中是可以进行数据初始化,但是我们封装的组件一般都要考虑用户变动配置项之后要再次进行初始化操作,举个常见例子

我们根据用户配置的url、method、params去异步获取数据,初始化的时候根据用户传入的配置项拉取数据(created中完成), 但是用户再次修改prams中的参数我们需要再次进行初始化流程,这个时候我们可以这么做

<script>
export default {
  data() {
    return {
      newOptions: []
    }
  }
  computed: {
    // 只要这三个参数有一个变动,就会触发重新计算
    requestOption({ url, method, params }) {
      let paramsKey = method.toUpperCase() === 'GET' ? 'params' : 'data'
      return {
        url,
        method,
        [paramsKey]: params
      }
    },
    watch: {
      requestOption: {
        async handler(requestOption) {
          this.newOptions = await this.getAsyncOptions(requestOption)
        },
        deep: true,
        immediate: true,
      }
    }
  },
  methods: {
    getAsyncOptions() {
      // 异步获取数据
    }
  },
  render() {
    h = this.$createElement
    this.requestOption
    // => 只要这里get了requestOption,
    // requestOption变动之后render函数就会重新渲染
  }
}
</script>

像上面这种实现的话我们就不需要在created钩子去进行初始化了,后续参数变动也会重新触发初始化的流程。我们通过compute进行建立多个依赖收集,watch弥补computed中不能执行异步操作的问题

接下来继续完善最后异步获取数据及解析数据部分

<!-- checkbox -->
<el-card slot="left" shadow="never" header="checkbox">
  <dynamic-checkbox
    v-model="testVal3"
    :options="data"
    type="checkbox"
    size="mini"
    :button="true"
    @change="checkboxChang('checkbox', $event)"
  />
</el-card>
<!-- checkbox-Group -->
<el-card slot="left" shadow="never" header="checkbox-group">
  <el-checkbox
    v-model="checkAll"
    :indeterminate="isIndeterminate"
    @change="handleCheckAllChange"
  >全选
  </el-checkbox>
  <dynamic-checkbox
    ref="checkbox-group"
    v-model="testVal4"
    :options-url="url"
    size="medium"
    :border="true"
    :formatter="formatterOptions"
    @change="checkboxChang('checkbox-group', $event)"
  />
</el-card>

效果图💗

dynamic-checkbox.vue

<script>
export default {
  data() {
    return {
      newOptions: []
    }
  }
  computed: {
    // 只要这三个参数有一个变动,就会触发重新计算
   requestOption({ $attrs, getParamskeyByMethod }) {
      const {
        method = 'get'
      } = $attrs
      return {
        url: getAttrsName($attrs, 'optionsUrl'),
        [getParamskeyByMethod(method)]: $attrs.params,
        method: method
      }
    },
   watch: {
    // 异步获取数据
    group: {
      async handler(isGroup) {
        if (!this.isAsyncOptions({ isGroup, $attrs: this.$attrs })) {
          return
        }

        this.newOptions = await this.getAsyncOptions(this.$attrs)
      },
      immediate: true
    },
    requestOption: {
      async handler() {
        const isAsync = this.isAsyncOptions({
          isGroup: this.$attrs.isGroup,
          $attrs: this.$attrs
        })
        if (!isAsync) {
          return
        }
        this.newOptions = await this.getAsyncOptions(this.$attrs)
      },
      deep: true
    }
  },
  },
  methods: {
    // 异步获取数据
    async getAsyncOptions($attrs) {
      const {
        $http,
        requestOption
      } = this
      let request
      if ($http) {
        request = $http
      }
      // 动态加载axios
      const options = await import('@/utils/request')
        .then(module => {
          request = module.default
          return request(requestOption)
        })
        .then(res => {
          return $attrs.parseData && $attrs.parseData(res) ||
          res.pageData ||
          res.data
        })
        .catch(err => {
          console.error(err)
        })
      return options || []
    },
    // 根据method获取params的Key
    getParamskeyByMethod(method) {
      return method.toUpperCase() === 'GET' ? 'params' : 'data'
    },
    // 必须是组合且提供了optionsUrl才会被认定异步获取数据
    isAsyncOptions({ isGroup, $attrs }) {
      return isGroup && getAttrsName($attrs, 'optionsUrl')
    }
  },
  render() {
    h = this.$createElement
    this.requestOption
    // => 只要这里get了requestOption,
    // requestOption变动之后render函数就会重新渲染
  }
}
</script>

对于动态请求的文件,我们可以使用import(),比如上面我们通过判断如果Vue原型上没有被挂载$http就直接引入request文件中定义的axios, 这里如果文件路径不存在,catch中也可以接住,程序不会出任何错误

完整代码 github.com/it-beige/bl…

在线卑微,如果觉得这篇文章对你有帮助的话欢迎大家点个赞👻

写在最后

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下

我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

业精于勤,荒于嬉

系列文章

手把手教你玩转render函数 dynamic-input

往期文章

【建议追更】以模块化的思想来搭建中后台项目

【以模块化的思想开发中后台项目】第一章

【前端体系】从一道面试题谈谈对EventLoop的理解 (更新了四道进阶题的解析)

【前端体系】从地基开始打造一座万丈高楼

【前端体系】正则在开发中的应用场景可不只是规则校验

「函数式编程的实用场景 | 掘金技术征文-双节特别篇」

【建议收藏】css晦涩难懂的点都在这啦