Серия анализа исходного кода элемента 8-Cascader (каскадный селектор)

JavaScript Element Vue.js HTML

Введение

Каскадный селектор, как показано на рисунке ниже, также является часто используемым компонентом, этот компонент будет более сложным.

Файлы, связанные с этим компонентом в Element,main.vueа такжеmenu.vue2, первый представляет часть поля ввода, второй представляет часть каскадного выбора ниже, а дополнительные файлы js popper.js и vue.popper.js используются для обработки логики всплывающего окна. статья, эти 4 файла Общий размер кода файла около 2000 строк.Прежде всего должно быть понятно, что логика всплывающего окна отделена от Element и размещена в специальном popper.js, потому что многие компоненты используйте всплывающее окно. Код официального сайта компонентакликните сюда

HTML-структура поля ввода каскадного селектора

Первый взглядmain.vuehtml структура в ,main.vueПредставляет часть поля ввода, упрощенная структура html выглядит следующим образом.

<span class="el-cascader">
    <el-input>
        <template slot="suffix">
            <i v-if></i>
            <i v-else></i>
        </template>
    </el-input>
    <span class="el-cascader__label">
        ...
    </span>
</span>

Структура очень проста. Внешний диапазон охватывает все элементы. Диапазон расположен относительно и является встроенным блоком.<el-input>Компонент поля ввода, поле ввода используется для поиска целевого содержимого (содержимое — это данные каскадного селектора), а затем<el-input>Шаблон внутри хранит 2 тега i в качестве слота, обратите внимание наslot="suffix"Это для того, чтобы вставить эти 2 i-тега в качестве содержимого именованного слота.<el-input>Соответствующее положение в центре, с таблицей с нижней стрелкой и полем ввода. Затем диапазон, этот диапазон является текстом в поле на картинке ниже.

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

Вы не нашли html структуру выпадающего меню? Поскольку раскрывающееся меню монтируется в document.body и управляется popper.js, структура разделена

Анализ кода для каскадных полей ввода селектора

Давайте сначала посмотрим на код самого внешнего диапазона.

<span
    class="el-cascader"
    :class="[
      {
        'is-opened': menuVisible,
        'is-disabled': cascaderDisabled
      },
      cascaderSize ? 'el-cascader--' + cascaderSize : ''
    ]"
    @click="handleClick"
    @mouseenter="inputHover = true"
    @focus="inputHover = true"
    @mouseleave="inputHover = false"
    @blur="inputHover = false"
    ref="reference"
    v-clickoutside="handleClickoutside"
    @keydown="handleKeydown"
  >

Как самый внешний диапазон компонента, его основная функция состоит в том, чтобы всплывать/скрывать раскрывающийся список после щелчка.Предыдущая часть класса — это стиль, который определяет, отключено ли поле ввода.is-openedЭтот класс очень странный, нет исходного кода, и элемент проверки также обнаружил, что класс пустой,menuVisibleЯвляется ли переменная в компоненте компонентом, управляющим отображением выпадающего меню, естественно вы можете придумать, ниже@click="handleClick"Есть код для управления этой переменной, метод следующий

handleClick() {
      if (this.cascaderDisabled) return;
      this.$refs.input.focus();
      if (this.filterable) {
        this.menuVisible = true;
        return;
      }
      this.menuVisible = !this.menuVisible;
    },

Сначала определите, отключен ли компонент, если отключен, вернитесь напрямую, второе предложениеthis.$refs.input.focus()получается из компонента<el-input>И позвольте ему получить фокус, фокус - это собственное использование, обратите внимание, что по умолчанию поле ввода в компоненте доступно только для чтения, и фокус может быть получен только тогда, когда включено состояние поиска, а поиск включенfilterableЭта опора управляет, пользователь проходит,<el-input>начальство:readonly="readonly"Это предложение - только для чтения, только для чтения атрибут рассчитывается следующим образом

readonly() {
      const isIE = !this.$isServer && !isNaN(Number(document.documentMode));
      return !this.filterable || (!isIE && !this.menuVisible);
    }

Здесь сначала определите, является ли это браузером ie, сначала определите, является ли это серверным рендерингом, если да, верните false напрямую, а затем это предложение!isNaN(Number(document.documentMode)Вы можете легко судить, так ли это, т.е. я помню, что это обычно используетсяnavigator.userAgent.indexOf("MSIE")>0Судя по всему, documentMode является специфичным для ie свойством.

т.е. возвращает число, другие браузеры возвращают undefined, тогда Numer(undefined) равно NaN, так почему бы не использовать его напрямуюdocument.documentMode!==undefinedСудить, тут я не понимаю, боится, что undefined не является истинным undefined? Потому что undefined можно изменить. Продолжайте смотреть логику возврата, если это открытый поисковый статус (Filterable стоит true, то вообще поле ввода readonly должно быть false, указывая на то, что его можно писать), обратите на это внимание, продолжайте судить(!isIE && !this.menuVisible), если браузер ie, то в поле ввода можно писать, вопрос, зачем судить ie? Тут я немного запутался, пробовал IE и Chrome, но проблем не увидел.

Вернитесь к handleClick,if (this.filterable)Это предложение означает, что если состояние поиска включено, щелкните поле ввода и вернитесь напрямую, не переключая состояние раскрывающегося меню, что разумно, поскольку в состоянии поиска раскрывающееся меню должно отображаться все время. для вашего удобства Последнее предложениеthis.menuVisible = !this.menuVisibleнастоящий переключатель

Затем посмотрите на эти 4 предложения на пролете

@mouseenter="inputHover = true"
@focus="inputHover = true"
@mouseleave="inputHover = false"
@blur="inputHover = false"

Это кнопка ответвления, которая управляет отображением поля ввода, используемого для очистки поля ввода, как показано ниже.

mouseenter и mouseleave указывают, что inputHover переключается, когда мышь входит и выходит из диапазона.Обратите внимание, что это не mouseover и mouseout, потому что эти два будут срабатывать на дочерних элементах. Но фокус и размытие странные, потому что это связано с диапазоном обычных html-элементов, как правило, только при вводе, по умолчанию у span нет tabindex, поэтому нажатие Tab не может сделать его фокусом, если не добавлен атрибут tabindex, но официальный сайт не указывает, что этот атрибут есть, так как же диапазон получает фокус? После внимательного просмотра атрибутов элемента span, как показано ниже

Я обнаружил, что у него есть tabindex, но он равен -1, -1 означает, что к нему нельзя получить доступ через клавишу табуляции. У меня здесь есть 2 момента, которые я не понимаю. Во-первых, как добавляется атрибут tabindex, и прочее.@keydown="handleKeydown"В этом предложении путем печати обнаруживается, что, когда ввод в компоненте получает фокус, срабатывает нажатие клавиши на этом диапазоне.

@keydown="handleKeydown"Последнее предложение здесь тоже очень странное.К спану привязан метод keydown.Метод срабатывает только тогда, когда спан находится в фокусе.После внимательного наблюдения оказывается, что ввод в спане имеет фокус для срабатывания фокуса метод, а затем всплывает на родительский диапазон. При активации фокуса родительского диапазона в это время клавиша может инициировать нажатие клавиши родительского диапазона.

увидеть снова<el-input>код

<el-input
      ref="input"
      :readonly="readonly"
      :placeholder="currentLabels.length ? undefined : placeholder"
      v-model="inputValue"
      @input="debouncedInputChange"
      @focus="handleFocus"
      @blur="handleBlur"
      @compositionstart.native="handleComposition"
      @compositionend.native="handleComposition"
      :validate-event="false"
      :size="size"
      :disabled="cascaderDisabled"
      :class="{ 'is-focus': menuVisible }"
    >

Прежде всего, должно быть ясно, что функция этого поля ввода содержит только текст, введенный пользователем при использовании функции поиска.v-model="inputValue"Это предложение указывает значение привязки поля ввода, когда пользователь вводит значение, значение обновляется, а INPUTVALUE является атрибутом в компоненте в компоненте.@input="debouncedInputChange"В этом предложении объявляется функция привязки входного события, которая используется здесь из имениСтабилизатор, Короче говоря, анти-тряска здесь - это то, как долго пользователь делает паузу при вводе текста перед срабатываниемdebouncedInputChange,Поскольку функция поиска будет вызывать ajax, она асинхронная, и нужно контролировать частоту запросов к серверу. Если не задать, введите символ для срабатывания один раз, что явно слишком высокая частота. посмотрите на debouncedInputChange

this.debouncedInputChange = debounce(this.debounce, value => {
      const before = this.beforeFilter(value);
      if (before && before.then) {
        this.menu.options = [{
          __IS__FLAT__OPTIONS: true,
          label: this.t('el.cascader.loading'),
          value: '',
          disabled: true
        }];
        before
          .then(() => {
            this.$nextTick(() => {
              this.handleInputChange(value);
            });
          });
      } else if (before !== false) {
        this.$nextTick(() => {
          this.handleInputChange(value);
        });
      }
    });
  },

Здесь debounce — это высокоуровневая функция, полная реализация функции защиты от сотрясений.Подробности см. в npm.Первый параметр — это время устранения дребезга, а второй параметр — указанная функция обратного вызова, которая возвращает новую функцию как указанная функция привязки входного события. Параметром этой функции обратного вызова является значение, которое представляет собой новое введенное значение поля ввода.Первое предложение в функцииconst before = this.beforeFilter(value)beforeFilter — это функция

beforeFilter: {
      type: Function,
      default: () => (() => {})
    },

Эта функция - это функция, прежде чем его возвращаемое значение, функция передается пользователем, цель должна использоваться в качестве крючка до фильтра функции поиска, параметр является входным значением, если он возвращает ложь или возвращает обещание И отклонен, затем прекратить фильтрацию.

тогдаif (before && before.then)Если возвращаемое значение функции истинно и у нее есть метод then, это означает, что это обещание, сначала измените menu.options для состояния загрузки, а затем выполните его в then.this.handleInputChange(value)Подлинная операция, иначе если это не промис, а инструкция и возвращает true, метод handleInputChange прямого выполнения, вот зачем использовать nextTick, пока не ясно

<el-input>Назад@compositionstart.native="handleComposition"<el-input>Собственное событие компонента, который слушает корневой элемент вместо собственного элемента html, необходимо использовать модификатор native.

Затем обратите внимание, что в смонтированном методе есть такое предложение

mounted() {
    this.flatOptions = this.flattenOptions(this.options);
  }

Это классическая операция выравнивания массива, this.options — это массив данных, переданный пользователем, используемый для отображения раскрывающегося меню, и каждое значение массива является объектом со значением, меткой, дочерними элементами и дочерними элементами. встроенный Подмассив набора эквивалентен меню второго уровня и многоуровневому меню, так почему же он должен быть плоским? Причина в том, что функция поиска должна перебирать все элементы данных, поэтому сглаженный массив легче просматривать, вот код

flattenOptions(options, ancestor = []) {
      let flatOptions = [];
      options.forEach((option) => {
        const optionsStack = ancestor.concat(option);
        if (!option[this.childrenKey]) {
          flatOptions.push(optionsStack);
        } else {
          if (this.changeOnSelect) {
            flatOptions.push(optionsStack);
          }
          flatOptions = flatOptions.concat(this.flattenOptions(option[this.childrenKey], optionsStack));
        }
      });
      return flatOptions;
    },

Принцип заключается в рекурсивной операции, чтобы определить, есть ли дочерний элемент, если да, вызовите себя рекурсивно, а затем конкатируйте к flatOptions и верните, в противном случае нажмите напрямую, где второй параметр этого метода используется для сохранения многоуровневого меню, а затем перейдите к просмотру кода поиска, основная логика поиска выглядит следующим образом

let filteredFlatOptions = flatOptions.filter(optionsStack => {
        return optionsStack.some(option => new RegExp(escapeRegexpString(value), 'i')
          .test(option[this.labelKey]));
      });

Это операция фильтрации расширенного массива с использованием регулярных выражений для сопоставления, значение — это значение, введенное пользователем для запроса, где optionStack — это массив, если какой-либо элемент в нем удовлетворен, он вернет true, чтобы указать, что он найдено, через некоторую функцию более высокого порядка окончательно получаемfilteredFlatOptionsрезультаты поиска

Анализ раскрывающегося меню каскадного селектора

просмотревmain.vueВ коде обнаружил, что html часть не имеет структуры выпадающего меню.На самом деле выпадающее меню монтируется на тело.Естественно возникает вопрос, как устроены поле ввода и выпадающая часть часть меню подключена? Заглянув в исходный код, я нашел метод initMenu, который будет вызываться при первом вызове showMenu, код выглядит следующим образом

initMenu() {
      this.menu = new Vue(ElCascaderMenu).$mount();
      this.menu.options = this.options;
      this.menu.props = this.props;
      this.menu.expandTrigger = this.expandTrigger;
      this.menu.changeOnSelect = this.changeOnSelect;
      this.menu.popperClass = this.popperClass;
      this.menu.hoverThreshold = this.hoverThreshold;
      this.popperElm = this.menu.$el;
      this.menu.$refs.menus[0].setAttribute('id', `cascader-menu-${this.id}`);
      this.menu.$on('pick', this.handlePick);
      this.menu.$on('activeItemChange', this.handleActiveItemChange);
      this.menu.$on('menuLeave', this.doDestroy);
      this.menu.$on('closeInside', this.handleClickoutside);
    },

Обратите внимание на первое предложениеthis.menu = new Vue(ElCascaderMenu).$mount()Это показывает, что ElCascaderMenu используется в качестве объекта параметра, а затем создается новый экземпляр Vue.Этот экземпляр является экземпляром раскрывающегося меню, ElCascaderMenu является компонентом меню и$mount()Никакие параметры не передаются, что означает, что он отображается вне документа, но не монтируется в дом. Конкретная операция монтирования выполняется в vue-popper.js. Здесь this.menu используется для сохранения экземпляра раскрывающееся меню, чтобы пользователь мог управлять раскрывающимся меню и обрабатывать события через this.menu, поэтому они связаны друг с другом, см.this.popperElm = this.menu.$elОдним словом, это предложение тоже очень важно. Оно присваивает элементу popperElm корневой dom выпадающего меню. Откуда берется popperElm? вот как это произошло

const popperMixin = {
  props: {
    placement: {
      type: String,
      default: 'bottom-start'
    },
    appendToBody: Popper.props.appendToBody,
    arrowOffset: Popper.props.arrowOffset,
    offset: Popper.props.offset,
    boundariesPadding: Popper.props.boundariesPadding,
    popperOptions: Popper.props.popperOptions
  },
  methods: Popper.methods,
  data: Popper.data,
  beforeDestroy: Popper.beforeDestroy
};

Poppermixin внутри метода Vue-popper.js, поле ввода данных, подобно смешанному разделу, целью того, что в этом компонент в сборке. initmanu Последние немногие раскрывающиеся меню прослушивают с $ Emit Trigger Различные события

До сих пор я еще не видел, как выпадающее меню монтируется на тело, а не в initMenu, давайте продолжим видеть, при нажатии на поле ввода всплывает раскрывающееся меню, запускает showMenu, и введите шоуменю

showMenu() {
      if (!this.menu) {
        this.initMenu();
      }
      ...
      this.$nextTick(_ => {
        this.updatePopper();
        this.menu.inputWidth = this.$refs.input.$el.offsetWidth - 2;
      });
    },

можно увидеть внутриthis.updatePopperЭто необходимо для выполнения операции раскрывающегося меню обновления. Обратите внимание, что здесь должен быть nextTick, потому что данные изменяются в initMenu. В это время необходимо получить обновленный дом. UpdatePopper смешивается с частью поля ввода. через popperMixin, который находится в vue-popper.js

updatePopper() {
      const popperJS = this.popperJS;
      if (popperJS) {
        popperJS.update();
        if (popperJS._popper) {
          popperJS._popper.style.zIndex = PopupManager.nextZIndex();
        }
      } else {
        this.createPopper();
      }
    },

Здесь popperJS представляет собой зрелый плагин popper с более чем строками кода 2000. Если вам интересно, вы можете узнать о нем. Здесь сначала определите, существует ли popperJS. Он не должен существовать, когда вы запускаете его в первый раз. Войтиthis.createPopper()Выполните операцию инициализации, продолжайте видетьthis.createPopper()

createPopper() {
    ...
    const popper = this.popperElm = this.popperElm || this.popper || this.$refs.popper;
    ...
    if (this.appendToBody) document.body.appendChild(this.popperElm);
}

Здесь, чтобы пройти через это .pperelm добраться до корневого элемента раскрывающегося меню DOM, то есть из предыдущего анализа, а затем определить, можно ли монтировать на корпус, если старый прямой приложение, так вот это полное крепление Расплавленное меню, определенное расположение обновления операции также в этой популярности, тем больше проблем.

Давайте посмотрим на структуру раскрывающегося меню HTML

return (
        <transition name="el-zoom-in-top" on-before-enter={this.handleMenuEnter} on-after-leave={this.handleMenuLeave}>
          <div
            v-show={visible}
            class={[
              'el-cascader-menus el-popper',
              popperClass
            ]}
            ref="wrapper"
          >
            <div x-arrow class="popper__arrow"></div>
            {menus}
          </div>
        </transition>
      );

Этот возврат означает, что раскрывающееся меню создается функцией визуализации рендеринга, которая похожа на форму реакции jsx. Самый внешний переход объявляет эффект анимации компонента. Этот эффект анимации — от transform: scaleY(1) до transform: scaleY(0) и процесс обратного масштабирования, затем посмотрите на 2 хуковые функции,this.handleMenuEnterСрабатывает при вставке выпадающего меню в дом, что здесь делается?

handleMenuEnter() {
        this.$nextTick(() => this.$refs.menus.forEach(menu => this.scrollMenu(menu)));
      }

Здесь завернут слой nextTick, потому что его можно вызвать только после вставки dom, иначе может быть сообщено об ошибке. Затем посмотрите на scrollMenu

scrollMenu(menu) {
        scrollIntoView(menu, menu.getElementsByClassName('is-active')[0]);
      },

Как следует из названия, он перемещает элемент dom второго параметра в видимый диапазон dom, где находится первый параметр, что это значит, смотрите на рисунке ниже.

Когда вы выбираете элемент рейтинга, затем закрываете меню, а затем щелкаете поле ввода, чтобы развернуть меню, элемент рейтинга автоматически попадает в поле зрения, это функция scrollIntoView.

export default function scrollIntoView(container, selected) {
  if (!selected) {
    container.scrollTop = 0;
    return;
  }
  const offsetParents = [];
  let pointer = selected.offsetParent;
  while (pointer && container !== pointer && container.contains(pointer)) {
    offsetParents.push(pointer);
    pointer = pointer.offsetParent;
  }
  const top = selected.offsetTop + offsetParents.reduce((prev, curr) => (prev + curr.offsetTop), 0);
  const bottom = top + selected.offsetHeight;
  const viewRectTop = container.scrollTop;
  const viewRectBottom = viewRectTop + container.clientHeight;

  if (top < viewRectTop) {
    container.scrollTop = top;
  } else if (bottom > viewRectBottom) {
    container.scrollTop = bottom - container.clientHeight;
  }
}

Основная идея приведенного выше кода заключается в непрерывном накоплении значения offsetTop выбранного элемента. В цикле while указатель.offsetParent используется для получения собственного родительского элемента смещения. offsetParent — это ближайший элемент-предок, позиция которого не статический, а затем он сохраняется как массив, а затем значение offsetTop накапливается по очереди через предложение offsetParents.reduce, и, наконец, получается выбранный элементНижнийРасстояние от верхней части элемента контейнера. Наконец, обновите scrollTop контейнера, чтобы переместить полосу прокрутки так, чтобы элемент просто попадал в поле зрения.ScrollIntoView на самом деле является новой функцией h5, новым API, позволяющим элементам перемещаться в поле зрения страницы. но это не относится к прокрутке элементов в контейнере, да и с сексом совместимо не очень.

Затем продолжайте смотреть на часть структуры html.v-show={visible}Отображением и сокрытием выпадающего меню управляет visible.visible обновляется в main.vue, то есть обновляется в showMenu, когда пользователь щелкает поле ввода, а затем часть класса'el-cascader-menus'Этот класс объявляет некоторые базовые стили, а затемel-popperКласс делает это раскрывающееся меню отступом от поля ввода.<div x-arrow class="popper__arrow"></div>Он представляет собой небольшую треугольную стрелку раскрывающегося меню. Это написание - это классические три границы, которые являются прозрачными, и одна граница имеет цвет, чтобы сформировать треугольник.

Тогда в DIV есть только одно {menu}, представляющее собой список UL в раскрывающемся меню, которое генерируется следующим методом.

const menus = this._l(activeOptions, (menu, menuIndex) => {
    ...
    const items = this._l(menu, item => {
        ...
         return (
            <li>...</li>
            )
    }
    return (<ul>{items}</ul)
}

Этот метод очень длинный.Вышеупомянутая упрощенная логика.Видно, что сначала генерируется список li в каждом ul, а затем генерируется список ul.Тогда возникает проблема.this._lЧто это за метод? Я не смог найти его в этом файле и связанных с ним файлах и, наконец, обнаружил, что это что-то в исходном коде Vue. Поиск этого метода в исходном коде Vue является псевдонимом renderList.RenderList выглядит следующим образом

export function renderList (
  val: any,
  render: (
    val: any,
    keyOrIndex: string | number,
    index?: number
  ) => VNode
): ?Array<VNode> {
  let ret: ?Array<VNode>, i, l, keys, key
  if (Array.isArray(val) || typeof val === 'string')
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
    
  ...
  
  return ret
}

Вот код в формате потока, который используется для контроля типа. renderList — это функция высшего порядка. Второй параметр нужно передать в методе rander, и тогда логика if внутри такова, что если параметр val равен массив, тоret[i] = render(val[i], i)Выполните каждый элемент массива по очереди и сохраните возвращенный результат в массиве ret и вернитесь в конце. Эта функция предназначена для обработки каждого элемента входящего параметра val, а затем возвращает обработанный новый массив. Итак, вышеthis._l(activeOptions, (menu, menuIndex)После обработки возвращает<li>Затем массивы вставляются в HTML-код рендеринга.

const menus = this._l(activeOptions, (menu, menuIndex) => {}Второй параметр этой функции сверхсложный, он обрабатывает различные события мыши и события клавиатуры li, конкретная логика не будет прописана и вообще не будет доработана. Наконец, давайте поговорим о функции события клика при нажатии на элемент выпадающего меню. Сначала посмотрите на картинку ниже

Когда мы раскрываем 3-х уровневое меню и нажимаем на рейтинг, то нам нужно получить такой текст пути из меню первого уровня в меню последнего уровняоценка компонента/формы/скоростиКаждый li в меню здесь привязан к событию клика, как показано ниже.

activeItem(item, menuIndex) {
        const len = this.activeOptions.length;
        this.activeValue.splice(menuIndex, len, item.value);
        this.activeOptions.splice(menuIndex + 1, len, item.children);
        if (this.changeOnSelect) {
          this.$emit('pick', this.activeValue.slice(), false);
        } else {
          this.$emit('activeItemChange', this.activeValue);
        }
      },

Первый параметр — это сам li, второй параметр — это индекс меню, это значение является передним.const menus = this._l(activeOptions, (menu, menuIndex) => {}Передаваемое в него значение представляет собой уровень меню. Затем сначала получите длину activeOptions, что такое activeOptions, это список активированных в данный момент опций, таких как состояние, как показано ниже.

У нас активировано 3 подменю, далее activeOptions это такой двумерный массив как показано на рисунке ниже, в котором хранятся данные 3-х подменю

оглядыватьсяthis.activeValue.splice(menuIndex, len, item.value)В этом предложении activeValue — это выбранный нами массив элементов активации. На приведенном выше рисунке значением activeValue является ['Guide', 'Design Principle', 'Consistent'], а объединение используется для добавления и удаления элементов из массива.

Затем сращивание выше начинает удалять из menuIndex, удаляет элементы len, а затем добавляет вновь выбранное значение item.value в массив для обновления выбранного элемента, обратите внимание на следующее предложениеthis.activeOptions.splice(menuIndex + 1, len, item.children)Первый параметр здесь — menuIndex+1, потому что вы хотите удалить собственное подменю вместо себя, так что это следующая позиция, а затем добавить новое подменю в массив

Суммировать

Код этого компонента немного сложен, а часть кода непонятна.В любом случае, смотрите не спеша.Вы не должны понимать многого при первом прочтении.