Vant 源码解析——TreeSelect

概述

本篇笔者来讲解一下 tree-select 的实现原理和细节处理,结合实际场景会对其进行拓展,以及优化用户体验问题,以便满足更多的业务场景。当然笔者会结合自身的理解,已经为每个核心的方法增加了必要的注释,会尽最大努力将其中的原理讲清楚,若有不妥之处,还望不吝赐教,欢迎批评指正。

预览

优化前 优化后
tree-select-before.gif
tree-select-after.gif

原理

tree-select的层级主要由 侧边导航栏内容页(Content) 组成,层级结构也非常清晰明了,业务逻辑也比较简单,主要用到技术可能就是双向绑定,比如:main-active-index 表示左侧高亮选项的索引,active-id 表示右侧高亮选项的 id。

emit(ctx, 'update:main-active-index', index);
emit(ctx, 'update:active-id', newActiveId);

拓展

tree-select在我们项目中使用的还算高频,尽管组件易用,原理简单的同时,也存在些许美中不足,其主要还是用户体验的问题,本次tree-select拓展就是优化用户体验。项目中用户反馈最多的两个问题如下:

  • 左侧导航栏和右侧内容能滚动时,点击左侧选项和点击右侧选项时,能滚动到 tree-select 的中间位置。
  • 由于移动端大多数都是keep-alive模式,当tree-select可视时,左侧高亮选项和右侧选项也得可视。

上面看似两个问题,实则是一个问题,只要我们在用户点击左侧选项和点击右侧选项,以及在activated方法中,将此选项滚动到中间位置即可。具体实现如下,大家看看代码就会一目了然。


export default createComponent({
  props: {
    max: {
      type: [Number, String],
      default: Infinity,
    },
    items: {
      type: Array,
      default: () => [],
    },
    height: {
      type: [Number, String],
      default: 300,
    },
    activeId: {
      type: [Number, String, Array],
      default: 0,
    },
    selectedIcon: {
      type: String,
      default: 'success',
    },
    mainActiveIndex: {
      type: [Number, String],
      default: 0,
    },
  },

  data() {
    return {

    }
  },

  computed: {
    isMultiple(){
      return Array.isArray(this.activeId);
    }
  },

  watch: {
    // 也需要监听左侧导航栏索引的变化,但是滚动时不加动画,因为此值有可能是异步设置的
    mainActiveIndex(val) {
      // 这种场景直接滚动
      this.scrollIntoNavView(+val, true)
    },

    // 也需要监听右侧内容栏索引的的变化,但是滚动时不加动画,因为此值有可能是异步设置的
    activeId() {
      // 这种场景直接滚动
      this.scrollIntoContentView(this.getCurrentContentIndex(+this.mainActiveIndex), true)
    },

    // 数据源变化了 设置一下
    items(){
      this.init()
    }
  },

  async mounted() {
    await this.$nextTick()
    // 获取滚动器
    const { navScroller, scroller } = this.$refs
    this.navScroller = navScroller.$el
    this.scroller = scroller

    // 滚动指定的nav和content索引
    this.scrollToView(true)
  },

  activated() {
    this.scrollToView(true)
  },

  methods: {

    async init() {
      // 这里要置空
      this.lastScrollActiveId = null

      await this.$nextTick()

      // 滚动指定的nav和content索引
      this.scrollToView(true)
    },

    isActiveItem(id) {
      return this.isMultiple
        ? this.activeId.indexOf(id) !== -1
        : this.activeId === id;
    },


    renderContent() {
      if (this.slots.content) {
        return this.slots.content();
      }

      const selectedItem = this.items[+this.mainActiveIndex] || {};
      const subItems = selectedItem.children || [];

      return subItems.map((item, index) => (
        <div
          key={item.id}
          class={[
            'van-ellipsis',
            bem('item', {
              active: this.isActiveItem(item.id),
              disabled: item.disabled,
            }),
          ]}
          onClick={() => {
            if (!item.disabled) {
              let newActiveId = item.id;
              if (this.isMultiple) {
                newActiveId = (this.activeId).slice();

                const index = newActiveId.indexOf(item.id);

                if (index !== -1) {
                  newActiveId.splice(index, 1);
                } else if (newActiveId.length < this.max) {
                  newActiveId.push(item.id);
                }
              }

              this.$emit('update:active-id', newActiveId);
              this.$emit('click-item', item);
              // compatible with legacy usage, should be removed in next major version
              this.$emit('itemclick', item);

              // 滚动到指定的index
              this.scrollIntoContentView(index)

              // 由于content可以支持多选,所以要记录最后一次滚动的item
              this.lastScrollActiveId = item.id
            }
          }}
        >
          {item.text}
          {this.isActiveItem(item.id) && (
            <Icon name={this.selectedIcon} class={bem('selected')} />
          )}
        </div>
      ));
    },

    // 获取当前content索引
    getCurrentContentIndex(mainActiveIndex) {
      let index = 0
      const activeId = this.isMultiple ? this.lastScrollActiveId : this.activeId
      const selectedItem = this.items[mainActiveIndex] || {};
      const subItems = selectedItem.children || [];
      for (let i = 0; i < subItems.length; i++) {
        const { id } = subItems[i];
        if (activeId === id) {
          index = i
          break
        }
      }
      return index
    },

    // 滚动到指定的 nav 和 content
    scrollToView(immediate) {
      this.navScroller && this.scrollIntoNavView(+this.mainActiveIndex, immediate)
      this.scroller && this.scrollIntoContentView(this.getCurrentContentIndex(+this.mainActiveIndex), immediate)
    },

    // 滚动到某个nav 居中
    scrollIntoNavView(index, immediate) {
      const {navScroller} = this;

      // 容错
      if (!navScroller) {
        return;
      }

      // 获取当前nav index
      const mIdx = index;
      if (mIdx < 0) {
        return;
      }

      // 获取
      const nav = navScroller.children[mIdx];
      if (!nav) {
        return;
      }

      // 如果还在动画中 直接过掉
      if (this.navAnimating) {
        return
      }

      // 动画开始
      this.navAnimating = true
      // 滚动到指定位置
      const to =
        nav.offsetTop - (navScroller.offsetHeight - nav.offsetHeight) / 2;
      scrollTopTo(navScroller, to, immediate ? 0 : 0.3, () => {
        // 动画结束
        this.navAnimating = false
      });
    },

    // 滚动到指定view
    scrollIntoContentView(index, immediate) {
      const {scroller} = this;

      // 容错
      if (!scroller) {
        return;
      }

      // index
      if (index < 0) {
        return;
      }

      // 获取
      const content = scroller.children[index];
      if (!content) {
        return;
      }

      // 如果还在动画中 直接过掉
      if (this.contentAnimating) {
        return
      }

      // 动画开始
      this.contentAnimating = true;

      // 滚动到指定位置
      const to =
        content.offsetTop - (scroller.offsetHeight - content.offsetHeight) / 2;
      scrollTopTo(scroller, to, immediate ? 0 : 0.3, () => {
        this.contentAnimating = false;
      });
    },
  },

  render() {

    if (process.env.NODE_ENV === 'development') {
      if (this.$listeners.navclick) {
        console.warn(
          '[Vant] TreeSelect: "navclick" event is deprecated, use "click-nav" instead.'
        );
      }
      if (this.$listeners.itemclick) {
        console.warn(
          '[Vant] TreeSelect: "itemclick" event is deprecated, use "click-item" instead.'
        );
      }
    }

    const Navs = this.items.map((item) => (
      <SidebarItem
        dot={item.dot}
        info={item.badge ?? item.info}
        title={item.text}
        disabled={item.disabled}
        class={[bem('nav-item'), item.className]}
      />
    ));

    return (
      <div class={bem()} style={{ height: addUnit(this.height) }}>
        <Sidebar
          ref="navScroller"
          class={bem('nav')}
          activeKey={this.mainActiveIndex}
          onChange={(index) => {
            this.$emit('update:main-active-index', index);
            this.$emit('click-nav', index);
            // compatible with legacy usage, should be removed in next major version
            this.$emit('navclick', index);

            // 滚动到指定位置
            this.scrollIntoNavView(index);

            // 滚动到指定的contentView
            this.scrollIntoContentView(this.getCurrentContentIndex(index), true);
          }}
        >
          {Navs}
        </Sidebar>
        <div ref="scroller" class={bem('content')}>{this.renderContent()}</div>
      </div>
    );
  }
});


// API
import { scrollTopTo } from '../tabs/utils';

export function scrollTopTo(
  scroller: HTMLElement,
  to: number,
  duration: number,
  callback: Function
) {
  let current = getScrollTop(scroller);

  const isDown = current < to;
  const frames = duration === 0 ? 1 : Math.round((duration * 1000) / 16);
  const step = (to - current) / frames;

  function animate() {
    current += step;

    if ((isDown && current > to) || (!isDown && current < to)) {
      current = to;
    }

    setScrollTop(scroller, current);

    if ((isDown && current < to) || (!isDown && current > to)) {
      raf(animate);
    } else if (callback) {
      raf(callback as FrameRequestCallback);
    }
  }

  animate();
}

Q&A

Q:Vant尚未支持以上滚动特性,前端该如何处理?

A:笔者拿vant提供的Demo来实现以上特性。其实无非就是把上面的逻辑处理拎出来即可,关键代码如下详见👉 src/tree-select/demo/index.vue


  <tree-select
    height="55vw"
    ref="treeSelect"
    :items="items"
    :active-id.sync="activeId"
    :main-active-index.sync="activeIndex"
    @click-item="onClickItem"
    @click-nav="onClickNav"
  />

  async mounted() {
    // 获取
    await this.$nextTick();

    const { children } = this.$refs.treeSelect;
    const [navScroller, scroller] = children;

    this.navScroller = navScroller;
    this.scroller = scroller;

    // 滚动指定的nav和content索引
    this.scrollToView(true)
  },

  activated() {
    // 滚动指定的nav和content索引
    this.scrollToView(true)
  },

  methods: {

    // 点击左侧导航时触发 或者 mainActiveIndex 切换也会调用
    async onClickNav(index) {

      // 0 获取 item
      // 3、navScroller一直滚动到中间位置
      this.scrollIntoNavView(index, false);

      // 4、重置content的滚动条
      // 滚动到指定的contentView
      this.scrollIntoContentView(this.getCurrentContentIndex(index), true);
    },

    // 点击右侧选择项时触发
    async onClickItem(item) {
      const { id } = item;
      let index = -1
      const { children } = this.items[+this.activeIndex]
      for (let i = 0; i < children.length; i++) {
        const c = children[i];
        if (c.id === id) {
          index = i
          break
        }
      }

      // content 要滚动到中间
      this.scrollIntoContentView(index);

      // 由于content可以支持多选,所以要记录最后一次滚动的item
      this.lastScrollActiveId = item.id
    },


    // 获取当前content索引
    getCurrentContentIndex(mainActiveIndex) {
      let index = 0
      const activeId = this.isMultiple ? this.lastScrollActiveId : this.activeId
      const selectedItem = this.items[mainActiveIndex] || {};
      const subItems = selectedItem.children || [];
      for (let i = 0; i < subItems.length; i++) {
        const { id } = subItems[i];
        if (activeId === id) {
          index = i
          break
        }
      }
      return index
    },

    // 滚动到指定的 nav 和 content
    scrollToView(immediate) {
      this.navScroller && this.scrollIntoNavView(+this.activeIndex, immediate)
      this.scroller && this.scrollIntoContentView(this.getCurrentContentIndex(+this.activeIndex), immediate)
    },

    // 滚动到某个nav 居中
    scrollIntoNavView(index, immediate) {
      const {navScroller} = this;

      // 容错
      if (!navScroller) {
        return;
      }

      // 获取当前nav index
      const mIdx = index;
      if (mIdx < 0) {
        return;
      }

      // 获取
      const nav = navScroller.children[mIdx];
      if (!nav) {
        return;
      }

      // 滚动到指定位置
      const to =
        nav.offsetTop - (navScroller.offsetHeight - nav.offsetHeight) / 2;
      scrollTopTo(navScroller, to, immediate ? 0 : 0.3);
    },

    // 滚动到指定view
    scrollIntoContentView(index, immediate) {
      const {scroller} = this;

      // 容错
      if (!scroller) {
        return;
      }

      // 获取当前nav index
      if (index < 0) {
        return;
      }

      // 获取
      const content = scroller.children[index];
      if (!content) {
        return;
      }

      // 滚动到指定位置
      const to =
        content.offsetTop - (scroller.offsetHeight - content.offsetHeight) / 2;
      scrollTopTo(scroller, to, immediate ? 0 : 0.3);
    },
  },

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:vant-learn
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容