Углубленный анализ исходного кода компонента прокрутки элемента ScrollBar

Vue.js

Углубленный анализ исходного кода компонента element-ui ScrollBar

Корневой каталог компонента полосы прокрутки включает файл index.js и папку src.index.js — это место для регистрации подключаемого модуля Vue.плагин, содержимое каталога src является основным кодом компонента полосы прокрутки, а его входной файл — main.js.

Перед тем, как приступить к разбору исходного кода, поговорим о принципе работы пользовательских полос прокрутки, чтобы вы могли лучше понять.

scrollbar.png
Как показано на рисунке, черный обтекатель — это отображаемая область для прокрутки. Наш прокручиваемый контент прокручивается в этой области. Представление — это фактическое прокручиваемое содержимое, а содержимое за пределами отображаемой области обтекания будет скрыто. Правая дорожка — это дорожка, по которой ползунок прокрутки полосы прокрутки перемещается вверх и вниз.

Когда содержимое в обертке переполняется, будут созданы собственные полосы прокрутки каждого браузера.Чтобы реализовать собственные полосы прокрутки, мы должны удалить собственные полосы прокрутки. Предположим, мы обернули другой слой div снаружи обертки и установили стиль этого div наoverflow:hiddenВ то же время мы устанавливаем отрицательное значение для marginRight и marginBottom обтекания, и значение точно равно ширине собственной полосы прокрутки, В это время из-за свойства overflow:hidden родительского контейнера родную полосу прокрутки можно скрыть. Затем мы полностью позиционируем настраиваемую полосу прокрутки справа и снизу контейнера обертки и добавляем логику прокрутки, такую ​​как события прокрутки и перетаскивания, для реализации пользовательской полосы прокрутки.

Далее мы начинаем с записи main.js и подробно анализируем, как элемент реализует эту логику.

Объект напрямую экспортируется из файла main.js. Этот объект использует функцию рендеринга для рендеринга компонента полосы прокрутки. Доступный интерфейс компонента выглядит следующим образом:

props: {
  native: Boolean,  // 是否采用原生滚动(即只是隐藏掉了原生滚动条,但并没有使用自定义的滚动条)
  wrapStyle: {},  // 内联方式 自定义wrap容器的样式
  wrapClass: {},  // 类名方式 自定义wrap容器的样式
  viewClass: {},  // 内联方式 自定义view容器的样式
  viewStyle: {},  // 类名方式 自定义view容器的样式
  noresize: Boolean, // 如果 container 尺寸不会发生变化,最好设置它可以优化性能
  tag: {  				// view容器用那种标签渲染,默认为div
    type: String,
    default: 'div'
  }
}

Как видите, это интерфейс, предоставляемый всем компонентом ScrollBar, в основном включая интерфейс для пользовательских стилей обтекания и представления, а также интерфейс noresize для оптимизации производительности.

Затем давайте снова проанализируем функцию рендеринга:

render(){
	let gutter = scrollbarWidth();  // 通过scrollbarWidth()方法 获取浏览器原生滚动条的宽度
  let style = this.wrapStyle;

  if (gutter) {
    const gutterWith = `-${gutter}px`;
    
    // 定义即将应用到wrap容器上的marginBottom和marginRight,值为上面求出的浏览器滚动条宽度的负值
    const gutterStyle = `margin-bottom: ${gutterWith}; margin-right: ${gutterWith};`;

    // 这一部分主要是根据接口wrapStyle传入样式的数据类型来处理style,最终得到的style可能是对象或者字符串
    if (Array.isArray(this.wrapStyle)) {
      style = toObject(this.wrapStyle);
      style.marginRight = style.marginBottom = gutterWith;
    } else if (typeof this.wrapStyle === 'string') {
      style += gutterStyle;
    } else {
      style = gutterStyle;
    }
  }
  
  ...
}

Самое важное в этом фрагменте кода — способ получить родную ширину полосы прокрутки браузера, для этого элемента специально определен метод scrllbarWidth, который импортируется извне.import scrollbarWidth from 'element-ui/src/utils/scrollbar-width';, давайте посмотрим на эту функцию:

import Vue from 'vue';

let scrollBarWidth;

export default function() {
  if (Vue.prototype.$isServer) return 0;
  if (scrollBarWidth !== undefined) return scrollBarWidth;

  const outer = document.createElement('div');
  outer.className = 'el-scrollbar__wrap';
  outer.style.visibility = 'hidden';
  outer.style.width = '100px';
  outer.style.position = 'absolute';
  outer.style.top = '-9999px';
  document.body.appendChild(outer);

  const widthNoScroll = outer.offsetWidth;
  outer.style.overflow = 'scroll';

  const inner = document.createElement('div');
  inner.style.width = '100%';
  outer.appendChild(inner);

  const widthWithScroll = inner.offsetWidth;
  outer.parentNode.removeChild(outer);
  scrollBarWidth = widthNoScroll - widthWithScroll;

  return scrollBarWidth;
};

На самом деле, это очень просто: динамически создать внешний подэлемент тела, задать фиксированную ширину 100 пикселей и установить переполнение для прокрутки, чтобы обтекание генерировало полосу прокрутки. вложенный элемент внутри внешнего и установите его ширину. Установите на 100%. Поскольку у внешнего есть полоса прокрутки, ширина внутреннего не должна быть равна ширине внешнего.В это время ширина внешнего вычитается из ширины внутреннего, а ширина полосы прокрутки браузера получается. Это тоже очень просто, и, наконец, не забудьте уничтожить динамически созданный внешний элемент из тела.

Давайте вернемся и посмотрим на функцию рендеринга.После динамического создания переменной стиля style в соответствии с шириной полосы прокрутки браузера и wrapStyle, следующим шагом будет создание HTML-кода компонента ScrollBar в функции рендеринга.

// 生成view节点,并且将默认slots内容插入到view节点下
const view = h(this.tag, {
  class: ['el-scrollbar__view', this.viewClass],
  style: this.viewStyle,
  ref: 'resize'
}, this.$slots.default);

// 生成wrap节点,并且给wrap绑定scroll事件
const wrap = (
  <div
  	ref="wrap"
  	style={ style }
		onScroll={ this.handleScroll }
		class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
  		{ [view] }
	</div>
);

Затем обертка собирается в соответствии с нативом, и представление генерирует все дерево узлов HTML.

let nodes;

if (!this.native) {
  nodes = ([
    wrap,
    <Bar
    	move={ this.moveX }
			size={ this.sizeWidth }></Bar>,
		<Bar
      vertical
      move={ this.moveY }
      size={ this.sizeHeight }></Bar>
	]);
} else {
  nodes = ([
    <div
      ref="wrap"
      class={ [this.wrapClass, 'el-scrollbar__wrap'] }
			style={ style }>
 			 { [view] }
		</div>
	]);
}
return h('div', { class: 'el-scrollbar' }, nodes);

Вы можете видеть, что если native имеет значение false, используется пользовательская полоса прокрутки, а если это правда, пользовательская полоса прокрутки не используется. HTML-код, сгенерированный приведенной выше упрощенной функцией рендеринга, выглядит следующим образом:

<div class="el-scrollbar">
  <div class="el-scrollbar__wrap">
    <div class="el-scrollbar__view">
    	this.$slots.default
    </div>
  </div>
  <Bar vertical move={ this.moveY } size={ this.sizeHeight } />
  <Bar move={ this.moveX } size={ this.sizeWidth } />
</div>

Для самой внешней полосы прокрутки установлено значение overflow: hidden, чтобы скрыть собственную полосу прокрутки браузера, сгенерированную в обертке. При использовании компонента ScrollBar содержимое, записанное в компоненте ScrollBar, будет распределяться внутри представления через слот. Кроме того, три интерфейса перемещения, размера и вертикали используются для вызова компонента Bar, который на схеме является Track и Thumb. Давайте взглянем на компонент Bar:

props: {
  vertical: Boolean,  // 当前Bar组件是否为垂直滚动条
  size: String,  // 百分数,当前Bar组件的thumb长度 / track长度的百分比 
  move: Number   // 滚动条向下/向右发生transform: translate的值
},

Поведение компонента Bar контролируется этими тремя интерфейсами.В предыдущем анализе мы видели, что когда компонент Bar вызывается в полосе прокрутки, эти три реквизита передаются соответственно. Так как же родительский компонент инициализирует и обновляет значения этих трех параметров, чтобы обновить компонент Bar. Сначала вызовите метод обновления в смонтированном хуке, чтобы инициализировать размер:

update() {
  let heightPercentage, widthPercentage;
  const wrap = this.wrap;
  if (!wrap) return;

  heightPercentage = (wrap.clientHeight * 100 / wrap.scrollHeight);
  widthPercentage = (wrap.clientWidth * 100 / wrap.scrollWidth);

  this.sizeHeight = (heightPercentage < 100) ? (heightPercentage + '%') : '';
  this.sizeWidth = (widthPercentage < 100) ? (widthPercentage + '%') : '';
}

Как видите, основное содержание здесь — вычисление длины большого пальца, heightPercentage/widthPercentage. Здесь используйте wrap.clientHeight / wrap.scrollHeight, чтобы получить процент длины большого пальца. Почему это

Анализируя схематическую диаграмму полосы прокрутки, которую мы нарисовали ранее, бегунок прокручивается вверх и вниз по дорожке, а вид прокручиваемой области прокручивается вверх и вниз в обтекании видимой области.Относительное отношение между бегунком и дорожкой можно рассматривать как относительное отношение между оберткой и просмотром одного изминиатюры(миниатюрный ответ), а значение полосы прокрутки используется для отражения относительной связи движения между представлением и переносом. С другой точки зрения, мы можем превратить прокрутку представления в обтекании в прокрутку вверх и вниз обтекания в представлении.Разве это не увеличенная полоса прокрутки?

Из этого сходства мы можем вывести пропорциональную зависимость: wrap.clientHeight / wrap.scrollHeight = thumb.clientHeight / track.clientHeight. Здесь нам не нужно находить конкретное значение thumb.clientHeight, нам нужно только установить процент css-высоты большого пальца в соответствии с соотношением thumb.clientHeight / track.clientHeight.

Еще один момент, на который стоит обратить внимание, это то, что когда соотношение больше или равно 100%, то есть когда wrap.clientHeight (высота контейнера) больше или равно wrap.scrollHeight (высота прокрутки), полосы прокрутки в этом случае не нужны. время, поэтому установите размер пустой строки.

Далее, давайте взглянем на перемещение, которое является обновлением положения прокрутки полосы прокрутки.

handleScroll() {
  const wrap = this.wrap;

  this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
  this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
}

moveX/moveY используется для управления положением прокрутки полосы прокрутки.Когда это значение передается компоненту Bar, будет вызываться функция рендеринга компонента Bar.renderThumbStyleметод, чтобы преобразовать его в стиль trumbtransform: translateX(${moveX}%) / transform: translateY(${moveY}%). Из отношения подобия, проанализированного ранее, когда wrap.scrollTop точно равен wrap.clientHeight, бегунок должен прокручиваться вниз на расстояние, равное его собственной длине, то есть transform: translateY(100%). Таким образом, при прокрутке прокрутки расстояние, на которое должен прокручиваться большой палец, точно равно преобразованию: translateY(wrap.scrollTop / wrap.clientHeight ). Это логика функции прокрутки обтекания handleScroll.

Теперь, когда мы полностью разобрались со всей логикой в ​​компоненте полосы прокрутки, давайте посмотрим, как работает компонент Bar после получения реквизита.

render(h) {
  const { size, move, bar } = this;

  return (
    <div
      class={ ['el-scrollbar__bar', 'is-' + bar.key] }
      onMousedown={ this.clickTrackHandler } >
      <div
        ref="thumb"
        class="el-scrollbar__thumb"
        onMousedown={ this.clickThumbHandler }
        style={ renderThumbStyle({ size, move, bar }) }>
      </div>
    </div>
  );
}

Функция рендеринга получает размер, переданный родительским компонентом, и после перемещения передаетrenderThumbStyleдля создания большого пальца и привязки события onMousedown для отслеживания и большого пальца соответственно.

clickThumbHandler(e) {
  this.startDrag(e);
  // 记录this.y , this.y = 鼠标按下点到thumb底部的距离
  // 记录this.x , this.x = 鼠标按下点到thumb左侧的距离
  this[this.bar.axis] = (e.currentTarget[this.bar.offset] - (e[this.bar.client] - e.currentTarget.getBoundingClientRect()[this.bar.direction]));
},
 
// 开始拖拽函数
startDrag(e) {
  e.stopImmediatePropagation();
  // 标识位, 标识当前开始拖拽
  this.cursorDown = true;

  // 绑定mousemove和mouseup事件
  on(document, 'mousemove', this.mouseMoveDocumentHandler);
  on(document, 'mouseup', this.mouseUpDocumentHandler);
  
  // 解决拖动过程中页面内容选中的bug
  document.onselectstart = () => false;
},
  
mouseMoveDocumentHandler(e) {
  // 判断是否在拖拽过程中,
  if (this.cursorDown === false) return;
  // 刚刚记录的this.y(this.x) 的值
  const prevPage = this[this.bar.axis];

  if (!prevPage) return;

  // 鼠标按下的位置在track中的偏移量,即鼠标按下点到track顶部(左侧)的距离
  const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
  // 鼠标按下点到thumb顶部(左侧)的距离
  const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
  // 当前thumb顶部(左侧)到track顶部(左侧)的距离,即thumb向下(向右)偏移的距离 占track高度(宽度)的百分比
  const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);
	// wrap.scrollHeight / wrap.scrollLeft * thumbPositionPercentage得到wrap.scrollTop / wrap.scrollLeft
  // 当wrap.scrollTop(wrap.scrollLeft)发生变化的时候,会触发父组件wrap上绑定的onScroll事件,
  // 从而重新计算moveX/moveY的值,这样thumb的滚动位置就会重新渲染
  this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},

mouseUpDocumentHandler(e) {
  // 当拖动结束,将标识位设为false
  this.cursorDown = false;
  // 将上一次拖动记录的this.y(this.x)的值清空
  this[this.bar.axis] = 0;
  // 取消页面绑定的mousemove事件
  off(document, 'mousemove', this.mouseMoveDocumentHandler);
  // 清空onselectstart事件绑定的函数
  document.onselectstart = null;
}

Приведенный выше код представляет собой всю логику обработки перетаскивания полосы прокрутки большого пальца Общая идея состоит в том, чтобы динамически вычислять расстояние от верхней (левой стороны) бегунка до верхней (левой стороны) дорожки в процессе перетаскивания бегунка. , который учитывает высоту (ширину) самой дорожки в процентах, а затем использовать этот процент для динамического изменения значения wrap.scrollTop, запуская тем самым прокрутку страницы и перерасчет положения полосы прокрутки для достижения эффекта прокрутки.

微信图片_20190121160845.jpg

Предыдущая картинка удобна для понимания всем ( ̄▽ ̄)"

Логика треков onMousedown и trumb аналогична, стоит отметить два момента:

  1. Обратный вызов события onMousedown для отслеживания не будет привязывать события mousemove и mouseup к странице, поскольку отслеживание эквивалентно событию щелчка.
  2. В событии onmousedown дорожки способ, которым мы вычисляем верхнюю часть бегунка до начала дорожки, состоит в том, чтобы вычесть половину высоты бегунка из расстояния от точки щелчка мыши до верхней части дорожки, потому что после щелчка по треку середина бегунка находится точно в точке щелчка мыши.

На этом анализ всего исходного кода полосы прокрутки закончен.Оглядываясь назад, можно сказать, что реализация полосы прокрутки не сложна.Главное — разобраться в различных соотношениях прокрутки, длине бегунка и способе положения прокрутки. определяется отношением между оберткой и представлением. Эта часть может быть довольно запутанной. Студенты, которые этого не понимают, предлагают вам нарисовать и изучить ее самостоятельно. Пока вы понимаете этот принцип скользящего движения, его очень просто реализовать.