菜单栏中子文件显示/隐藏的切换动画
最初调研的 rc-collapse 组件,但是其 Collapse 与 Panel 的设置并不适合于文件目录结构的展示,并且这两者父子组件耦合严重,便转而调研单纯的 Collapse组件,比如react-collapse。这是单纯的一个 component-wrapper for collapse animation,在实现目录结构的展示上对开发时的限制减少了很多。但是在实际使用中发现了另一个比较重要的问题:这些 wrapper 都有一个属性isOpened来控制当前组件是展开还是折叠状态,这由我们传入 props 控制,而当切换展示文件时(也就是改变了model 中的 activeId)就会触发该 wrapper 的rerender,即如果该组件原本是展开的,那么切换展示文件之后,该组件就会出现由先折叠(默认状态)转为展开(props 使然)的动画。
在当前的场景(展示多级文件目录)下,动画依靠数据/状态驱动,当前打开的文件即 activeItem,是根据当前页面状态中的 activeId ===item.id? 来添加 active 的样式的。那么在多级菜单栏中,切换文件之后,activeId 改变, activeItem 也必然改变,此时整个目录是在做 diff 比较然后刷新的,那么涉及到文件夹的显示/隐藏必然也将重新渲染(如果文件 isOpened状态保存在每个 Collapse 组件内部,则 rerender 之后都会是恢复 state的初始值,如果放在 props 则必然会显示组件重新渲染的动画过程)而这是我们不希望看到的。
此时想要 jquery 时代的控制:只有在我点击文件夹的时候才进行展开/折叠动画的过程切换,其余 rerender 的时候不应用动画效果。同时点击之后展开/折叠的状态还需要在 props 中去更新,当切换文件时不至于使得原本打开的文件夹被折叠上。此时动画就需要自己使用 CSS 控制去实现就更容易一些,同时基于 props 记录管理文件夹的当前状态。
实现:基于 原生 div 展示 sidebar ,同时默认折叠,当点击文件夹时 通过 updateProps 更新该文件夹 props.isCollapsed 的 值,进而触发对 class 进行修改,实现折叠/显示的切换。当切换激活文件时,整个sideMenu 仍然会rerender, 但因为 props.isCollapsed 一直没变,添加的 class 也不变,所以不会有动画过程出现。所以在整体 rerender 的过程中,如果想要保证内部组件的动画过程在 rerender 时不出现,自行控制 css 是不错的方法。
其中记录各个文件夹的props.isCollapsed状态由 model 中一个对象记录各个文件夹的状态
collapseObj={
dirId1:true,
dirId2:false
}
对于每个文件夹结构独立为一个组件(代码有删改):
haddleClick=()=>{
this.props.updateCollapseObj({
id:this.props.id,
state:this.props.getCollapseObj[this.props.id]?false:true
})
}
render(){
const {id,name,panel}=this.props;
let divClass= classNames({
'panel':true,
'show':this.props.getCollapseObj[this.props.id]
})
return (
<div>
<p data-id={id} onClick={this.haddleClick} className="panelName">
<i className="iconfont icon-folder-closed"></i>
{name}
</p>
<div className={divClass}>
{panel}
</div>
</div>
)
}
针对菜单栏添加 contextMenu 如新建文件/重命名/删除文件等操作。
js 支持右键自定义事件contextMenu,但是自己实现时需要封装好一些功能,其中最重要的是不论点击rename/createFile/deleteFile 哪个按钮,我们都需要得到触发该 contextMenu 的元素id。调研的有react-contextmenu
和react-contexify
,尽管后者 star数量上比较少,但更能满足我们的需求,因为在当前场景(展示多级目录)下,我们需要简单的得到触发 contextMenu 的元素,前者对此的支持度并不好。
react-contexify
封装在 Item 上的click方法会接受3个参数handleClick(targetNode,ref,data)
。得到触发该 contextMenu 的元素targetNode之后,我们如何得到其 id 属性呢,此处不要忘了威力无穷的属性data-xxx
,可以给 targetNode 添加data-id
属性,然后通过targetNode.dataset.id
得到。
对文件的 delete/rename/create 操作,我们由易到难来介绍:
- deleteOperation:对于删除操作,在前端我们比较容易得到将要删除的文件的 id,直接提交即可,如果要在 model 中处理的话,记得这是一个多级的文件目录结构来说,各种处理都要进行深度(拷贝/过滤)
-
renameOperation:这是一个副作用比较多的操作,触发 rename 之后应该该文件名可编辑,且其初始内容为点击之前的展示内容,进行修改之后,回车键触发内容提交,文件名更新,退出可编辑状态。如果是
esc
键或者点击了输入框之外的区域,默认是撤销修改,退出可编辑状态,文件名仍显然之前状态。对于能够不断切换是否可编辑状态的元素,在这儿使用 input 再何时不过,其初始不可编辑 disabled,当触发 rename之后改变其 disabled=false。我们都知道input 之类的 form 表单相关组件在 react 中不同于其他组件,我们要使用受控组件实现组件显示与用户输入的实时交互,那么将每个文件名(包含 input的组件)独立为一个组件,在组件内部通过 state 实现对 当前input组件的控制。 在此考虑下
ContextMenuProvider
(react-contexify提供的触发 contextMenu 的容器)包裹在哪个元素上比较合适?每个文件名的 DOM 结构如:('div',{'i','input'})
。因为ContextMenuItem 的 onClick 事件是可以直接得到 targetNode 的,在副作用很多的地方如果我们可以直接与 input 交互是很方便的,所以文件名组件主要结构如下:render(){ const {fileId, setActiveId} = this.props; let activeClass=classNames({ 'list-item':true, 'active':this.isCurrentFile(fileId) }) return ( <div className={activeClass} onClick={()=>{ setActiveId(fileId) }}> <i className="iconfont icon-file"></i> <ContextMenuProvider className="provider" id="menu_id" > <input className="inputClass" data-id={fileId} type="text" value={this.state.value} onChange={this.handleChange.bind(this)} disabled="disabled"/> </ContextMenuProvider> </div> ) }
再回到 rename操作的交互过程,控制 input 编辑状态与退出编辑状态后的显示。注意三点:
- 执行
targetNode.blur()
方法后也会触发已注册的事件'blur',所以 blur 之后的副作用都放在blur 事件中处理。 - 在blur 事件中,在处理完之后需要将判断条件invalidEditing置为非,否则在 blur事件完成之前该段代码可能会执行随机n次。
- 在 onClick 中添加的监听事件,切记使用完成后移除。
renameFile(targetNode, ref, data){ const targetId = targetNode.dataset.id; let {renameOperation} =this.props const ESCAPE_KEY = 27; const ENTER_KEY = 13; let invalidEditing=true let prevText=targetNode.value; targetNode.disabled=false targetNode.spellcheck = false; targetNode.focus() targetNode.addEventListener('blur',function blurHandler(e){ if (invalidEditing) { targetNode.value=prevText; } targetNode.disabled=true invalidEditing=false targetNode.removeEventListener('blur',blurHandler,false); }) targetNode.addEventListener('keydown',function keydownHandler(e){ if (e.which===ESCAPE_KEY) { targetNode.blur() }else if(e.which===ENTER_KEY){ invalidEditing=false; let newName=targetNode.value targetNode.blur() targetNode.removeEventListener('keydown',keydownHandler,false); if(newName==prevText){ return } //model 方法,提交更改信息 renameOperation({ id:targetId, name:newName }) } }) }
- 执行
-
createOperation : 得到parentId 后,向其数组中插入(unshift)一项 默认数据。因为 model 的改变此时菜单栏会刷新。对于创建操作,我们还想要实现:对该文件名直接进入编辑模式,此后就和 renameOperation相同了,只要得到相应的 targetNode 触发renameOperation 方法就好。那么在数据驱动的应用中,我们如何实现这后续的衔接?--基于 react 的生命周期方法。
在菜单栏 rerender 完成之后一定会触发
componentDidUpdate
方法。但是componentDidUpdate
方法在很多情况下都会被触发,我们需要一个变量来判断只有是 createOperation 导致的更新才执行一下操作,并且在完成任务之后将该变量置非:if (this.props.getFileNameIsCreating) { let {getActiveId} =this.props let untitledNode = document.querySelector(`input[data-id="${getActiveId}"]`); this.renameFile(untitledNode) this.props.closeCreatingFileNameState(); }