使用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