当前位置:   article > 正文

徒手撸UI之Tree

自撸ui
QingUI是一个UI组件库

目前拥有的组件:DatePicker, TimePicker, Paginator, Tree, Cascader, Checkbox, Radio, Switch, InputNumber, Input

ES6语法编写,无依赖

原生模块化,Chrome63以上支持,请开启静态服务器预览效果, 静态服务器传送门

采用CSS变量配置样式

辛苦造轮子,欢迎来github仓库star: QingUI

四月份找工作,求内推,坐标深圳

写在前面

去年年底项目中尝试着写过一个分页的Angular组件,然后就有了写QingUI的想法

过程还是非常有意思的

接下来我会用几篇文章分别介绍每个组件的大概思路,请大家耐心等待

这一篇介绍Tree树结构

最重要的,求star,求内推

repo: QingUI

少废话,先上图

img failed

渲染

作为树组件,想都不用想,肯定用递归

但是QingUI的组件统一用一个div.qing qing-component包裹,所以用div把递归函数包起来

  1. const tpl = `
  2. <div class="qing qing-tree">
  3. ${this.renderTrunk(this.data)}
  4. </div>
  5. `;

然后我们再来看renderTrunk函数

首先简要介绍一下标签结构

  1. <div class="trunk">
  2. <div class="fruit">
  3. <span class="arrow"></span>
  4. <span class="cb"></span>
  5. <span class="label"></span>
  6. </div>
  7. <div class="sub"></div>
  8. </div>

trunk可以理解为树的一项,是树干

fruit是包裹信息用的,里面有三角箭头,checkbox和label

如果它有子项,则fruit后面再加一个sub,sub里面当然又是一个或多个trunk

indent

Tree配置项里有一个indent,指的是所有的子项相对于父项缩进的距离

顶层项没有父项,所以不需要缩进

于是我们就需要在递归的时候判断,现在是顶层项还是子项

这个简单

  1. // data是递归函数传进来的
  2. const inner = data !== this.data;

于是我们的缩进也解决了

const marginLeft = `${inner ? `style="margin-left: ${this.indent}px;"` : ''}`;

expand

Tree配置项里还有一个expand,它有三个选项:none、all和first

它决定的是初始加载的时候子项是全部闭合、全部展开还是只有第一个顶层项展开

子项是否展开是如何控制的呢?

当然是通过高度,height: 0; overflow: hidden;的时候闭合,height: auto; overflow: hidden;的时候展开

于是就成了下面这样

expand等于first时,意思是只有顶层项的第一项才会展开

这里有一个小技巧

expand我们给了三个可选项,但是万一用户偏偏传个hello进来呢?

反正none是默认项,条件判断的时候,我只认all和first,除此之外都是默认配置

这属于对错误参数的静默处理,我也不告诉你传错了,但是你也别想要任何效果

这样就不需要对参数多做一个校验了

  1. for (let i = 0; i < data.length; i++) {
  2. let arrowTpl, subTpl;
  3. if (this.expand === 'all') {
  4. arrowTpl = '<span class="arrow active"></span>';
  5. subTpl = '<div class="sub" style="height: auto;">';
  6. } else if (this.expand === 'first') {
  7. const boo = !inner && i === 0;
  8. arrowTpl = boo ? '<span class="arrow active"></span>' : '<span class="arrow"></span>';
  9. subTpl = `<div class="sub" ${boo ? 'style="height: auto;"' : ''}>`;
  10. } else {
  11. arrowTpl = '<span class="arrow"></span>';
  12. subTpl = '<div class="sub">';
  13. }
  14. }

fruit

fruit这里,如果没有子项,是不需要三角箭头的

另外不需要checkbox的话就不显示checkbox,只作为树结构展示用

  1. tpl += `
  2. <div class="fruit">
  3. ${item.sub ? arrowTpl : '<span class="blank"></span>'}
  4. ${this.checkable ? '<span class="cb"></span>' : ''}
  5. <span class="label">${item.label}</span>
  6. </div>
  7. `;

最后,如果有子项,别忘了递归

  1. if (item.sub) {
  2. tpl += subTpl;
  3. tpl += this.renderTrunk(item.sub);
  4. }

整个模板部分大概就是这样子的

我们看一下全貌:

  1. render() {
  2. const tpl = `
  3. <div class="qing qing-tree">
  4. ${this.renderTrunk(this.data)}
  5. </div>
  6. `;
  7. this.$mount.innerHTML = tpl;
  8. }
  9. renderTrunk(data) {
  10. const inner = data !== this.data;
  11. const marginLeft = `${inner ? `style="margin-left: ${this.indent}px;"` : ''}`;
  12. let tpl = '';
  13. for (let i = 0; i < data.length; i++) {
  14. const item = data[i];
  15. tpl += `<div class="trunk" ${marginLeft}>`;
  16. let arrowTpl, subTpl;
  17. if (this.expand === 'all') {
  18. arrowTpl = '<span class="arrow active"></span>';
  19. subTpl = '<div class="sub" style="height: auto;">';
  20. } else if (this.expand === 'first') {
  21. const boo = !inner && i === 0;
  22. arrowTpl = boo ? '<span class="arrow active"></span>' : '<span class="arrow"></span>';
  23. subTpl = `<div class="sub" ${boo ? 'style="height: auto;"' : ''}>`;
  24. } else {
  25. arrowTpl = '<span class="arrow"></span>';
  26. subTpl = '<div class="sub">';
  27. }
  28. tpl += `
  29. <div class="fruit">
  30. ${item.sub ? arrowTpl : '<span class="blank"></span>'}
  31. ${this.checkable ? '<span class="cb"></span>' : ''}
  32. <span class="label">${item.label}</span>
  33. </div>
  34. `;
  35. if (item.sub) {
  36. tpl += subTpl;
  37. tpl += this.renderTrunk(item.sub);
  38. tpl += '</div>';
  39. }
  40. tpl += '</div>';
  41. }
  42. return tpl;
  43. }

映射

当我需要点击checkbox导致data的某个对象的checked属性变更时,我的做法是维护一套DOM结构的映射

映射跟DOM结构是一一对应的,跟data也是一一对应的

它可以很好的作为桥梁同步数据

为什么不直接操作data呢?

data是用户传进来的结构化数据,后面还需要通过回调传回去的,我们不应该修改用户的数据结构

映射需要保存些什么?

  • 保存对应的DOM节点
  • 保存checked属性的值
  • 保存当前节点在树结构中的位置

queue

一个小问题,如何保存当前节点在树结构中的位置呢?

当我们说在树结构中的位置的时候,我们想知道的是当前节点在第几级,以及当前节点在该级的第几个

我用queue关键字来保存位置信息,可能不是非常语义化,你来打我呀!

举个栗子

假如我要知道宋佳的位置,queue的值为'111',表示它是一个三级子项,它的爷爷第一级是第二项,它的爹爹第二级也是第二项,它自己也是第二项

再举个栗子,倪妮的queue就是'10'

有两个例子,应该不会有理解上的偏差了吧

  1. data = [
  2. {
  3. label: '霍思燕',
  4. },
  5. {
  6. label: '江疏影',
  7. sub: [
  8. {
  9. label: '倪妮',
  10. },
  11. {
  12. label: '高圆圆',
  13. sub: [
  14. {
  15. label: '张雨绮',
  16. },
  17. {
  18. label: '宋佳',
  19. },
  20. ],
  21. },
  22. ],
  23. },
  24. ];

ES6有一个新特性,如果对象的键和值变量是一样的,那么不需要写冒号,又给你省了不少时间可以用来浪费,开不开心!

  1. buildCbTree(fatherCb, item) {
  2. const childCbs = fatherCb.children;
  3. for (let i = 0; i < childCbs.length; i++) {
  4. const fruit = childCbs[i].firstElementChild;
  5. const sub = fruit.nextElementSibling;
  6. const cb = fruit.firstElementChild.nextElementSibling;
  7. const checked = cb.classList.contains('checked');
  8. const queue = item.queue ? item.queue + String(i) : String(i);
  9. let obj = {cb, checked, queue};
  10. if (sub) {
  11. obj.sub = [];
  12. this.buildCbTree(sub, obj);
  13. }
  14. item.sub.push(obj);
  15. }
  16. }

它的初始参数是什么呢?

this.$mount.firstElementChild就是开始加的包裹元素,上面有标识QingUI的class,所以它就是根元素

this.buildCbTree(this.$mount.firstElementChild, {sub: this.cbTree});

checkbox事件

我们先来捋一捋,当我们点击某一项的checkbox时

它自己只有两种状态,要么checked,要么去除checked

如果它有子项,则所有子项以及孙项以及所有的后代项跟随它的脚步,要么全部checked,要么全部去除checked

但是如果它有父项(它是有可能没有父项的,如果自己是顶层项的话),需要根据自己的兄弟来决定父项的checked属性,依照这个逻辑往上递归

如果自己和兄弟都去除了checked,则父项去除checked

如果自己和兄弟都checked,则父项checked

如果自己或者兄弟至少有一个checked,则父项somechecked

所以每一次点击都要做三条线的处理

  1. $cb.addEventListener('click', function(event) {
  2. event.stopPropagation();
  3. const checked = !this.classList.contains('checked');
  4. // cb事件
  5. checked ? self.$cbEvent(item, 'all') : self.$cbEvent(item, 'none');
  6. // cb子代事件
  7. checked ? self.childCbsEvent($sub, 'all') : self.childCbsEvent($sub, 'none');
  8. // cb父代事件
  9. self.fatherCbsEvent(queue);
  10. });

checkbox自代事件

自己虽然只有两种状态,但我们可以把它作为抽象函数,因为无论是父项、子项还是自己,都是checkbox而已,只不过是一个还是多个

所以我们给它三种状态,作为action参数传进来

  1. $cbEvent(item, action) {
  2. const $cb = item.cb;
  3. const CL = $cb.classList;
  4. switch (action) {
  5. case 'all':
  6. item.checked = true;
  7. CL.contains('somechecked') ? CL.remove('somechecked') : '';
  8. CL.add('checked');
  9. break;
  10. case 'some':
  11. item.checked = false;
  12. CL.contains('checked') ? CL.remove('checked') : '';
  13. CL.add('somechecked');
  14. break;
  15. case 'none':
  16. item.checked = false;
  17. CL.contains('somechecked') ? CL.remove('somechecked') : '';
  18. CL.contains('checked') ? CL.remove('checked') : '';
  19. break;
  20. }
  21. }

checkbox子代事件

子代事件也只有两种状态,跟自身同步

只需要递归就好了

需要提醒的是,这里所有的操作都是针对映射cbTree进行的

  1. childCbsEvent($sub, action) {
  2. if (!$sub) {
  3. return;
  4. }
  5. const self = this;
  6. function recursive($sub) {
  7. for (const $item of $sub) {
  8. self.$cbEvent($item, action);
  9. if ($item.sub) {
  10. recursive($item.sub);
  11. }
  12. }
  13. }
  14. recursive($sub);
  15. }

checkbox父代事件

要决定父项的状态,先要找到自己的兄弟,家族财产怎么分割,还得兄弟一起商量

兄弟怎么找呢?

先要找到包裹自己和兄弟的那个实体,你才能遍历呀,这时候queue就派上用场了

现在我知道实体的位置,只需要this.cbTree[a][b][c]这么找下去不就完了!

  1. findFatherItem(queue) {
  2. const n = queue.length - 1;
  3. // 顶级item没有父item
  4. if (n === 0) {
  5. return;
  6. }
  7. let fatherItem = this.cbTree;
  8. for (let i = 0; i < n; i++) {
  9. const char = queue.charAt(i);
  10. if (i < n - 1) {
  11. fatherItem = fatherItem[char].sub;
  12. } else {
  13. fatherItem = fatherItem[char];
  14. }
  15. }
  16. return fatherItem;
  17. }

找到了实体,接下来遍历就行了

当然,这里面的兄弟实际上是包括自己的

因为DOM操作比较慢,我原以为这里做计算的时候,有可能自身的class变更还没生效呢

因为在我的印象里,DOM操作好像是异步的,其实不是的,所以把自己纳入进来是没问题的

  1. findSiblingCbs($sub) {
  2. let $siblingCbs = [];
  3. for (const $item of $sub) {
  4. $siblingCbs.push($item.cb);
  5. }
  6. return $siblingCbs;
  7. }

最后就是根据最初的逻辑把结果丢进那个抽象函数里

当然,记住树结构里一切都是递归的

  1. fatherCbsEvent(queue) {
  2. const fatherItem = this.findFatherItem(queue);
  3. if (!fatherItem) {
  4. return;
  5. }
  6. const $siblingCbs = this.findSiblingCbs(fatherItem.sub);
  7. let allFlag = true;
  8. let noneFlag = true;
  9. for (const $item of $siblingCbs) {
  10. const cl = $item.classList;
  11. if (!cl.contains('checked')) {
  12. allFlag = false;
  13. }
  14. if (cl.contains('checked') || cl.contains('somechecked')) {
  15. noneFlag = false;
  16. }
  17. // flag全都已经变化,退出循环
  18. if (!allFlag && !noneFlag) {
  19. break;
  20. }
  21. }
  22. if (allFlag) {
  23. this.$cbEvent(fatherItem, 'all');
  24. } else if (noneFlag) {
  25. this.$cbEvent(fatherItem, 'none');
  26. } else {
  27. this.$cbEvent(fatherItem, 'some');
  28. }
  29. this.fatherCbsEvent(fatherItem.queue);
  30. }

更新data

不知道你们意识到没有,上面做的所有事,仅仅是改变了DOM样式和映射cbTree

用户想在回调里拿到的是一开始传进来的data呀

不过这好办,因为cbTree和data的结构是一毛一样的

只需要一个递归就解决问题

最终我们得到的data就是checked属性已经产生变化的data

  1. updateData(tree, data) {
  2. for (let i = 0; i < tree.length; i++) {
  3. const ti = tree[i];
  4. const di = data[i];
  5. if (ti.checked) {
  6. di.checked = true;
  7. } else {
  8. di.checked = false;
  9. }
  10. if (ti.sub) {
  11. this.updateData(ti.sub, di.sub);
  12. }
  13. }
  14. }

高度动画

前面讲expand配置项的时候,我们提到过高度为0或是auto的问题

初始是什么没关系

我们想让展开或收起的过程更加平滑一点

然而我们知道height: auto;是无法产生CSS动画的,CSS动画必须得有一个确定的数值

那么怎么办呢?

有人说可以用max-height属性,给max-height一个很大的固定的值,就可以产生动画了

我觉得这样,动画效果不会好,而且很不优雅

试想一下,比如说现在高度是0,我能不能把高度改成auto,再通过JS计算出真实的高度,再把高度改成0

现在我们手里有它本来的确定的高度值,就可实现动画了

最后又把它的高度改成auto,因为它有可能有子项,固定高度会出问题的

有点绕,看代码

  1. subHeightToggle($sub) {
  2. let h = $sub.getBoundingClientRect().height;
  3. if (h > 0) {
  4. // 从auto变成具体的值
  5. $sub.style.height = `${h}px`;
  6. setTimeout(() => {
  7. $sub.style.height = '0px';
  8. }, 0);
  9. } else {
  10. $sub.style.height = 'auto';
  11. h = $sub.getBoundingClientRect().height;
  12. $sub.style.height = '0px';
  13. setTimeout(() => {
  14. $sub.style.height = `${h}px`;
  15. }, 0);
  16. // 动画完成变成auto
  17. setTimeout(() => {
  18. $sub.style.height = 'auto';
  19. }, 200);
  20. }
  21. }

我们老是说尽量不要操作DOM,其实这是要看付出回报比的,如果能够实现平滑效果,实现方式又足够优雅

咱们的计算机没你说的那么脆弱

写在后面

Tree比较核心的逻辑就在这里了

树结构算是QingUI里比较难的组件了,其中的实现方式我也试过好几版

越是难,其实越有成就感

下一篇文章介绍Cascader,敬请期待

最后,求star,求内推

repo: QingUI
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/木道寻08/article/detail/944805
推荐阅读
相关标签
  

闽ICP备14008679号