阅读 276

[day-ui] Affix 组件学习

固钉组件是把页面某个元素相对页面 HTML 或者某个 dom 内定位显示,例如固定页面顶部/底部显示,页面宽高改变也会保持原位置。如果进行滚动,超过定义的范围就会固定定位,否则会跟随页面滚动

上一节我们介绍了 DButtonDIcon 的实现,所以新建 affix 文件目录结构我们就不多介绍了。我们主要学习一下内部实现方式,本质就是位置定位,我们要看下用了哪些判断和第三方库,如果有哪里不对欢迎指正。

效果分析

  1. 第一种情况是没有设置容器,可以根据 position 位置设置固定定位,如果位置设置 top,那么当监听到页面滚动,如果当前元素的 top 值小于设置的偏移量,设置 fixed 定位(反之 bottom 是比较 bottom 值大于页面高度和偏移量的差值设置 fixed 定位)
  2. 第二种情况是设置容器,那么 top / bottom 的是只在容器内显示的,容器不在页面后,定位元素也就消失。如果设置的 top 值,那么当当前元素 top 值小于偏移量同时容器的 bottom 大于0,元素 fixed 定位(反之 bottom 偏移需要计算页面高度和 bottom 值得对比)。

最近学习了解到fixed 定位默认是相对与窗口的,但是如果给父节点定义属性 transform、filter、perspective,fixed 定位就会相对父集,大家感兴趣的话可以自行查看。

代码分析

dom 结构

<template>
  <div ref="root" class="d-affix" :style="rootStyle">
    <!-- 定位元素 滚动时监听 root 位置和页面可视区的关系设置 fixed,定位的时候设置样式-->
    <div :class="{ 'd-affix--fixed': state.fixed }" :style="affixStyle">
      <slot></slot>
    </div>
  </div>
</template>
复制代码

外层定义 d-affix 类,高度和内部的元素相同,为了当内部元素 fixed 定位脱离文档流时,页面占位结构不变;同时需要对比 d-affixtopbottom 值判断元素何时脱离文档,何时复位。

属性

props: {
  // 定位元素的层级
  zIndex: {
    type: Number,
    default: 100
  },
  // 在哪个容器内,没传就是视图
  target: {
    type: String,
    default: ''
  },
  // 上下偏移量
  offset: {
    type: Number,
    default: 0
  },
  // 距上边距下边距
  position: {
    type: String,
    default: 'top'
  }
},
// 对外暴露两个方法,监听滚动和 fixed 状态改变
emits: ['scroll', 'change'],
复制代码

setUp 核心

// 定位元素属性
const state = reactive({
  fixed: false,
  height: 0, // height of target 滚动时获取赋值
  width: 0, // width of target
  scrollTop: 0, // scrollTop of documentElement
  clientHeight: 0, // 窗口高度
  transform: 0 // 元素在 target 中定位时 y 方向移动
})

// 计算属性,滚动时才能具体获取

// d-affix 类一直存在文档流中,只要宽高,滚动位置判断是否 fixed
const rootStyle = computed(() => {
  return {
    height: state.fixed ? `${state.height}px` : '',
    width: state.fixed ? `${state.width}px` : ''
  }
})
// 定位元素属性
const affixStyle = computed(() => {
  if (!state.fixed) return
  const offset = props.offset ? `${props.offset}px` : 0
  const transform = state.transform
    ? `translateY(${state.transform}px)`
    : ''

  return {
    height: `${state.height}px`,
    width: `${state.width}px`,
    top: props.position === 'top' ? offset : '',
    bottom: props.position === 'bottom' ? offset : '',
    transform: transform,
    zIndex: props.zIndex
  }
})
复制代码

滚动时定位属性的判断:

const updateState = () => {
  // 获取 d-affix 节点信息
  const rootRect = root.value.getBoundingClientRect()
  // 获取 target 节点的信息
  const targetRect = target.value.getBoundingClientRect()
  state.height = rootRect.height
  state.width = rootRect.width
  // 没有 target 取 html 的 scrollTOP(有 target 在 target 中滚动)
  state.scrollTop =
    scrollContainer.value === window
      ? document.documentElement.scrollTop
      : scrollContainer.value.scrollTop

  state.clientHeight = document.documentElement.clientHeight
  // 设置上边距
  if (props.position === 'top') {
    if (props.target) {
      // 定位元素在 target 元素中滑动距离,bottom 持续改变
      const difference = targetRect.bottom - props.offset - state.height
      // target 元素top在可视区外面,bottom在可视区进行定位
      state.fixed = props.offset > rootRect.top && targetRect.bottom > 0
      state.transform = difference < 0 ? difference : 0
    } else {
      // 以html为相对容器,页面滚动,固定定位(d-affix 在可视区外)
      state.fixed = props.offset > rootRect.top
    }
  } else {
  // 设置下边距
    if (props.target) {
      const difference =
        state.clientHeight - targetRect.top - props.offset - state.height
      state.fixed =
        state.clientHeight - props.offset < rootRect.bottom &&
        state.clientHeight > targetRect.top
      state.transform = difference < 0 ? -difference : 0
    } else {
      // offset + bottom > 视图高度,元素进行定位
      state.fixed = state.clientHeight - props.offset < rootRect.bottom
    }
  }
}
复制代码
const onScroll = () => {
  updateState()
  emit('scroll', {
    scrollTop: state.scrollTop,
    fixed: state.fixed
  })
}

watch(
  () => state.fixed,
  () => {
    emit('change', state.fixed)
  }
)
// 页面挂载的时候
onMounted(() => {
  if (props.target) {
    // 注意传的格式
    target.value = document.querySelector(props.target)
    if (!target.value) {
      throw new Error(`target is not existed: ${props.target}`)
    }
  } else {
    target.value = document.documentElement // html
  }
  // 下面我们分析辅助函数
  scrollContainer.value = getScrollContainer(root.value)
  // 函数式编程,on 改写的 addEventListener
  on(scrollContainer.value, 'scroll', onScroll)
  addResizeListener(root.value, updateState)
})
// 页面即将关闭取消监听移除
onBeforeMount(() => {
  off(scrollContainer.value, 'scroll', onScroll)
  removeResizeListener(root.value, updateState)
})
复制代码

辅助函数

  • on
// 函数式编程处理元素监听
export const on = function(element, event, handler, useCapture = false) {
  if (element && event && handler) {
    element.addEventListener(event, handler, useCapture)
  }
}
复制代码
  • off
export const off = function(element, event, handler, useCapture = false) {
  if (element && event && handler) {
    element.removeEventListener(event, handler, useCapture)
  }
}
复制代码
  • getScrollContainer
/**
 * 获取滚动容器
 * @param {*} el 滚动的容器
 * @param {*} isVertical 竖直滚动还是水平滚动
 * @returns
 */
export const getScrollContainer = (el, isVertical) => {
  if (isServer) return
  let parent = el
  while (parent) {
    // 都没有就是 window
    if ([window, document, document.documentElement].includes(parent)) {
      return window
    }
    // 容器是否可滚动
    if (isScroll(parent, isVertical)) {
      return parent
    }
    parent = parent.parentNode
  }
  return parent
}
复制代码
  • isSserver
export default typeof window === 'undefined'
复制代码
  • isScroll
/**
 *
 * @param {*} el
 * @param {*} isVertical 是否垂直方向 overflow-y
 * @returns
 */
export const isScroll = (el, isVertical) => {
  if (isServer) return
  const determineDirection = isVertical === null || isVertical === undefined
  const overflow = determineDirection
    ? getStyle(el, 'overflow')
    : isVertical
    ? getStyle(el, 'overflow-y')
    : getStyle(el, 'overflow-x')

  return overflow.match(/(scroll|auto)/)
}
复制代码
  • getStyle
// 获取元素的属性值
export const getStyle = function(element, styleName) {
  if (isServer) return
  if (!element || !styleName) return null
  styleName = camelize(styleName)
  if (styleName === 'float') {
    /**
     * ie6~8下:style.styleFloat
        FF/chrome 以及ie9以上:style.cssFloat
     */
    styleName = 'cssFloat' // FF/chrome 以及ie9以上   float兼容性写法
  }
  try {
    const style = element.style[styleName]
    if (style) return style
    // 获取window对象, firefox低版本3.6 才能使用getComputed方法,iframe pupup extension window === document.defaultView,否则指向错误
    // https://www.cnblogs.com/yuan-shuai/p/4125511.html
    const computed = document.defaultView.getComputedStyle(element, '')
    return computed ? computed[styleName] : ''
  } catch (e) {
    return element.style[styleName]
  }
}
复制代码

resize-observer-polyfill 库

这个库是我第一次见到,如果不看源码都不知道的。觉得还是挺有意思的,这里做个简单介绍。

这个库主要作用是监听元素 size 改变。通常情况下我们监听大小改变只能使用 window.size 或者 window.orientationchange(移动端屏幕横向纵向显示)。resize 事件会在 1s内触发 60 次左右,所以很容易在改变窗口大小时候引发性能问题,所以当我们监听某个元素变化的时候就显得有些浪费。

ResizeObserver API 是新增的,在有些浏览器还存在兼容性,这个库可以很好的进行兼容。ResizeObserver 使用了观察者模式,当元素 size 发生改变时候触发(节点的出现隐藏也会触发)。

用法

const observer = new ResizeObserver(entries => {
  entries.forEach(entry => {
    console.log('大小位置', entry.contentRect)
    console.log('监听的dom', entry.target)
  })
})
// 监听的对象是body,可以改变浏览器窗口大小看打印效果
observer.observe(document.body)// dom节点,不是类名 id名
复制代码

  • width:指元素本身的宽度,不包含 padding,border
  • height:指元素本身的高度,不包含 padding,border
  • top:指 padidng-top 的值
  • left:指 padding-left 的值
  • right:指 left + width 的值
  • bottom: 值 top + height 的值

方法

  • ResizeObserver.disconnect() 取消所有元素的监听
  • ResizeObserver.observe() 监听元素
  • ResizeObserver.unobserve() 结束某个元素的监听

组件使用

我们在 onMounted 中对 root 元素监听。页面滚动时候要监听,元素大小改变也要监听

import ResizeObserver from 'resize-observer-polyfill'
import isServer from './isServer'

const resizeHandler = function(entries) {
  for (const entry of entries) {
    /**
     * const {left, top, width, height} = entry.contentRect;
     * 'Element:', entry.target
        Element's size: ${ width }px x ${ height }px`
        Element's paddings: ${ top }px ; ${ left }px`
     */
    const listeners = entry.target.__resizeListeners__ || []
    if (listeners.length) {
      // 元素改变直接执行方法
      listeners.forEach(fn => fn())
    }
  }
}
// 监听element元素size改变,执行fn
export const addResizeListener = function(element, fn) {
  if (isServer || !element) return
  if (!element.__resizeListeners__) {
    element.__resizeListeners__ = []
    /**
     * https://github.com/que-etc/resize-observer-polyfill
     *
     */
    element.__ro__ = new ResizeObserver(resizeHandler)
    // 观察的对象
    element.__ro__.observe(element)
  }
  element.__resizeListeners__.push(fn)
}
// 退出移除监听
export const removeResizeListener = function(element, fn) {
  if (!element || !element.__resizeListeners__) return
  element.__resizeListeners__.splice(element.__resizeListeners__.indexOf(fn), 1)
  if (!element.__resizeListeners__.length) {
    // 取消监听
    element.__ro__.disconnect()
  }
}
复制代码

以上就是对 affix 组件的学习。如有不对欢迎指正。下一篇我们进行 alert 组件的学习。

文章分类
前端
文章标签