1500行TypeScript代码在React中实现组件keep-alive

  • 2019 年 10 月 5 日
  • 筆記

现代框架的本质其实还是Dom操作,今天看到一句话特别喜欢,不要给自己设限,到最后,大多数的技术本质是相同的。

例如后端用到的Kafka , redis , sql事务写入 ,Nginx负载均衡算法,diff算法,GRPC,Pb 协议的序列化和反序列化,锁等等,都可以在前端被类似的大量复用逻辑,即便jsNode.js都是单线程的

认真看完本文与源码,你会收获不少东西

框架谁优谁劣,就像Web技术的开发效率与Native开发的用户体验一样谁也不好一言而论谁高谁低,不过可以确定的是,web技术已经越来越接近Native端体验了

作者是一位跨平台桌面端开发的前端工程师,由于是即时通讯应用,项目性能要求很高。于是苦寻名医,为了达到想要的性能,最终选定了非常冷门的几种优化方案拼凑在一起

过程虽然非常曲折,但是市面上能用的方案都用到了,尝试过了,但是后面发现,极致的优化,并不是1+1=2,要考虑业务的场景,因为一旦优化方案多了,他们之间的技术出发点,考虑的点可能会冲突。

这也是前端需要架构师的原因,开发重型应用如果前端有了一位架构师,那么会少走很多弯路。

后端也是如此

Vue.js中的keep-alive使用:

Vue.js中,尤大大是这样定义的:

keep-alive主要用于保留组件状态或避免重新渲染

基础使用:

<keep-alive>    <component :is="view"></component>  </keep-alive>

大概思路:

这里本来做了gif图,不知道为什存后切换也是非常平滑,没有任何的闪屏

特别提示: 这里每个组件,下面还有一个1000行的列表哦~ 切换也是秒级

图看完了,开始梳理源码

第一步,初次渲染缓存

import {Provider , KeepAlive} from 'react-component-keepalive';

将需要缓存渲染的组件包裹,并且给一个name属性即可

例如:

import Content from './Content.jsx'    export default App extends React.PureComponent{      render(){          return(              <div>                  <Provider>                      <KeepAlive name="Content">                          <Content/>                      </KeepAlive>                  </Provider>              </div>          )      }  }

这样这个组件你就可以在第二次需要渲染他的时候直接取缓存渲染了

下面是一组被缓存的一个组件,

仔细看上面的注释内容,再看当前body中多出来的div

那么他们是不是对应上了呢? 会是怎样缓存渲染的呢?

到底怎么缓存的

找到库的源码入口:

import Provider from './components/Provider';  import KeepAlive from './components/KeepAlive';  import bindLifecycle from './utils/bindLifecycle';  import useKeepAliveEffect from './utils/useKeepAliveEffect';    export {    Provider,    KeepAlive,    bindLifecycle,    useKeepAliveEffect,  };

最主要先看 Provider,KeepAlive这两个组件:

缓存组件这个功能是通过 React.createPortal API 实现了这个效果。

react-component-keepalive 有两个主要的组件 <Provider><KeepAlive><Provider> 负责保存组件的缓存,并在处理之前通过 React.createPortal API 将缓存的组件渲染在应用程序的外面。缓存的组件必须放在 <KeepAlive> 中,<KeepAlive> 会把在应用程序外面渲染的组件挂载到真正需要显示的位置。

这样很明了了,原来如此

开始源码:

Provider组件生命周期

 public componentDidMount() {      //创建`body`的div标签      this.storeElement = createStoreElement();      this.forceUpdate();    }

createStoreElement函数其实就是创建一个类似UUID的附带注释内容的div标签在body

import {prefix} from './createUniqueIdentification';    export default function createStoreElement(): HTMLElement {    const keepAliveDOM = document.createElement('div');    keepAliveDOM.dataset.type = prefix;    keepAliveDOM.style.display = 'none';    document.body.appendChild(keepAliveDOM);    return keepAliveDOM;  }

调用createStoreElement的结果:

然后调用forceUpdate强制更新一次组件

这个组件内部有大量变量锁:

export interface ICacheItem {    children: React.ReactNode; //自元素节点    keepAlive: boolean;   //是否缓存    lifecycle: LIFECYCLE;   //枚举的生命周期名称    renderElement?: HTMLElement;  //渲染的dom节点    activated?: boolean;    //  已激活吗    ifStillActivate?: boolean;      //是否一直保持激活    reactivate?: () => void;     //重新激活的函数  }    export interface ICache {    [key: string]: ICacheItem;  }    export interface IKeepAliveProviderImpl {    storeElement: HTMLElement;   //刚才渲染在body中的div节点    cache: ICache;  //缓存遵循接口 ICache  一个对象 key-value格式    keys: string[]; //缓存队列是一个数组,里面每一个key是字符串,一个标识    eventEmitter: any;  //这是自己写的自定义事件触发模块    existed: boolean; //是否退出状态    providerIdentification: string;  //提供的识别    setCache: (identification: string, value: ICacheItem) => void; 。//设置缓存    unactivate: (identification: string) => void; //设置不活跃状态    isExisted: () => boolean; //是否退出,会返回当前组件的Existed的值  }

上面看不懂 别急,看下面:

接着是Provider组件真正渲染的内容代码:

 <React.Fragment>            {innerChildren}            {              keys.map(identification => {                const currentCache = cache[identification];                const {                  keepAlive,                  children,                  lifecycle,                } = currentCache;                let cacheChildren = children;                  //中间省略若干细节判断                return ReactDOM.createPortal(                  (                    cacheChildren                      ? (                        <React.Fragment>                          <Comment>{identification}</Comment>                          {cacheChildren}                          <Comment                            onLoaded={() => this.startMountingDOM(identification)}                          >{identification}</Comment>                        </React.Fragment>                      )                      : null                  ),                  storeElement,                );              })            }          </React.Fragment>

innerChildren即是传入给Providerchildren

一开始我们看见的缓存组件内容显示的都是一个注释内容 那为什么可以渲染出东西来呢

Comment组件是重点

Comment组件

public render() {      return <div />;    }

初始返回是一个空的div标签

但是看他的生命周期ComponentDidmount

 public componentDidMount() {      const node = ReactDOM.findDOMNode(this) as Element;      const commentNode = this.createComment();      this.commentNode = commentNode;      this.currentNode = node;      this.parentNode = node.parentNode as Node;      this.parentNode.replaceChild(commentNode, node);      ReactDOM.unmountComponentAtNode(node);      this.props.onLoaded();    }

这个逻辑到这里并没有完,我们需要进一步查看KeepAlive组件源码

KeepAlive源码:

组件componentDidMount生命周期钩子:

  public componentDidMount() {      const {        _container,      } = this.props;      const {        notNeedActivate,        identification,        eventEmitter,        keepAlive,      } = _container;      notNeedActivate();      const cb = () => {        this.mount();        this.listen();        eventEmitter.off([identification, START_MOUNTING_DOM], cb);      };      eventEmitter.on([identification, START_MOUNTING_DOM], cb);      if (keepAlive) {        this.componentDidActivate();      }    }

其他逻辑先不管,重点看:

    const cb = () => {        this.mount();        this.listen();        eventEmitter.off([identification, START_MOUNTING_DOM], cb);      };      eventEmitter.on([identification, START_MOUNTING_DOM], cb);
当接收到事件被触发后,调用`mout和listen`方法,然后取消监听这个事件
  private mount() {      const {        _container: {          cache,          identification,          storeElement,          setLifecycle,        },      } = this.props;      this.setMounted(true);      const {renderElement} = cache[identification];      setLifecycle(LIFECYCLE.UPDATING);      changePositionByComment(identification, renderElement, storeElement);    }

changePositionByComment这个函数是整个调用的重点,下面会解析

  private listen() {      const {        _container: {          identification,          eventEmitter,        },      } = this.props;      eventEmitter.on(        [identification, COMMAND.CURRENT_UNMOUNT],        this.bindUnmount = this.componentWillUnmount.bind(this),      );      eventEmitter.on(        [identification, COMMAND.CURRENT_UNACTIVATE],        this.bindUnactivate = this.componentWillUnactivate.bind(this),      );    }

listen函数监听的自定义事件为了触发componentWillUnmountcomponentWillUnactivate

COMMAND.CURRENT_UNMOUNT这些都是枚举而已

changePositionByComment函数:

export default function changePositionByComment(identification: string, presentParentNode: Node, originalParentNode: Node) {    if (!presentParentNode || !originalParentNode) {      return;    }    const elementNodes = findElementsBetweenComments(originalParentNode, identification);    const commentNode = findComment(presentParentNode, identification);    if (!elementNodes.length || !commentNode) {      return;    }    elementNodes.push(elementNodes[elementNodes.length - 1].nextSibling as Node);    elementNodes.unshift(elementNodes[0].previousSibling as Node);    // Deleting comment elements when using commet components will result in component uninstallation errors    for (let i = elementNodes.length - 1; i >= 0; i--) {      presentParentNode.insertBefore(elementNodes[i], commentNode);    }    originalParentNode.appendChild(commentNode);  }

老规矩,上图解析源码:

很多人看起来云里雾里,其实最终的实质就是通过了Coment组件的注释,来查找到对应的需要渲染真实节点再进行替换,而这些节点都是缓存在内存中,DOM操作速度远比框架对比后渲染快。这里再次得到体现

这个库,无论是否路由组件都可以使用,虚拟列表+缓存KeepAlive组件的Demo体验地址

库原链接地址为了项目安全,我自己重建了仓库自己定制开发这个库

感谢原先作者的贡献 在我出现问题时候也第一时间给了我技术支持 谢谢!

新的库名叫react-component-keepalive

直接可以在npm中找到

npm i react-component-keepalive

就可以正常使用了