Skip to main content

· One min read
leomYili

Test-Driven Development(测试驱动开发,以下简述TDD)是一种设计方法论, 原理是在开发功能代码之前, 先编写单元测试用例代码, 通过测试来推动整个开发的进行.

本文详细描述了在创作 react-stillness-component 组件的过程中, 是如何学习 TDD 的思想来完成功能开发以及测试的.关于组件实现的原理以及细节,可以查看上一篇文章 《react 中如何实现 keep-alive 效果》

一.前言-自动化测试相关

本篇文章是针对编写react组件过程中所产生的质量与功能保障的角度去写的,由于涉及到部分的名词,可能需要提前有部分的自动化测试相关知识;

本文的侧重点主要是在如何设计以及为何要使用 tdd 的方式来做组件测试,有任何的问题,也欢迎与作者进行讨论 😁

二.我所认为的 tdd 实际落地流程

首先简单回顾一下 tdd:

tdd

对应的实际行为可能是(来源于 wiki)

  1. 添加一个测试用例
  2. 运行该测试用例,得到失败的结果(因为还没有实现任何功能)
  3. 编写刚好能通过的最简单的代码
  4. 重新运行该测试用例,得到成功的结果
  5. 根据需要进行重构,并保证每次重构之后都能通过之前的测试用例
  6. 重复这个步骤,直至完成整个开发

当然,在实际的开发过程中,作者也针对现实情况做了一定的改造,先看下改造之后的流程:

new flow

这里主要是针对前端组件场景增加了一些比较重要的步骤

  • 确认用户场景,在什么样的情况下才考虑用到这个组件?包括了普通用户和专业用户涉及到的场景.需要考虑到涉及到 UI 框架的场景
  • 确认用户行为,也就是用户的具体操作是什么?可以先从自身的角度出发,再进行实际调研,观察类似的组件是如何被使用的
  • 确认用户环境,这里的环境包括了现代浏览器环境以及框架自身所处的开发环境.

在每次完成测试用例编写之前先是确认环节,保证功能不偏离初心;在每次测试完之后,再进行验证,而验证的手段可以是 BDD(后文会提到)也可以结合现实线上例子进行结合考虑,如果可以解决实际问题,则证明功能已完成.

当然,由于作者本身的日常习惯是先列计划 😂,这次也不例外:

mind node1

可以看到最下面的部分就是与 TDD 相关的测试用例规划,实际编写用例过程中会有额外的情况,因此第二个部分也就是 e2e 模拟测试就是框定的使用范围,第一期只要不超出即可.

下面来看实际案例

三.实际案例

本文中使用的测试框架为jest,相关配置可参考问题汇总的第一点

provider

首先从最外层开始,该组件内部大量使用了context,因此,需要提供一个全局的provider,因为providervalue来源于createStillnessManager(),所以我们的第一个例子就是判断当提供了这个方法的时候,provider是否可以正常运行

it('Verify that the StillnessManager is correct', () => {
let capturedManager;
let manager = createStillnessManager(); // let mockManager: any = jest.fn();

render(
<StillnessContext.Provider value={{ stillnessManager: manager }}>
<StillnessContext.Consumer>
{({ stillnessManager }) => {
capturedManager = stillnessManager;
return null;
}}
</StillnessContext.Consumer>
</StillnessContext.Provider>
);

expect(capturedManager).toBe(manager);
});

这里前期使用的其实是jest mock functions,当createStillnessManager()编写完成之后,才被替换成真实的函数,两者的区别在于mock的方式可以过滤掉provider编写代码时的干扰项

那我们现在就可以开始 run test 了,当然,这时由于代码已编写完毕,无论是使用真实的参数还是模拟的参数都可以得到成功的例子.

而在一开始,未编写代码时,就可以遵循流程,编写真实代码了.

provider除了初始化之外,当然还会有其他功能,比如:

  • 卸载时会自动清除全局的缓存对象
  • 预防多个 provider 嵌套产生的错误,需要主动提醒用户

而针对这两点,我们就可以继续写测试用例了

it('stores StillnessManager in global context and cleans up on unmount', () => {
let capturedManager;

const { container, unmount } = render(
<StillnessProvider>
<StillnessContext.Consumer>
{({ stillnessManager }) => {
capturedManager = stillnessManager;
return null;
}}
</StillnessContext.Consumer>
</StillnessProvider>
);

const globalInstance = () => (global as any)[INSTANCE_SYM] as any;

expect(globalInstance().stillnessManager).toEqual(capturedManager);
unmount();
expect(globalInstance()).toEqual(null);
});

可以看到,这里通过调用返回的方法,从而达到了模拟卸载的效果.

class Component

首先来看下整个库的核心, <OffscreeenComponent>,相比较经过HOC包装之后的组件,原始组件的 props 就要复杂的多了

  • uniqueId: UniqueId;
  • parentId: UniqueId;
  • parentIsStillness: boolean;
  • isStillness: boolean;
  • stillnessManager: StillnessManager;

测试用例也围绕这几点即可,举个例子:

it('Does it prompt an error message when there is no context?', () => {
global.console.error = jest.fn();

expect(() => {
render(
<OffscreenComponent
visible={true}
isStillness={false}
uniqueId="test1"
parentId={rootId}
parentIsStillness={false}
>
<div />
</OffscreenComponent>
);
}).toThrow(/stillnessManager is required/i);
});

组件是没办法在缺少 context 的情况下运行的,那我们在编写例子的时候只要排除这个参数就行,如果组件捕获了异常并抛出了,则说明功能是 ok 的,这个属于较为简单的例子

来看一个更加复杂的:

it('When the passed isStillness changes, clear the corresponding dom element or reload the original one', async () => {
const Demo = ({ isStillness }: any) => {
return (
<OffscreenComponent
visible={true}
isStillness={isStillness}
uniqueId="test1"
stillnessManager={mockStillnessManager()}
parentId={rootId}
parentIsStillness={false}
>
<div data-testid="content" />
</OffscreenComponent>
);
};

const { queryByTestId, rerender } = render(<Demo isStillness={false} />);

rerender(<Demo isStillness={true} />);
expect(queryByTestId('content')).not.toBeInTheDocument();

rerender(<Demo isStillness={false} />);
expect(queryByTestId('content')).toBeInTheDocument();
});

组件的isStillness属性是比较重要的,也是用来控制组件的静止与否的条件,在这里通过真实模拟render,并通过修改传参的方法,来直接模拟效果,如果传递了true,则组件应该会渲染在 body 中,也就是查找idcontent的元素一定可以找到,反之就找不到.

通过这种方法,就可以测试 class Component

更多例子请参考 Offscreen.spec.tsx

HOC

HOC是如何进行测试的呢,以<Offscreen>组件为例:

props 为:

  • visible:boolean 类型,控制组件是否静止
  • type:string or number,标识组件的类型,可重复,同一类型的静止行为会保持一致
  • scrollRest:boolean 类型,控制组件静止时是否缓存滚动位置

但这些props实际上是经过处理传递给<OffscreenComponent>组件的,

对于HOC自身来说,只需要保证在未找到context时进行捕获异常即可:

it('throw an error if rendered', () => {
console.error = jest.fn();

class TestClass extends React.Component<
React.PropsWithChildren<OffscreenInnerProps>
> {}

const DecoratedClass = withNodeBridge(TestClass);

expect(() => {
render(<DecoratedClass visible />);
}).toThrow(/Expected stillness component context/);
});

至于上面的props,由于涉及到了其他模块,属于BDD测试的范畴了,会在下一篇 BDD 测试相关进行介绍

hooks

针对hooks相关,需要用到 @testing-library/react-hooks 该库可以直接运行 hooks 并断言结果

举例说明:

现在有一个根据依赖项从而返回最新结果的 hooks useOptionalFactory

代码为:

function useOptionalFactory<T>(
arg: FactoryOrInstance<T>,
deps?: unknown[]
): T {
const memoDeps = [...(deps || [])];
if (deps == null && typeof arg !== 'function') {
memoDeps.push(arg);
}
return useMemo<T>(() => {
return typeof arg === 'function' ? (arg as () => T)() : (arg as T);
}, memoDeps);
}

测试用例的代码为:

import { renderHook, act } from '@testing-library/react-hooks';

const useTest = () => {
const [count, setCount] = React.useState(0);

const addCount = () => {
setCount(count + 1);
};

const optionFactoryFn = useOptionalFactory(
() => ({
collect: () => {
return {};
},
}),
[count]
);

return { addCount, optionFactoryFn };
};

describe('useOptionalFactory', () => {
let hook;
it('Depending on the variation of the dependency value, different results are generated', () => {
act(() => {
hook = renderHook(() => useTest());
});

let memoValue = hook.result.current.optionFactoryFn;

act(() => {
hook.result.current.addCount();
});

expect(memoValue).not.toStrictEqual(hook.result.current.optionFactoryFn);
});
});

通过使用 renderHooks()act(),即可简单进行测试,当测试的依赖项变化时,返回值则跟随进行变化.

四.问题汇总

  1. 如何搭建测试环境?

    整体架构为lerna+Typescript+React+rollup+Jest,其实社区也有了很多的实例了,这里只介绍搭建过程中遇到的问题,

    • 如何单独搭建子包的测试环境? lerna 的架构,很好的分离了每个包的环境,可以使用不同的测试框架在每个子包中,单独配置,举例: 13 可以在每个包中配置不同的 jest.config

    • 测试代码也希望使用Typescript?

      // jest-transformer.js
      const babelJest = require('babel-jest');

      module.exports = babelJest.createTransformer({
      presets: [
      [
      '@babel/preset-env',
      {
      targets: {
      node: 'current',
      esmodules: true,
      },
      bugfixes: true,
      loose: true,
      },
      ],
      '@babel/preset-typescript',
      ],
      plugins: [
      ['@babel/plugin-proposal-class-properties', { loose: true }],
      '@babel/plugin-transform-react-jsx',
      ['@babel/plugin-proposal-private-methods', { loose: true }],
      [
      '@babel/plugin-proposal-private-property-in-object',
      { loose: true },
      ],
      '@babel/plugin-proposal-object-rest-spread',
      '@babel/plugin-transform-runtime',
      ],
      });

      //jest.config.js
      module.exports = {
      setupFilesAfterEnv: ['./jest-setup.ts'],
      testMatch: ["**/__tests__/**/?(*.)(spec|test).[jt]s?(x)"],
      // testRegex: 'decorateHandler.spec.tsx',
      transform: {
      "\\.[jt]sx?$": "./jest-transformer.js",
      },
      collectCoverageFrom: [
      '**/src/**/*.tsx',
      '**/src/**/*.ts',
      '!**/__tests__/**',
      '!**/dist/**',
      ],
      globals: {
      __DEV__: true,
      },
      };

      只需要增加transform配置即可

  2. 如何测试实际的渲染效果?

    可使用 @testing-library/jest-dom,该库提供了关于 DOM 状态的相关 jest 匹配器,可用来检查元素的树形,文本,样式等,本文也介绍了一些,比如:

    • toBeInTheDocument:判断文档中是否存在元素
    • toHaveClass:判断给定元素中是否在其class属性中具有相应的类名
    • toBeVisible:判断给定元素是否对用户可见
  3. 想要单独测试某一个例子怎么办?

    //jest.config.js
    module.exports = {
    setupFilesAfterEnv: ['./jest-setup.ts'],
    //testMatch: ["**/__tests__/**/?(*.)(spec|test).[jt]s?(x)"],
    testRegex: 'decorateHandler.spec.tsx',
    transform: {
    "\\.[jt]sx?$": "./jest-transformer.js",
    },
    collectCoverageFrom: [
    '**/src/**/*.tsx',
    '**/src/**/*.ts',
    '!**/__tests__/**',
    '!**/dist/**',
    ],
    globals: {
    __DEV__: true,
    },
    };

    可以简单的修改配置文件,使用testRegex针对某一个文件进行测试,当然,这里作者只是列出了自身认为比较简单的方法,如果有更简单的方法,欢迎提出👏👏

  4. 如何自动测试?

    组件库中的自动流程体现在推送分支和github的自动发版流程上

    // package.json
    "scripts": {
    "test": "jest --projects ./packages/*/",
    "test:coverage": "jest --coverage --projects ./packages/*/",
    "precommit": "lint-staged",
    "release": "bash ./scripts/release.sh",
    "lint:staged": "lint-staged",
    "ci": "run-s test:coverage vs git-is-clean",
    },
    "lint-staged": {
    "*./packages/**/*.{js,ts,json,css,less,md}": [
    "prettier --write",
    "yarn lint"
    ],
    "*./packages/**/__tests__/**/?(*.)(spec|test).[jt]s?(x)": [
    "yarn test"
    ]
    }

五.总结

本文总结了在编写一个 react 组件的过程中是如何思考以及组织测试代码的,当然,在实际的生产开发阶段,有一定的测试时间才是最宝贵的,也是 TDD 测试能推行的基础,如果说 TDD 测试保证了基础功能,那么 BDD 测试则扩展了使用场景;

按照代码比例来说,作者自身认为 TDD 占 70%,而 BDD 则占到剩下的 30%;

这里面是性价比的考量,毕竟日常工作中,需求的改动是很频繁的,这也就意味着组件可能会遇到各种不同的场景,而 TDD 测试用例大部分仍然可以保留,但 BDD 测试就不一定了.

这是 《前端如何做组件测试》的第一篇,如果有任何问题,欢迎讨论.

· One min read
leomYili

项目相关地址: react-stillness-component,目前测试率已达到 90%,欢迎大家试用

官方历史相关讨论: 地址

最新 react18 官方方案讨论: 地址

本文详细描述了如何构思并实现一个具有全局状态缓存的组件 react-stillness-component.

一.前言-现有类似组件分析

作者正常需要额外编写通用组件的场景都是遇到了特殊的问题, 且现有组件无法实现或者成本会非常高的情况下才会考虑去重新开发新的组件.

而面对组件缓存的这个场景来说,社区目前好的选择应该会是 React Activation, ,将组件实际渲染在外部隐藏组件层级中,在组件真实渲染时再通过 DOM 的操作将其移入对应组件的对应容器中,这样就可以如下的语法来控制组件的缓存:

import KeepAlive from 'react-activation'

// keepAlive中的组件实际上是提前渲染到了外部的Keeper中
// 之后在keepAlive开始渲染时,再通过Keeper中存储的数据,将对应的dom节点移动到这里即可
...

function App() {
const [show, setShow] = useState(true)

return (
<div>
<button onClick={() => setShow(show => !show)}>Toggle</button>
{show && (
<KeepAlive>
<Counter />
</KeepAlive>
)}
</div>
)
}

...

在 react18 之前已经算是比较不错的方法了, 不过对于我们的场景来说还是会有几个问题:

  1. 老项目代码十分庞大,上述的实现方法会带来对依赖生命周期顺序的功能造成影响,比如 ref 的取值等,虽然可以通过 setTimeout 的方式来延时获取,但一个是成本略大,另一个需要改变之前的写法,项目中随处可见的 setTimeout 也会影响代码的阅读以及代码 review
  2. context 实际上也是如此,但相比上面的情况要好很多,只需要切换成 react-activation提供的 createContext 即可
  3. 合成事件冒泡会失效,这也是最终未采用上述方案的根本原因,作者所在的团队会有多维表等复杂组件,针对拖拽悬浮定位都会有一定的要求,缓存相比只能算是体验优化,不能影响主要功能.
  4. 在手动缓存时需要给每个<KeepAlive>组件增加name,也会增加一定的成本.

而如果是针对新项目来说,这个库实际上已经可以达到生产环境的级别了.

二.理想效果

这里的理想效果是作者最终想要达到的目标.

  1. 首先,keepalive的效果只能算是锦上添花,它不能影响项目中其他功能的开发,所以类似 context,事件冒泡,动画之类都不能受到影响.
  2. 同时,上手成本不能太高,api 要足够简单,类似手动增加唯一标识并进行管理的方式成本就有点高了,最好可以不用声明唯一标识,但也能进行手动卸载.
  3. 性能优先,懒加载,真实移除 DOM 节点.
  4. 需要记忆组件级别的滚动效果.
  5. 解决嵌套组件中的缓存效果不一致,如果仅仅使用一个 state 去控制是否缓存,则嵌套中的keep-alive组件就没办法实时更新了.
  6. 统一的数据通信机制以及局部更新

也因此,针对上述目标,作者最终选用了 Portals以及 redux(用来管理缓存状态)来解决这些问题

三.实现原理

先来看一段伪代码

import { Offscreen,useStillness } from 'react-stillness-component';

...
function App() {
const [show, setShow] = useState(true)

return (
<div>
<button onClick={() => setShow(show => !show)}>Toggle</button>
<Offscreen visible={show}>
<Count />
</Offscreen>
</div>
);
}

...
function Count() {
const collected = useStillness({
collect: (contract) => ({
stillnessId: contract.getStillnessId(),
unset: contract.unset,
clear: contract.clear,
}),
});

return (
<div>
....
</div>
);
}
...

相比目前社区中利用didMount,unMount的能力,这里简化为一个 prop,同时提供相关 hooks,来支持手动控制缓存.

核心就是:

<Offscreen visible={show}>
<Count />
</Offscreen>

会不会有很熟悉的感觉,如果把Offscreen换成div, visible换成visibility:visible|hidden,那么就只是一段显隐的逻辑就可以完成缓存的实际效果了 😬

当然这里确实没有这么简单,否则也不用单独开发组件了,但这确实是作者希望的组件使用方式.

原理示例

转换为代码:

...

targetElement = document.createElement('div');

// didMount
containerRef.current.insertAdjacentElement(
'afterend',
targetElement
);

ReactDOM.createPortal(props.children, targetElement)

...

然后就是对于核心的扩展了,需要解决嵌套下 <keepAlive> 相关组件行为的一致性以及整体的缓存控制.

四.功能设计

出于对性能的考量,redux 中存储的只是缓存节点的数据映射,在每一个缓存节点被真实载入之后,都会同步建立一个对应的数据节点,有了第一步的数据之后,下面就是建立层级结构即可,得益于 react tree 以及 context,可以很轻易的推导出每个节点与其他节点之间的关系.

context应用

每一层只要拿到最近一层的 StillnessNodeContext 中的 id,就可以建立嵌套组件关系的映射,

所以工作的重点如下:

  • 缓存节点数据状态设计
  • 节点之间的状态同步
  • 性能优化,懒加载

1. 状态数据结构设计

状态设计

这里的 vNode 表现为:

interface vNodeState {
uniqueId: UniqueId; // 唯一标识
type?: UniqueId; // 类型
parentId: UniqueId; // 父节点标识
visible?: boolean; // props中的显隐属性
isStillness?: boolean; // 计算之后真实的静止状态
}

operation 可能不太好理解,这里主要用来标记一些可能会影响全局中节点的行为,比如:

  • unset: 重置静止节点的历史状态
  • clear: 重置所有静止节点的历史状态
  • mount: 有节点触发了静止状态
  • unmount: 有节点脱离了静止状态

当上述任何一个事件触发时,都需要根据起始节点产生依赖影响更改,有的时候甚至需要将所有缓存节点都更新一遍.

max 则提供了自动控制缓存的方法,当用户声明最大缓存节点数量时,组件会根据规则(第一层<Offscreen>节点才会算作是一个节点,其子节点全部跟随父节点)并利用 lru 算法自动清除或加入缓存之中.

2. 状态同步

这里的同步主要指的就是父节点触发了静止操作之后,需要实时通知到其下的所有子节点.得益于数据结构的设计,当一个节点触发了静止或者解除了静止操作之后,都可以根据 uniqueId 以及 parentId 计算出所有需要变更状态的节点

状态同步

3. 性能优化

性能优化主要体现在两个方面

  • 局部更新:利用了 redux,以及状态数据结构设计,每次更新节点状态只会影响相关联的节点
  • 懒加载:实际上 <Offscreen> 节点上的 visible 属性是可以进行优化的,如果一开始visible属性就为false,则children 是不需要直接加载的
useIsomorphicLayoutEffect(() => {
if (isMountRef.current) {
const parentIsStillness = globalMonitor.isStillness(stillnessParentId);
uniqueNodeRegistration.update({
...props,
parentId: stillnessParentId,
isStillness: parentIsStillness || !props.visible,
});

// 获取到真实静止状态
const thisIsStillness = globalMonitor.isStillness(
uniqueNodeRegistration.getUniqueId()
);

...

if (!thisIsStillness) {
setIsCurrentlyMounted(true);
}
}
}, [props, stillnessParentId]);

useEffect(() => {
if (isCurrentlyMounted === false) {
if (isMountRef.current) {
setIsCurrentlyMounted(true);
} else {
isMountRef.current = true;
}
}
}, [isCurrentlyMounted]);

const RenderedWrappedComponent = useMemo(
() => <Decorated {...wrapperProps} />,
[wrapperProps]
);

return isCurrentlyMounted ? RenderedWrappedComponent : null;

这里只需要注意有可能父节点已经是静止状态了,所以子节点即使visibletrue,但也是需要懒加载的.

4. 滚动状态记忆

因为节点经过 DOM 操作之后会重置滚动位置,所以我们需要把 <Offscreen>下的第一层 dom 节点的滚动状态记录下来,在解除静止状态时再进行设值即可还原

listenerTargetElementChildScroll = () => {
if (this.props?.scrollReset) {
this.targetElement.addEventListener(
'scroll',
throttle(
(e: any) => {
if (isRealChildNode(this.targetElement, e.target)) {
let index = this.cacheNodes.findIndex((el) => {
return el.node === e.target;
});

if (index !== -1) {
this.cacheNodes[index] = {
node: e.target,
left: e.target.scrollLeft || 0,
top: e.target.scrollTop || 0,
};
} else {
this.cacheNodes.push({
node: e.target,
left: e.target.scrollLeft || 0,
top: e.target.scrollTop || 0,
});
}
}
},
this,
120
),
true
);
}
};

这里因为涉及到父子嵌套组件,所以作者采用了事件监听的方法,在每个 <Offscreen> 节点下产生滚动事件时,对其下的滚动元素进行记忆,并保存在该节点的作用域中.

5. HOC

解决了最重要的问题之后,后面就是提供各种快捷的使用方法了,该组件支持HOCHooks的用法,

HOC 只需要提供一个spec即可:

import { connectStillness } from 'react-stillness-component';

...

const spec = {
mounted: (props, contract) => {
return 'mounted';
},
unmounted: (props, contract) => {
return 'unmounted';
},
collect: (props, contract) => {
return {
isStillness: contract.isStillness(),
stillnessId: contract.getStillnessId(),
};
}
};

export const WithCount = connectStillness(spec)(CountComponent);
...

spec 参数可以参考

speccollect函数返回的值就是组件新的props;

6. Hook

Hooks方面主要有两个hook来帮助用户更好的完成缓存节点的控制

  • useStillnessManager:偏底层一些,将内部的方法也做了一定的归纳,并提供给用户进行自定义
  • useStillness:与connectStillness效果一致
import { useStillness, useStillnessManager } from 'react-stillness-component';

function Count(props) {
const stillnessManager = useStillnessManager();
// stillnessManager.getStore();

const [count, setCount] = useState(0);
const collected = useStillness({
mounted: (contract) => {
return 'mounted';
},
unmounted: (contract) => {
return 'unmounted';
},
collect: (contract) => {
return {
isStillness: contract.isStillness(),
stillnessId: contract.getStillnessId(),
item: contract.getStillnessItem(),
};
},
});

useEffect(() => {
console.log(collected);
}, [collected]);

return <div>...</div>;
}

以上就是整体的架构设计.有兴趣的小伙伴可以看下源码,结构借鉴了react-dnd的想法,也算是重新阅读了一遍它的数据状态与 UI 分离是如何设计的.

之后会给大家演示一遍 react-stillness-component 的实际应用.

五.实战演练

以下提供的例子仅仅是作者根据自身情况从而编写的例子,实际上组件本身的功能非常简单,并没有很明显的兼容问题,如果有结合其他库无法达到效果的情况,也欢迎联系作者.

1. 首先是简单 demo

简单实例

可以通过在线 demo查看具体效果.

2. 然后就是最常见的 react-router ,这里分为 v5 版本和 v6 版本

react-router v5

react-router-v5中最主要的还是自定义了<Switch>组件,从而达到了路由缓存的效果,更多详细介绍,可以参考,并自行调试

react-router v6

react-router-v6版本就简单了很多,只需要定制outlet,就可以达到缓存的效果,源码可以参考,并自行调试

3. 然后是在 umi v3 框架中的应用,这也是作者目前所在团队的基础框架

首先需要安装已封装好的插件 yarn add umi-plugin-stillness react-stillness-component;

其次在.umirc.ts中进行使用:

import { defineConfig } from 'umi';

export default defineConfig({
nodeModulesTransform: {
type: 'none',
},
routes: [
{
exact: false,
path: '/',
component: '@/layouts/index',
routes: [
{
exact: false,
path: '/home',
component: '@/pages/home',
stillness: true,
routes: [
{
path: '/home/a',
component: '@/pages/a',
stillness: true,
},
],
},
{ path: '/about', component: '@/pages/about', stillness: true },
{ path: '/list', component: '@/pages/list' },
],
},
],
stillness: {},
});

需要做缓存处理的节点增加stillness:true即可

效果:

umi demo

其中最重要的还是自定义<Switch>组件,使用 modifyRendererPath 能力,重新定义新的 renderer,再使用与react-route-v5类似的修改方法,就可以达到效果了.坏处是需要及时同步与更新,比如新的react18的相关能力,作者就还没有更新上去.

在线地址,可自行调试

4. 以及作者自己较为感兴趣的 next.js 框架

nextjs相对特殊,文件路由系统无法通过外部修改,因此,自定义了_app.js,通过增加 StillnessSwitch组件,简单的将其下的路由组件变成了可静止的组件.

import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { Offscreen } from 'react-stillness-component';

function matchPath(pathname, routes) {
const result = routes.find(({ path }) => path === pathname) || null;

return result;
}

const StillnessSwitch = (props) => {
const { Component, pageProps } = props;
const router = useRouter();
const [stillnessRoutes, setStillnessRoutes] = useState([]);
const [route, setRoute] = useState([]);

useEffect(() => {
if (pageProps.stillness) {
!matchPath(router.pathname, stillnessRoutes) &&
setStillnessRoutes([
...stillnessRoutes,
{ Page: Component, _props: pageProps, path: router.pathname },
]);
setRoute([]);
} else {
setRoute([
{
Page: Component,
_props: pageProps,
path: router.pathname,
},
]);
}
}, [Component, router.pathname]);

return (
<>
{stillnessRoutes.concat(route).map(({ Page, _props, path }) => {
if (_props.stillness) {
return (
<Offscreen
key={path}
type={path}
visible={path === router.pathname}
>
<Page {..._props} />
</Offscreen>
);
}

return <Page {..._props} />;
})}
</>
);
};

export default StillnessSwitch;

nextjs demo

在线地址,可自行调试

总结

本文详细介绍了在react中如何实现keep-alive的效果,并详细描述了具体思路,作者一开始其实是希望介绍组件的自动化测试相关,后面实际场景中遇到了这个需求,那索性就先把组件实现,之后再用实际的组件来完成前端测试.这是 《前端如何做组件测试》的前置篇,如果有任何问题,欢迎讨论.