Vue读懂这篇,进阶高级

18,486 阅读10分钟

Vue读懂这篇,进阶高级

注:得VueComponent着得Vue,通过此文了解VueComponent的强大。

超级代理

我们已经知道了 "props down events up",但日常的业务远远不止父子之间的“交互”,例如:子孙之间、曾孙之间、曾曾孙之间……!,我该如何想我的下级传递命令?下级做好了一件事,如何向上级报告?情况就越演越烈。

通常的解决办法如下:

严格遵守单向数据流props一层一层的传递,像传递奥运火炬一样。events一层一层向上冒泡。不仅在编写方面冗余且容易出错,更加大了组件间的“交互”成本。

Bus又过于称重且不易维护,在日常开发过程中,往往因为业务功能加大了组件的维护成本,那有没有一个方法可以直达?

一、$listeners【代理events】

官方解读:在 vue2.4 中,Vue 提供了一个$listeners属性,它是一个对象,里面包含了作用在这个组件上的所有监听器。可以配合 v-on="$listeners" 将所有的事件监听器指向这个组件的某个特定的子元素。

// these are also reactive so they may trigger child update if the child
vm.$listeners = listeners || emptyObject

官方文档解释甚微,以下我以km-grid里的一段代码为例:

注:km-grid-itemkm-grid的子组件

<div :class="cls" :style="tableStyles">
    <km-grid-item v-if="fixedLeftCol&&fixedLeftCol.length" fixed="left" v-on="$listeners" :columns="fixedLeftCol" :header-styles="leftFixedHeaderStyles" :body-styles="leftFixedBodyStyles"></km-grid-item>
    <km-grid-item v-on="$listeners" :columns="centerCol" :expandColumn="expandCol" :header-styles="headerStyles" :body-styles="bodyStyles"></km-grid-item>
    <km-grid-item v-if="fixedRightCol&&fixedRightCol.length" fixed="right" v-on="$listeners" :columns="fixedRightCol" :header-styles="rightFixedHeaderStyles" :body-styles="rightFixedBodyStyles"></km-grid-item>
</div>

这里在km-grid-item加上 v-on="$listeners"意思就是说将所有km-grid的监听器指向 km-grid-item,即我在km-grid-item里的所有通过$emit抛出的事件都可以被km-grid$listeners属性采集到。即km-grid-item代理了km-grid的事件。

通过v-on="$listeners",我们就可以消除 events地狱,降低组件“交互”间的成本,提高了代码可维护性,提高了性能。

二、$attrs【代理props】

官方解读:包含了父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外)。当一个组件没有声明任何 prop 时,这里会包含所有父作用域的绑定 (class 和 style 除外),并且可以通过 v-bind="$attrs" 传入内部组件——在创建高级别的组件时非常有用。

// these are also reactive so they may trigger child update if the child
vm.$attrs = parentVnode.data.attrs || emptyObject

即当我在子组件加上 v-bind="$attrs"时,并没有在子组件内部用props接收,在Vue v2.4之后,多余的属性将会被$attrs接收,即可在子组件内部通过this.$attrs获取。

通过v-bind="$attrs",我们不用在子组件上去同步props,代理了props,但仍解决不了props地狱的问题。

推荐:使用$parent$parent.$parent获取父组件的 VueCompont,因为VueCompont是响应式的。在子组件、孙组件引用只是对对象的引用,能解决props地狱的问题,但切记这是对对象的引用,若只想获取父组件值,请使用deepCopy方法。

export const deepCopy = data => {
  const t = typeOf(data)
  let o
  if (t === 'array') {
    o = []
  } else if (t === 'object') {
    o = {}
  } else {
    return data
  }
  if (t === 'array') {
    for (let i = 0; i < data.length; i++) {
      o.push(deepCopy(data[i]))
    }
  } else if (t === 'object') {
    for (let i in data) {
      o[i] = deepCopy(data[i])
    }
  }
  return o
}

超越父子亲情

使用这两个方法之前要在render树上存在父子孙关系,可越级。不熟悉render树的可以看我之前写的一篇js执行过程及vue编译过程

什么是render树?即我们使用的.vue文件最终都会通过vue-Compiler生成render树。通过slot最终也会正确的变为render树,是vnode的原型,也是DOM树的映射。如下是一个简单的render树

with(this){
  return (isShow) ?
    _c('ul', {
        staticClass"list",
        class: bindCls
      },
      _l((data), function(item, index{
        return _c('li', {
          on: {
            "click"function($event{
              clickItem(index)
            }
          }
        },
        [_v(_s(item) + ":" + _s(index))])
      })
    ) : _e()
}

各位看官也可在chorme浏览器调试模式下的Sources查看编译之后的代码。

一、dispatch

dispatch子广播,父接收,跟events有点区别。

export default {
  methods: {
    dispatch(componentName, eventName, params) {
      let parent = this.$parent || this.$root
      let name = parent.$options.name
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent
        if (parent) name = parent.$options.name
      }
      if (parentparent.$emit.apply(parent, [eventName].concat(params))
    }
  }
}

以下我以添加form表单label自适应的需求为例:

<Form ref="entity" class="simpleModal" justify :model="entity">
  <Row>
    <Col span="24">
      <FormItem label="角色名称" prop="name" :required="true" :maxLen="10">
        <Input v-model="entity.name" placeholder :maxlength="10" />
      </FormItem>
    </Col>
  </Row>
  <Row>
    <Col span="24">
      <FormItem label="职能范围" :required="true" prop="functionScope">
        <Select
          v-model="entity.functionScope"
          :disabled="!entity.isNewEntity"
          @on-change="changeFunctionScope"
        >

          <Option v-for="(txt,key) in functionScopes" :value="key" :key="key">{{ txt }}</Option>
        </Select>
      </FormItem>
    </Col>
  </Row>
</Form>

首先我们在父组件Formcreated钩子里添加监听:

created() {
  this.$on('on-form-item-label', (field) => {
    this.labelWidthArr.push(field)
  })
}
computed: {
  labelWidthMax: {
    get () {
      return this.labelWidthArr.sort((a, b) => {return a - b;})[this.labelWidthArr.length-1];
    },
    set (val) {
      this.labelWidthArr.sort((a, b) => {return a - b;})[this.labelWidthArr.length-1] = val
    }
  }
}

然后在子组件FormItemcreated钩子里dispatch

created () {
  if (this.form.justify) {
    let span = document.createElement('span')
    let mock = document.createElement('div')
    let FormItemPadding = 12
    span.innerHTML = this.label
    span.style.fontSize = '14px'
    mock.appendChild(span)
    document.body.appendChild(mock)
    let widthContained = span.offsetWidth
    document.body.removeChild(mock)
    if (this.required || this.getRules().some(v => v.required)) {
        widthContained += 10
    }
    widthContained += FormItemPadding
    this.dispatch('iForm''on-form-item-label', widthContained)
  }
}

通过这样,子组件每次created都像父组件抛出当前计算的label,然后在父组件接收计算出最大值,按最大的那个加载,即可实现自适应。dispatch适用于无限向上发送,只要是存在最终通过vue-Compiler编译的 render树上的父子层级关系即可。

显然Form组件里面有很多的slot,但是存在render树的“父子”,是子主动dispatch

二、broadcast

broadcast父主动派发,子孙接收,跟props有点区别。

function broadcast(componentName, eventName, params{
  this.$children.forEach(child => {
    const name = child.$options.name
    if (name === componentName
{
      child.$emit.apply(child, [eventName].concat(params))
    } else {
      broadcast.apply(child, [componentName, eventName].concat([params]))
    }
  })
}
export default {
  methods: {
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params)
    }
  }
}

以下,我一实现一个在路由激活时,重新加载以下数据为例:
首先在需要更新的组件B里监听:

created () {
  this.loadInventory()
  this.$on('on-load'() => { this.loadInventory() })
}

然后在当前路由组件A里的activated主动broadcast

activated () {
  const listDataCache = this.$refs.list.data.DataList
  if (listDataCache && listDataCache.length) {
    this.broadcast('checkWatch''on-load')
  }
}

当然这两个组件之间没有任何的父子关系,只是存在render树的“父子”,即A组件在render树的名义上和B组件存在父子关系,且是父主动broadcast

唾手可得的VueComponent

有的同学要问,什么是VueComponentVueComponent有什么用?

VueComponent即为组件实例,是响应式的,也是Vue的核心。我们可以通过VueComponent访问组件的datacomputed,调用methods。而且可以通过挂载的$xx来获取更多的信息,并且这些信息都是响应式的。可以说 得VueComponent着得天下

属性名 描述
$attrs 上面已说明
$listeners 上面已说明
$data Vue 实例观察的数据对象。Vue 实例代理了对其 data 对象属性的访问。
$router vue-router路由对象
$route 当前路由对象
$slots 用来访问被插槽分发的内容。每个具名插槽 有其相应的属性 (例如:v-slot:foo 中的内容将会在 vm.$slots.foo 中被找到)。default 属性包括了所有没有被包含在具名插槽中的节点,或 v-slot:default 的内容。
$scopedSlots 用来访问作用域插槽。对于包括 默认 slot 在内的每一个插槽,该对象都包含一个返回相应 VNode 的函数。
$el 返回DOM,HtmlElement对象
$refs 一个对象,持有注册过 ref 特性 的所有 DOM 元素和组件实例。
$children 当前实例的直接子组件。需要注意 $children 并不保证顺序,也不是响应式的。如果你发现自己正在尝试使用 $children 来进行数据绑定,考虑使用一个数组配合 v-for 来生成子组件,并且使用 Array 作为真正的来源。
$data Vue 实例观察的数据对象。Vue 实例代理了对其 data 对象属性的访问。
$props 当前组件接收到的 props 对象。Vue 实例代理了对其 props 对象属性的访问。
$root 当前组件树的根 Vue 实例。如果当前实例没有父实例,此实例将会是其自己。
$parent 父实例,如果当前实例有的话。
$options 用于当前 Vue 实例的初始化选项。需要在选项中包含自定义属性时会有用。

一、春晖寸草 findComponentUpward & findComponentUpward

1、findComponentUpward
export const findComponentUpward = (context, componentName, componentNames) => {
  if (typeof componentName === 'string') {
      componentNames = [componentName]
  } else {
      componentNames = componentName
  }
  let parent = context.$parent
  let name = parent.$options.name
  while (parent && (!name || componentNames.indexOf(name) < 0)) {
      parent = parent.$parent
      if (parent) name = parent.$options.name
  }
  return parent
}

findComponentUpward 向上匹配最近的componentNameVueComponentcomponentName可传StringArray,切记componentNameVue.use()的那个name,若使用Vue.component()注册,则是注册的key

//match one
const Tree = findComponentUpward(this'Tree');

//match someone
this.$Modal.confirm({
  el: findComponentUpward(this, ['SheetPage''InfoPage']).$el,
  content: `确定要删除${this.title}吗?`,
  onOk: async () => {
    let { data } = await this.doDeleteEntity(this.id, this.Action)
    if (data.code === 0) {
      this.$Message.success('删除成功。')
      this.$emit('on-sheet-delete'this.id)
    }
  },
  onCancel: () => { }
})
2、findComponentsUpward
export const findComponentsUpward = (context, componentName) => {
  let parents = []
  const parent = context.$parent
  if (parent) {
    if (parent.$options.name === componentName) parents.push(parent)
    return parents.concat(findComponentsUpward(parent, componentName))
  } else {
    return []
  }
}

findComponentsUpward 向上匹配所有的componentNameVueComponentcomponentNameString,切记componentNameVue.use()的那个name,若使用Vue.component()注册,则是注册的key

二、老牛舐犊 findComponentDownward & findComponentsDownward

1、findComponentDownward
export const findComponentDownward = (context, componentName) => {
  const childrens = context.$children
  let children = null
  if (childrens.length) {
    for (const child of childrens) {
      const name = child.$options.name
      if (name === componentName) {
        children = child
        break
      } else {
        children = findComponentDownward(child, componentName)
        if (children) break
      }
    }
  }
  return children
}

findComponentDownward 向下匹配最近的componentNameVueComponentcomponentNameString,切记componentNameVue.use()的那个name,若使用Vue.component()注册,则是注册的key

this.infoInstence = findComponentDownward(this.$parent, 'SheetPage')
2、findComponentsDownward
export const findComponentsDownward = (context, componentName) => {
  return context.$children.reduce((components, child) => {
    if (child.$options.name === componentName) components.push(child)
    const foundChilds = findComponentsDownward(child, componentName)
    return components.concat(foundChilds)
  }, [])
}

findComponentsDownward 向下匹配所有的componentNameVueComponentcomponentNameString,切记componentNameVue.use()的那个name,若使用Vue.component()注册,则是注册的key

let SlideInfoVnode = this.$refs.SlideInfo.$children[0]
let FormItems = findComponentsDownward(SlideInfoVnode, 'FormItem')

四、情同手足 findBrothersComponents

export const findBrothersComponents = (context, componentName, exceptMe = true) => {
  let res = context.$parent.$children.filter(item => {
    return item.$options.name === componentName
  })
  let index = res.findIndex(item => item._uid === context._uid)
  if (exceptMe) res.splice(index, 1)
  return res
}

findBrothersComponents 向兄弟匹配所有的componentNameVueComponentcomponentNameString,切记componentNameVue.use()的那个name,若使用Vue.component()注册,则是注册的keyexceptMe默认为false,排除自己,否则不排除自己。

进阶render

一、函数式组件 functional

官方解读:使组件无状态 (没有 data) 和无实例 (没有 this 上下文)。他们用一个简单的 render 函数返回虚拟节点使它们渲染的代价更小。

以下我以km-grid业务自定义下拉render为例,首先我在columns定义

this.columns = [
  {
    type: 'expand',
    width: 50,
    render: (h, params) => {
      if (params.row.status != '4'return ''
      return h(checkWatch, {
        props: {
          row: params.row
        }
      })
    }
  }
]

km-grid-item render,直接调用columns配置的render函数,这个render函数会传至td-render这个组件里面:

<div v-if="!fixed&&expandColumn.render" :class="cls+'-tr-expand'">
  <div style="width:100%" v-if="row._clicked">
    <td-render :row="row" :render="expandColumn.render"></td-render>
  </div>
</div>

td-render是如何调用配置的render来实现渲染的呢?

export default {
  name'TdRender',
  functionaltrue,
  props: {
    rowObject,
    renderFunction,
    indexNumber,
    column: {
      typeObject,
      defaultnull
    }
  },
  render(h, ctx) => {
    const params = {
      row: ctx.props.row,
      index: ctx.props.index
    };
    if (ctx.props.column) params.column = ctx.props.column;
    return ctx.props.render(h, params);
  }
}

原来创建组件有两种方法,一种是通常的template模板字符串形式,另一种是字符串模板的代替方案,允许你发挥 JavaScript最大的编程能力。该渲染函数接收一个 createElement 方法作为第一个参数用来创建 VNode。若组件标记为 functional,这意味它无状态 (没有响应式数据),也没有实例 (没有 this 上下文),于是我们可以利用render来提供上下文。

td-render通过render提供第二个参数context作为上下文来渲染,并且开销比template要小。