码上掘金的编辑框尺寸调整功能是如何实现的

1,001 阅读5分钟

之前写了一篇关于码上掘金完整实现的文章,其中包含的一个小功能是编辑器区域和预览区域的尺寸调整,我看了下码上掘金的实现,应该是直接引用了开源库,这种库还是比较多的,例如 react-split-panesplit

不过呢,我不太喜欢码上掘金现在的布局,我想把编辑框放到页面左边,预览放到右边,这种布局 codepen已经实现了,如下图,我看了下找到几个 split库,好像都不好直接拿来就用,想了下,实现起来也并不困难,所以就自己实现了这个功能

1.png

本文所要实现的效果,就类似于下面的这个小demo,我在之前手撸码上掘金文章中的编辑框尺寸调整功能就是基于本文的逻辑,也可以直接去看那个实际例子

编辑框本身尺寸调整

根据上面 codepen 的左右布局图,当拖动 htmlcssjs 这三个编辑框的 header 元素的时候,能够调整编辑框高度

拖动这个动作,其实就是 mousemove事件,当鼠标 mousedown 的时候,则注册 mousemovemouseup 事件,鼠标 move 了多少距离,就调整多少编辑框的高度,然后在 mouseup 事件发生的时候,removemousemove 事件

如何知道鼠标移动了多少距离呢?根据 move过程中的 pageY 属性即可得到,如果 next.pageY > prev.pageY,则表示编辑框高度需要增大,否则就需要减小

这个功能可用写成一个 react 或者 vue 组件,不过考虑到此功能实际上跟 UI 的关系不大,且本身具备很多状态,所以决定写成一个 class 类,使用起来会更加灵活

type TToggleEles = {
  list: { moveData?: { ele: HTMLElement }[]; containerEle: HTMLElement }[]
}

class Resize {
  private elesLength = 0

  constructor(private eles: TToggleEles[]) {
    this.elesLength = eles.length
    this.upListener = this.upListener.bind(this)
    this.registerListeners()
  }
}

定义 Resize 类,接收一个初始化参数 eles,类型为 TToggleEleslist 字段代表需要调整尺寸的相关元素集合,例如 htmlcssjs这三个编辑框元素,因为这三个编辑框之间是有联动关系的,所以必须要放到一起看。containerEle指的就是编辑框容器,调整尺寸就是它的尺寸,moveData 指的是鼠标拖动编辑框内的哪个元素能够改变 containerEle的尺寸,moveData 是一个数组,是考虑到在不同的场景下,能够调整 containerEle尺寸的元素可能不止一个

constructor 方法中,elesLength 用于缓存 eles的长度,主要是考虑到后面会有计算逻辑需要频繁取 eles的长度,这里缓存一下后面就可以直接拿来用了,不用每次都取一遍,稍微提升一下性能,moveListenerupListener 就是鼠标触发 mousedown 事件之后,需要注册的两个事件

class Resize {
  private mouseActiveIndex = -1
  private mouseActiveEleIndex = -1
  private prevPageY = 0
  private eleSizes: { width: number; height: number }[] = []
  private mousedownElesListeners: { ele: HTMLElement; fn: TMouseEventListener }[] = []
  // ...

  registerListeners() {
    this.eles.list.forEach((ele, i) => {
      ((ele, i) => {
        const downListener = (moveEle: HTMLElement, eleIndex: number) => {
          const fn = (e: MouseEvent) => {
            if (!ele.moveData) return
            this.mouseActiveIndex = i
            this.mouseActiveEleIndex = eleIndex
            this.eleSizes = this.eles.list.map(data => ({ width: data.containerEle.offsetWidth, height: data.containerEle.offsetHeight }))
            this.prevPageY = e.pageY
            document.addEventListener('mousemove', this.moveListener)
            document.addEventListener('mouseup', this.upListener)
          }
          this.mousedownElesListeners.push({ ele: moveEle, fn })
          return fn
        }
        ele.moveData?.forEach((d, eleIndex) => d.ele.addEventListener('mousedown', downListener(d.ele, eleIndex)))
      })(ele, i)
    })
  }
}

看下 registerListeners 方法,由于 moveData[number].ele 才是鼠标能拖动的元素,所以对这个元素注册 mousedown 事件,可以看到,这里我用了一个自执行函数,将 elei 作为参数传了进去,这是为了避免在 forEach 循环里 downListener 丢失了这两个变量应该的值

mouseActiveIndex 指的是当前鼠标的拖动会改变的编辑框(htmlcssjs)的下标,mouseActiveEleIndex 指的是被拖动元素的下标,eleSizes 用于缓存鼠标发生拖动时各编辑框的高度,方便后续在 move的时候在此基础上进行增减,这样就不用减少了一次频繁测量高度的 dom计算,从内存取数消耗的性能肯定是远远小于页面重绘的,也是一种提升性能的小手段

再往下,我是对 document 而不是 moveData[number].ele 注册 mousemovemousedup 方法,这是考虑到在拖动的过程中,浏览器不可能在时间上完全精确地响应用户操作,如果鼠标移动过快,可能会存在鼠标脱离拖动元素的情况,也就是事件响应滞后,那么这个时候显然拖动元素就无法正确响应这两个事件了,所以放在 document上更保险

mousedown的事件存入 mousedownElesListeners,则是为了当组件销毁的时候,移除掉监听事件

private moveListener = (e: MouseEvent) => {
  const diff = this.prevPageY - e.pageY
  this.prevPageY = e.pageY
  // diffY < 0 往下
  this.eleSizes[this.mouseActiveIndex].height += diff
  this.eles.list[this.mouseActiveIndex].containerEle.style.height = this.eleSizes[this.mouseActiveIndex].height + 'px'
}

moveListener 方法中,根据 diff更新 this.eleSizes 的值,并且更新设置需要更新尺寸的容器高度,这样一来,编辑框的高度就会随着鼠标的拖动而改变了

编辑框联动尺寸调整

就本例而言,htmlcssjs 这三个编辑框的高度加起来应该是刚好等于页面高度的,其中一个编辑框增加或减小高度,为了让三个编辑框高度加起来仍然是一页的高度,那么就必须至少还要有另外一个编辑框也增加或减小高度

例如,当 css编辑框减小的时候,html的就必须要增大,js编辑框高度减小的时候,js的就必须要增大,也就是说,一个编辑框的高度减小,那么它下一个编辑框的高度就必须增加

反过来,当一个编辑框的高度增加的时候,那么它上一个编辑框就必须减小

private moveListener = (e: MouseEvent) => {
  const diff = this.prevPageY - e.pageY
  this.prevPageY = e.pageY
  // diffY < 0,代表鼠标往下拖动
  this.eleSizes[this.mouseActiveIndex].height += diff
  this.eles.list[this.mouseActiveIndex].containerEle.style.height = this.eleSizes[this.mouseActiveIndex].height + 'px'
  // 本身高度减小,上一个高度增加
  // 本身高度增加,上一个高度减小
  this.eleSizes[this.mouseActiveIndex - 1].height += (diff < 0 ? -diff : diff)
  this.eles.list[this.mouseActiveIndex - 1].containerEle.style.height = this.eleSizes[this.mouseActiveIndex - 1].height + 'px'
}

当拖动 css编辑框减少其高度,直到其编辑框内容的高度为 0的时候,根据 codepen的交互,如果继续拖动鼠标,因为 css编辑框内容高度已经为 0了,高度不可能是负的,所以这部分减少的高度就会转移到下一个编辑框,即会导致 js编辑框高度继续减少,当 js编辑框内容高度也为 0的时候,再继续拖动鼠标,因为 js编辑框已经没有下一个编辑框可以继续减少高度了,所以三个编辑框尺寸就不变了

说得通用一点,即当鼠标往下拖动的时候,会导致当前拖动的编辑框高度减少,其上一个编辑框高度增加,当其高度减少到 0的时候,继续让当前编辑框的下一个编辑框减少高度,如果下一个编辑框高度也减少到 0的时候,如果还有下下一个编辑框,则继续减少其高度,否则不再响应鼠标拖动事件

反之,当鼠标往上拖动的时候也一样,只不过换成了当前编辑框高度增加,而在其上的编辑框高度依次减少,直到全部为 0

很明显,无论是鼠标往上还是往下拖动,其影响到的编辑框下标符合递增、递减的规律,所以需要用到循环

对于鼠标往下拖动的情况,会使得当前编辑框高度减少,其上面编辑框高度增加

private moveListener = (e: MouseEvent) => {
  const diff = this.prevPageY - e.pageY
  this.prevPageY = e.pageY
  if (diff < 0) {
    this.pullEffectShrink(diff, this.mouseActiveIndex)
  } else {
    this.pullEffectExpand(diff, this.mouseActiveIndex)
  }
}

当鼠标往下拖动的时候,高度会增加的编辑框肯定是其上一个,也就是 containerEle,至于高度会减少的编辑框在其下面,具体是哪一个就需要实时计算一下,当前编辑框高度是否足够补齐鼠标拖动过的距离,如果不够,则当前高度减少到 0,不够的由下一个编辑框补上,也就是需要用到循环的地方

type TToggleEles = {
  list: { moveData?: { ele: HTMLElement }[]; minHeight?: number; containerEle: HTMLElement }[]
}

private pullEffectShrink(diff: number, activeIndex: number) {
  let rest = diff
  const minSizeName = 'minHeight'
  const sizeName = 'height'
  const containerEleIndex = activeIndex - 1
  const containerEle = this.eles.list[containerEleIndex].containerEle
  for (let index = activeIndex; index < this.elesLength; index++) {
    const ele = this.eles.list[index]
    const size = this.eleSizes[index][sizeName]
    // 由于可拖动元素也是在编辑框的一部分,这部分元素高度肯定不能为 0 的,ele.minHeight 在这里指的就是可拖动元素的高度
    if (size + rest < ele[minSizeName]!) {
      const maxDiff = size - ele[minSizeName]!
      // 当前编辑框如果不够补齐鼠标拖动过的距离,则能补齐多少是多少,剩下的交给下一个编辑框来补
      rest = rest + maxDiff
      this.eleSizes[containerEleIndex][sizeName] += maxDiff
      this.eleSizes[index][sizeName] -= maxDiff
      containerEle.style[sizeName] = this.eleSizes[containerEleIndex][sizeName] + 'px'
      ele.containerEle.style[sizeName] = this.eleSizes[index][sizeName] + 'px'
    } else {
      this.eleSizes[containerEleIndex][sizeName] -= rest
      this.eleSizes[index][sizeName] += rest
      containerEle.style[sizeName] = this.eleSizes[containerEleIndex][sizeName] + 'px'
      ele.containerEle.style[sizeName] = this.eleSizes[index][sizeName] + 'px'
      // 当前编辑框足够补齐鼠标拖动过的距离,就没必要麻烦后面的编辑框了,直接 break
      break
    }
  }
}

同样的,当鼠标往上拖动的时候,高度会增加的编辑框肯定就是当前编辑框,至于高度会减少的编辑框在其上面,具体是哪一个就需要实时计算一下,其前面一个编辑框的高度是否足够补齐鼠标拖动过的距离,如果不够,则继续由上上一个编辑框补上,也就是需要用到循环的地方

private pullEffectExpand(diff: number, activeIndex: number) {
  let rest = diff
  const containerEleIndex = activeIndex
  const containerEle = this.eles.list[containerEleIndex].containerEle
  const minSizeName = 'minHeight'
  const sizeName = 'height'
  for (let index = activeIndex - 1; index >= 0; index--) {
    const ele = this.eles.list[index]
    const size = this.eleSizes[index][sizeName]
    if (size - rest < ele[minSizeName]!) {
      const maxDiff = size - ele[minSizeName]!
      rest = rest - maxDiff
      this.eleSizes[containerEleIndex][sizeName] += maxDiff
      this.eleSizes[index][sizeName] -= maxDiff
      containerEle.style[sizeName] = this.eleSizes[containerEleIndex][sizeName] + 'px'
      ele.containerEle.style[sizeName] = this.eleSizes[index][sizeName] + 'px'
    } else {
      this.eleSizes[containerEleIndex][sizeName] += diff
      this.eleSizes[index][sizeName] -= diff
      containerEle.style[sizeName] = this.eleSizes[containerEleIndex][sizeName] + 'px'
      ele.containerEle.style[sizeName] = this.eleSizes[index][sizeName] + 'px'
      break
    }
  }
}

逻辑跟 pullEffectShrink 差别不大,唯一需要注意的是,这里的循环用的是倒序

兼容垂直、水平方向的尺寸调整

目前我们已经支持了编辑框高度的改变,而预览框的尺寸也是可以调整,只不过其是宽度的调整而非高度的调整,不过我们上述的逻辑其实跟高度还是宽度耦合得并不严重,只需要稍加改动即可同时兼容高度和宽度

enum EDirection {
  /** 上下移动 */
  V, 
  /** 左右移动 */
  H
}

type TToggleEles = {
  direction: EDirection
  list: { moveData?: { ele: HTMLElement }[]; minHeight?: number; minWidth?: number; containerEle: HTMLElement }[]
}

TToggleEles增加 direction属性,用于标识调整的是容器的高度还是宽度

private moveListener = (e: MouseEvent) => {
  if (this.mouseActiveIndex === -1) return
  if (this.eles.direction === EDirection.V) {
    const diffY = this.prevPageY - e.pageY
    this.prevPageY = e.pageY
    this.pullEffect(diffY, false)
  } else {
    const diffX = this.prevPageX - e.pageX
    this.prevPageX = e.pageX
    this.pullEffect(diffX, true)
  }
}

private pullEffect(diff: number, isHorizontal: boolean) {
  if (diff === 0) return
  if (diff < 0) {
    this.pullEffectShrink(diff, isHorizontal, this.mouseActiveIndex)
  } else {
    this.pullEffectExpand(diff, isHorizontal, this.mouseActiveIndex)
  }
}

private pullEffectShrink(diff: number, isHorizontal: boolean, activeIndex: number) {
  // ...
  const minSizeName = isHorizontal ? 'minWidth' : 'minHeight'
  const sizeName = isHorizontal ? 'width' : 'height'
  // ...
}
private pullEffectExpand(diff: number, isHorizontal: boolean, activeIndex: number) {
  // ...
  const minSizeName = isHorizontal ? 'minWidth' : 'minHeight'
  const sizeName = isHorizontal ? 'width' : 'height'
  // ...
}

小结

这个功能只是个小功能,难度不高,代码量也不多,但个人认为有效代码率还是挺高的,很多代码都需要稍加思索一下,例如如何兼顾性能、如何设计数据结构、如何更好地兼容不同场景等,对于业务经验和代码编写水平有着一定的要求(当然,也不高),个人认为类似于这种小功能的实现,如果作为面试题的话,应该比考八股文更能体现出候选人的能力