1500行TypeScript代码在React中实现组件keepalive

30次阅读

共计 6230 个字符,预计需要花费 16 分钟才能阅读完成。

现代框架的本质其实还是 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

就可以正常使用了

如果你对 React 并不了解,可以看一些我之前的文章:

从零编写一个 React 框架

如何优化您的超大型 React 应用

欢迎关注我的前端公众号:前端巅峰

本人专注前端最前沿技术,跨平台重型应用开发,即时通讯等技术。

正文完
 0