翻翻现成组件库源码,找点乐子【vuetify源码解析】

1,407 阅读6分钟

国内的组件库都用太多了,于是想翻翻国外的看看有没有什么有意思的东西,看了看大致都大同小异。本文是基于vuetify组件库的源码,找了部分几个比较有意思的组件实现,对核心的实现方法和代码进行解析。

过渡样式:transition

过渡样式的主要实现思路都是使用了vue的transition进行封装。

思路一:transition的基础使用

是直接使用写在全局的scss里的样式,直接在所有需要使用的组件加上transition,进行包裹即可。这也是我们平时项目开发常用的实现方式。

//下面是对应写在全局的scss样式
.scale-rotate-transition {
    &-enter-active,
    &-leave-active {
        transition: all .3s ease;
    }

    &-move {
      transition: transform .6s;
    }

    &-enter, &-leave, &-leave-to {
      opacity: 0;
      transform: scale(0) rotate(-45deg);
    }
}
//直接引用即可
  <transition name="scale-rotate-transition">
    <p v-if="show">hello</p>
  </transition>

思路二:函数时组件封装transition

封装一个函数式组件,将transition的内容封装至render中

//函数式组件,源代码使用ts进行封装的
export function createJavascriptTransition (
  name: string,
  functions: Record<string, any>,
  mode = 'in-out'
): FunctionalComponentOptions {
  return {
    name,
    functional: true,
    props: {
      mode: {
        type: String,
        default: mode,
      },
    },

    render (h, context): VNode {
      //参数里的h是createElement函数,具体可以参照vue官方文档
      return h(
        'transition',
        //mergeData是组件库封装的合并对象的写法,会以右边的对象的值为主,合并左边参数传入的对象
        mergeData(context.data, {
          //具体的transition的name所包含的样式都是提前写到全局的样式里面的
          props: { name },
          on: functions,
        }),                          
        context.children
      )
    },
  }
}

封装好之后再在需要添加过渡的组件中。

function testCompoennt(Vue){
  Vue.component('anchored-heading', {
    render: function (createElement) {
      return createElement(createSimpleTransition('tab-reverse-transition'), {...}, 
        //content是包裹的内容
      [content])
    },
    props: {
      //......
    }
  })
}

然后再统一封装所有组件至install中,再将封装好的函数传入Vue.use()中进行调用

//大致是这样,源码里很多个文件传来传去的,最终就是输出一个install
const component = {
    install:testCompoennt
}
// 然后用在vue.use中使用该组件即可引入

Vue.use(component)

然后就可以在vue的单文件组件中的html中调用

<anchored-heading></anchored-heading>

引入和注册方式省略...

懒加载组件:v-lazy

v-lazy主要功能是,给你需要懒加载的组件,当当前窗口滚动到组件的视图范围内再进行加载。

主要的实现是使用到了Intersection Observer这个方法。目前兼容性大部分主流浏览器都支持。

image.png

这个api是用来监听两个不同的元素是否相交,如果相交的时候会触发相对应的回调事件。懒加载的实现就是判断元素与屏幕可视范围相交时,再将元素显示出来即可。

源代码较多,我提取了一下,主要逻辑是以下部分

Vue.directive('lazy', {
  // 当被绑定的元素插入到 DOM 中时……
  inserted: function (el) {
    //源代码中options是可以通过传参定制的
    let options = {
      root: null,         //options里的root的参数默认为根元素
      rootMargin: '0px',
      threshold: 1.0
    }
    let callback =(entries) => {
      entries.forEach(entry => {
        console.log(entry);
        //然后如果判断entry.isIntersecting为true的话,就把元素显示出来
      });
    };
    let observer = new IntersectionObserver(callback, options);
    let target = el;
    observer.observe(target);
  }
})

虚拟列表:Virtual scroller

虚拟列表是长列表的优化,假设现在又一万条数据的列表,实际渲染的时候只渲染视图内的元素,然后还能照常上下滚动。

image.png

具体调用的时候代码如下

<v-virtual-scroll
  :bench="benched"
  :items="items"
  height="300"
  item-height="64"
>
  <template v-slot:default="{ item }">
    <!--列表元素样式-->
  </template>
</v-virtual-scroll>
然后源码具体实现如下,具体仔细的逻辑我都备注了。大致的整体实现逻辑就是
  • 1.先设定好窗口的高度,以及列表里每个元素的固定高度,最外面的元素设定position:relative
  • 2.计算当前滚动距离,应该窗口中应该显示列表中从第几位到第几位的元素
  • 3.将传入的列表进行裁剪,只显示当前窗口需要显示的元素,每个元素的位置用absolute+top来设定位置,top根据元素的固定高度和在当前列表的index来计算
  • 4.添加滚动监听,重复第2、3步
Vue.extend({
  //...省略部分代码
  computed: {
    __bench (): number {
      return parseInt(this.bench, 10)
    },
    __itemHeight (): number {
      return parseInt(this.itemHeight, 10)
    },
    firstToRender (): number {
      return Math.max(0, this.first - this.__bench)
    },
    lastToRender (): number {
      return Math.min(this.items.length, this.last + this.__bench)
    },
  },
  watch: {
    height: 'onScroll',    //窗口高度
    itemHeight: 'onScroll',  //元素高度
  },

  mounted () {
    this.last = this.getLast(0)
  },

  methods: {
    getChildren (): VNode[] {
      //firstToRender是需要显示的首个元素的index
      //lastToRender是当前显示的最后一个元素的index,只截取需要显示的部分
      return this.items.slice(
        this.firstToRender,   
        this.lastToRender,
      ).map(this.genChild)
    },
    genChild (item: any, index: number) {
      index += this.firstToRender
      //里面的元素用top来控制显示的位置,这样就可以保证视图范围内一直有需要列表内容显示
      const top = convertToUnit(index * this.__itemHeight)
      return this.$createElement('div', {
        staticClass: 'v-virtual-scroll__item',
        //赋予每一个单独的元素一个top
        style: { top },
        key: index,
      }, getSlot(this, 'default', { index, item }))
    },
    getFirst (): number {
      return Math.floor(this.scrollTop / this.__itemHeight)
    },
    getLast (first: number): number {
      const height = parseInt(this.height || 0, 10) || this.$el.clientHeight

      return first + Math.ceil(height / this.__itemHeight)
    },
    //监听元素的滚动事件
    onScroll () {
      this.scrollTop = this.$el.scrollTop   //只要滚动了就会重新去调用获取当前视图第一个和最后一个元素的index
      this.first = this.getFirst()
      this.last = this.getLast(this.first)
    },
  },
  //每次数据更新的时候都会重新触发render
  render (h): VNode {
    //content为最终渲染的内容
    const content = h('div', {
      staticClass: 'v-virtual-scroll__container',
      style: {
        //里面的height是用来设置整体内容的高度的,所以要把整个数组的长度都算进去,这样滚动条才会正常
        height: convertToUnit((this.items.length * this.__itemHeight)),
      },
    }, this.getChildren())   //具体显示的内容从getChildren中获取
     
    return h('div', {
      staticClass: 'v-virtual-scroll',
      style: this.measurableStyles,
      directives: [{
        name: 'scroll',
        modifiers: { self: true },
        value: this.onScroll,
      }],
      on: this.$listeners,
    }, [content])
  }
})

点击元素外的检测:click outside

点击元素外的检测这个功能在很多弹窗类、冒泡类的组件经常会用到。比如下图的这种弹窗,需要你点击页面的任意非弹窗的地方,都将弹窗隐藏起来。

image.png

大致实现思路其实很简单:

  • 1.通过点击事件来获取当前点击的元素
  • 2.判断绑定改命令的元素的是否包含了点击元素。用node.contains(otherNode)来实现检查元素的包含关系

源码解析如下:

import { attachedRoot } from '../../util/dom'   //这个方法是遍历最顶层的节点
import { VNodeDirective } from 'vue/types/vnode'   

interface ClickOutsideBindingArgs {            
  handler: (e: Event) => void                   //判断点击为外面时会触发的事件
  closeConditional?: (e: Event) => boolean      //可以设置这个为false时,整个点击外部事件的方法都不生效
  include?: () => HTMLElement[]                 //可以把不需要触发事件的元素传进来
}

interface ClickOutsideDirective extends VNodeDirective {
  value?: ((e: Event) => void) | ClickOutsideBindingArgs
}

function defaultConditional () {
  return true
}

function checkEvent (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirective): boolean {
  // 因为接下来遍历的方法很耗费资源,所以在这里先检察一遍是否有传入元素,且closeConditional不为false,否则直接返回
  if (!e || checkIsActive(e, binding) === false) return false

  //这里是用来判断我们点击的dom元素和页面显示的dom元素是不是属于同一个树,避免点击的是shadowroot里面的节点。
  const root = attachedRoot(el)
  if (root instanceof ShadowRoot && root.host === e.target) return false

  // elements的元素是最终用来遍历点击的元素包不包含在里面,
  // 1.先把不需要触发事件的includes传进来的元素放进elements
  const elements = ((typeof binding.value === 'object' && binding.value.include) || (() => []))()
  // 2.然后把绑定事件的元素el放入elements
  elements.push(el)

  // 判断elements里的所有元素是否有点击事件e.target的元素
  return !elements.some(el => el.contains(e.target as Node))
}

//检查绑定的元素是否有value和closeConditional的值,都为true才进行下一步
function checkIsActive (e: PointerEvent, binding: ClickOutsideDirective): boolean | void {
  const isActive = (typeof binding.value === 'object' && binding.value.closeConditional) || defaultConditional
  return isActive(e)
}

//点击外面时,回调事件的处理
function directive (e: PointerEvent, el: HTMLElement, binding: ClickOutsideDirective) {
  const handler = typeof binding.value === 'function' ? binding.value : binding.value!.handler

  el._clickOutside!.lastMousedownWasOutside && checkEvent(e, el, binding) && setTimeout(() => {
    checkIsActive(e, binding) && handler && handler(e)
  }, 0)
}

//查找根元素
function handleShadow (el: HTMLElement, callback: Function): void {
  const root = attachedRoot(el)
  callback(document.body)

  if (root instanceof ShadowRoot) {
    callback(root)
  }
}

export const ClickOutside = {
  inserted (el: HTMLElement, binding: ClickOutsideDirective) {
    const onClick = (e: Event) => directive(e as PointerEvent, el, binding)
    const onMousedown = (e: Event) => {
      //!.是typescript的写法,避免元素为空
      el._clickOutside!.lastMousedownWasOutside = checkEvent(e as PointerEvent, el, binding)
    }

    handleShadow(el, (app: HTMLElement) => {
      app.addEventListener('click', onClick, true)
      app.addEventListener('mousedown', onMousedown, true)
    })

    el._clickOutside = {
      lastMousedownWasOutside: true,
      onClick,
      onMousedown,
    }
  },

  unbind (el: HTMLElement) {
    if (!el._clickOutside) return

    handleShadow(el, (app: HTMLElement) => {
      if (!app || !el._clickOutside) return
      app.removeEventListener('click', el._clickOutside.onClick, true)
      app.removeEventListener('mousedown', el._clickOutside.onMousedown, true)
    })

    delete el._clickOutside
  },
}

export default ClickOutside
****

看完觉得有收获的老哥麻烦点个赞呗~

image.png