使用react context实现一个支持组件组合和嵌套的React Tab组件

纵观react的tab组件中,即使是github上star数多的tab组件,实现原理都非常冗余。

例如Github上star数超四百星的react-tab,其在render的时候都会动态计算哪个tab是被选中的,哪个该被隐藏:

  getChildren() {
    let index = 0;
    let count = 0;
    const children = this.props.children;
    const state = this.state;
    const tabIds = this.tabIds = this.tabIds || [];
    const panelIds = this.panelIds = this.panelIds || [];
    let diff = this.tabIds.length - this.getTabsCount();

    // Add ids if new tabs have been added
    // Don't bother removing ids, just keep them in case they are added again
    // This is more efficient, and keeps the uuid counter under control
    while (diff++ < 0) {
      tabIds.push(uuid());
      panelIds.push(uuid());
    }

    // Map children to dynamically setup refs
    return React.Children.map(children, (child) => {
      // null happens when conditionally rendering TabPanel/Tab
      // see https://github.com/rackt/react-tabs/issues/37
      if (child === null) {
        return null;
      }

      let result = null;

      // Clone TabList and Tab components to have refs
      if (count++ === 0) {
        // TODO try setting the uuid in the "constructor" for `Tab`/`TabPanel`
        result = cloneElement(child, {
          ref: 'tablist',
          children: React.Children.map(child.props.children, (tab) => {
            // null happens when conditionally rendering TabPanel/Tab
            // see https://github.com/rackt/react-tabs/issues/37
            if (tab === null) {
              return null;
            }

            const ref = `tabs-${index}`;
            const id = tabIds[index];
            const panelId = panelIds[index];
            const selected = state.selectedIndex === index;
            const focus = selected && state.focus;

            index++;

            return cloneElement(tab, {
              ref,
              id,
              panelId,
              selected,
              focus,
            });
          }),
        });

        // Reset index for panels
        index = 0;
      }
      // Clone TabPanel components to have refs
      else {
        const ref = `panels-${index}`;
        const id = panelIds[index];
        const tabId = tabIds[index];
        const selected = state.selectedIndex === index;

        index++;

        result = cloneElement(child, {
          ref,
          id,
          tabId,
          selected,
        });
      }

      return result;
    });
  }

getChildren每次都会在render里面执行,虽然每次动态计算都会比较耗时,但这不是个大问题,真正让人担心的是里面用到的是cloneElement,cloneElement会生成新的实例对象,而这就会导致不必要的re-render(重新渲染)!!就算是银弹头pure render checking也无力挽回。

 

难道一个小小的tab组件用react实现就这么复杂吗?jQuery也就没几行代码,如果是这样那还不如使用jQuery,ReactJS的组件优势又是什么。。

 

现在我们回归到问题的本质,为什么要实现上面的代码?上面的代码其实是动态给组件增加props属性,例如给每个TabTitle组件添加是否selected的状态,因为组件内部无法知道selected状态,只能通过外部传入,但每个TabTitle组件又都需要这些组件,这就导致一个问题我要遍历所有TabTitle组件,然后把属性传进去。像上面的代码用在扁平结构的HTML标签倒还好,例如:

<Tabs>
    <TabTitle to="1">
        tab1
    </TabTitle>
    <TabTitle to="2">
        tab2
    </TabTitle>
    <TabPanel for="1">
        TabPanel1
    </TabPanel>
    <TabPanel for="2">
        TabPanel2
    </TabPanel>
</Tabs>

 

但如果我要支持组件组合使用,例如下面这样:

<Tabs onSelect={ this.onSelect } activeLinkStyle={ { color: 'red' } } defaultSelectedTab="2">
    <div>
        <TabTitle to="1">
            tab1
        </TabTitle>
    </div>
    <div>
        <TabTitle to="2">
            tab2
        </TabTitle>
    </div>
    <div>
        <TabPanel for="1">
            TabPanel1
        </TabPanel>
    </div>
    <div>
        <TabPanel for="2">
            TabPanel2
        </TabPanel>
    </div>
</Tabs>

 

上面的代码其实应用场景更广泛,因为如果你无法控制产品经理,他就会给你整这么一出!

这样的话前面的getChildren可能就要递归遍历子元素查找,时间复杂度又增加了。

 

即使解决了这么个问题,如果我的产品里一个tab里面嵌套了另一个tab,如何才能不让它们冲突呢?

<Tab defaultSelectedTab="b">
    <TabTitle label="a">
        TabTitle a
    </TabTitle>
    <TabTitle label="b">
        TabTitle b
    </TabTitle>
    <TabTitle label="c">
        TabTitle c
    </TabTitle>
    <TabPanel for="a">
        TabPanel a
    </TabPanel>
    <TabPanel for="b">
        TabPanel b
    </TabPanel>
    <TabPanel for="c">
        <Tab>
            <TabTitle label="a">
                TabTitle a
            </TabTitle>
            <TabTitle label="b">
                TabTitle b
            </TabTitle>
            <TabPanel for="a">
                TabPanel a
            </TabPanel>
            <TabPanel for="b">
                TabPanel b
            </TabPanel>
        </Tab>
    </TabPanel>
</Tab>

 

尼玛,这也太复杂了吧!!

如果单纯只用state和props来处理就是这样麻烦,就算是使用redux(虽然我并不推荐使用redux封装组件)也要每次自己管理全局状态。

Context to rescue

什么是context?

context是react的一个高级技巧,通过它你可以不用给每个组件都传props。具体解释请看官方文档: context

我们的根组件的context属性可以在子元素任意位置下获取到,利用这个特性我们就可以很轻易地实现上面说的组合组件和嵌套Tabs。

实现代码的代码可以在我的github里查看到,里面还有可执行的·demo。也欢迎大家点赞~~

我们把selectedTab放到context里面,这样子组件通过this.context.selectedTab是否和自己相同就可以推断出当前是否被激活了。

export default class Tabs extends Component {
    constructor(props, context) {
        super(props, context);

        this.state = {
            selectedTab: null
        };

        this.firstTabLabel = null;
    }

    getChildContext(){
        return {
            onSelect: this.onSelect.bind(this),
            selectedTab: this.state.selectedTab || this.props.defaultSelectedTab,
            activeStyle: this.props.activeLinkStyle || defaultActiveStyle,
            firstTabLabel: this.firstTabLabel
        };
    }

    onSelect(tab, ...rest) {
        if(this.state.selectedTab === tab) return;

        this.setState({
            selectedTab: tab
        });

        if(typeof this.props.onSelect === 'function') {
            this.props.onSelect(tab, ...rest);
        }
    }

    findfirstTabLabel(children){
        if (typeof children !== 'object' || this.firstTabLabel) {
            return;
        }

        React.Children.forEach(children, (child) => {
            if(child.props && child.props.label) {
                if(this.firstTabLabel == null){
                    this.firstTabLabel = child.props.label;
                    return;
                }
            }

            this.findfirstTabLabel(child.props && child.props.children);
        });
    }

    render() {
        this.findfirstTabLabel(this.props.children);

        return (
            <div {...this.props}>
                {this.props.children}
            </div>
        );
    }
}
Tabs.defaultProps = {
    onSelect: null,
    activeLinkStyle: null,
    defaultSelectedTab: ''
};
Tabs.propTypes = {
    onSelect: PropTypes.func,
    activeLinkStyle: PropTypes.object,
    defaultSelectedTab: PropTypes.string
};
Tabs.childContextTypes = {
    onSelect: PropTypes.func,
    selectedTab: PropTypes.string,
    activeStyle: PropTypes.object,
    firstTabLabel: PropTypes.string
};

上面是Tab组件的实现代码,我们在context里还增加了onSelect, activeStyle, 和firstTabLabel。

onSelect是指我们自定义的onSelect事件, firstTabLabel主要是用来保存第一个Tab的label名称的,如果使用者没有指定默认tab就使用第一个。

接下来是TabTitle和TabPanel的实现:

const defaultActiveStyle = {
    fontWeight: 'bold'
};

export class TabTitle extends Component {
    constructor(props, context){
        super(props, context);

        this.onSelect = this.onSelect.bind(this);
    }

    onSelect(){
        this.context.onSelect(this.props.label);
    }

    componentDidMount() {
        if (this.context.selectedTab === this.props.label || this.context.firstTabLabel === this.props.label) {
            this.context.onSelect(this.props.label);
        }
    }

    render() {
        let style = null;
        let isActive = this.context.selectedTab === this.props.label;
        if (isActive) {
            style = this.context.activeStyle;
        }

        return (
            <div
                className={ this.props.className + (isActive ? ' active' : '') }
                style={style}
                onClick={ this.onSelect }
            >
                {this.props.children}
            </div>
        );
    }
}
TabTitle.defaultProps = {
    label: '',
    className: 'tab-link'
};
TabTitle.propTypes = {
    label: PropTypes.string.isRequired,
    className: PropTypes.string
};
TabTitle.contextTypes = {
    onSelect: PropTypes.func,
    firstTabLabel: PropTypes.string,
    activeStyle: PropTypes.object,
    selectedTab: PropTypes.string
};

 

const styles = {
    visible: {
        display: 'block'
    },
    hidden: {
        display: 'none'
    }
};

export class TabPanel extends Component {
    constructor(props, context){
        super(props, context);
    }

    render() {
        let displayStyle = this.context.selectedTab === this.props.for 
            ? styles.visible : styles.hidden;

        return (
            <div
                className={ this.props.className }
                style={ displayStyle }>
                {this.props.children}
            </div>
        );
    }
}
TabPanel.defaultProps = {
    for: '',
    className: 'tab-content'
};
TabPanel.propTypes = {
    for: PropTypes.string.isRequired,
    className: PropTypes.string
};
TabPanel.contextTypes = {
    selectedTab: PropTypes.string
};

使用context后代码量少多了,而且还实现了更复杂的功能,真是一举两得。

更多请参考我的github: https://github.com/LukeLin/react-tab/blob/master/index.js

posted @ 2016-06-30 23:54  LukeLin  阅读(4147)  评论(0编辑  收藏  举报