Repository: alibaba/hooks Branch: master Commit: 289989fe688f Files: 657 Total size: 1.0 MB Directory structure: gitextract_8eg352yw/ ├── .babelrc ├── .coveralls.yml ├── .cursor/ │ └── rules/ │ ├── demo.mdc │ ├── docs.mdc │ ├── git.mdc │ ├── project.mdc │ ├── testing.mdc │ └── typescript.mdc ├── .editorconfig ├── .github/ │ ├── PULL_REQUEST_TEMPLATE/ │ │ └── pr_cn.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── comment-when-needs-more-info.yml │ ├── gitleaks.yml │ ├── issue-close-require.yml │ ├── issue-reply.yml │ ├── pkg.pr.new.yml │ ├── static.yml │ └── test.yml ├── .gitignore ├── .gitleaks.toml ├── .husky/ │ ├── commit-msg │ └── pre-commit ├── .npmrc ├── .travis.yml ├── CONTRIBUTING.MD ├── CONTRIBUTING.zh-CN.MD ├── LICENSE ├── README.md ├── README.zh-CN.md ├── SECURITY.md ├── biome.json ├── config/ │ ├── config.ts │ └── hooks.ts ├── docs/ │ ├── guide/ │ │ ├── blog/ │ │ │ ├── function.en-US.md │ │ │ ├── function.zh-CN.md │ │ │ ├── hmr.en-US.md │ │ │ ├── hmr.zh-CN.md │ │ │ ├── ssr.en-US.md │ │ │ ├── ssr.zh-CN.md │ │ │ ├── strict.en-US.md │ │ │ └── strict.zh-CN.md │ │ ├── dom.en-US.md │ │ ├── dom.zh-CN.md │ │ ├── index.en-US.md │ │ ├── index.zh-CN.md │ │ ├── upgrade.en-US.md │ │ └── upgrade.zh-CN.md │ ├── index.en-US.md │ └── index.zh-CN.md ├── example/ │ └── .gitkeep ├── gulpfile.js ├── package.json ├── packages/ │ ├── hooks/ │ │ ├── gulpfile.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── createDeepCompareEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── createUpdateEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── createUseStorageState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── global.d.ts │ │ │ ├── index.spec.ts │ │ │ ├── index.ts │ │ │ ├── useAntdTable/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── cache.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── init.tsx │ │ │ │ │ ├── ready.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ └── validate.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ ├── index.zh-CN.md │ │ │ │ └── types.ts │ │ │ ├── useAsyncEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useBoolean/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useClickAway/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ ├── demo4.tsx │ │ │ │ │ ├── demo5.tsx │ │ │ │ │ └── demo6.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useControllableValue/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useCookieState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useCountDown/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useCounter/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useCreation/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDebounce/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── debounceOptions.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDebounceEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDebounceFn/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDeepCompareEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDeepCompareLayoutEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDocumentVisibility/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDrag/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ └── index.ts │ │ │ ├── useDrop/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useDynamicList/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ └── demo4.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useEventEmitter/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useEventListener/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useEventTarget/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useExternal/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useFavicon/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useFocusWithin/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ └── index.zh-CN.md │ │ │ ├── useFullscreen/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ └── demo4.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useFusionTable/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── cache.tsx │ │ │ │ │ ├── form.tsx │ │ │ │ │ ├── init.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ └── validate.tsx │ │ │ │ ├── fusionAdapter.ts │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ ├── index.zh-CN.md │ │ │ │ └── types.ts │ │ │ ├── useGetState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useHistoryTravel/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useHover/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useInViewport/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useInfiniteScroll/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── default.tsx │ │ │ │ │ ├── mutate.tsx │ │ │ │ │ ├── pagination.tsx │ │ │ │ │ ├── reload.tsx │ │ │ │ │ ├── scroll.tsx │ │ │ │ │ └── scrollTop.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ ├── index.zh-CN.md │ │ │ │ └── types.ts │ │ │ ├── useInterval/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useIsomorphicLayoutEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useKeyPress/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ ├── demo4.tsx │ │ │ │ │ ├── demo5.tsx │ │ │ │ │ ├── demo6.tsx │ │ │ │ │ ├── demo7.tsx │ │ │ │ │ └── demo8.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useLatest/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useLocalStorageState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ └── demo4.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useLockFn/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useLongPress/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useMap/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useMemoizedFn/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useMount/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useMouse/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useMutationObserver/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useNetwork/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── usePagination/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ └── demo4.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ ├── index.zh-CN.md │ │ │ │ └── types.ts │ │ │ ├── usePrevious/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useRafInterval/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── node.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useRafState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useRafTimeout/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ └── node.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useReactive/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ ├── demo3.tsx │ │ │ │ │ ├── demo4.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useRequest/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── index.spec.ts │ │ │ │ │ ├── useAutoRunPlugin.spec.ts │ │ │ │ │ ├── useCachePlugin.spec.tsx │ │ │ │ │ ├── useDebouncePlugin.spec.ts │ │ │ │ │ ├── useLoadingDelayPlugin.spec.ts │ │ │ │ │ ├── usePollingPlugin.spec.ts │ │ │ │ │ ├── useRefreshOnWindowFocusPlugin.spec.ts │ │ │ │ │ ├── useRetryPlugin.spec.ts │ │ │ │ │ └── useThrottlePlugin.spec.ts │ │ │ │ ├── doc/ │ │ │ │ │ ├── basic/ │ │ │ │ │ │ ├── basic.en-US.md │ │ │ │ │ │ ├── basic.zh-CN.md │ │ │ │ │ │ └── demo/ │ │ │ │ │ │ ├── cancel.tsx │ │ │ │ │ │ ├── default.tsx │ │ │ │ │ │ ├── lifeCycle.tsx │ │ │ │ │ │ ├── manual-run.tsx │ │ │ │ │ │ ├── manual-runAsync.tsx │ │ │ │ │ │ ├── mutate.tsx │ │ │ │ │ │ ├── params.tsx │ │ │ │ │ │ └── refresh.tsx │ │ │ │ │ ├── cache/ │ │ │ │ │ │ ├── cache.en-US.md │ │ │ │ │ │ ├── cache.zh-CN.md │ │ │ │ │ │ └── demo/ │ │ │ │ │ │ ├── cacheKey.tsx │ │ │ │ │ │ ├── clearCache.tsx │ │ │ │ │ │ ├── params.tsx │ │ │ │ │ │ ├── setCache.tsx │ │ │ │ │ │ ├── share.tsx │ │ │ │ │ │ └── staleTime.tsx │ │ │ │ │ ├── debounce/ │ │ │ │ │ │ ├── debounce.en-US.md │ │ │ │ │ │ ├── debounce.zh-CN.md │ │ │ │ │ │ └── demo/ │ │ │ │ │ │ └── debounce.tsx │ │ │ │ │ ├── index/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ ├── default.tsx │ │ │ │ │ │ │ └── manual.tsx │ │ │ │ │ │ ├── index.en-US.md │ │ │ │ │ │ └── index.zh-CN.md │ │ │ │ │ ├── loadingDelay/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ └── loadingDelay.tsx │ │ │ │ │ │ ├── loadingDelay.en-US.md │ │ │ │ │ │ └── loadingDelay.zh-CN.md │ │ │ │ │ ├── polling/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ ├── polling.tsx │ │ │ │ │ │ │ └── pollingError.tsx │ │ │ │ │ │ ├── polling.en-US.md │ │ │ │ │ │ └── polling.zh-CN.md │ │ │ │ │ ├── ready/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ ├── manualReady.tsx │ │ │ │ │ │ │ └── ready.tsx │ │ │ │ │ │ ├── ready.en-US.md │ │ │ │ │ │ └── ready.zh-CN.md │ │ │ │ │ ├── refreshDeps/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ ├── refreshDeps.tsx │ │ │ │ │ │ │ └── refreshDepsAction.tsx │ │ │ │ │ │ ├── refresyDeps.en-US.md │ │ │ │ │ │ └── refresyDeps.zh-CN.md │ │ │ │ │ ├── refreshOnWindowFocus/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ └── refreshOnWindowFocus.tsx │ │ │ │ │ │ ├── refreshOnWindowFocus.en-US.md │ │ │ │ │ │ └── refreshOnWindowFocus.zh-CN.md │ │ │ │ │ ├── retry/ │ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ │ └── retry.tsx │ │ │ │ │ │ ├── retry.en-US.md │ │ │ │ │ │ └── retry.zh-CN.md │ │ │ │ │ └── throttle/ │ │ │ │ │ ├── demo/ │ │ │ │ │ │ └── throttle.tsx │ │ │ │ │ ├── throttle.en-US.md │ │ │ │ │ └── throttle.zh-CN.md │ │ │ │ ├── index.ts │ │ │ │ └── src/ │ │ │ │ ├── Fetch.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── useAutoRunPlugin.ts │ │ │ │ │ ├── useCachePlugin.ts │ │ │ │ │ ├── useDebouncePlugin.ts │ │ │ │ │ ├── useLoadingDelayPlugin.ts │ │ │ │ │ ├── usePollingPlugin.ts │ │ │ │ │ ├── useRefreshOnWindowFocusPlugin.ts │ │ │ │ │ ├── useRetryPlugin.ts │ │ │ │ │ └── useThrottlePlugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── useRequest.ts │ │ │ │ ├── useRequestImplement.ts │ │ │ │ └── utils/ │ │ │ │ ├── cache.ts │ │ │ │ ├── cachePromise.ts │ │ │ │ ├── cacheSubscribe.ts │ │ │ │ ├── isDocumentVisible.ts │ │ │ │ ├── isOnline.ts │ │ │ │ ├── limit.ts │ │ │ │ ├── subscribeFocus.ts │ │ │ │ └── subscribeReVisible.ts │ │ │ ├── useResetState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useResponsive/ │ │ │ │ ├── __tests__/ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ └── index.spec.ts.snap │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSafeState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useScroll/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSelections/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSessionStorageState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSet/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSetState/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useSize/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.tsx │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useTextSelection/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ ├── demo2.tsx │ │ │ │ │ └── demo3.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useTheme/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useThrottle/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ ├── index.zh-CN.md │ │ │ │ └── throttleOptions.ts │ │ │ ├── useThrottleEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useThrottleFn/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useTimeout/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useTitle/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useToggle/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useTrackedEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useUnmount/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useUnmountedRef/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.tsx │ │ │ │ └── index.zh-CN.md │ │ │ ├── useUpdate/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useUpdateEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useUpdateLayoutEffect/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useVirtualList/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ ├── demo1.tsx │ │ │ │ │ └── demo2.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useWebSocket/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ ├── useWhyDidYouUpdate/ │ │ │ │ ├── __tests__/ │ │ │ │ │ └── index.spec.ts │ │ │ │ ├── demo/ │ │ │ │ │ └── demo1.tsx │ │ │ │ ├── index.en-US.md │ │ │ │ ├── index.ts │ │ │ │ └── index.zh-CN.md │ │ │ └── utils/ │ │ │ ├── __tests__/ │ │ │ │ └── index.spec.ts │ │ │ ├── createEffectWithTarget.ts │ │ │ ├── depsAreSame.ts │ │ │ ├── depsEqual.ts │ │ │ ├── domTarget.ts │ │ │ ├── getDocumentOrShadow.ts │ │ │ ├── index.ts │ │ │ ├── isAppleDevice.ts │ │ │ ├── isBrowser.ts │ │ │ ├── isDev.ts │ │ │ ├── lodash-polyfill.ts │ │ │ ├── noop.ts │ │ │ ├── rect.ts │ │ │ ├── testingHelpers.ts │ │ │ ├── tests.tsx │ │ │ ├── useDeepCompareWithTarget.ts │ │ │ ├── useEffectWithTarget.ts │ │ │ ├── useIsomorphicLayoutEffectWithTarget.ts │ │ │ └── useLayoutEffectWithTarget.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.pro.json │ │ ├── vitest.config.ts │ │ └── webpack.config.js │ └── use-url-state/ │ ├── README.md │ ├── __tests__/ │ │ ├── browser.spec.tsx │ │ ├── router.spec.tsx │ │ └── setup.tsx │ ├── demo/ │ │ ├── demo1.tsx │ │ ├── demo2.tsx │ │ ├── demo3.tsx │ │ └── demo4.tsx │ ├── gulpfile.js │ ├── package.json │ ├── src/ │ │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.pro.json │ ├── use-url-state.en-US.md │ ├── use-url-state.zh-CN.md │ ├── vitest.config.ts │ └── webpack.config.js ├── pnpm-workspace.yaml ├── public/ │ ├── style.css │ └── useExternal/ │ ├── bootstrap-badge.css │ └── test-external-script.js ├── scripts/ │ └── build-with-relative-paths.js ├── tsconfig.base.json ├── tsconfig.pro.json ├── umd.html ├── vitest.config.ts └── webpack.common.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": [["@babel/env"], "@babel/react"], "plugins": ["@babel/plugin-transform-runtime"] } ================================================ FILE: .coveralls.yml ================================================ service_name: travis-pro repo_token: dO6l2UfWXIPwCzK3lmEUKQyQsfzXZUZml ================================================ FILE: .cursor/rules/demo.mdc ================================================ --- description: globs: components/*/demo/** alwaysApply: false --- # Demo 规范 - demo 代码尽可能简洁 - 避免冗余代码,方便用户复制到项目直接使用 - 每个 demo 聚焦展示一个功能点 - 提供中英文两个版本的说明 - demo 文件命名: - 英文 demo: index.en-US.md - 中文 demo: index.zh-CN.md - 确保 demo 在各种尺寸下都能正常展示 - 对于复杂交互提供必要的操作说明 ## 文件组织 - 每个组件演示包含 `.md`(说明文档)和 `.ts`(实际代码)两部分 - 位置:hooks 目录下的 `src` 子目录,如 `packages/hooks/src/useHover` - 文件名应简洁地描述示例内容 ## MD 文档规范 - 必须包含 `## zh-CN` 和 `## en-US` 两种语言说明 - 内容简洁明了,突出组件特性和用法 - 避免冗长段落,必要时使用列表或粗体 - 标注注意事项和实验性功能 ## 代码质量 - 实用且专注于单一功能 - 关键处添加简洁注释 - 使用有意义的数据和变量 - 优先使用 ahooks 内置 hook 或者公共方法,减少外部依赖 ## 质量要求 - 确保代码运行正常,无控制台错误 - 适配常见浏览器 - 避免过时 API,及时更新到新推荐用法 ================================================ FILE: .cursor/rules/docs.mdc ================================================ --- description: 规范项目文档和 Changelog globs: ["**/CHANGELOG*.md", "components/**/index.*.md"] alwaysApply: false --- # Changelog Emoji 规范 - 🐞 Bug 修复 - 💄 样式更新或 token 更新 - 🆕 新增特性,新增属性 - 🔥 极其值得关注的新增特性 - 🇺🇸🇨🇳🇬🇧 国际化改动 - 📖 📝 文档或网站改进 - ✅ 新增或更新测试用例 - 🛎 更新警告/提示信息 - ⌨️ ♿ 可访问性增强 - 🗑 废弃或移除 - 🛠 重构或工具链优化 - ⚡️ 性能提升 # 文档规范 - 提供中英文两个版本 - 新属性需声明可用的版本号 - 属性命名符合 API 命名规则 - hook 文档包含:使用场景、基础用法、API 说明 - 文档示例应简洁明了 - 属性的描述应清晰易懂 - 对复杂功能提供详细说明 - 加入 TypeScript 定义 - 提供常见问题解答 - 更新文档时同步更新中英文版本 ## 其他要求 - 新增属性时,建议用易于理解的语言描述用户可以感知的变化 - 存在破坏性改动时,尽量给出原始的 PR 链接,社区提交的 PR 改动加上提交者的链接 ================================================ FILE: .cursor/rules/git.mdc ================================================ --- description: globs: alwaysApply: true --- # Git 规范 ## 开发流程 1. 从保护分支(通常是 `master`)创建新的功能分支 2. 在新分支上进行开发 3. 提交 Pull Request 到目标分支 4. 等待 Code Review 和 CI 通过 5. 合并到目标分支 ## 分支命名规范 - 功能开发:`feat/description-of-feature` - 例如:`feat/add-dark-mode` - 例如:`feat/improve-table-performance` - 问题修复:`fix/issue-number-or-description` - 例如:`fix/button-style-issue` - 例如:`fix/issue-1234` - 文档更新:`docs/what-is-changed` - 例如:`docs/update-api-reference` - 例如:`docs/fix-typos` - 代码重构:`refactor/what-is-changed` - 例如:`refactor/button-component` - 例如:`refactor/remove-deprecated-api` - 样式修改:`style/what-is-changed` - 例如:`style/update-button-tokens` - 例如:`style/improve-mobile-layout` - 测试相关:`test/what-is-changed` - 例如:`test/add-button-test` - 例如:`test/improve-coverage` - 构建相关:`build/what-is-changed` - 例如:`build/upgrade-webpack` - 例如:`build/fix-ts-config` - 持续集成:`ci/what-is-changed` - 例如:`ci/add-e2e-test` - 例如:`ci/fix-deploy-script` - 性能优化:`perf/what-is-changed` - 例如:`perf/optimize-render` - 例如:`perf/reduce-bundle-size` - 依赖升级:`deps/package-name-version` - 例如:`deps/upgrade-react-19` - 例如:`deps/update-dependencies` ## 分支命名注意事项 1. 使用小写字母 2. 使用连字符(-)分隔单词 3. 简短但具有描述性 4. 避免使用下划线或其他特殊字符 5. 如果与 Issue 关联,可以包含 Issue 编号 ## Pull Request 规范 ### PR 标题 - PR 标题始终使用英文 - 遵循格式:`类型: 简短描述` - 例如:`fix: fix button style issues in Safari browser` - 例如:`feat: add dark mode support` ### PR 内容 - PR 内容默认使用英文 - 尽量简洁清晰地描述改动内容和目的 - 可以视需要在英文描述后附上中文说明 ### PR 提交注意事项 1. **审核流程**: - PR 需要由至少一名维护者审核通过后才能合并 - 确保所有 CI 检查都通过 - 解决所有 Code Review 中提出的问题 2. **PR 质量要求**: - 确保代码符合项目代码风格 - 添加必要的测试用例 - 更新相关文档 - 大型改动需要更详细的说明和更多的审核者参与 3. **工具标注**: - 如果是用 Cursor 提交的代码,请在 PR body 末尾进行标注:`> Submitted by Cursor` ## 新增内容 - Pull Request 标题格式:[组件名]: 描述 - 从 master 分支创建新分支 - 分支命名规范: - feature/xxx:新特性 - fix/xxx:Bug 修复 - docs/xxx:文档更新 - PR 说明中选择改动类型: - 🆕 新特性提交 - 🐞 Bug 修复 - 📝 文档改进 - 📽️ 演示代码改进 - 💄 样式/交互改进 - 🤖 TypeScript 更新 - 📦 包体积优化 - ⚡️ 性能优化 - 🌐 国际化改进 - 提供改动背景和解决方案 - 更新日志同时提供英文和中文版本 ================================================ FILE: .cursor/rules/project.mdc ================================================ --- description: globs: alwaysApply: true --- # 项目背景 这是由蚂蚁团队开发的一个高质量、可靠的 React Hooks 库。 - 易学易用 - 支持 SSR - 对输入输出函数做了特殊处理,避免闭包问题 - 包含大量提炼自业务的高级 Hooks - 包含丰富的基础 Hooks - 使用 TypeScript 构建,提供完整的类型定义文件 # 编码规范 - 使用 TypeScript 和 React 书写 - 避免引入新依赖,严控打包体积 - 兼容现代浏览器 - 支持服务端渲染 - 保持向下兼容,避免 breaking change - 合理使用 React.memo、useMemo 和 useCallback 优化性能 ================================================ FILE: .cursor/rules/testing.mdc ================================================ --- description: globs: **/__tests__/**,**/*.test.tsx,**/*.test.ts alwaysApply: false --- # 测试规范 - 使用 vitest 和 @testing-library/react 编写单元测试 - 测试覆盖率要求 100% - 测试文件放在 __tests__ 目录,命名格式为:index.spec.ts 或 xxx.spec.ts ================================================ FILE: .cursor/rules/typescript.mdc ================================================ # TypeScript 规范 ## 基本原则 - 所有组件和函数必须提供准确的类型定义 - 尽量避免使用 `any` 类型,尽可能精确地定义类型 - 使用接口而非类型别名定义对象结构 - 导出所有公共接口类型,方便用户使用 - 严格遵循 TypeScript 类型设计原则,确保类型安全 - 确保编译无任何类型错误或警告 ## hook 类型定义 - 复杂的数据结构应拆分为多个接口定义 - 所有函数类型应明确定义参数和返回值 ## 泛型使用 - 适当使用泛型增强类型灵活性 - 为泛型参数提供合理的默认类型和约束 - 避免过度使用泛型导致类型复杂化 - 在泛型参数上应用限制条件(constraints)确保类型安全 - 为复杂泛型提供类型别名以提高可读性 ## 类型合并与扩展 - 使用交叉类型(&)合并多个类型 - 使用 Partial、Pick、Omit 等工具类型修改现有类型 - 扩展原生 DOM 元素属性时,继承相应的内置类型 - 使用 type 定义联合类型和交叉类型 - 优先使用自带的工具类型,避免重复定义 ## 枚举和常量 - 使用字面量联合类型定义有限的选项集合 - 为复杂的枚举值提供类型守卫函数 - 避免使用 `enum`,优先使用联合类型和 `as const` - 对于关键常量,使用 `as const` 断言确保类型严格 - 为联合类型中的每个值提供适当的注释 ## 类型推断与断言 - 尽可能依赖 TypeScript 的类型推断 - 只在必要时使用类型断言(as) - 使用类型守卫函数进行运行时类型检查 - 尽量避免使用非空断言操作符(!) - 使用 `instanceof` 和 `typeof` 进行类型守卫 - 为自定义类型创建类型谓词(type predicates)函数 ## JSDoc 注释 - 为复杂的类型、函数、hook 添加 JSDoc 注释 - 使用 `@deprecated` 标记已废弃的 API - 在注释中提供使用示例 - 说明参数和返回值的含义与约束 - 在 interface 和重要类型定义上添加文档注释 ## 类型兼容性 - 确保类型定义兼容不同版本的 React - 避免使用实验性或不稳定的 TypeScript 特性 - 为第三方库未提供的类型编写声明文件 - 使用条件类型处理复杂的类型逻辑 - 验证类型在不同 TypeScript 版本下的兼容性 ## 严格使用 TypeScript 类型 - 导出组件类型和接口 - 避免使用 any,优先使用 unknown - 组件 Props 使用 interface 定义 - 使用明确的命名约定 - 合理使用泛型提高复用性 - 导出类型时使用 export type - 组件属性使用 JSDoc 注释说明用途 ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [Makefile] indent_style = tab ================================================ FILE: .github/PULL_REQUEST_TEMPLATE/pr_cn.md ================================================ [[English Template / 英文模板](https://github.com/alibaba/hooks/blob/master/.github/PULL_REQUEST_TEMPLATE.md)] ### 🤔 这个变动的性质是? - [ ] 新特性提交 - [ ] 日常 bug 修复 - [ ] 站点、文档改进 - [ ] 演示代码改进 - [ ] TypeScript 定义更新 - [ ] 包体积优化 - [ ] 性能优化 - [ ] 功能增强 - [ ] 国际化改进 - [ ] 重构 - [ ] 代码风格优化 - [ ] 测试用例 - [ ] 分支合并 - [ ] 其他改动(是关于什么的改动?) ### 🔗 相关 Issue ### 💡 需求背景和解决方案 ### 📝 更新日志 | 语言 | 更新描述 | | ------- | -------- | | 🇺🇸 英文 | | | 🇨🇳 中文 | | ### ☑️ 请求合并前的自查清单 ⚠️ 请自检并全部**勾选全部选项**。⚠️ - [ ] 文档已补充或无须补充 - [ ] 代码演示已提供或无须提供 - [ ] TypeScript 定义已补充或无须补充 - [ ] Changelog 已提供或无须提供 ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ [[中文版模板 / Chinese template](https://github.com/alibaba/hooks/blob/master/.github/PULL_REQUEST_TEMPLATE/pr_cn.md)] ### 🤔 This is a ... - [ ] New feature - [ ] Bug fix - [ ] Site / documentation update - [ ] Demo update - [ ] TypeScript definition update - [ ] Bundle size optimization - [ ] Performance optimization - [ ] Enhancement feature - [ ] Internationalization - [ ] Refactoring - [ ] Code style optimization - [ ] Test Case - [ ] Branch merge - [ ] Other (about what?) ### 🔗 Related issue link ### 💡 Background and solution ### 📝 Changelog | Language | Changelog | | ---------- | --------- | | 🇺🇸 English | | | 🇨🇳 Chinese | | ### ☑️ Self Check before Merge ⚠️ Please check all items below before review. ⚠️ - [ ] Doc is updated/provided or not needed - [ ] Demo is updated/provided or not needed - [ ] TypeScript definition is updated/provided or not needed - [ ] Changelog is provided or not needed ================================================ FILE: .github/workflows/comment-when-needs-more-info.yml ================================================ name: Comment When Needs More Info Label Added on: issues: types: [labeled] jobs: create-comment: runs-on: ubuntu-latest if: github.event.label.name == 'needs more info' steps: - name: Create comment uses: actions-cool/issues-helper@v3 with: actions: 'create-comment' token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} body: | Hi, ${{ github.event.issue.user.login }}. It seems that this issue is a bit vague and lacks some necessary information. 看起来这条 issue 描述得有些模糊,缺少一些必要的信息。 ================================================ FILE: .github/workflows/gitleaks.yml ================================================ name: gitleaks on: [push, pull_request] jobs: gitleaks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: wget uses: wei/wget@v1 with: args: -O .gitleaks.toml https://raw.githubusercontent.com/ycjcl868/gitleaks/master/.gitleaks.toml - name: gitleaks-action uses: gitleaks/gitleaks-action@v1.6.0 ================================================ FILE: .github/workflows/issue-close-require.yml ================================================ name: Issue Close Require on: schedule: - cron: '0 0 * * *' jobs: close-issues: runs-on: ubuntu-latest steps: - name: need reproduce uses: actions-cool/issues-helper@v3 with: actions: 'close-issues' labels: '🤔 Need Reproduce' inactive-day: 3 - name: needs more info uses: actions-cool/issues-helper@v3 with: actions: 'close-issues' labels: 'needs more info' inactive-day: 3 body: | Since the issue was labeled with `needs more info`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply. 由于该 issue 被标记为需要更多信息,却 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。 ================================================ FILE: .github/workflows/issue-reply.yml ================================================ name: Issue Reply on: issues: types: [labeled] jobs: reply-helper: runs-on: ubuntu-latest steps: - name: help wanted if: github.event.label.name == 'help wanted' uses: actions-cool/issues-helper@v3 with: actions: 'create-comment' issue-number: ${{ github.event.issue.number }} body: | Hello @${{ github.event.issue.user.login }}. We totally like your proposal/feedback, welcome to [send us a Pull Request](https://help.github.com/en/articles/creating-a-pull-request) for it. Please send your Pull Request to proper branch (feature branch for the new feature, master for bugfix and other changes), fill the [Pull Request Template](https://github.com/alibaba/hooks/blob/master/.github/PULL_REQUEST_TEMPLATE.md) here, provide changelog/TypeScript/documentation/test cases if needed and make sure CI passed, we will review it soon. We appreciate your effort in advance and looking forward to your contribution! 你好 @${{ github.event.issue.user.login }},我们完全同意你的提议/反馈,欢迎直接在此仓库 [创建一个 Pull Request](https://help.github.com/en/articles/creating-a-pull-request) 来解决这个问题。请将 Pull Request 发到正确的分支(新特性发到 feature 分支,其他发到 master 分支),务必填写 Pull Request 内的[预设模板](https://github.com/alibaba/hooks/blob/master/.github/PULL_REQUEST_TEMPLATE.md),提供改动所需相应的 changelog、TypeScript 定义、测试用例、文档等,并确保 CI 通过,我们会尽快进行 Review,提前感谢和期待您的贡献。 ![giphy](https://user-images.githubusercontent.com/507615/62342668-4735dc00-b51a-11e9-92a7-d46fbb1cc0c7.gif) - name: 🤔 Need Reproduce if: github.event.label.name == '🤔 Need Reproduce' uses: actions-cool/issues-helper@v3 with: actions: 'create-comment' issue-number: ${{ github.event.issue.number }} body: | Hello @${{ github.event.issue.user.login }}. Please provide a online reproduction by forking this link https://codesandbox.io/s/ok2fe or a minimal GitHub repository. Issues labeled by `Need Reproduce` will be closed if no activities in 3 days. 你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个在线的重现实例以便于我们帮你排查问题。你可以通过点击 [此处](https://codesandbox.io/s/ok2fe) 创建一个 codesandbox 或者提供一个最小化的 GitHub 仓库。3 天内未跟进此 issue 将会被自动关闭。 ![](https://gw.alipayobjects.com/zos/antfincdn/y9kwg7DVCd/reproduce.gif) ================================================ FILE: .github/workflows/pkg.pr.new.yml ================================================ name: Publish Any Commit on: push: branches: - master pull_request: types: [opened, synchronize, reopened] jobs: build: runs-on: ubuntu-latest if: github.repository == 'alibaba/hooks' steps: - name: Checkout uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 name: Install pnpm with: run_install: false - name: Install Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - name: Install dependencies run: pnpm install - name: Build run: pnpm build # https://github.com/stackblitz-labs/pkg.pr.new#readme - run: pnpx pkg-pr-new publish './packages/*' --no-template --compact ================================================ FILE: .github/workflows/static.yml ================================================ name: Deploy static content to Pages on: # Runs on pushes targeting the default branch push: branches: ["master"] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: permissions: contents: write pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest env: NODE_OPTIONS: --openssl-legacy-provider steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 22 - name: Setup pnpm run: | npm install --global corepack@latest corepack enable corepack prepare pnpm@latest --activate echo "$(pnpm bin --global)" >> $GITHUB_PATH - name: Install dependencies run: pnpm install - name: Cache pnpm dependencies uses: actions/cache@v4 with: path: | ~/.pnpm-store node_modules key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm- - name: Build documentation run: npm run build:doc - name: Setup Pages uses: actions/configure-pages@v5 - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./dist # 构建后的静态文件目录 force_orphan: true ================================================ FILE: .github/workflows/test.yml ================================================ name: Test CI on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - run: pnpm install - run: pnpm run tsc test: runs-on: ubuntu-latest strategy: matrix: mode: ['normal', 'strict'] node-version: [20, 22] steps: - uses: actions/checkout@v4 - name: Install pnpm uses: pnpm/action-setup@v4 - name: Get pnpm store directory id: pnpm-cache run: | echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT" - name: Setup pnpm cache uses: actions/cache@v4 with: path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-pnpm-store- - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: pnpm run install, build run: | pnpm run init - name: test with react normal mode if: ${{ matrix.mode == 'normal' }} run: | pnpm run test - name: test with react strict mode if: ${{ matrix.mode == 'strict' }} run: | pnpm run test:strict ================================================ FILE: .gitignore ================================================ dist es lib .docz node_modules .history .idea .vscode coverage .doc .DS_Store .umi .umi-production page lerna-debug.log tsconfig.tsbuildinfo packages/hooks/README.md yarn-error.log package-lock.json metadata.json .eslintcache ================================================ FILE: .gitleaks.toml ================================================ title = "gitleaks config" [[rules]] description = "AWS Manager ID" regex = '''(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}''' tags = ["key", "AWS"] [[rules]] description = "AWS Secret Key" regex = '''(?i)aws(.{0,20})?(?-i)['\"][0-9a-zA-Z\/+]{40}['\"]''' tags = ["key", "AWS"] [[rules]] description = "AWS MWS key" regex = '''amzn\.mws\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}''' tags = ["key", "AWS", "MWS"] [[rules]] description = "Facebook Secret Key" regex = '''(?i)(facebook|fb)(.{0,20})?(?-i)['\"][0-9a-f]{32}['\"]''' tags = ["key", "Facebook"] [[rules]] description = "Facebook Client ID" regex = '''(?i)(facebook|fb)(.{0,20})?['\"][0-9]{13,17}['\"]''' tags = ["key", "Facebook"] [[rules]] description = "Twitter Secret Key" regex = '''(?i)twitter(.{0,20})?['\"][0-9a-z]{35,44}['\"]''' tags = ["key", "Twitter"] [[rules]] description = "Twitter Client ID" regex = '''(?i)twitter(.{0,20})?['\"][0-9a-z]{18,25}['\"]''' tags = ["client", "Twitter"] [[rules]] description = "Github" regex = '''(?i)github(.{0,20})?(?-i)['\"][0-9a-zA-Z]{35,40}['\"]''' tags = ["key", "Github"] [[rules]] description = "Github Token" regex = '''[0-9a-zA-Z]{35,40}''' tags = ["key", "Github Token"] [[rules]] description = "Alibaba" regex = '''((alibaba|antfin|aliyun|alipay)(-inc|\.net)|intranetproxy\.alipay)''' tags = ["key", "Alibaba"] [[rules]] description = "antfin" regex = '''(?i)antfin(.{0,20})?(?-i)['\"][0-9a-zA-Z]{35,40}['\"]''' tags = ["key", "Antfin"] [[rules]] description = "LinkedIn Client ID" regex = '''(?i)linkedin(.{0,20})?(?-i)['\"][0-9a-z]{12}['\"]''' tags = ["client", "LinkedIn"] [[rules]] description = "LinkedIn Secret Key" regex = '''(?i)linkedin(.{0,20})?['\"][0-9a-z]{16}['\"]''' tags = ["secret", "LinkedIn"] [[rules]] description = "Slack" regex = '''xox[baprs]-([0-9a-zA-Z]{10,48})?''' tags = ["key", "Slack"] [[rules]] description = "Asymmetric Private Key" regex = '''-----BEGIN ((EC|PGP|DSA|RSA|OPENSSH) )?PRIVATE KEY( BLOCK)?-----''' tags = ["key", "AsymmetricPrivateKey"] [[rules]] description = "Public Key" regex = '''ssh-rsa''' tags = ["keys", "public key"] [[rules]] description = "Gitlab Key" regex = '''privateToken|private-token''' tags = ["keys", "Gitlab"] [[rules]] description = "Generic Credential" regex = '''(?i)(api_key|apikey|secret)(.{0,20})?['|"][0-9a-zA-Z]{16,45}['|"]''' tags = ["key", "API", "generic"] [[rules]] description = "Google API key" regex = '''AIza[0-9A-Za-z\\-_]{35}''' tags = ["key", "Google"] [[rules]] description = "Heroku API key" regex = '''(?i)heroku(.{0,20})?['"][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}['"]''' tags = ["key", "Heroku"] [[rules]] description = "MailChimp API key" regex = '''(?i)(mailchimp|mc)(.{0,20})?['"][0-9a-f]{32}-us[0-9]{1,2}['"]''' tags = ["key", "Mailchimp"] [[rules]] description = "Mailgun API key" regex = '''(?i)(mailgun|mg)(.{0,20})?['"][0-9a-z]{32}['"]''' tags = ["key", "Mailgun"] [[rules]] description = "PayPal Braintree access token" regex = '''access_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}''' tags = ["key", "Paypal"] [[rules]] description = "Picatic API key" regex = '''sk_live_[0-9a-z]{32}''' tags = ["key", "Picatic"] [[rules]] description = "SendGrid API Key" regex = '''SG\.[\w_]{16,32}\.[\w_]{16,64}''' tags = ["key", "SendGrid"] [[rules]] description = "Slack Webhook" regex = '''https://hooks.slack.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}''' tags = ["key", "slack"] [[rules]] description = "Stripe API key" regex = '''(?i)stripe(.{0,20})?['\"][sk|rk]_live_[0-9a-zA-Z]{24}''' tags = ["key", "Stripe"] [[rules]] description = "Square access token" regex = '''sq0atp-[0-9A-Za-z\-_]{22}''' tags = ["key", "square"] [[rules]] description = "Square OAuth secret" regex = '''sq0csp-[0-9A-Za-z\\-_]{43}''' tags = ["key", "square"] [[rules]] description = "Twilio API key" regex = '''(?i)twilio(.{0,20})?['\"][0-9a-f]{32}['\"]''' tags = ["key", "twilio"] [whitelist] description = "Whitelisted files" file = '''(^\.?gitleaks.toml$|(.*?)(jpg|gif|doc|pdf|bin)$|^package-lock.json$|yarn.lock|pnpm-lock.yaml|node_modules)''' ================================================ FILE: .husky/commit-msg ================================================ #!/bin/sh . "$(dirname "$0")/_/husky.sh" npx commitlint --edit $1 ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" npm run pretty ================================================ FILE: .npmrc ================================================ shamefully-hoist=true ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - 'lts/*' install: - pnpm install - pnpm install -g surge script: - pnpm run build:doc - surge ./dist ahooks-$(git rev-parse --short HEAD).surge.sh - pnpm run coveralls # generate static files cache: directories: - 'node_modules' ================================================ FILE: CONTRIBUTING.MD ================================================ # Contributing The following is a set of guidelines for contributing to `ahooks`. Please spend several minutes reading these guidelines before creating an issue or pull request. ## Open Development All work on ahooks happens directly on [GitHub](https://github.com/alibaba/hooks). Pull requests sent by both core team members and external contributors will go through the same review process. ## New Features If you want to add a new Hook, we recommend that you first create an issue that describes the scenario and usage of the Hook, see [[RFC] useLockFn](https://github.com/alibaba/hooks/issues/562). Then you can initialize a new Hook based on an existing Hook. ## Pull Request We are monitoring for pull requests. We will review your pull request and either merge it, request changes to it, or close it with an explanation. Before submitting a pull request, please make sure the following is done: 1. Create your branch from the master. 2. If you've fixed a bug or added code that should be tested, add tests! 3. Ensure the test suite passes `pnpm run test`. ## Development Workflow After cloning `ahooks`, run `pnpm run init` to fetch its dependencies. Then, you can run several commands: 1. `pnpm start` runs `ahooks` website locally. 2. `pnpm run test` runs the complete test suite. 3. `pnpm run build` to build. ================================================ FILE: CONTRIBUTING.zh-CN.MD ================================================ # 贡献指南 这篇指南会指导你如何为 `ahooks` 贡献一份自己的力量,请在你要提 issue 或者 pull request 之前花几分钟来阅读一遍这篇指南。 ## 透明的开发 我们所有的工作都会放在 [GitHub](https://github.com/alibaba/hooks) 上。不管是核心团队的成员还是外部贡献者的 pull request 都需要经过同样流程的 review。 ## 新增功能 如果你想新增一个 Hooks,我们建议你先建立一个 issue,说明该 Hooks 的应用场景及用法,参考 [[RFC] useLockFn](https://github.com/alibaba/hooks/issues/562)。 然后你可以基于已有 Hook 来初始化一个新的 Hook。 ## Pull Request 我们会关注所有的 pull request,会 review 以及合并你的代码,也有可能要求你做一些修改或者告诉你我们为什么不能接受这样的修改。 在你发送 Pull Request 之前,请确认你是按照下面的步骤来做的: 1. 基于 master 分支做修改。 2. 如果你修复了一个 bug 或者新增了一个功能,请确保写了相应的测试,这很重要。 3. 确认所有的测试是通过的 `pnpm run test`。 ## 开发流程 在你 clone 代码并且使用 `pnpm run init` 安装完依赖后,你还可以运行下面几个常用的命令: 1. `pnpm start` 在本地运行 `ahooks` 网站。 2. `pnpm run test` 运行测试。 3. `pnpm run build` 构建编译。 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019-present ahooks Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

A high-quality & reliable React Hooks library. [![NPM version][image-1]][1] [![NPM downloads][image-2]][2] [![npm](https://img.shields.io/npm/dw/ahooks-v2?label=downloads%28v2%29)](https://www.npmjs.com/package/ahooks-v2) [![npm](https://img.shields.io/github/issues/alibaba/hooks)](https://github.com/alibaba/hooks/issues) [![Coverage Status](https://coveralls.io/repos/github/alibaba/hooks/badge.svg?branch=master)](https://coveralls.io/github/alibaba/hooks?branch=master) ![gzip size](https://img.badgesize.io/https:/unpkg.com/ahooks/dist/ahooks.js?label=gzip%20size&compression=gzip) [![Percentage of issues still open](http://isitmaintained.com/badge/open/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open") [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue") ![GitHub](https://img.shields.io/github/license/alibaba/hooks) English | [简体中文](https://github.com/alibaba/hooks/blob/master/README.zh-CN.md)
## 📚 Documentation - [English](https://ahooks.js.org/) - [中文](https://ahooks.js.org/zh-CN/) ## ✨ Features - Easy to learn and use - Supports SSR - Special treatment for functions, avoid closure problems - Contains a large number of advanced Hooks that are refined from business scenarios - Contains a comprehensive collection of basic Hooks - Written in TypeScript with predictable static types ## 📦 Install ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## 🔨 Usage ```ts import { useRequest } from "ahooks"; ``` ## 💻 Online Demo [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ## 🤝 Contributing ```bash $ git clone git@github.com:alibaba/hooks.git $ cd hooks $ pnpm run init $ pnpm start ``` Open your browser and visit http://127.0.0.1:8000 We welcome all contributions, please read our [CONTRIBUTING.MD](https://github.com/alibaba/hooks/blob/master/CONTRIBUTING.MD) first, let's build a better hooks library together. Thanks to all the contributors: contributors ## 👥 Discuss ahooks discussion group 1 ahooks discussion group 2 ahooks discussion group 3 [1]: https://www.npmjs.com/package/ahooks [2]: https://npmjs.org/package/ahooks [image-1]: https://img.shields.io/npm/v/ahooks.svg?style=flat [image-2]: https://img.shields.io/npm/dw/ahooks.svg?style=flat ================================================ FILE: README.zh-CN.md ================================================

一套高质量可靠的 React Hooks 库 [![NPM version][image-1]][1] [![NPM downloads][image-2]][2] [![npm](https://img.shields.io/npm/dw/ahooks-v2?label=downloads%28v2%29)](https://www.npmjs.com/package/ahooks-v2) [![Coverage Status](https://coveralls.io/repos/github/alibaba/hooks/badge.svg?branch=master)](https://coveralls.io/github/alibaba/hooks?branch=master) [![npm](https://img.shields.io/github/issues/alibaba/hooks)](https://github.com/alibaba/hooks/issues) ![gzip size](https://img.badgesize.io/https:/unpkg.com/ahooks/dist/ahooks.js?label=gzip%20size&compression=gzip) [![Percentage of issues still open](http://isitmaintained.com/badge/open/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open") [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue") ![GitHub](https://img.shields.io/github/license/alibaba/hooks) [English](https://github.com/alibaba/hooks/blob/master/README.md) | 简体中文
## 📚 文档 - [English](https://ahooks.js.org/) - [中文](https://ahooks.js.org/zh-CN/) ## ✨ 特性 - 易学易用 - 支持 SSR - 对输入输出函数做了特殊处理,避免闭包问题 - 包含大量提炼自业务的高级 Hooks - 包含丰富的基础 Hooks - 使用 TypeScript 构建,提供完整的类型定义文件 ## 📦 安装 ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## 🔨 使用 ```js import { useRequest } from "ahooks"; ``` ## 💻 在线体验 [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ## 🤝 参与共建 ```bash $ git clone git@github.com:alibaba/hooks.git $ cd hooks $ pnpm run init $ pnpm start ``` 打开浏览器访问 http://127.0.0.1:8000 我们欢迎所有人参与共建,请参考[CONTRIBUTING.MD](https://github.com/alibaba/hooks/blob/master/CONTRIBUTING.zh-CN.MD) 感谢所有贡献者: contributors ## 👥 交流讨论 [1]: https://www.npmjs.com/package/ahooks [2]: https://npmjs.org/package/ahooks [image-1]: https://img.shields.io/npm/v/ahooks.svg?style=flat [image-2]: https://img.shields.io/npm/dw/ahooks.svg?style=flat ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability Please report vulnerabilities to brickspert.fjl@antfin.com or guangbo.hgb@alibaba-inc.com ================================================ FILE: biome.json ================================================ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "files": { "ignoreUnknown": true }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "linter": { "rules": { "style": { "noNonNullAssertion": "off" }, "correctness": { "useHookAtTopLevel": "error" }, "suspicious": { "noExplicitAny": "off" } } }, "formatter": { "lineWidth": 100, "indentStyle": "space" }, "javascript": { "parser": { "unsafeParameterDecoratorsEnabled": true }, "formatter": { "quoteStyle": "single" } }, "css": { "parser": { "cssModules": true }, "formatter": { "enabled": true }, "linter": { "enabled": true } } } ================================================ FILE: config/config.ts ================================================ import { menus } from './hooks'; const packages = require('../packages/hooks/package.json'); export default { // ssr: {}, exportStatic: {}, nodeModulesTransform: { type: 'none', exclude: [], }, // https://github.com/alibaba/hooks/issues/2155 extraBabelIncludes: ['filter-obj'], extraBabelPlugins: [ [ 'babel-plugin-import', { libraryName: 'antd', libraryDirectory: 'es', style: true, }, 'antd', ], [ 'babel-plugin-import', { libraryName: '@alifd/next', style: false, }, 'fusion', ], ], mode: 'site', title: 'ahooks 3.0', favicon: '/simple-logo.svg', logo: '/logo.svg', dynamicImport: {}, manifest: {}, hash: true, publicPath: '/', alias: { ahooks: process.cwd() + '/packages/hooks/src/index.ts', '@ahooksjs/use-url-state': process.cwd() + '/packages/use-url-state/src/index.ts', }, resolve: { includes: ['docs', 'packages/hooks/src', 'packages/use-url-state'], }, links: [ { rel: 'stylesheet', href: 'https://unpkg.com/@alifd/theme-design-pro@0.6.2/dist/next-noreset.min.css', }, { rel: 'stylesheet', href: '/style.css' }, ], navs: { 'zh-CN': [ { title: '指南', path: '/zh-CN/guide' }, { title: 'Hooks', path: '/zh-CN/hooks' }, { title: '历史版本', children: [ { title: 'v2.x', path: 'https://ahooks-v2.js.org/', }, { title: 'v1.x', path: 'http://hooks.umijs.org/', }, ], }, { title: '更新日志', path: 'https://github.com/alibaba/hooks/releases' }, { title: '备用镜像', path: 'https://alibaba.github.io/hooks/' }, { title: 'GitHub', path: 'https://github.com/alibaba/hooks' }, ], 'en-US': [ { title: 'Guide', path: '/guide' }, { title: 'Hooks', path: '/hooks' }, { title: 'Legacy Versions', children: [ { title: 'v2.x', path: 'https://ahooks-v2.js.org/', }, { title: 'v1.x', path: 'http://hooks.umijs.org/', }, ], }, { title: 'Releases', path: 'https://github.com/alibaba/hooks/releases' }, { title: '国内镜像', path: 'https://alibaba.github.io/hooks/' }, { title: 'GitHub', path: 'https://github.com/alibaba/hooks' }, ], }, menus: { '/': [ { title: 'Home', path: 'index', }, ], '/zh-CN': [ { title: '首页', path: 'index', }, ], '/guide': [ { title: 'Intro', path: '/guide', }, { title: 'v2 to v3', path: '/guide/upgrade', }, { title: 'Hooks of dom specification', path: '/guide/dom', }, { title: 'Blog', children: [ { title: 'ahooks function specification', path: '/guide/blog/function', }, { title: 'React Hooks & SSR', path: '/guide/blog/ssr', }, { title: 'React Hooks & react-refresh(HMR)', path: '/guide/blog/hmr', }, { title: 'React Hooks & strict mode', path: '/guide/blog/strict', }, ], }, ], '/zh-CN/guide': [ { title: '介绍', path: '/guide', }, { title: 'v2 to v3', path: '/guide/upgrade', }, { title: 'DOM 类 Hooks 使用规范', path: '/guide/dom', }, { title: 'Blog', children: [ { title: 'ahooks 输入输出函数处理规范', path: '/zh-CN/guide/blog/function', }, { title: 'React Hooks & SSR', path: '/zh-CN/guide/blog/ssr', }, { title: 'React Hooks & react-refresh(HMR)', path: '/zh-CN/guide/blog/hmr', }, { title: 'React Hooks & strict mode', path: '/zh-CN/guide/blog/strict', }, ], }, ], '/hooks': menus, '/zh-CN/hooks': menus, }, scripts: [ 'https://s4.cnzz.com/z_stat.php?id=1278992092&web_id=1278992092', ` const insertVersion = function() { const logo = document.querySelector('.__dumi-default-navbar-logo'); if (!logo) return; const dom = document.createElement('span'); dom.id = 'logo-version'; dom.innerHTML = '${packages.version}'; logo.parentNode.insertBefore(dom, logo.nextSibling); }; const observer = new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const logoVersion = document.querySelector('#logo-version'); if (logoVersion) { observer.disconnect(); } else { insertVersion(); } } } }); observer.observe(document.body, { childList: true, subtree: true }); `, ], }; ================================================ FILE: config/hooks.ts ================================================ export const menus = [ { title: 'useRequest', children: [ 'useRequest/doc/index', 'useRequest/doc/basic', 'useRequest/doc/loadingDelay', 'useRequest/doc/polling', 'useRequest/doc/ready', 'useRequest/doc/refreshDeps', 'useRequest/doc/refreshOnWindowFocus', 'useRequest/doc/debounce', 'useRequest/doc/throttle', 'useRequest/doc/cache', 'useRequest/doc/retry', ], }, { title: 'Scene', children: [ 'useAntdTable', 'useFusionTable', 'useInfiniteScroll', 'usePagination', 'useDynamicList', 'useVirtualList', 'useHistoryTravel', 'useNetwork', 'useSelections', 'useCountDown', 'useCounter', 'useTextSelection', 'useWebSocket', 'useTheme', ], }, { title: 'LifeCycle', children: ['useMount', 'useUnmount', 'useUnmountedRef'], }, { title: 'State', children: [ 'useSetState', 'useBoolean', 'useToggle', 'use-url-state', 'useCookieState', 'useLocalStorageState', 'useSessionStorageState', 'useDebounce', 'useThrottle', 'useMap', 'useSet', 'usePrevious', 'useRafState', 'useSafeState', 'useGetState', 'useResetState', ], }, { title: 'Effect', children: [ 'useUpdateEffect', 'useUpdateLayoutEffect', 'useAsyncEffect', 'useDebounceEffect', 'useDebounceFn', 'useThrottleFn', 'useThrottleEffect', 'useDeepCompareEffect', 'useDeepCompareLayoutEffect', 'useInterval', 'useRafInterval', 'useTimeout', 'useRafTimeout', 'useLockFn', 'useUpdate', ], }, { title: 'Dom', children: [ 'useEventListener', 'useClickAway', 'useDocumentVisibility', 'useDrop', 'useEventTarget', 'useExternal', 'useTitle', 'useFavicon', 'useFullscreen', 'useHover', 'useMutationObserver', 'useInViewport', 'useKeyPress', 'useLongPress', 'useMouse', 'useResponsive', 'useScroll', 'useSize', 'useFocusWithin', ], }, { title: 'Advanced', children: [ 'useControllableValue', 'useCreation', 'useEventEmitter', 'useIsomorphicLayoutEffect', 'useLatest', 'useMemoizedFn', 'useReactive', ], }, { title: 'Dev', children: ['useTrackedEffect', 'useWhyDidYouUpdate'], }, ]; ================================================ FILE: docs/guide/blog/function.en-US.md ================================================ # ahooks function specification ahooks tries its best to help everyone avoid the closure problem by specially processing the input and output functions. **1. All the output functions of ahooks, the references are stable** ```ts const [state, setState] = useState(); ``` As we all know, the reference of the `setState` function returned by `React.useState` is fixed, and there is no need to consider weird problems when using it, and there is no need to put `setState` in the dependencies of other Hooks. All functions returned by ahooks Hooks have the same characteristics as `setState`, the reference will not change, just feel free to use it. **2. For all user input functions, always use the latest one** For the received function, ahooks will do a special process to ensure that the function called each time is always the latest. ```ts const [state, setState] = useState(); useInterval(() => { console.log(state); }, 1000); ``` For example, in the above example, the function called by `useInterval` at any time is always the latest, that is, the state is always the latest. ## Principle For the input function, we use `useRef` to make a record to ensure that the latest function can be accessed anywhere. ```js const fnRef = useRef(fn); fnRef.current = fn; ``` For example, the useUnmount code is as follows: ```js const useUnmount = (fn) => { const fnRef = useRef(fn); fnRef.current = fn; useEffect( () => () => { fnRef.current(); }, [], ); }; ``` In the above code, because we use ref for memorizing the latest function to solve the closure problem. For the output function, we use the [useMemoizedFn](/zh-CN/hooks/use-memoized-fn) wrapped to ensure that the reference address will never change. For a simple example, given a `useToggle` Hook, the code is like this: ```js const useToggle = (left, right) => { const [state, setState] = useState(left); const toggle = useCallback(() => { setState((s) => (s === left ? right : left)); }, [left, right]); return [state, toggle]; }; ``` The `toggle` function returned in this demo will change according to the changes of `left/right`, which is uncomfortable for users to use. Then we replace `useCallback` with `useMemoizedFn` to realize that the `toggle` reference will never change. ```js const useToggle = (left, right) => { const [state, setState] = useState(left); const toggle = useMemoizedFn(() => { setState((s) => (s === left ? right : left)); }); return [state, toggle]; }; ``` ================================================ FILE: docs/guide/blog/function.zh-CN.md ================================================ # ahooks 函数处理规范 ahooks 通过对输入输出函数做特殊处理,尽力帮助大家避免闭包问题。 **1. ahooks 所有的输出函数,地址都是不会变化的** ```ts const [state, setState] = React.useState(); ``` 众所周知,`React.useState` 返回的 `setState` 函数地址是固定的,使用时不需要考虑奇奇怪怪的问题,不需要把 `setState` 放到各种依赖中。 ahooks 所有 Hooks 返回的函数,都拥有和 `setState` 一样的特性,地址不会变化,放心大胆的使用即可。 **2. 所有用户输入的函数,永远使用最新的一份** 对于接收的函数,ahooks 会做一次特殊处理,保证每次调用的函数永远是最新的。 ```ts const [state, setState] = useState(); useInterval(() => { console.log(state); }, 1000); ``` 比如以上示例,`useInterval` 任何时候调用的函数永远是最新的,也就是 state 永远是最新的。 ## 实现原理 针对输入函数,我们通过 `useRef` 做一次记录,以保证在任何地方都能访问到最新的函数。 ```js const fnRef = useRef(fn); fnRef.current = fn; ``` 比如 useUnmount 代码如下: ```js const useUnmount = (fn) => { const fnRef = useRef(fn); fnRef.current = fn; useEffect( () => () => { fnRef.current(); }, [], ); }; ``` 在上面的代码中,由于我们通过 ref 来记忆最新的函数,解决闭包问题。 针对输出函数,我们通过 ahooks 的 [useMemoizedFn](/zh-CN/hooks/use-memoized-fn) 包裹,保证地址永远不会变化。 举一个比较简单的例子,假如我们有一个 `useToggle` Hook,代码是这样的 ```js const useToggle = (left, right) => { const [state, setState] = useState(left); const toggle = useCallback(() => { setState((s) => (s === left ? right : left)); }, [left, right]); return [state, toggle]; }; ``` 这个 demo 中返回的 `toggle` 函数,会根据 `left/right` 的变化而变化的,用户用起来很难受。 然后我们将 `useCallback` 替换成 `useMemoizedFn`,即可实现 `toggle` 地址永远不变化。 ```js const useToggle = (left, right) => { const [state, setState] = useState(left); const toggle = useMemoizedFn(() => { setState((s) => (s === left ? right : left)); }); return [state, toggle]; }; ``` ================================================ FILE: docs/guide/blog/hmr.en-US.md ================================================ # React Hooks & react-refresh(HMR) ## What is react-refresh? [react-refresh-webpack-plugin](https://github.com/pmmmwh/react-refresh-webpack-plugin) is a hot module replacement (HMR) plugin provided by React. > A Webpack plugin to enable "Fast Refresh" (also previously known as Hot Reloading) for React components. In the development, react-refresh can keep state in component, and only change the edited part. In [umi](https://umijs.org/zh-CN/docs/fast-refresh), can enable this feature by config `fastRefresh: {}`. ![fast-refresh.gif](https://camo.githubusercontent.com/244b53f735f2a78cfbce79a3914600840cdedac545e5f309d32ac7be4fdb2517/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631353937363932382d33633832353564642d396165342d343933342d613833322d3965643934636565353762632e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753234363633316564266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d666173742d726566726573682e676966266f726967696e4865696768743d363136266f726967696e57696474683d31303030266f726967696e616c547970653d62696e6172792673697a653d35313534393234267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7565316263613762312d393035362d343431392d613438382d6231393365366236643936) This gif shows the development experience of using the react-refresh. After edit some code, the username and password that have been filled in remain unchanged, only the edited part has been changed. ## Simple Principles of react-refresh For the Class component, react-refresh are always refresh (remount), existing state will be reset. For function components, react-refresh retains the existing state. Therefore, react-refresh provides a better experience for function components. This article mainly explains the weird behavior of React Hooks in react-refresh mode. Now let us look at the working mechanism of react-refresh on function components. - To maintain the state during hot replacement, the value of `useState` and `useRef` will not update. - During hot replacement, [To avoid some problems](<(https://github.com/facebook/react/issues/21019#issuecomment-800650091)>), `useEffect`、`useCallback`、`useMemoRun` will re-executed. > When we update the code, we need to "clean up" the effects that hold onto past values (e.g. passed functions), and "setup" the new ones with updated values. Otherwise, the values used by your effect would be stale and "disagree" with value used in your rendering, which makes Fast Refresh much less useful and hurts the ability to have it work with chains of custom Hooks. ![Kapture 2021-05-10 at 11.37.54.gif](https://camo.githubusercontent.com/d9452c7cb9035fd422d9be908d1815ad25f0ca496d938fc3962d317c6d29fc61/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631383030393232392d63656261323438342d656430612d343336392d393731612d3636353931383933313238642e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753737313339373665266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031312e33372e35342e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d31373038383131267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7561343663386239322d656234342d343862372d383634352d3738323232623438646464) As shown in the gif, after the text is modified, `state` remains unchanged and `useEffect` is executed again. ## Problem caused by react-refresh Under the above working mechanism, there will be many problems. Next, I will give a few specific examples. ### First problem ```js import React, { useEffect, useState } from 'react'; export default () => { const [count, setState] = useState(0); useEffect(() => { setState((s) => s + 1); }, []); return
{count}
; }; ``` The above code is very simple. In normal mode, the maximum value of `count` is `1`. Because `useEffect` will only be executed once during initialization. But in the react-refresh mode, the `state` does not change every time it is hot updated, but the re-execution of `useEffect` will cause the value of `count` to keep increasing. ![Kapture 2021-05-10 at 12.09.47.gif](https://camo.githubusercontent.com/82528f255af3a88133d66824de55dd1f6e665030caf0bae81291951d5fe75943/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631393831313739312d34383161323862302d396262642d343938302d626635322d3731313561633336363262352e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753263313732313030266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031322e30392e34372e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d31333532383932267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7533346432653861302d333430642d346638632d383039302d6232346638303433623266) As shown in the gif, `count` increases with each hot replacement. ### Second problem If you used [ahooks v2](https://github.com/alibaba/hooks/blob/release/v2.x/packages/hooks/src/useUpdateEffect/index.ts) or [react-use](https://github.com/streamich/react-use/blob/master/docs/useUpdateEffect.md) `useUpdateEffect` will also have unexpected behavior in HMR. ```javascript import React, { useEffect } from 'react'; import useUpdateEffect from './useUpdateEffect'; export default () => { useEffect(() => { console.log('useEffect'); }, []); useUpdateEffect(() => { console.log('useUpdateEffect'); }, []); return
hello world
; }; ``` Compared with `useEffect`, `useUpdateEffect` ignores the first execution and only executes when the deps changes. In the normal mode of the above code, `useUpdateEffect` will never be executed, because deps is an empty array and will never change. But in react-refresh mode, during HMR, `useUpdateEffect` and `useEffect` are executed at the same time. ![Kapture 2021-05-10 at 12.26.19.gif](https://camo.githubusercontent.com/18000e2859234c5ca4d7613985dab82cba0a654cca53a9df5bc63dfcd126cce7/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303632303739373138392d36613561366434302d616637372d343339642d616462632d3230666430343664636663302e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753065323737343631266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031322e32362e31392e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d373937383135267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7564613336343363622d386435312d346437322d626461322d3362333431353762313530) The reason for this problem is that `useUpdateEffect` uses `ref` to record whether it is currently executed for the first time, see the code below. ```javascript import { useEffect, useRef } from 'react'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isMounted = useRef(false); useEffect(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default useUpdateEffect; ``` The key of the above code is `isMounted`. - During initialization, after the `useEffect` is executed, the `isMounted` is changed to `true` - After the HMR, when the `useEffect` is re-executing, because the `isMounted` is already `true`, so the whole effect is executed again. ### Third problem The first time discovered this problem is the `useRequest` of ahooks, after HMR, the `loading` would always be `true`. After an inspection, the reason is use the `isUnmount` ref to mark whether the component is unmount. ```javascript import React, { useEffect, useState } from 'react'; function getUsername() { return new Promise((resolve) => { setTimeout(() => { resolve('test'); }, 1000); }); } export default function IndexPage() { const isUnmount = React.useRef(false); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); getUsername().then(() => { if (isUnmount.current === false) { setLoading(false); } }); return () => { isUnmount.current = true; }; }, []); return loading ?
loading
:
hello world
; } ``` As the code above, during the hot replacement, `isUnmount.current` becomes `true`, causing the code to think that the component has been unmounted during the second execution. ## How to solve these problems ### First solution The first solution is to solve it from the code, that is, when we write code, we can always remember the weird behavior in react-refresh mode. For example, with `useUpdateEffect`, we can initialize the `isMounted` ref during initialization or hot replacement. as follows: ```diff import { useEffect, useRef } from 'react'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isMounted = useRef(false); + useEffect(() => { + isMounted.current = false; + }, []); useEffect(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default useUpdateEffect; ``` This solution is effective for both questions 2 and 3 above. ### Second solution According to [Official Document](https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#reset), we can solve this problem by adding the following comment in the file . ```javascript /* @refresh reset */ ``` After adding this question, every hot replacement will remount, that is, the component will be re-executed. `useState` and `useRef` will also be reset, so the above problem will not occur. ## Official attitude There are already many unspoken rules for React Hooks. When using react-refresh, there are still unspoken rules to pay attention to. But the official reply stated that this is expected behavior, see the [issue](https://github.com/facebook/react/issues/21019). > Effects are not exactly "mount"/"unmount" — they're more like "show"/"hide". ================================================ FILE: docs/guide/blog/hmr.zh-CN.md ================================================ # React Hooks & react-refresh(HMR) ## 什么是 react-refresh [react-refresh-webpack-plugin](https://github.com/pmmmwh/react-refresh-webpack-plugin) 是 React 官方提供的一个 模块热替换(HMR)插件。 > A Webpack plugin to enable "Fast Refresh" (also previously known as Hot Reloading) for React components. 在开发环境编辑代码时,react-refresh 可以保持组件当前状态,仅仅变更编辑的部分。在 [umi](https://umijs.org/zh-CN/docs/fast-refresh) 中可以通过 `fastRefresh: {}`快速开启该功能。 ![fast-refresh.gif](https://camo.githubusercontent.com/244b53f735f2a78cfbce79a3914600840cdedac545e5f309d32ac7be4fdb2517/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631353937363932382d33633832353564642d396165342d343933342d613833322d3965643934636565353762632e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753234363633316564266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d666173742d726566726573682e676966266f726967696e4865696768743d363136266f726967696e57696474683d31303030266f726967696e616c547970653d62696e6172792673697a653d35313534393234267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7565316263613762312d393035362d343431392d613438382d6231393365366236643936) 这张 gif 动图展示的是使用 react-refresh 特性的开发体验,可以看出,修改组件代码后,已经填写的用户名和密码保持不变,仅仅只有编辑的部分变更了。 ## react-refresh 的简单原理 对于 Class 类组件,react-refresh 会一律重新刷新(remount),已有的 state 会被重置。而对于函数组件,react-refresh 则会保留已有的 state。所以 react-refresh 对函数类组件体验会更好。 本篇文章主要讲解 React Hooks 在 react-refresh 模式下的怪异行为,现在我来看下 react-refresh 对函数组件的工作机制。 - 在热更新时为了保持状态,`useState` 和 `useRef` 的值不会更新。 - 在热更新时,[为了解决某些问题](https://github.com/facebook/react/issues/21019#issuecomment-800650091),`useEffect`、`useCallback`、`useMemo` 等会重新执行。 > When we update the code, we need to "clean up" the effects that hold onto past values (e.g. passed functions), and "setup" the new ones with updated values. Otherwise, the values used by your effect would be stale and "disagree" with value used in your rendering, which makes Fast Refresh much less useful and hurts the ability to have it work with chains of custom Hooks. ![Kapture 2021-05-10 at 11.37.54.gif](https://camo.githubusercontent.com/d9452c7cb9035fd422d9be908d1815ad25f0ca496d938fc3962d317c6d29fc61/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631383030393232392d63656261323438342d656430612d343336392d393731612d3636353931383933313238642e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753737313339373665266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031312e33372e35342e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d31373038383131267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7561343663386239322d656234342d343862372d383634352d3738323232623438646464) 如上图所示,在文本修改之后,`state` 保持不变,`useEffect` 被重新执行了。 ## react-refresh 工作机制导致的问题 在上述工作机制下,会带来很多问题,接下来我会举几个具体的例子。 ### 第一个问题 ```js import React, { useEffect, useState } from 'react'; export default () => { const [count, setState] = useState(0); useEffect(() => { setState((s) => s + 1); }, []); return
{count}
; }; ``` 上面的代码很简单,在正常模式下,`count`值最大为 `1`。因为 `useEffect` 只会在初始化的时候执行一次。 但在 react-refresh 模式下,每次热更新的时候,`state` 不变,但 `useEffect` 重新执行,就会导致 `count` 的值一直在递增。 ![Kapture 2021-05-10 at 12.09.47.gif](https://camo.githubusercontent.com/82528f255af3a88133d66824de55dd1f6e665030caf0bae81291951d5fe75943/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303631393831313739312d34383161323862302d396262642d343938302d626635322d3731313561633336363262352e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753263313732313030266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031322e30392e34372e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d31333532383932267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7533346432653861302d333430642d346638632d383039302d6232346638303433623266) 如上图所示,`count` 随着每一次热更新在递增。 ### 第二个问题 如果你使用了 [ahooks v2](https://github.com/alibaba/hooks/blob/release/v2.x/packages/hooks/src/useUpdateEffect/index.ts) 或者 [react-use](https://github.com/streamich/react-use/blob/master/docs/useUpdateEffect.md) 的 `useUpdateEffect`,在热更新模式下也会有不符合预期的行为。 ```javascript import React, { useEffect } from 'react'; import useUpdateEffect from './useUpdateEffect'; export default () => { useEffect(() => { console.log('执行了 useEffect'); }, []); useUpdateEffect(() => { console.log('执行了 useUpdateEffect'); }, []); return
hello world
; }; ``` `useUpdateEffect` 与 `useEffect`相比,它会忽略第一次执行,只有在 deps 变化时才会执行。以上代码的在正常模式下,`useUpdateEffect` 是永远不会执行的,因为 deps 是空数组,永远不会变化。 但在 react-refresh 模式下,热更新时,`useUpdateEffect` 和 `useEffect` 同时执行了。 ![Kapture 2021-05-10 at 12.26.19.gif](https://camo.githubusercontent.com/18000e2859234c5ca4d7613985dab82cba0a654cca53a9df5bc63dfcd126cce7/68747470733a2f2f63646e2e6e6c61726b2e636f6d2f79757175652f302f323032312f6769662f3138393335302f313632303632303739373138392d36613561366434302d616637372d343339642d616462632d3230666430343664636663302e67696623636c69656e7449643d7563376235663533362d656661652d342666726f6d3d64726f702669643d753065323737343631266d617267696e3d2535426f626a6563742532304f626a656374253544266e616d653d4b617074757265253230323032312d30352d3130253230617425323031322e32362e31392e676966266f726967696e4865696768743d383736266f726967696e57696474683d31323534266f726967696e616c547970653d62696e6172792673697a653d373937383135267374617475733d646f6e65267374796c653d6e6f6e65267461736b49643d7564613336343363622d386435312d346437322d626461322d3362333431353762313530) 造成这个问题的原因,就是 `useUpdateEffect` 用 `ref` 来记录了当前是不是第一次执行,见下面的代码。 ```javascript import { useEffect, useRef } from 'react'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isMounted = useRef(false); useEffect(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default useUpdateEffect; ``` 上面代码的关键在 `isMounted` - 初始化时,`useEffect` 执行,标记 `isMounted` 为 `true` - 热更新后,`useEffect` 重新执行了,此时 `isMounted` 为 `true`,就往下执行了 ### 第三个问题 最初发现这个问题,是 ahooks 的 `useRequest` 在热更新后,`loading` 会一直为 `true`。经过分析,原因就是使用 `isUnmount` ref 来标记组件是否卸载。 ```javascript import React, { useEffect, useState } from 'react'; function getUsername() { return new Promise((resolve) => { setTimeout(() => { resolve('test'); }, 1000); }); } export default function IndexPage() { const isUnmount = React.useRef(false); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); getUsername().then(() => { if (isUnmount.current === false) { setLoading(false); } }); return () => { isUnmount.current = true; }; }, []); return loading ?
loading
:
hello world
; } ``` 如上代码所示,在热更新时,`isUnmount.current` 变为了 `true`,导致二次执行时,代码以为组件已经卸载了,不再响应异步操作。 ## 如何解决这些问题 ### 方案一 第一个解决方案是从代码层面解决,也就是要求我们在写代码的时候,时时能想起来 react-refresh 模式下的怪异行为。 比如 `useUpdateEffect` 我们就可以在初始化或者热替换时,将 `isMounted` ref 初始化掉。如下: ```diff import { useEffect, useRef } from 'react'; const useUpdateEffect: typeof useEffect = (effect, deps) => { const isMounted = useRef(false); + useEffect(() => { + isMounted.current = false; + }, []); useEffect(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default useUpdateEffect; ``` 这个方案对上面的问题二和三都是有效的。 ### 方案二 根据[官方文档](https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/docs/API.md#reset),我们可以通过在文件中添加以下注释来解决这个问题。 ```javascript /* @refresh reset */ ``` 添加这个问题后,每次热更新,都会 remount,也就是组件重新执行。`useState` 和 `useRef` 也会重置掉,也就不会出现上面的问题了。 ## 官方态度 本来 React Hooks 已经有蛮多潜规则了,在使用 react-refresh 时,还有潜规则要注意。但官方回复说这是预期行为,见该 [issue](https://github.com/facebook/react/issues/21019)。 > Effects are not exactly "mount"/"unmount" — they're more like "show"/"hide". ================================================ FILE: docs/guide/blog/ssr.en-US.md ================================================ # React Hooks & SSR Server-Side Rendering refers to the page processing technology where the HTML structure of the page is spliced on the server side. Generally used to solve SEO problems and speed up the first screen. Since SSR executes JS code in a non-browser environment, there will be many problems. This article mainly introduces the common problems and solutions of React Hooks in SSR mode. ## Problem 1: DOM/BOM is missing SSR is to run React code in a node environment, while global properties such as window, document, and navigator are not available at this time. If you use these properties directly, you will get errors like `window is not defined, document is not defined, navigator is not defined`, etc. A common misuse is that global properties, such as document, are used directly during the execution of Hooks. ```js import React, { useState } from 'react'; export default () => { const [state, setState] = useState(document.visibilityState); return state; }; ``` ### Solution 1. Put the code of accessing the DOM/BOM in useEffect/useLayoutEffect (the server will not execute it) to avoid errors when the server executes, for example: ```js import React, { useState, useEffect } from 'react'; export default () => { const [state, setState] = useState(); useEffect(() => { setState(document.visibilityState); }, []); return state; }; ``` 2. Differentiate the environments by `isBrowser` ```js import React, { useState } from 'react'; function isBrowser() { return !!(typeof window !== 'undefined' && window.document && window.document.createElement); } export default () => { const [state, setState] = useState(isBrowser() && document.visibilityState); return state; }; ``` ## Problem 2: useLayoutEffect Warning If `useLayoutEffect` is used, the following warning will appear in SSR mode > ⚠️ Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://fb.me/react-uselayouteffect-ssr for common fixes. ### Solution 1. Use useEffect instead of useLayoutEffect (nonsense) 2. Dynamically specify whether to use useEffect or useLayoutEffect according to the environment. This is a hack solution from the community, currently in [react-redux](https://github.com/reduxjs/react-redux/blob/d16262582b2eeb62c05313fca3eb59dc0b395955/src/components/connectAdvanced.js#L40), [react-use](https://github.com/streamich/react-use/blob/master/src/useIsomorphicLayoutEffect.ts), [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd/blob/master/src/view/use-isomorphic-layout-effect.js). ```js import { useLayoutEffect, useEffect } from 'react'; const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect; export default useIsomorphicLayoutEffect; ``` ## Summary: Need to pay attention when writing Hooks 1. Do not use DOM/BOM properties directly in non-useEffect/useLayoutEffect 2. When using DOM/BOM properties other than useEffect/useLayoutEffect, use `isBrowser` to determine whether to execute in the browser environment 3. If a Hook needs to receive DOM/BOM properties, it needs to support passing the properties via a function type parameter. Take the useEventListener of ahooks as an example, it must support the function type to specify the target option. ```diff import React, { useState } from 'react'; import { useEventListener } from 'ahooks'; export default () => { const [value, setValue] = useState(0); const clickHandler = () => { setValue(value + 1); }; useEventListener( 'click', clickHandler, { - target: document.getElementById('click-btn') + target: () => document.getElementById('click-btn') } ); return ( ); }; ``` 4. Use `useIsomorphicLayoutEffect` instead of `useLayoutEffect` ## Reference - [fix: useDocumentVisiblility support ssr](https://github.com/alibaba/hooks/pull/935/files) - [UmiJS 服务端渲染](https://umijs.org/docs/ssr#window-is-not-defined-document-is-not-defined-navigator-is-not-defined) - [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a) ================================================ FILE: docs/guide/blog/ssr.zh-CN.md ================================================ # React Hooks & SSR 服务端渲染(Server-Side Rendering),是指由服务侧完成页面的 HTML 结构拼接的页面处理技术。一般用于解决 SEO 问题和首屏加载速度问题。 由于 SSR 是在非浏览器环境执行 JS 代码,所以会出现很多问题。本文主要介绍 React Hooks 在 SSR 模式下常见问题及解决方案。 > 更多关于 SSR 的介绍可以看 UmiJS 的文档《[服务端渲染(SSR)](https://umijs.org/zh-CN/docs/ssr#服务端渲染(ssr))》。 ## 问题一:DOM/BOM 缺失 SSR 是在 node 环境下运行 React 代码,而此时 window、document、navigator 等全局属性没有。如果直接使用了这些属性,就会报错 `window is not defined, document is not defined, navigator is not defined` 等。 常见的错误用法是在 Hooks 执行过程中,直接使用了 document 等全局属性。 ```js import React, { useState } from 'react'; export default () => { const [state, setState] = useState(document.visibilityState); return state; }; ``` ### 解决方案 1. 将访问 DOM/BOM 的方法放在 useEffect/useLayoutEffect 中(服务端不会执行),避免服务端执行时报错,例如: ```js import React, { useState, useEffect } from 'react'; export default () => { const [state, setState] = useState(); useEffect(() => { setState(document.visibilityState); }, []); return state; }; ``` 2. 通过 `isBrowser` 来做环境判断 ```js import React, { useState } from 'react'; function isBrowser() { return !!(typeof window !== 'undefined' && window.document && window.document.createElement); } export default () => { const [state, setState] = useState(isBrowser() && document.visibilityState); return state; }; ``` ## 问题二 useLayoutEffect Warning 如果使用了 `useLayoutEffect`,在 SSR 模式下,会出现以下警告 > ⚠️ Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://fb.me/react-uselayouteffect-ssr for common fixes. ### 解决方案 1. 使用 useEffect 代替 useLayoutEffect(废话) 2. 根据环境动态的指定是使用 useEffect 还是 useLayoutEffect。这是来自社区的一种 hack 解决方案,目前在 [react-redux](https://github.com/reduxjs/react-redux/blob/d16262582b2eeb62c05313fca3eb59dc0b395955/src/components/connectAdvanced.js#L40)、[react-use](https://github.com/streamich/react-use/blob/master/src/useIsomorphicLayoutEffect.ts)、[react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd/blob/master/src/view/use-isomorphic-layout-effect.js) 均使用的这种方案。 ```js import { useLayoutEffect, useEffect } from 'react'; const useIsomorphicLayoutEffect = isBrowser() ? useLayoutEffect : useEffect; export default useIsomorphicLayoutEffect; ``` ## 总结:写 Hooks 时需要注意 1. 不要在非 useEffect/useLayoutEffect 中,直接使用 DOM/BOM 属性 2. 在非 useEffect/useLayoutEffect 使用 DOM/BOM 属性时,使用 `isBrowser` 判断是否在浏览器环境执行 3. 如果某个 Hook 需要接收 DOM/BOM 属性,需要支持函数形式传参。以 ahooks 的 useEventListener 举例,必须支持函数形式来指定 target 属性。 ```diff import React, { useState } from 'react'; import { useEventListener } from 'ahooks'; export default () => { const [value, setValue] = useState(0); const clickHandler = () => { setValue(value + 1); }; useEventListener( 'click', clickHandler, { - target: document.getElementById('click-btn') + target: () => document.getElementById('click-btn') } ); return ( ); }; ``` 4. 使用 `useIsomorphicLayoutEffect` 来代替 `useLayoutEffect` ## 参考资料 - [fix: useDocumentVisiblility support ssr](https://github.com/alibaba/hooks/pull/935/files) - [UmiJS 服务端渲染](https://umijs.org/zh-CN/docs/ssr#window-is-not-defined-document-is-not-defined-navigator-is-not-defined) - [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a) ================================================ FILE: docs/guide/blog/strict.en-US.md ================================================ # React Hooks & strict mode ## What is strict mode In React, there are many historical APIs or writing methods that will be obsolete in future versions and are now marked as deprecated. such as `componentWillMount`, in normal mode, you can use it normally. But in strict mode, a warning will be thrown: ![image.png](https://user-images.githubusercontent.com/12526493/140928679-cafd5b58-2937-41a9-87e8-f68aa6d978d9.png) So **strict mode is for future development, all APIs or writing methods that are not recommended will throw warnings (only effective in development mode).** We can use `React.StrictMode` to enable strict mode. ```javascript import React from 'react'; function ExampleApplication() { return (
); } ``` For more documents, please refer to "[Strict Mode](https://reactjs.org/docs/strict-mode.html)" ## Points to note in React Hooks One of the most important capabilities of strict mode is "[Detecting Unexpected Side Effects](https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)", in the upcoming concurrent mode, the component is divided into two phases: - **Render phase**: Generate DOM tree, will execute constructor, componentWillMount, componentWillReceiveProps, componentWillUpdate, getDerivedStateFromProps, shouldComponentUpdate, render, **useState, useMemo, useCallback** and other life cycles - **Commit stage**: Apply DOM changes, trigger componentDidMount, componentDidUpdate, **useEffect** and other life cycles Generally, the render phase is time-consuming, and the commit phase is executed quickly. Therefore, in the upcoming concurrent mode, the render phase may be suspended and re-executed. That is, the life cycle of the rendering phase may be executed multiple times. ```javascript constructor(){ services.getUserInfo().then(() => { ..... }); } ``` As above, if we initiate a network request in the constructor, it may be executed multiple times. So **do not perform operations with side effects during the render phase.** But if you perform side-effect operations during the rendering phase, React will not be able to perceive it. **But in strict mode, React will intentionally repeat the render phase method, making it easier for us to find such bugs in the development phase** (not all the life cycles of the rendering phase will be re-executed, see [Official Documentation](https://reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)). ```javascript const useTest = () => { const [state, setState] = useState(() => { console.log('get state'); return 'state'; }); const memoState = useMemo(() => { console.log('get memo state'); return 'state'; }, []); console.log('render'); return state; }; ``` In the above code, the first parameter of `useState`, `useMemo` and the Hook function body are all executed twice. [Demo](https://codesandbox.io/s/xvv55893mp?file=/src/index.js) Please remember the conclusion: **In strict mode, the first parameter of `useState`, `useMemo`, `useReducer` and the Hook function body will be executed twice. Do not perform operations with side effects here.** ================================================ FILE: docs/guide/blog/strict.zh-CN.md ================================================ # React Hooks & strict mode ## 什么是严格模式 在 React 中,有很多历史的 API 或写法,在未来版本中会被废弃,现在被标记为不建议使用。既然是不建议使用,那还是可以用的,比如 `componentWillMount`,在普通模式下,你可以正常使用。但在严格模式下,就会抛出警告: ![image.png](https://user-images.githubusercontent.com/12526493/140928679-cafd5b58-2937-41a9-87e8-f68aa6d978d9.png) 所以**严格模式就是面向未来开发,所有不建议的 API 或写法,都会抛出警告(只在开发模式生效)。** 一般我们可以通过 `React.StrictMode` 来局部启用严格模式。 ```javascript import React from 'react'; function ExampleApplication() { return (
); } ``` 更多文档参考《[严格模式](https://zh-hans.reactjs.org/docs/strict-mode.html)》 ## 在 React Hooks 中需要注意的点 严格模式很重要的一个能力是《[检测意外的副作用](https://zh-hans.reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)》,在未来的 concurrent 模式中,组件被分为两个阶段: - **渲染(render)阶段**:生成 DOM 树,会执行 constructor、componentWillMount、componentWillReceiveProps、componentWillUpdate、getDerivedStateFromProps、shouldComponentUpdate、render、**useState、useMemo、useCallback** 等生命周期 - **提交(commit)阶段**:操作 DOM,触发 componentDidMount、componentDidUpdate、**useEffect** 等生命周期 一般渲染阶段会比较耗时,提交阶段执行很快。所以在未来的 concurrent 模式中,渲染阶段可能会被暂停、重新执行。也就是渲染阶段的生命周期,可能会被多次执行。 ```javascript constructor(){ services.getUserInfo().then(() => { ..... }); } ``` 如上,我们在 constructor 中发起网络请求,就可能被执行多次。所以**不要在渲染阶段执行带有副作用的操作。** 但假如你在渲染阶段执行了副作用操作,React 也是无法感知的。**但是 React 在严格模式下,会故意重复执行渲染阶段的方法,使得我们在开发阶段能更容易发现这类 bug**(并不是所有渲染阶段的生命周期都会被重新执行,具体见[官方文档](https://zh-hans.reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects))。 ```javascript const useTest = () => { const [state, setState] = useState(() => { console.log('get state'); return 'state'; }); const memoState = useMemo(() => { console.log('get memo state'); return 'state'; }, []); console.log('render'); return state; }; ``` 在上面的代码中 `useState`、`useMemo` 的第一个参数、Hook 函数体均执行了两次。 [在线体验](https://codesandbox.io/s/xvv55893mp?file=/src/index.js) 请记住结论:**在严格模式下,`useState`、`useMemo`、`useReducer` 的第一个参数、Hook 函数体都会被执行两次,不要在这里执行带有副作用的操作。** ================================================ FILE: docs/guide/dom.en-US.md ================================================ ## Hooks of DOM specification Most of the DOM Hooks will receive the `target` parameter, which indicates the element to be processed. `target` supports three types `React.MutableRefObject`, `HTMLElement`, `() => HTMLElement`. 1. Support `React.MutableRefObject` ```ts export default () => { const ref = useRef(null); const isHovering = useHover(ref); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` 2. Support `HTMLElement` ```ts export default () => { const isHovering = useHover(document.getElementById('test')); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` 3. Support `() => HTMLElement`, generally applicable in SSR scenarios ```ts export default () => { const isHovering = useHover(() => document.getElementById('test')); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` In addition, **the `target` of DOM Hooks supports dynamic changes**. for example: ```ts export default () => { const [boolean, { toggle }] = useBoolean(); const ref = useRef(null); const ref2 = useRef(null); const isHovering = useHover(boolean ? ref : ref2); return ( <>
{isHovering ? 'hover' : 'leaveHover'}
{isHovering ? 'hover' : 'leaveHover'}
); }; ``` ================================================ FILE: docs/guide/dom.zh-CN.md ================================================ ## DOM 类 Hooks 使用规范 ahooks 大部分 DOM 类 Hooks 都会接收 `target` 参数,表示要处理的元素。 `target` 支持三种类型 `React.MutableRefObject`、`HTMLElement`、`() => HTMLElement`。 1. 支持 `React.MutableRefObject` ```ts export default () => { const ref = useRef(null); const isHovering = useHover(ref); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` 2. 支持 `HTMLElement` ```ts export default () => { const isHovering = useHover(document.getElementById('test')); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` 3. 支持 `() => HTMLElement`,一般适用在 SSR 场景 ```ts export default () => { const isHovering = useHover(() => document.getElementById('test')); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ``` 另外,**DOM 类 Hooks 的 `target` 是支持动态变化的**。比如: ```ts export default () => { const [boolean, { toggle }] = useBoolean(); const ref = useRef(null); const ref2 = useRef(null); const isHovering = useHover(boolean ? ref : ref2); return ( <>
{isHovering ? 'hover' : 'leaveHover'}
{isHovering ? 'hover' : 'leaveHover'}
); }; ``` ================================================ FILE: docs/guide/index.en-US.md ================================================ ## Intro ahooks, pronounced [eɪ hʊks], is a high-quality and reliable React Hooks library. In the current React project development process, a set of easy-to-use React Hooks library is indispensable, hope ahooks can be your choice. ## Features - Easy to learn and use - Supports SSR - Special treatment for functions, avoid closure problems - Contains a large number of advanced Hooks that are refined from business scenarios - Contains a comprehensive collection of basic Hooks - Written in TypeScript with predictable static types ## Install ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## Usage ```ts import { useRequest } from 'ahooks'; ``` ## Online Demo [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ================================================ FILE: docs/guide/index.zh-CN.md ================================================ # 介绍 ahooks,发音 [eɪ hʊks],是一套高质量可靠的 React Hooks 库。在当前 React 项目研发过程中,一套好用的 React Hooks 库是必不可少的,希望 ahooks 能成为您的选择。 ## 特性 - 易学易用 - 支持 SSR - 对输入输出函数做了特殊处理,且避免闭包问题 - 包含大量提炼自业务的高级 Hooks - 包含丰富的基础 Hooks - 使用 TypeScript 构建,提供完整的类型定义文件 ## 安装 ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## 使用 ```ts import { useRequest } from 'ahooks'; ``` ## 💻 在线体验 [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ================================================ FILE: docs/guide/upgrade.en-US.md ================================================ ## v2 to v3 Compared with the ahooks v2 version, the changes in the ahooks v3 version mainly include: - New `useRequest` - Support SSR - Special treatment for input and output functions to avoid closure problems - Hooks of DOM support dynamic target - Solved the problem in Strict Mode - Solved the problem in react-refresh (HMR) mode - Fixed known issues - Added more Hooks ## Upgrade suggestion We have released the `ahooks-v2` package, you can install v2 and v3 dependencies at the same time to transition upgrades. ```bash npm install ahooks-v2 --save npm install ahooks --save ``` ## New useRequest useRequest has been rewritten: - Organized the source code through a plug-in pattern, the core code is extremely simple, and can be easily extended for more advanced features. - Provides step-by-step documentation. - Fixed the way of exception handling, provides `run` and `runAsync` two trigger functions. - The `options` parameter supports dynamic changes. - Deleted `pagination`, `loadMore`, `formatResult` options to avoid the overload of TypeScript, it is more convenient for encapsulating more advanced Hooks based on `useRequest`. ### Detailed changes - Deleted `UseRequestProvider`, it is recommended to encapsulate advanced Hooks based on `useRequest` instead. - Removed `pagination` related options, it is recommended to use `usePagination` or `useAntdTable` to achieve paging ability. - Removed `loadMore` related options, it is recommended to use `useInfiniteScroll` to achieve unlimited loading ability. - Removed `fetchKey`, that is, deleted concurrent request. - Removed `formatResult`, `initialData`, and `throwOnError`. - The request library is no longer integrated by default, and `service` no longer supports string or object. - Added `runAsync` and `refreshAsync`, the original `run` no longer returns Promise. - Added error retry ability. - Added `onBefore` and `onFinally` life cycles. - Added cache clear ability. - All options support dynamic changes. - In debounce/throttle mode, `runAsync` can return current Promise. - Debounce/throttle mode supports more options. - Only successful request data will be cached. - Upgraded `ready` behavior [How is useRequest compatible with deleted capabilities?](#how-is-userequest-compatible-with-deleted-capabilities) ## Support SSR ahooks v3 fully supports SSR, and related documents can be found in "[React Hooks & SSR](/guide/blog/ssr)". ## Hooks of DOM support dynamic target Hooks of DOM support dynamic target, and related documents can be found in "[Hooks of DOM specification](/guide/dom)". ## Avoid closure problems Inside ahooks, we have made special treatment for the functions input by the user and the functions returned, to avoid the closure problem as much as possible. **The reference address of all output functions of ahooks will not change.** ```ts const [state, setState] = React.useState(); ``` As we all know, the `setState` reference address returned by `React.useState` will not change. All functions returned in ahooks have the same characteristics as `setState`, and the reference address will not change. ```ts const [state, { toggle }] = useToggle(); ``` For example, the reference address of `toggle` function returned by `useToggle` is always stable. **All input functions of ahooks always use the latest one.** For the received function, ahooks will do a special process to ensure that the function called each time is always the latest. ```ts const [state, setState] = useState(); useInterval(() => { console.log(state); }, 1000); ``` For example, in the above example, the function called by `useInterval` is always the latest. Related documents can be found in "[ahooks function specification](/guide/blog/function)". ## Support strict mode v3 fixed some problems in strict mode. Refer to "[React Hooks & strict mode](/guide/blog/strict)" ## Support react-refresh (HMR) mode v3 fixed some problems in react-refresh (HMR) mode. Refer to "[React Hooks & react-refresh (HMR)](/guide/blog/hmr)" ## More changes ### New Hooks - [useRafState](/hooks/use-raf-state) - [useSetState](/hooks/use-set-state) - [useAsyncEffect](/hooks/use-async-effect) - [useDeepCompareEffect](/hooks/use-deep-compare-effect) - [useIsomorphicLayoutEffect](/hooks/use-isomorphic-layout-effect) - [useLatest](/hooks/use-latest) - [usePagination](/hooks/use-pagination) - [useLongPress](/hooks/use-long-press) - [useInfiniteScroll](/hooks/use-infinite-scroll) ### Breaking Changes - useBoolean - `toggle` no longer accepts parameters - Added `set` - useToggle - `toggle` no longer accepts parameters - Added `set` - useSet - Removed `has` method, use `state.has` instead - useCookieState - `setState(null)` is no longer supported to delete cookies, please use `setState(undefined)` or `setState()` instead - useCountDown - Deleted the return value of `setTargetDate`, you can dynamically change `options.targetDate` to achieve the same effect - useLocalStorageState / useSessionStorageState - The second parameter changed from `defaultValue` to `Options`, use `options.defaultValue` instead - Added `options.serializer` and `options.deserializer` to support custom sequence method - useDynamicList - `sortForm` was renamed to `sortList` - useDrag & useDrop - API is redesigned and needs to be upgraded according to the new document - useExternal - API has undergone major adjustments, please refer to the documentation - No longer supports image type resources - The resource becomes globally unique and will not be loaded repeatedly. At the same time, if there are multiple references, the resource will be deleted only after all references are unloaded - useFullscreen - API has been renamed, please refer to the documentation - useVirtualList - API is redesigned and needs to be upgraded according to the new document - Added a `data` parameter to the function type `options.itemHeight` parameter - useInViewport - API has been upgraded, please refer to the documentation - Added visible ratio ability - useScroll - The return value type is changed from `{ left?: number, top?: number }` to `{ left: number, top: number } | undefined` - useSize - The return value type is changed from `{ width?: number, height?: number }` to `{ width: number, height: number } | undefined` - useKeyPress - All aliases have been modified, please refer to the documentation - useAntdTable - Removed `options.formatResult` - More changes are the same as useRequest - useFusionTable - Removed `options.formatResult` - More changes are the same as useRequest - usePersistFn was renamed to useMemoizedFn - Deprecated the useControlledValue naming left over from 1.0, please use useControllableValue instead ### Optimization - useUrlState - Supported React Router v6 - useControllableValue - Optimized logic to avoid unnecessary re-render - More other optimizations ## FAQ ### How is useRequest compatible with deleted capabilities? The new version of useRequest only provides the underlying capabilities of Promise management, and more advanced capabilities can be supported by encapsulating advanced Hooks based on useRequest. 1. `options.formatResult` is deleted, and the service is expected to return the data in the final format. for example: ```ts const { data } = useRequest(async () => { const result = await getData(); return result.data; }); ``` 2. The original concurrent mode of `options.fetchKey` is deleted. It is expected that each request action and UI will be encapsulated as a component instead of placing all requests in the parent. 3. `options.initialData` is deleted, you can do this ```ts const { data = initialData } = useRequest(getData); ``` 4. The request library is no longer integrated by default, and `service` no longer supports string or object. It is expected to be supported by encapsulating advanced Hooks based on useReqeust. for example: ```ts const useCustomHooks = (pathname, options) => { return useRequest(() => { return axios(pathname); }, options); }; ``` ================================================ FILE: docs/guide/upgrade.zh-CN.md ================================================ ## v2 to v3 相较于 ahooks v2 版本,ahooks v3 版本的变更主要包括: - 全新的 `useRequest` - 全面支持 SSR - 对输入输出函数做特殊处理,避免闭包问题 - DOM 类 Hooks 支持 target 动态变化 - 解决了在严格模式(Strict Mode)下的问题 - 解决了在 react-refresh(HMR)模式下的问题 - 修复了已知问题 - 新增了更多的 Hooks ## 升级建议 我们发布了 `ahooks-v2` 包,你可以同时安装 v2 和 v3 依赖,以过渡升级。 ```bash npm install ahooks-v2 --save npm install ahooks --save ``` ## 全新的 useRequest useRequest 完全进行了重写: - 通过插件式组织代码,核心代码极其简单,可以很方便的扩展出更高级的能力。 - 提供了循序渐进的文档。 - 彻底修复了异常处理方式,提供了 `run` 和 `runAsync` 两种触发函数。 - `options` 参数支持动态变化。 - 删除了 `pagination`、`loadMore`、`formatResult` 属性,避免了 `useRequest` TypeScript 重载,可以更方便的基于 `useRequest` 封装更高级的 Hooks。 ### 详细变更 - 删除了 `UseRequestProvider`,建议自行基于 `useRequest` 封装高级 Hooks 来代替。 - 删除了 `pagination` 相关属性,建议使用 `usePagination` 或 `useAntdTable` 来实现分页能力。 - 删除了 `loadMore` 相关属性,建议使用 `useInfiniteScroll` 来实现无限加载能力。 - 删除了 `fetchKey`,也就是删除了并行能力。 - 删除了 `formatResult`、`initialData`、`throwOnError`。 - 不再默认集成请求库,`service` 不再支持字符或对象。 - 新增了 `runAsync` 和 `refreshAsync`,原来的 `run` 不再返回 Promise。 - 新增了错误重试能力。 - 新增了 `onBefore`、`onFinally` 生命周期。 - 新增了缓存清理能力。 - 所有参数支持动态变化。 - 防抖/节流模式下,`runAsync` 可以返回正常 Promise。 - 防抖/节流支持更多参数。 - 只有成功的请求数据才会缓存。 - `ready` 行为升级 [被删除的参数如何兼容?](#userequest-被删除的能力如何兼容) ## SSR 支持 ahooks v3 全面支持 SSR,相关文档可见《[React Hooks & SSR](/zh-CN/guide/blog/ssr)》。 ## DOM 类 Hooks 支持 target 动态变化 DOM 类 Hooks 支持 target 动态变化,相关文档可见《[DOM 类 Hooks 使用规范](/zh-CN/guide/dom)》 ## 避免闭包问题 ahooks v3 通过对输入输出函数做特殊处理,尽力帮助大家避免闭包问题。 **所有的输出函数,地址是不会变化的。** ```ts const [state, setState] = React.useState(); ``` 大家熟知的`React.useState`返回的 `setState` 函数,地址是不会变化的。 v3 所有 Hooks 返回的函数,也有和 `setState` 一样的特性,地址不会变化。 ```ts const [state, { toggle }] = useToggle(); ``` 比如 `useToggle` 返回的 `toggle` 函数,地址就是永远固定的。 **所有的输入函数,永远使用最新的一份。** 对于接收的函数,v3 会做一次特殊处理,保证每次调用的函数永远是最新的。 ```ts const [state, setState] = useState(); useInterval(() => { console.log(state); }, 1000); ``` 比如以上示例,`useInterval` 调用的函数永远是最新的。 相关文档可见《[ahooks 输入输出函数处理规范](/zh-CN/guide/blog/function)》。 ## 支持严格模式 v3 修复了在严格模式下的一些问题。参考《[React Hooks & strict mode](/zh-CN/guide/blog/strict)》 ## 支持 react-refresh(HMR)模式 v3 修复了在 react-refresh(HMR)模式下的一些问题。参考《[React Hooks & react-refresh(HMR)](/zh-CN/guide/blog/hmr)》 ## 更多变更 ### 新增 Hooks - [useRafState](/zh-CN/hooks/use-raf-state) - [useSetState](/zh-CN/hooks/use-set-state) - [useAsyncEffect](/zh-CN/hooks/use-async-effect) - [useDeepCompareEffect](/zh-CN/hooks/use-deep-compare-effect) - [useIsomorphicLayoutEffect](/zh-CN/hooks/use-isomorphic-layout-effect) - [useLatest](/zh-CN/hooks/use-latest) - [usePagination](/zh-CN/hooks/use-pagination) - [useLongPress](/zh-CN/hooks/use-long-press) - [useInfiniteScroll](/zh-CN/hooks/use-infinite-scroll) ### Breaking Changes - useBoolean - `toggle` 不再接收参数 - 增加了 `set` - useToggle - `toggle` 不再接收参数 - 增加了 `set` - useSet - 删除了 `has` 方法,使用 `state.has` 代替 - useCookieState - 不再支持 `setState(null)` 删除 Cookie,请使用 `setState(undefined)` 或 `setState()` 替代 - useCountDown - 删除了 `setTargetDate` 返回值,可以动态改变 `options.targetDate` 实现相同效果 - useLocalStorageState / useSessionStorageState - 第二个参数从 `defaultValue` 变为了 `Options`,使用 `options.defaultValue` 代替 - 增加了 `options.serializer` 和 `options.deserializer`,支持自定义序列法方法 - useDynamicList - `sortForm` 改名为 `sortList` - useDrag & useDrop - API 重新设计,需要对照新的文档做升级 - useExternal - API 进行了比较大的调整,请查阅文档 - 不再支持图片类型资源 - 资源在全局变成唯一的,不会重复加载,同时如果有多处引用,只有等全部引用卸载之后,才会删除该资源 - useFullscreen - API 进行了重命名,请查阅文档 - useVirtualList - API 重新设计,需要对照新的文档做升级 - `options.itemHeight` 函数型参数增加了 `data` 参数 - useInViewport - API 进行了升级,请查阅文档 - 增加了可见比例能力 - useScroll - 返回值类型从 `{ left?: number, top?: number }` 改为 `{ left: number, top: number } | undefined` - useSize - 返回值类型从 `{ width?: number, height?: number }` 改为 `{ width: number, height: number } | undefined` - useKeyPress - 修改了所有别名,请查阅文档 - useAntdTable - 删除了 `options.formatResult` - 更多变更同 useRequest - useFusionTable - 删除了 `options.formatResult` - 更多变更同 useRequest - usePersistFn 更名为 useMemoizedFn - 废弃了 1.0 遗留的 useControlledValue 命名,请使用 useControllableValue 代替 ### 优化 - useUrlState - 支持了 React Router v6 - useControllableValue - 优化了代码逻辑,避免了不必要的 re-render - 更多其它优化 ## FAQ ### useRequest 被删除的能力如何兼容? 新版 useRequest 只做 Promise 管理的底层能力,更多高级能力可以基于 useRequest 封装高级 Hooks 来支持。 1. 原 `options.formatResult` 删除,期望 service 返回最终格式的数据。比如: ```ts const { data } = useRequest(async () => { const result = await getData(); return result.data; }); ``` 2. 原 `options.fetchKey` 并行模式删除,期望将每个请求动作和 UI 封装为一个组件,而不是把所有请求都放到父级。 3. 原 `options.initialData` 删除,可以这样做 ```ts const { data = initialData } = useRequest(getData); ``` 4. 不再默认集成请求库,`service` 不再支持字符或对象。期望基于 useReqeust 封装高级 Hooks 来支持。比如: ```ts const useCustomHooks = (pathname, options) => { return useRequest(() => { return axios(pathname); }, options); }; ``` ================================================ FILE: docs/index.en-US.md ================================================ --- title: ahooks - React Hooks Library hero: image: /logo.svg desc: A high-quality & reliable React Hooks library actions: - text: Guide link: /guide - text: Hooks List link: /hooks footer: Open-source MIT Licensed | Copyright © 2019-present
Powered by [dumi](https://d.umijs.org) --- [![NPM version][image-1]][1] [![NPM downloads][image-2]][2] [![npm](https://img.shields.io/npm/dw/ahooks-v2?label=downloads%28v2%29)](https://www.npmjs.com/package/ahooks-v2) [![npm](https://img.shields.io/github/issues/alibaba/hooks)](https://github.com/alibaba/hooks/issues) [![Coverage Status](https://coveralls.io/repos/github/alibaba/hooks/badge.svg?branch=master)](https://coveralls.io/github/alibaba/hooks?branch=master) ![gzip size](https://img.badgesize.io/https:/unpkg.com/ahooks/dist/ahooks.js?label=gzip%20size&compression=gzip) [![Percentage of issues still open](http://isitmaintained.com/badge/open/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open") [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue") ![GitHub](https://img.shields.io/github/license/alibaba/hooks) ## ✨ Features - Easy to learn and use - Supports SSR - Special treatment for functions, avoid closure problems - Contains a large number of advanced Hooks that are refined from business scenarios - Contains a comprehensive collection of basic Hooks - Written in TypeScript with predictable static types ## 📦 Install ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## 🔨 Usage ```ts import { useRequest } from "ahooks"; ``` ## 💻 Online Demo [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ## 🤝 Contributing ```bash $ git clone git@github.com:alibaba/hooks.git $ cd hooks $ pnpm run init $ pnpm start ``` Open your browser and visit http://127.0.0.1:8000 We welcome all contributions, please read our [CONTRIBUTING.MD](https://github.com/alibaba/hooks/blob/master/CONTRIBUTING.MD) first, let's build a better hooks library together. Thanks to all the contributors: contributors ## 👥 Discuss ahooks discussion group 1 ahooks discussion group 2 ahooks discussion group 3 [1]: https://www.npmjs.com/package/ahooks [2]: https://npmjs.org/package/ahooks [image-1]: https://img.shields.io/npm/v/ahooks.svg?style=flat [image-2]: https://img.shields.io/npm/dm/ahooks.svg?style=flat ================================================ FILE: docs/index.zh-CN.md ================================================ --- title: ahooks - React Hooks Library hero: image: /logo.svg desc: 一套高质量可靠的 React Hooks 库 actions: - text: 指南 link: /zh-CN/guide - text: Hooks 列表 link: /zh-CN/hooks footer: Open-source MIT Licensed | Copyright © 2019-present
Powered by [dumi](https://d.umijs.org) --- [![NPM version][image-1]][1] [![NPM downloads][image-2]][2] [![npm](https://img.shields.io/npm/dw/ahooks-v2?label=downloads%28v2%29)](https://www.npmjs.com/package/ahooks-v2) [![npm](https://img.shields.io/github/issues/alibaba/hooks)](https://github.com/alibaba/hooks/issues) [![Coverage Status](https://coveralls.io/repos/github/alibaba/hooks/badge.svg?branch=master)](https://coveralls.io/github/alibaba/hooks?branch=master) ![gzip size](https://img.badgesize.io/https:/unpkg.com/ahooks/dist/ahooks.js?label=gzip%20size&compression=gzip) [![Percentage of issues still open](http://isitmaintained.com/badge/open/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open") [![Average time to resolve an issue](http://isitmaintained.com/badge/resolution/alibaba/hooks.svg)](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue") ![GitHub](https://img.shields.io/github/license/alibaba/hooks) ## ✨ 特性 - 易学易用 - 支持 SSR - 对输入输出函数做了特殊处理,避免闭包问题 - 包含大量提炼自业务的高级 Hooks - 包含丰富的基础 Hooks - 使用 TypeScript 构建,提供完整的类型定义文件 ## 📦 安装 ```bash $ npm install --save ahooks # or $ yarn add ahooks # or $ pnpm add ahooks # or $ bun add ahooks ``` ## 🔨 使用 ```ts import { useRequest } from "ahooks"; ``` ## 💻 在线体验 [![Edit demo for ahooks](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/demo-for-ahooks-forked-fg79k?file=/src/App.js) ## 🤝 参与共建 ```bash $ git clone git@github.com:alibaba/hooks.git $ cd hooks $ pnpm run init $ pnpm start ``` 打开浏览器访问 http://127.0.0.1:8000 我们欢迎所有人参与共建,请参考[CONTRIBUTING.MD](https://github.com/alibaba/hooks/blob/master/CONTRIBUTING.zh-CN.MD) 感谢所有贡献者: contributors ## 👥 交流讨论 ahooks 交流群1 ahooks 交流群2 ahooks 交流群3 [1]: https://www.npmjs.com/package/ahooks [2]: https://npmjs.org/package/ahooks [image-1]: https://img.shields.io/npm/v/ahooks.svg?style=flat [image-2]: https://img.shields.io/npm/dm/ahooks.svg?style=flat ================================================ FILE: example/.gitkeep ================================================ import React from 'react'; import { useBoolean } from 'ahooks'; export default function Demo() { const [state, { toggle, setTrue, setFalse }] = useBoolean(false); return (

Current state: {state ? 'ON' : 'OFF'}

); } ================================================ FILE: gulpfile.js ================================================ const gulp = require('gulp'); const babel = require('gulp-babel'); const ts = require('gulp-typescript'); const del = require('del'); gulp.task('clean', async () => { await del('lib/**'); await del('es/**'); await del('dist/**'); }); gulp.task('cjs', () => gulp .src(['./es/**/*.js']) .pipe( babel({ configFile: '../../.babelrc', }), ) .pipe(gulp.dest('lib/')), ); gulp.task('es', async () => { const { execSync } = require('child_process'); // 使用 tsc 直接编译 console.log('Running TypeScript compilation...'); execSync('npx tsc --project tsconfig.pro.json --outDir es --module esnext', { stdio: 'inherit' }); console.log('TypeScript compilation completed'); // 然后运行 babel 转换 console.log('Running Babel transformation...'); return gulp .src(['es/**/*.js']) .pipe( babel({ configFile: './.babelrc', }), ) .pipe(gulp.dest('es/')); }); gulp.task('declaration', () => { const tsProject = ts.createProject('tsconfig.pro.json', { declaration: true, emitDeclarationOnly: true, }); return tsProject.src().pipe(tsProject()).pipe(gulp.dest('es/')).pipe(gulp.dest('lib/')); }); gulp.task('copyReadme', async () => { await gulp.src('../../README.md').pipe(gulp.dest('../../packages/hooks')); }); exports.default = gulp.series('clean', 'es', 'cjs', 'declaration', 'copyReadme'); ================================================ FILE: package.json ================================================ { "name": "ahooks", "private": true, "packageManager": "pnpm@10.12.4", "engines": { "pnpm": ">=7 <=10" }, "repository": { "type": "git", "url": "git+https://github.com/alibaba/hooks.git" }, "scripts": { "init": "pnpm install && pnpm run build", "start": "pnpm run dev", "dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider dumi dev", "clean-dist": "rimraf 'packages/*/{lib,es,node_modules,dist}'", "clean": "pnpm run clean-dist && rimraf node_modules", "build": "pnpm -r --filter=./packages/* run build", "test": "pnpm --filter=./packages/* test", "test:strict": "cross-env REACT_MODE=strict pnpm --filter=./packages/* test:cov", "coveralls": "vitest run --coverage | coveralls", "lint": "biome lint --fix", "pretty": "biome format --fix --no-errors-on-unmatched", "build:doc": "cross-env NODE_OPTIONS=--openssl-legacy-provider dumi build", "build:doc-github": "node scripts/build-with-relative-paths.js", "pub:doc-surge": "surge ./dist --domain ahooks.js.org", "pub:doc-gitee": "cd ./dist && rm -rf .git && touch .spa && touch .nojekyll && git init && git remote add origin git@gitee.com:ahooks/ahooks.git && git add -A && git commit -m \"publish docs\" && git push origin main -f && echo https://gitee.com/ahooks/ahooks/pages", "pub:doc": "pnpm run build:doc && pnpm run pub:doc-surge && pnpm run build:doc-github", "pub": "pnpm run build && pnpm -r --filter=./packages/* publish", "pub:beta": "pnpm run build && pnpm -r --filter=./packages/* publish --tag beta", "preinstall": "npx only-allow pnpm", "prepare": "husky install", "commit": "git add -A && czg", "tsc": "pnpm --filter=./packages/* tsc" }, "devDependencies": { "@alifd/next": "^1.27.32", "@ant-design/icons": "^5.6.1", "@babel/cli": "^7.10.1", "@babel/core": "^7.10.2", "@babel/plugin-transform-runtime": "^7.19.6", "@biomejs/biome": "^2.0.6", "@commitlint/cli": "^17.1.2", "@commitlint/config-conventional": "^17.1.0", "@testing-library/react": "^16.3.0", "@types/lodash": "^4.17.20", "@types/mockjs": "^1.0.7", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@types/react-router": "^5.1.19", "@umijs/fabric": "^2.1.0", "@vitest/coverage-istanbul": "^3.2.4", "antd": "^5.26.3", "babel-plugin-import": "^1.12.0", "coveralls": "^3.1.1", "cross-env": "^7.0.3", "czg": "^1.12.0", "del": "^5.1.0", "dumi": "^1.1.54", "fast-glob": "^3.2.11", "fs-extra": "^10.0.1", "gray-matter": "^4.0.3", "gulp": "^4.0.2", "gulp-babel": "^8.0.0", "gulp-typescript": "^6.0.0-alpha.1", "husky": "^8.0.0", "jsdom": "^26.1.0", "mockjs": "^1.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-drag-listview": "^0.1.6", "react-json-view": "^1.21.3", "react-router": "^6.4.2", "react-shadow": "^20.6.0", "rimraf": "^3.0.2", "surge": "^0.21.3", "typescript": "^5.8.3", "vitest": "^3.2.4", "vitest-websocket-mock": "^0.5.0", "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ] } } ================================================ FILE: packages/hooks/gulpfile.js ================================================ const commonConfig = require('../../gulpfile'); const gulp = require('gulp'); const fs = require('fs'); const fse = require('fs-extra'); const fg = require('fast-glob'); const gm = require('gray-matter'); function camelToKebab(str) { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } async function genDesc(mdPath) { if (!fs.existsSync(mdPath)) { return; } const mdFile = fs.readFileSync(mdPath, 'utf8'); const { content } = gm(mdFile); let description = (content.replace(/\r\n/g, '\n').match(/# \w+[\s\n]+(.+?)(?:, |\. |\n|\.\n)/m) || [])[1] || ''; description = description.trim(); description = description.charAt(0).toLowerCase() + description.slice(1); return description; } async function genMetaData() { const metadata = { functions: [], }; const hooks = fg .sync('src/use*', { onlyDirectories: true, }) .map((hook) => hook.replace('src/', '')) .sort(); await Promise.allSettled( hooks.map(async (hook) => { const description = await genDesc(`src/${hook}/index.en-US.md`); return { name: hook, docs: `https://ahooks.js.org/hooks/${camelToKebab(hook)}`, description, }; }), ).then((res) => { metadata.functions = res.map((item) => { if (item.status === 'fulfilled') { return item.value; } return null; }); }); return metadata; } gulp.task('metadata', async function () { const metadata = await genMetaData(); await fse.writeJson('metadata.json', metadata, { spaces: 2 }); }); exports.default = gulp.series(commonConfig.default, 'metadata'); ================================================ FILE: packages/hooks/package.json ================================================ { "name": "ahooks", "version": "3.9.6", "description": "react hooks library", "keywords": [ "ahooks", "umi hooks", "react hooks" ], "main": "./lib/index.js", "module": "./es/index.js", "types": "./lib/index.d.ts", "unpkg": "dist/ahooks.js", "sideEffects": false, "authors": { "name": "brickspert", "email": "brickspert.fjl@alipay.com" }, "publishConfig": { "registry": "https://registry.npmjs.org/" }, "repository": "https://github.com/alibaba/hooks", "homepage": "https://github.com/alibaba/hooks", "scripts": { "build": "gulp && webpack-cli", "test": "vitest run --color", "test:cov": "vitest run --color --coverage", "tsc": "tsc --noEmit" }, "files": [ "dist", "lib", "es", "metadata.json", "package.json", "README.md" ], "dependencies": { "@types/js-cookie": "^3.0.6", "@babel/runtime": "^7.21.0", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "license": "MIT", "gitHead": "11f6ad571bd365c95ecb9409ca3050cbbfc9b34a" } ================================================ FILE: packages/hooks/src/createDeepCompareEffect/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useEffect, useLayoutEffect, useState } from 'react'; import { describe, expect, test } from 'vitest'; import { createDeepCompareEffect } from '../index'; describe('createDeepCompareEffect', () => { test('should work for useEffect', async () => { const useDeepCompareEffect = createDeepCompareEffect(useEffect); const hook = renderHook(() => { const [x, setX] = useState(0); const [y, setY] = useState({ foo: 'foo', bar: ['baz'] }); useDeepCompareEffect(() => { setX((prevX) => prevX + 1); }, [y]); return { x, setY }; }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({ foo: 'foo', bar: ['baz'] }); }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({ foo: 'foo', bar: ['bazz'] }); }); expect(hook.result.current.x).toBe(2); }); test('should work for useLayoutEffect', async () => { const useDeepCompareLayoutEffect = createDeepCompareEffect(useLayoutEffect); const hook = renderHook(() => { const [x, setX] = useState(0); const [y, setY] = useState({ foo: 'foo', bar: ['baz'] }); useDeepCompareLayoutEffect(() => { setX((prevX) => prevX + 1); }, [y]); return { x, setY }; }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({ foo: 'foo', bar: ['baz'] }); }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({ foo: 'foo', bar: ['bazz'] }); }); expect(hook.result.current.x).toBe(2); }); test('deps is undefined should rerender in useEffect', async () => { const useDeepCompareLayoutEffect = createDeepCompareEffect(useEffect); let count = 0; const hook = renderHook(() => { useDeepCompareLayoutEffect(() => { count++; }); }); expect(count).toBe(1); hook.rerender(); expect(count).toBe(2); hook.rerender(); expect(count).toBe(3); }); test('deps is undefined should rerender in useLayoutEffect', async () => { const useDeepCompareLayoutEffect = createDeepCompareEffect(useLayoutEffect); let count = 0; const hook = renderHook(() => { useDeepCompareLayoutEffect(() => { count++; }); }); expect(count).toBe(1); hook.rerender(); expect(count).toBe(2); hook.rerender(); expect(count).toBe(3); }); }); ================================================ FILE: packages/hooks/src/createDeepCompareEffect/index.ts ================================================ import { useRef } from 'react'; import type { DependencyList, useEffect, useLayoutEffect } from 'react'; import { depsEqual } from '../utils/depsEqual'; type EffectHookType = typeof useEffect | typeof useLayoutEffect; type CreateUpdateEffect = (hook: EffectHookType) => EffectHookType; export const createDeepCompareEffect: CreateUpdateEffect = (hook) => (effect, deps) => { const ref = useRef(undefined); const signalRef = useRef(0); if (deps === undefined || !depsEqual(deps, ref.current)) { signalRef.current += 1; } ref.current = deps; hook(effect, [signalRef.current]); }; ================================================ FILE: packages/hooks/src/createUpdateEffect/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { useEffect, useLayoutEffect } from 'react'; import { describe, expect, test } from 'vitest'; import { createUpdateEffect } from '../index'; describe('createUpdateEffect', () => { test('should work for useEffect', () => { const useUpdateEffect = createUpdateEffect(useEffect); let mountedState = 1; const hook = renderHook(() => useUpdateEffect(() => { mountedState = 2; }), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(2); }); test('should work for useLayoutEffect', () => { const useUpdateLayoutEffect = createUpdateEffect(useLayoutEffect); let mountedState = 1; const hook = renderHook(() => useUpdateLayoutEffect(() => { mountedState = 2; }), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(2); }); }); ================================================ FILE: packages/hooks/src/createUpdateEffect/index.ts ================================================ import { useRef } from 'react'; import type { useEffect, useLayoutEffect } from 'react'; type EffectHookType = typeof useEffect | typeof useLayoutEffect; export const createUpdateEffect: (hook: EffectHookType) => EffectHookType = (hook) => (effect, deps) => { const isMounted = useRef(false); // for react-refresh hook(() => { return () => { isMounted.current = false; }; }, []); hook(() => { if (!isMounted.current) { isMounted.current = true; } else { return effect(); } }, deps); }; export default createUpdateEffect; ================================================ FILE: packages/hooks/src/createUseStorageState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import type { Options } from '../index'; import { createUseStorageState } from '../index'; class TestStorage implements Storage { [name: string]: any; length = 0; _values = new Map(); clear(): void { this._values.clear(); this.length = 0; } getItem(key: string): string | null { return this._values.get(key) || null; } key(index: number): string | null { if (index >= this._values.size) { return null; } return Array.from(this._values.keys())[index]; } removeItem(key: string): void { if (this._values.delete(key)) { this.length -= 1; } } setItem(key: string, value: string): void { if (!this._values.has(key)) { this.length += 1; } this._values.set(key, value); } } interface StorageStateProps extends Pick, 'defaultValue'> { key: string; } describe('useStorageState', () => { const setUp = (props: StorageStateProps) => { const storage = new TestStorage(); const useStorageState = createUseStorageState(() => storage); return renderHook( ({ key, defaultValue }: StorageStateProps) => { const [state, setState] = useStorageState(key, { defaultValue }); return { state, setState }; }, { initialProps: props, }, ); }; test('should get defaultValue for a given key', () => { const hook = setUp({ key: 'key1', defaultValue: 'value1' }); expect(hook.result.current.state).toBe('value1'); hook.rerender({ key: 'key2', defaultValue: 'value2' }); expect(hook.result.current.state).toBe('value2'); }); test('should get default and set value for a given key', () => { const hook = setUp({ key: 'key', defaultValue: 'defaultValue' }); expect(hook.result.current.state).toBe('defaultValue'); act(() => { hook.result.current.setState('setValue'); }); expect(hook.result.current.state).toBe('setValue'); hook.rerender({ key: 'key' }); expect(hook.result.current.state).toBe('setValue'); }); test('should remove value for a given key', () => { const hook = setUp({ key: 'key' }); act(() => { hook.result.current.setState('value'); }); expect(hook.result.current.state).toBe('value'); act(() => { hook.result.current.setState(undefined); }); expect(hook.result.current.state).toBeUndefined(); act(() => hook.result.current.setState('value')); expect(hook.result.current.state).toBe('value'); act(() => hook.result.current.setState(undefined)); expect(hook.result.current.state).toBeUndefined(); }); }); ================================================ FILE: packages/hooks/src/createUseStorageState/index.ts ================================================ import { useRef, useState } from 'react'; import useEventListener from '../useEventListener'; import useMemoizedFn from '../useMemoizedFn'; import useUpdateEffect from '../useUpdateEffect'; import { isFunction, isUndef } from '../utils'; export const SYNC_STORAGE_EVENT_NAME = 'AHOOKS_SYNC_STORAGE_EVENT_NAME'; export type SetState = S | ((prevState?: S) => S); export interface Options { defaultValue?: T | (() => T); listenStorageChange?: boolean; serializer?: (value: T) => string; deserializer?: (value: string) => T; onError?: (error: unknown) => void; } export const createUseStorageState = (getStorage: () => Storage | undefined) => { const useStorageState = (key: string, options: Options = {}) => { let storage: Storage | undefined; const { listenStorageChange = false } = options; const serializer = isFunction(options.serializer) ? options.serializer : JSON.stringify; const deserializer = isFunction(options.deserializer) ? options.deserializer : JSON.parse; const onError = isFunction(options.onError) ? options.onError : console.error; // https://github.com/alibaba/hooks/issues/800 try { storage = getStorage(); } catch (err) { onError(err); } const getStoredValue = () => { try { const raw = storage?.getItem(key); if (raw) { return deserializer(raw); } } catch (e) { onError(e); } if (isFunction(options.defaultValue)) { return options.defaultValue(); } return options.defaultValue; }; const [state, setState] = useState(getStoredValue); const stateRef = useRef(state); stateRef.current = state; useUpdateEffect(() => { const nextState = getStoredValue(); if (Object.is(nextState, stateRef.current)) { return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render } stateRef.current = nextState; setState(nextState); }, [key]); const updateState = (value: SetState) => { const previousState = stateRef.current; const currentState = isFunction(value) ? value(previousState) : value; if (Object.is(currentState, previousState)) { return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render } if (!listenStorageChange) { stateRef.current = currentState; setState(currentState); } try { let newValue: string | null; const oldValue = storage?.getItem(key); if (isUndef(currentState)) { newValue = null; storage?.removeItem(key); } else { newValue = serializer(currentState); storage?.setItem(key, newValue); } dispatchEvent( // send custom event to communicate within same page // importantly this should not be a StorageEvent since those cannot // be constructed with a non-built-in storage area new CustomEvent(SYNC_STORAGE_EVENT_NAME, { detail: { key, newValue, oldValue, storageArea: storage, }, }), ); } catch (e) { onError(e); } }; const syncState = (event: StorageEvent) => { if (event.key !== key || event.storageArea !== storage) { return; } const nextState = getStoredValue(); if (Object.is(nextState, stateRef.current)) { return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render } stateRef.current = nextState; setState(nextState); }; const syncStateFromCustomEvent = (event: CustomEvent) => { syncState(event.detail); }; // from another document useEventListener('storage', syncState, { enable: listenStorageChange, }); // from the same document but different hooks useEventListener(SYNC_STORAGE_EVENT_NAME, syncStateFromCustomEvent, { enable: listenStorageChange, }); return [state, useMemoizedFn(updateState)] as const; }; return useStorageState; }; ================================================ FILE: packages/hooks/src/global.d.ts ================================================ declare module '*.jpg'; interface Window { TEST_SCRIPT?: any; } ================================================ FILE: packages/hooks/src/index.spec.ts ================================================ import { describe, expect, test } from 'vitest'; import * as ahooks from '.'; describe('ahooks', () => { test('exports modules should be defined', () => { Object.entries(ahooks).forEach(([key, value]) => { expect(value).toBeDefined(); }); }); }); ================================================ FILE: packages/hooks/src/index.ts ================================================ import { createUpdateEffect } from './createUpdateEffect'; import useAntdTable from './useAntdTable'; import useAsyncEffect from './useAsyncEffect'; import useBoolean from './useBoolean'; import useClickAway from './useClickAway'; import useControllableValue from './useControllableValue'; import useCookieState from './useCookieState'; import useCountDown from './useCountDown'; import useCounter from './useCounter'; import useCreation from './useCreation'; import useDebounce from './useDebounce'; import useDebounceEffect from './useDebounceEffect'; import useDebounceFn from './useDebounceFn'; import useDeepCompareEffect from './useDeepCompareEffect'; import useDeepCompareLayoutEffect from './useDeepCompareLayoutEffect'; import useDocumentVisibility from './useDocumentVisibility'; import useDrag from './useDrag'; import useDrop from './useDrop'; import useDynamicList from './useDynamicList'; import useEventEmitter from './useEventEmitter'; import useEventListener from './useEventListener'; import useEventTarget from './useEventTarget'; import useExternal from './useExternal'; import useFavicon from './useFavicon'; import useFocusWithin from './useFocusWithin'; import useFullscreen from './useFullscreen'; import useFusionTable from './useFusionTable'; import useGetState from './useGetState'; import useHistoryTravel from './useHistoryTravel'; import useHover from './useHover'; import useInfiniteScroll from './useInfiniteScroll'; import useInterval from './useInterval'; import useInViewport from './useInViewport'; import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; import useKeyPress from './useKeyPress'; import useLatest from './useLatest'; import useLocalStorageState from './useLocalStorageState'; import useLockFn from './useLockFn'; import useLongPress from './useLongPress'; import useMap from './useMap'; import useMemoizedFn from './useMemoizedFn'; import useMount from './useMount'; import useMouse from './useMouse'; import useNetwork from './useNetwork'; import usePagination from './usePagination'; import usePrevious from './usePrevious'; import useRafInterval from './useRafInterval'; import useRafState from './useRafState'; import useRafTimeout from './useRafTimeout'; import useReactive from './useReactive'; import useRequest, { clearCache } from './useRequest'; import useResetState from './useResetState'; import useResponsive, { configResponsive } from './useResponsive'; import useSafeState from './useSafeState'; import useScroll from './useScroll'; import useSelections from './useSelections'; import useSessionStorageState from './useSessionStorageState'; import useSet from './useSet'; import useSetState from './useSetState'; import useSize from './useSize'; import useTextSelection from './useTextSelection'; import useThrottle from './useThrottle'; import useThrottleEffect from './useThrottleEffect'; import useThrottleFn from './useThrottleFn'; import useTimeout from './useTimeout'; import useTitle from './useTitle'; import useToggle from './useToggle'; import useTrackedEffect from './useTrackedEffect'; import useUnmount from './useUnmount'; import useUnmountedRef from './useUnmountedRef'; import useUpdate from './useUpdate'; import useUpdateEffect from './useUpdateEffect'; import useUpdateLayoutEffect from './useUpdateLayoutEffect'; import useVirtualList from './useVirtualList'; import useWebSocket from './useWebSocket'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import useMutationObserver from './useMutationObserver'; import useTheme from './useTheme'; export { useRequest, useControllableValue, useDynamicList, useVirtualList, useResponsive, useEventEmitter, useLocalStorageState, useSessionStorageState, useSize, configResponsive, useUpdateEffect, useUpdateLayoutEffect, useBoolean, useToggle, useDocumentVisibility, useSelections, useThrottle, useThrottleFn, useThrottleEffect, useDebounce, useDebounceFn, useDebounceEffect, usePrevious, useMouse, useScroll, useClickAway, useFullscreen, useInViewport, useKeyPress, useEventListener, useHover, useUnmount, useSet, useMemoizedFn, useMap, useCreation, useDrag, useDrop, useMount, useCounter, useUpdate, useTextSelection, useEventTarget, useHistoryTravel, useCookieState, useSetState, useInterval, useWhyDidYouUpdate, useTitle, useNetwork, useTimeout, useReactive, useFavicon, useCountDown, useWebSocket, useLockFn, useUnmountedRef, useExternal, useSafeState, useLatest, useIsomorphicLayoutEffect, useDeepCompareEffect, useDeepCompareLayoutEffect, useAsyncEffect, useLongPress, useRafState, useTrackedEffect, usePagination, useAntdTable, useFusionTable, useInfiniteScroll, useGetState, clearCache, useFocusWithin, createUpdateEffect, useRafInterval, useRafTimeout, useResetState, useMutationObserver, useTheme, }; ================================================ FILE: packages/hooks/src/useAntdTable/__tests__/index.spec.ts ================================================ import type { RenderHookResult } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { Form } from 'antd'; import { useEffect } from 'react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useAntdTable from '../index'; interface Query { current: number; pageSize: number; [key: string]: any; } describe('useAntdTable', () => { let queryArgs: any; const asyncFn = (query: Query, formData: any = {}) => { queryArgs = { ...query, ...formData }; return Promise.resolve({ total: 20, list: [], }); }; let searchType = 'simple'; const form = { getInternalHooks: () => {}, initialValue: { name: 'default name', }, fieldsValue: { name: 'default name', }, getFieldsValue() { if (searchType === 'simple') { return { name: this.fieldsValue.name, }; } return this.fieldsValue; }, setFieldsValue(values: object) { this.fieldsValue = { ...this.fieldsValue, ...values, }; }, resetFields() { this.fieldsValue = { ...this.initialValue }; }, validateFields(fields: any[]) { const targetFields: Record = {}; fields.forEach((field: string | number) => { targetFields[field] = (this.fieldsValue as any)[field]; }); return Promise.resolve(targetFields); }, }; const changeSearchType = (type: any) => { searchType = type; }; const setUp = ( service: Parameters[0], options: Parameters[1], ) => renderHook((o) => useAntdTable(service, o || options)); let hook: RenderHookResult; test('should fetch after first render', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('simple'); act(() => { hook = setUp(asyncFn, {}); }); expect(hook.result.current.tableProps.loading).toBe(false); expect(hook.result.current.tableProps.pagination.current).toBe(1); expect(hook.result.current.tableProps.pagination.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.tableProps.pagination.total).toBe(20)); }); test('should defaultParams work', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('advance'); act(() => { hook = setUp(asyncFn, { form, defaultParams: [ { current: 2, pageSize: 10, }, { name: 'hello', phone: '123' }, ], defaultType: 'advance', }); }); const { search } = hook.result.current; expect(hook.result.current.tableProps.loading).toBe(false); await waitFor(() => expect(queryArgs.current).toBe(2)); expect(queryArgs.pageSize).toBe(10); expect(queryArgs.name).toBe('hello'); expect(queryArgs.phone).toBe('123'); expect(search.type).toBe('advance'); }); test('should stop the query when validate fields failed', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('advance'); act(() => { hook = setUp(asyncFn, { form: { ...form, validateFields: () => Promise.reject() }, defaultParams: [ { current: 2, pageSize: 10, }, { name: 'hello', phone: '123' }, ], defaultType: 'advance', }); }); await sleep(1); expect(queryArgs).toBeUndefined(); }); test('should ready work', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('advance'); act(() => { hook = setUp(asyncFn, { ready: false, form, defaultParams: [ { current: 2, pageSize: 10, }, { name: 'hello', phone: '123' }, ], defaultType: 'advance', }); }); await sleep(1); expect(queryArgs).toBeUndefined(); hook.rerender({ ready: true, form, defaultParams: [ { current: 2, pageSize: 10, }, { name: 'hello', phone: '456' }, ], defaultType: 'advance', }); const { search } = hook.result.current; expect(hook.result.current.tableProps.loading).toBe(false); await waitFor(() => expect(queryArgs.current).toBe(2)); expect(queryArgs.pageSize).toBe(10); expect(queryArgs.name).toBe('hello'); expect(queryArgs.phone).toBe('456'); expect(search.type).toBe('advance'); }); test('should antd v3 work', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('simple'); const v3Form = { ...form, getInternalHooks: undefined, validateFields: function (fields: any[], callback: (arg0: undefined, arg1: {}) => void) { const targetFields: Record = {}; fields.forEach((field: string | number) => { targetFields[field] = (this.fieldsValue as any)[field]; }); callback(undefined, targetFields); }, getFieldInstance(key: string) { // 根据不同的 type 返回不同的 fieldsValues if (searchType === 'simple') { return ['name'].includes(key) as any; } return ['name', 'email', 'phone'].includes(key) as any; }, }; act(() => { hook = setUp(asyncFn, { form: v3Form }); }); const { search } = hook.result.current; expect(hook.result.current.tableProps.loading).toBe(false); await waitFor(() => expect(queryArgs.current).toBe(1)); expect(queryArgs.pageSize).toBe(10); expect(queryArgs.name).toBe('default name'); expect(search.type).toBe('simple'); // /* 切换 分页 */ act(() => { hook.result.current.tableProps.onChange({ current: 2, pageSize: 5, }); }); await waitFor(() => expect(queryArgs.current).toBe(2)); expect(queryArgs.pageSize).toBe(5); expect(queryArgs.name).toBe('default name'); /* 改变 name,提交表单 */ v3Form.fieldsValue.name = 'change name'; act(() => { search.submit(); }); await waitFor(() => expect(queryArgs.current).toBe(1)); expect(queryArgs.current).toBe(1); // expect(queryArgs.pageSize).toBe(5); expect(queryArgs.name).toBe('change name'); }); test('should reset pageSize in defaultParams', async () => { queryArgs = undefined; form.resetFields(); act(() => { hook = setUp(asyncFn, { form, defaultParams: [ { current: 1, pageSize: 10, }, ], }); }); const { search, tableProps } = hook.result.current; expect(tableProps.loading).toBe(false); await waitFor(() => expect(queryArgs.current).toBe(1)); expect(queryArgs.pageSize).toBe(10); // change params act(() => { tableProps.onChange({ current: 2, pageSize: 5, }); }); await waitFor(() => { expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(5); }); // reset params act(() => { search.reset(); }); await waitFor(() => { expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(10); }); }); test('should reset pageSize in defaultPageSize', async () => { queryArgs = undefined; form.resetFields(); act(() => { hook = setUp(asyncFn, { form, defaultParams: { current: 1, pageSize: 10, } as any, defaultPageSize: 20, }); }); const { search, tableProps } = hook.result.current; expect(tableProps.loading).toBe(false); await waitFor(() => expect(queryArgs.current).toBe(1)); expect(queryArgs.pageSize).toBe(20); // change params act(() => { tableProps.onChange({ current: 2, pageSize: 5, }); }); await waitFor(() => { expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(5); }); // reset params act(() => { search.reset(); }); await waitFor(() => { expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(20); }); }); test('search submit use default params', async () => { queryArgs = undefined; form.resetFields(); act(() => { hook = setUp(asyncFn, { form, defaultParams: [ { current: 2, pageSize: 100, }, ], }); }); const { search } = hook.result.current; act(() => { search.submit(); }); await waitFor(() => { expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(100); }); }); test('should defaultParams work with manual is true', async () => { queryArgs = undefined; form.resetFields(); changeSearchType('advance'); act(() => { renderHook((o) => { const [myForm] = Form.useForm(); useAntdTable( asyncFn, o || { form: myForm, defaultParams: [ { current: 2, pageSize: 10, }, { name: 'hello', phone: '123' }, ], defaultType: 'advance', }, ); useEffect(() => { // defaultParams works expect(myForm.getFieldValue('name')).toBe('hello'); expect(queryArgs).toBe(undefined); }, []); }); }); }); }); ================================================ FILE: packages/hooks/src/useAntdTable/demo/cache.tsx ================================================ import { useState } from 'react'; import { Button, Col, Form, Input, Row, Table, Select } from 'antd'; import { useAntdTable, clearCache } from 'ahooks'; import ReactJson from 'react-json-view'; const { Option } = Select; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize, sorter, filters, extra, }: { current: number; pageSize: number; sorter: any; filters: any; extra: any; }, formData: Record, ): Promise => { console.log(sorter, filters, extra); let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; const UserList = () => { const [form] = Form.useForm(); const { tableProps, search, params } = useAntdTable(getTableData, { defaultPageSize: 5, form, cacheKey: 'useAntdTableCache', }); const { sorter = {}, filters = {} } = params[0] || ({} as any); const { type, changeType, submit, reset } = search; const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', sorter: true, sortOrder: sorter.field === 'phone' && sorter.order, }, { title: 'gender', dataIndex: 'gender', filters: [ { text: 'male', value: 'male' }, { text: 'female', value: 'female' }, ], filteredValue: filters.gender, }, ]; const advanceSearchForm = (
); const searchForm = (
); return (
{type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; const Demo = () => { const [show, setShow] = useState(true); return (
{show && }
); }; export default Demo; ================================================ FILE: packages/hooks/src/useAntdTable/demo/form.tsx ================================================ import { Button, Col, Form, Input, Row, Table, Select } from 'antd'; import { useAntdTable } from 'ahooks'; import ReactJson from 'react-json-view'; const { Option } = Select; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize, }: { current: number; pageSize: number; }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; export default () => { const [form] = Form.useForm(); const { tableProps, search, params } = useAntdTable(getTableData, { defaultPageSize: 5, form, }); const { type, changeType, submit, reset } = search; const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', }, { title: 'gender', dataIndex: 'gender', }, ]; const advanceSearchForm = (
); const searchForm = (
); return (
{type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; ================================================ FILE: packages/hooks/src/useAntdTable/demo/init.tsx ================================================ import { Button, Col, Form, Input, Row, Table, Select } from 'antd'; import { useAntdTable } from 'ahooks'; import ReactJson from 'react-json-view'; const { Option } = Select; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize, }: { current: number; pageSize: number; }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; export default () => { const [form] = Form.useForm(); const { tableProps, search, params } = useAntdTable(getTableData, { form, defaultParams: [ { current: 2, pageSize: 5 }, { name: 'hello', email: 'abc@gmail.com', gender: 'female' }, ], defaultType: 'advance', }); const { type, changeType, submit, reset } = search; const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', }, { title: 'gender', dataIndex: 'gender', }, ]; const advanceSearchForm = (
); const searchForm = (
); return (
{type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; ================================================ FILE: packages/hooks/src/useAntdTable/demo/ready.tsx ================================================ import { useState } from 'react'; import { Button, Col, Form, Input, Row, Table, Select } from 'antd'; import { useAntdTable } from 'ahooks'; import ReactJson from 'react-json-view'; const { Option } = Select; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize, }: { current: number; pageSize: number; }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; export default () => { const [form] = Form.useForm(); const [ready, setReady] = useState(false); const { tableProps, search, params } = useAntdTable(getTableData, { form, ready, cacheKey: 'demo-ready', defaultParams: [ { current: ready ? 2 : 1, pageSize: 5 }, { name: ready ? 'hello' : '', email: 'abc@gmail.com', gender: 'female' }, ], defaultType: 'advance', }); const { type, changeType, submit, reset } = search; const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', }, { title: 'gender', dataIndex: 'gender', }, ]; const advanceSearchForm = (
); const searchForm = (
); return (
{type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; ================================================ FILE: packages/hooks/src/useAntdTable/demo/table.tsx ================================================ import { Table } from 'antd'; import { useAntdTable } from 'ahooks'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ({ current, pageSize, }: { current: number; pageSize: number; }): Promise => { const query = `page=${current}&size=${pageSize}`; return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; export default () => { const { tableProps } = useAntdTable(getTableData); const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', }, { title: 'gender', dataIndex: 'gender', }, ]; return
; }; ================================================ FILE: packages/hooks/src/useAntdTable/demo/validate.tsx ================================================ import { Form, Input, Select, Table } from 'antd'; import { useAntdTable } from 'ahooks'; import ReactJson from 'react-json-view'; const { Option } = Select; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize, }: { current: number; pageSize: number; }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=55&${query}`) .then((res) => res.json()) .then((res) => ({ total: res.info.results, list: res.results, })); }; export default () => { const [form] = Form.useForm(); const { tableProps, search, params } = useAntdTable(getTableData, { defaultPageSize: 5, form, }); const { submit } = search; const columns = [ { title: 'name', dataIndex: ['name', 'last'], }, { title: 'email', dataIndex: 'email', }, { title: 'phone', dataIndex: 'phone', }, { title: 'gender', dataIndex: 'gender', }, ]; const searchForm = (
); return (
{searchForm}

Current Table:

Current Form:

); }; ================================================ FILE: packages/hooks/src/useAntdTable/index.en-US.md ================================================ --- nav: path: /hooks --- # useAntdTable `useAntdTable` is implemented based on `useRequest` and encapsulates the commonly used [Ant Design Form](https://ant.design/components/form/) and [Ant Design Table](https://ant.design/components/table/) data binding logic, and supports both antd v3 and v4. Before using it, you need to understand a few points that are different from `useRequest`: 1. `service` receives two parameters, the first parameter is the paging data `{ current, pageSize, sorter, filters, extra }`, and the second parameter is the form data. 2. The data structure returned by `service` must be `{ total: number, list: Item[] }`. 3. Additional `tableProps` and `search` fields will be returned to manage tables and forms. 4. When `refreshDeps` changes, it will reset `current` to the first page and re-initiate the request. ## Examples The following demos are for antd v4. For v3, please refer to: https://ahooks-v2.js.org/hooks/table/use-antd-table ### Table management `useAntdTable` will automatically manage the pagination data of `Table`, you only need to pass the returned `tableProps` to the `Table` component. ```tsx | pure
```
### Form and Table data binding When `useAntdTable` receives the `form` instance, it will return a search object to handle form related events. - `search.type` supports switching between `simple` and `advance` - `search.changeType`, switch form type - `search.submit` submit form - `search.reset` reset the current form In the following example, you can try out the data binding between form and table. ### Default Params `useAntdTable` sets the initial value through `defaultParams`, `defaultParams` is an array, the first item is paging related parameters, and the second item is form related data. If there is a second value, we will initialize the form for you! It should be noted that the initial form data can be filled with all the form data of `simple` and `advance`, and we will help you select the form data of the currently activated type. The following example sets paging data and form data during initialization. ### Form Validation Before the form is submitted, we will call `form.validateFields` to validate the form data. If the verification fails, the request will not be initiated. ### Data Caching By setting `cacheKey`, we can apply the data caching for the `Form` and `Table`. ## API All parameters and returned results of `useRequest` are applicable to `useAntdTable`, so we won't repeat them here. ```typescript type Data = { total: number; list: any[] }; type Params = [{ current: number; pageSize: number, filters?: any, sorter?: any, extra?: any }, { [key: string]: any }]; const { ..., tableProps: { dataSource: TData['list']; loading: boolean; onChange: ( pagination: any, filters?: any, sorter?: any, extra?: any, ) => void; pagination: { current: number; pageSize: number; total: number; }; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } = useAntdTable( service: (...args: TParams) => Promise, { ..., form?: any; defaultType?: 'simple' | 'advance'; defaultParams?: TParams, defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | Property | Description | Type | | ----------------- | ------------------------------------------ | --------------------- | | tableProps | The data required by the `Table` component | - | | search.type | Current form type | `simple` \| `advance` | | search.changeType | Switch form type | `() => void` | | search.submit | Submit form | `() => void` | | search.reset | Reset the current form | `() => void` | ### Params | Property | Description | Type | Default | | --------------- | ------------------------------------------------------------------------------------------ | ------------------------ | -------- | | form | `Form` instance | - | - | | defaultType | Default form type | `simple` \| `advance` | `simple` | | defaultParams | Default parameters, the first item is paging data, the second item is form data | `[pagination, formData]` | - | | defaultPageSize | Default page size | `number` | `10` | | refreshDeps | Changes in `refreshDeps` will reset current to the first page and re-initiate the request. | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/useAntdTable/index.tsx ================================================ import { useEffect, useRef, useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import usePagination from '../usePagination'; import useUpdateEffect from '../useUpdateEffect'; import type { Antd4ValidateFields, AntdTableOptions, Data, Params, Service, AntdTableResult, } from './types'; const useAntdTable = ( service: Service, options: AntdTableOptions = {}, ) => { const { form, defaultType = 'simple', defaultParams, manual = false, refreshDeps = [], ready = true, ...rest } = options; const result = usePagination(service, { ready, manual: true, ...rest, onSuccess(...args) { // eslint-disable-next-line @typescript-eslint/no-use-before-define runSuccessRef.current = true; rest.onSuccess?.(...args); }, }); const { params = [], run } = result; const cacheFormTableData = params[2] || ({} as any); const [type, setType] = useState(cacheFormTableData?.type || defaultType); const allFormDataRef = useRef>({}); const defaultDataSourceRef = useRef([]); const runSuccessRef = useRef(false); const isAntdV4 = !!form?.getInternalHooks; // get current active field values const getActiveFieldValues = () => { if (!form) { return {}; } // antd 4 if (isAntdV4) { return form.getFieldsValue(null, () => true); } // antd 3 const allFieldsValue = form.getFieldsValue(); const activeFieldsValue: Record = {}; Object.keys(allFieldsValue).forEach((key: string) => { if (form.getFieldInstance ? form.getFieldInstance(key) : true) { activeFieldsValue[key] = allFieldsValue[key]; } }); return activeFieldsValue; }; const validateFields = (): Promise> => { if (!form) { return Promise.resolve({}); } const activeFieldsValue = getActiveFieldValues(); const fields = Object.keys(activeFieldsValue); // antd 4 if (isAntdV4) { return (form.validateFields as Antd4ValidateFields)(fields); } // antd 3 return new Promise((resolve, reject) => { form.validateFields(fields, (errors, values) => { if (errors) { reject(errors); } else { resolve(values); } }); }); }; const restoreForm = () => { if (!form) { return; } // antd v4 if (isAntdV4) { return form.setFieldsValue(allFormDataRef.current); } // antd v3 const activeFieldsValue: Record = {}; Object.keys(allFormDataRef.current).forEach((key) => { if (form.getFieldInstance ? form.getFieldInstance(key) : true) { activeFieldsValue[key] = allFormDataRef.current[key]; } }); form.setFieldsValue(activeFieldsValue); }; const changeType = () => { const activeFieldsValue = getActiveFieldValues(); allFormDataRef.current = { ...allFormDataRef.current, ...activeFieldsValue, }; setType((t: string) => (t === 'simple' ? 'advance' : 'simple')); }; const _submit = (initPagination?: TParams[0]) => { if (!ready) { return; } setTimeout(() => { validateFields() .then((values = {}) => { const pagination = initPagination || { pageSize: options.defaultPageSize || 10, ...(params?.[0] || {}), current: 1, }; if (!form) { // @ts-ignore run(pagination); return; } // record all form data allFormDataRef.current = { ...allFormDataRef.current, ...values, }; // @ts-ignore run(pagination, values, { allFormData: allFormDataRef.current, type, }); }) .catch((err) => err); }); }; const reset = () => { if (form) { form.resetFields(); } _submit({ ...(defaultParams?.[0] || {}), pageSize: options.defaultPageSize || options.defaultParams?.[0]?.pageSize || 10, current: 1, }); }; const submit = (e?: any) => { e?.preventDefault?.(); _submit( runSuccessRef.current ? undefined : { pageSize: options.defaultPageSize || options.defaultParams?.[0]?.pageSize || 10, current: 1, ...(defaultParams?.[0] || {}), }, ); }; const onTableChange = (pagination: any, filters: any, sorter: any, extra: any) => { const [oldPaginationParams, ...restParams] = params || []; run( // @ts-ignore { ...oldPaginationParams, current: pagination.current, pageSize: pagination.pageSize, filters, sorter, extra, }, ...restParams, ); }; // init useEffect(() => { // if has cache, use cached params. ignore manual and ready. if (params.length > 0) { allFormDataRef.current = cacheFormTableData?.allFormData || {}; restoreForm(); // @ts-ignore run(...params); return; } if (ready) { allFormDataRef.current = defaultParams?.[1] || {}; restoreForm(); if (!manual) { _submit(defaultParams?.[0]); } } }, []); // change search type, restore form data useUpdateEffect(() => { if (!ready) { return; } restoreForm(); }, [type]); // refresh & ready change on the same time const hasAutoRun = useRef(false); hasAutoRun.current = false; useUpdateEffect(() => { if (!manual && ready) { hasAutoRun.current = true; if (form) { form.resetFields(); } allFormDataRef.current = defaultParams?.[1] || {}; restoreForm(); _submit(defaultParams?.[0]); } }, [ready]); useUpdateEffect(() => { if (hasAutoRun.current) { return; } if (!ready) { return; } if (!manual) { hasAutoRun.current = true; if (options.refreshDepsAction) { options.refreshDepsAction(); } else { result.pagination.changeCurrent(1); } } }, [...refreshDeps]); return { ...result, tableProps: { dataSource: result.data?.list || defaultDataSourceRef.current, loading: result.loading, onChange: useMemoizedFn(onTableChange), pagination: { current: result.pagination.current, pageSize: result.pagination.pageSize, total: result.pagination.total, }, }, search: { submit: useMemoizedFn(submit), type, changeType: useMemoizedFn(changeType), reset: useMemoizedFn(reset), }, } as AntdTableResult; }; export default useAntdTable; ================================================ FILE: packages/hooks/src/useAntdTable/index.zh-CN.md ================================================ --- nav: title: Hooks path: /hooks --- # useAntdTable `useAntdTable` 基于 `useRequest` 实现,封装了常用的 [Ant Design Form](https://ant.design/components/form-cn/) 与 [Ant Design Table](https://ant.design/components/table-cn/) 联动逻辑,并且同时支持 antd v3 和 v4。 在使用之前,你需要了解它与 `useRequest` 不同的几个点: 1. `service` 接收两个参数,第一个参数为分页数据 `{ current, pageSize, sorter, filters, extra }`,第二个参数为表单数据。 2. `service` 返回的数据结构为 `{ total: number, list: Item[] }`。 3. 会额外返回 `tableProps` 和 `search` 字段,管理表格和表单。 4. `refreshDeps` 变化,会重置 `current` 到第一页,并重新发起请求。 ## 代码演示 以下展示的是 antd v4 的 demo,v3 请参考:https://ahooks-v2.js.org/hooks/table/use-antd-table ### Table 管理 `useAntdTable` 会自动管理 `Table` 分页数据,你只需要把返回的 `tableProps` 传递给 `Table` 组件就可以了。 ```tsx | pure
```
### Form 与 Table 联动 `useAntdTable` 接收 `form` 实例后,会返回 search 对象,用来处理表单相关事件。 - `search.type` 支持 `simple` 和 `advance` 两个表单切换 - `search.changeType`,切换表单类型 - `search.submit` 提交表单行为 - `search.reset` 重置当前表单 以下示例你可以体验表单与表格联动。 ### 初始化数据 `useAntdTable` 通过 `defaultParams` 设置初始化值,`defaultParams` 是一个数组,第一项为分页相关参数,第二项为表单相关数据。如果有第二个值,我们会帮您初始化表单! 需要注意的是,初始化的表单数据可以填写 `simple` 和 `advance` 全量的表单数据,我们会帮您挑选当前激活的类型中的表单数据。 以下示例在初始化时设置了分页数据和表单数据。 ### 表单验证 表单提交之前,我们会调用 `form.validateFields` 来校验表单数据,如果验证不通过,则不会发起请求。 ### 缓存 通过设置 `cacheKey`,我们可以实现 `Form` 与 `Table` 数据缓存。 ## API `useRequest` 所有参数和返回结果均适用于 `useAntdTable`,此处不再赘述。 ```typescript type Data = { total: number; list: any[] }; type Params = [{ current: number; pageSize: number, filters?: any, sorter?: any, extra?: any }, { [key: string]: any }]; const { ..., tableProps: { dataSource: TData['list']; loading: boolean; onChange: ( pagination: any, filters?: any, sorter?: any, extra?: any, ) => void; pagination: { current: number; pageSize: number; total: number; }; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } = useAntdTable( service: (...args: TParams) => Promise, { ..., form?: any; defaultType?: 'simple' | 'advance'; defaultParams?: TParams, defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | 参数 | 说明 | 类型 | | ----------------- | --------------------------------------------------- | --------------------- | | tableProps | `Table` 组件需要的数据,直接透传给 `Table` 组件即可 | - | | search.type | 当前表单类型 | `simple` \| `advance` | | search.changeType | 切换表单类型 | `() => void` | | search.submit | 提交表单 | `() => void` | | search.reset | 重置当前表单 | `() => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | --------------- | ------------------------------------------------------------- | ------------------------ | -------- | | form | `Form` 实例 | - | - | | defaultType | 默认表单类型 | `simple` \| `advance` | `simple` | | defaultParams | 默认参数,第一项为分页数据,第二项为表单数据 | `[pagination, formData]` | - | | defaultPageSize | 默认分页数量 | `number` | `10` | | refreshDeps | `refreshDeps` 变化,会重置 current 到第一页,并重新发起请求。 | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/useAntdTable/types.ts ================================================ import type { PaginationOptions, PaginationResult } from '../usePagination/types'; export type Data = { total: number; list: any[] }; export type Params = [ { current: number; pageSize: number; sorter?: any; filters?: any; extra?: any; [key: string]: any; }, ...any[], ]; export type Service = ( ...args: TParams ) => Promise; export type Antd3ValidateFields = ( fieldNames: string[], callback: (errors: any, values: Record) => void, ) => void; export type Antd4ValidateFields = (fieldNames?: string[]) => Promise>; export interface AntdFormUtils { getFieldInstance?: (name: string) => Record; setFieldsValue: (value: Record) => void; getFieldsValue: (...args: any) => Record; resetFields: (...args: any) => void; validateFields: Antd3ValidateFields | Antd4ValidateFields; getInternalHooks?: any; [key: string]: any; } export interface AntdTableResult extends PaginationResult { tableProps: { dataSource: TData['list']; loading: boolean; onChange: (pagination: any, filters?: any, sorter?: any) => void; pagination: any; [key: string]: any; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } export interface AntdTableOptions extends PaginationOptions { form?: AntdFormUtils; defaultType?: 'simple' | 'advance'; } ================================================ FILE: packages/hooks/src/useAsyncEffect/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useAsyncEffect from '../index'; describe('useAsyncEffect', () => { test('should work without clean up', async () => { const hook = renderHook(() => { const [x, setX] = useState(0); useAsyncEffect(async () => { await sleep(100); setX(1); }, []); return x; }); expect(hook.result.current).toBe(0); await act(async () => { await sleep(150); }); expect(hook.result.current).toBe(1); }); test('should work with yield break', async () => { const hook = renderHook(() => { const [x, setX] = useState(1); const [y, setY] = useState(0); useAsyncEffect( async function* () { await sleep(100); yield; setY(x); }, [x], ); return { y, setX, }; }); expect(hook.result.current.y).toBe(0); await act(async () => { await sleep(50); hook.result.current.setX(2); }); expect(hook.result.current.y).toBe(0); await act(async () => { await sleep(20); }); expect(hook.result.current.y).toBe(0); await act(async () => { await sleep(50); hook.result.current.setX(3); }); expect(hook.result.current.y).toBe(0); await act(async () => { await sleep(80); }); expect(hook.result.current.y).toBe(0); await act(async () => { await sleep(50); }); expect(hook.result.current.y).toBe(3); }); }); ================================================ FILE: packages/hooks/src/useAsyncEffect/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Do async check when component is mounted. * * title.zh-CN: 基础用法 * desc.zh-CN: 组件加载时进行异步的检查 */ import { useAsyncEffect } from 'ahooks'; import { useState } from 'react'; function mockCheck(): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(true); }, 3000); }); } export default () => { const [pass, setPass] = useState(); useAsyncEffect(async () => { setPass(await mockCheck()); }, []); return (
{pass === undefined && 'Checking...'} {pass === true && 'Check passed.'}
); }; ================================================ FILE: packages/hooks/src/useAsyncEffect/demo/demo2.tsx ================================================ /** * title: Break off * desc: Use `yield` to stop the execution when effect has been cleaned up. * * title.zh-CN: 中断执行 * desc.zh-CN: 通过 `yield` 语句可以增加一些检查点,如果发现当前 effect 已经被清理,会停止继续往下执行。 */ import { useState } from 'react'; import { useAsyncEffect } from 'ahooks'; function mockCheck(val: string): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(val.length > 0); }, 1000); }); } export default () => { const [value, setValue] = useState(''); const [pass, setPass] = useState(); useAsyncEffect( async function* () { setPass(undefined); const result = await mockCheck(value); yield; // Check whether the effect is still valid, if it is has been cleaned up, stop at here. setPass(result); }, [value], ); return (
{ setValue(e.target.value); }} />

{pass === null && 'Checking...'} {pass === false && 'Check failed.'} {pass === true && 'Check passed.'}

); }; ================================================ FILE: packages/hooks/src/useAsyncEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useAsyncEffect useEffect support async function. ## 代码演示 ### Default usage ### Break off ## API ```typescript function useAsyncEffect( effect: () => AsyncGenerator | Promise, deps: DependencyList ); ``` ================================================ FILE: packages/hooks/src/useAsyncEffect/index.ts ================================================ import type { DependencyList } from 'react'; import { useEffect } from 'react'; import { isFunction } from '../utils'; function isAsyncGenerator( val: AsyncGenerator | Promise, ): val is AsyncGenerator { return isFunction((val as any)[Symbol.asyncIterator]); } function useAsyncEffect( effect: () => AsyncGenerator | Promise, deps?: DependencyList, ) { useEffect(() => { const e = effect(); let cancelled = false; async function execute() { if (isAsyncGenerator(e)) { while (true) { const result = await e.next(); if (result.done || cancelled) { break; } } } else { await e; } } execute(); return () => { cancelled = true; }; }, deps); } export default useAsyncEffect; ================================================ FILE: packages/hooks/src/useAsyncEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useAsyncEffect useEffect 支持异步函数。 ## 代码演示 ### 基础用法 ### 中断执行 ## API ```typescript function useAsyncEffect( effect: () => AsyncGenerator | Promise, deps: DependencyList ); ``` ================================================ FILE: packages/hooks/src/useBoolean/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useBoolean from '../index'; const setUp = (defaultValue?: boolean) => renderHook(() => useBoolean(defaultValue)); describe('useBoolean', () => { test('test on methods', async () => { const { result } = setUp(); expect(result.current[0]).toBe(false); act(() => { result.current[1].setTrue(); }); expect(result.current[0]).toBe(true); act(() => { result.current[1].setFalse(); }); expect(result.current[0]).toBe(false); act(() => { result.current[1].toggle(); }); expect(result.current[0]).toBe(true); act(() => { result.current[1].toggle(); }); expect(result.current[0]).toBe(false); act(() => { result.current[1].set(false); }); expect(result.current[0]).toBe(false); act(() => { result.current[1].set(true); }); expect(result.current[0]).toBe(true); act(() => { // @ts-ignore result.current[1].set(0); }); expect(result.current[0]).toBe(false); act(() => { // @ts-ignore result.current[1].set('a'); }); expect(result.current[0]).toBe(true); }); test('test on default value', () => { const hook1 = setUp(true); expect(hook1.result.current[0]).toBe(true); const hook2 = setUp(); expect(hook2.result.current[0]).toBe(false); // @ts-ignore const hook3 = setUp(0); expect(hook3.result.current[0]).toBe(false); // @ts-ignore const hook4 = setUp(''); expect(hook4.result.current[0]).toBe(false); // @ts-ignore const hook5 = setUp('hello'); expect(hook5.result.current[0]).toBe(true); }); }); ================================================ FILE: packages/hooks/src/useBoolean/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Toggle boolean, default value can be set optionally. * * title.zh-CN: 基础用法 * desc.zh-CN: 切换 boolean,可以接收默认值。 */ import { useBoolean } from 'ahooks'; export default () => { const [state, { toggle, setTrue, setFalse }] = useBoolean(true); return (

Effects:{JSON.stringify(state)}

); }; ================================================ FILE: packages/hooks/src/useBoolean/index.en-US.md ================================================ --- nav: path: /hooks --- # useBoolean A hook that elegantly manages boolean state. ## Examples ### Default usage ## API ```typescript const [state, { toggle, set, setTrue, setFalse }] = useBoolean( defaultValue?: boolean, ); ``` ### Params | Property | Description | Type | Default | | ------------ | ----------------------------------------- | --------- | ------- | | defaultValue | The default value of the state. Optional. | `boolean` | `false` | ### Result | Property | Description | Type | | -------- | -------------------------------------- | --------- | | state | Current value | `boolean` | | actions | A set of methods to update state value | `Actions` | ### Actions | Property | Description | Type | | -------- | -------------------- | -------------------------- | | toggle | Toggle state | `() => void` | | set | Set state | `(value: boolean) => void` | | setTrue | Set state to `true` | `() => void` | | setFalse | Set state to `false` | `() => void` | ================================================ FILE: packages/hooks/src/useBoolean/index.ts ================================================ import { useMemo } from 'react'; import useToggle from '../useToggle'; export interface Actions { setTrue: () => void; setFalse: () => void; set: (value: boolean) => void; toggle: () => void; } export default function useBoolean(defaultValue = false): [boolean, Actions] { const [state, { toggle, set }] = useToggle(!!defaultValue); const actions: Actions = useMemo(() => { const setTrue = () => set(true); const setFalse = () => set(false); return { toggle, set: (v) => set(!!v), setTrue, setFalse, }; }, []); return [state, actions]; } ================================================ FILE: packages/hooks/src/useBoolean/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useBoolean 优雅的管理 boolean 状态的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const [state, { toggle, set, setTrue, setFalse }] = useBoolean( defaultValue?: boolean, ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | ------------------------ | --------- | ------- | | defaultValue | 可选项,传入默认的状态值 | `boolean` | `false` | ### Result | 参数 | 说明 | 类型 | | ------- | -------- | --------- | | state | 状态值 | `boolean` | | actions | 操作集合 | `Actions` | ### Actions | 参数 | 说明 | 类型 | | -------- | ------------ | -------------------------- | | toggle | 切换 state | `() => void` | | set | 设置 state | `(value: boolean) => void` | | setTrue | 设置为 true | `() => void` | | setFalse | 设置为 false | `() => void` | ================================================ FILE: packages/hooks/src/useClickAway/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import useClickAway from '../index'; describe('useClickAway', () => { let container: HTMLDivElement; let container1: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); container1 = document.createElement('div'); container1.setAttribute('id', 'ele'); document.body.appendChild(container); document.body.appendChild(container1); }); afterEach(() => { document.body.removeChild(container); document.body.removeChild(container1); }); test('test on dom optional', async () => { let state: number = 0; const { rerender, unmount } = renderHook((dom: any) => useClickAway(() => { state++; }, dom), ); rerender(container); container.click(); expect(state).toBe(0); document.body.click(); expect(state).toBe(1); rerender(container1); container1.click(); expect(state).toBe(1); document.body.click(); expect(state).toBe(2); unmount(); document.body.click(); expect(state).toBe(2); }); test('should works on multiple target', async () => { let state: number = 0; const { rerender, unmount } = renderHook((dom: any) => useClickAway(() => { state++; }, dom), ); rerender([container, container1]); container.click(); expect(state).toBe(0); container1.click(); expect(state).toBe(0); document.body.click(); expect(state).toBe(1); unmount(); document.body.click(); expect(state).toBe(1); }); }); ================================================ FILE: packages/hooks/src/useClickAway/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Please click button or outside of button to show effects. * * title.zh-CN: 基础用法 * desc.zh-CN: 请点击按钮或按钮外查看效果。 */ import { useState, useRef } from 'react'; import { useClickAway } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); const ref = useRef(null); useClickAway(() => { setCounter((s) => s + 1); }, ref); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/demo/demo2.tsx ================================================ /** * title: Support DOM * desc: Support pass in a DOM element or function. * * title.zh-CN: 支持传入 DOM * desc.zh-CN: 支持直接传入 DOM 对象或 function。 */ import { useState } from 'react'; import { useClickAway } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); useClickAway( () => { setCounter((s) => s + 1); }, () => document.getElementById('use-click-away-button'), ); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/demo/demo3.tsx ================================================ /** * title: Support multiple DOM * desc: Support pass multiple DOM elements. * * title.zh-CN: 支持多个 DOM 对象 * desc.zh-CN: 支持传入多个目标对象。 */ import { useState, useRef } from 'react'; import { useClickAway } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); const ref1 = useRef(null); const ref2 = useRef(null); useClickAway(() => { setCounter((s) => s + 1); }, [ref1, ref2]); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/demo/demo4.tsx ================================================ /** * title: Listen to other events * desc: By setting eventName, you can specify the event to be listened, Try click the right mouse. * * title.zh-CN: 监听其它事件 * desc.zh-CN: 通过设置 eventName,可以指定需要监听的事件,试试点击鼠标右键。 */ import { useState, useRef } from 'react'; import { useClickAway } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); const ref = useRef(null); useClickAway( () => { setCounter((s) => s + 1); }, ref, 'contextmenu', ); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/demo/demo5.tsx ================================================ /** * title: Support multiple events * desc: Set up multiple events, you can try using the mouse click or right click. * * title.zh-CN: 支持传入多个事件名称 * desc.zh-CN: 设置了多个事件,你可以试试用鼠标左键或者右键。 */ import { useState, useRef } from 'react'; import { useClickAway } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); const ref = useRef(null); useClickAway( () => { setCounter((s) => s + 1); }, ref, ['click', 'contextmenu'], ); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/demo/demo6.tsx ================================================ /** * title: Support shadow DOM * desc: Add the addEventListener to shadow DOM root instead of the document * * title.zh-CN: 支持 shadow DOM * desc.zh-CN: 将 addEventListener 添加到 shadow DOM root */ import { useState, useRef } from 'react'; import { useClickAway } from 'ahooks'; import root from 'react-shadow'; export default () => { const [counter, setCounter] = useState(0); const ref = useRef(null); useClickAway( () => { setCounter((s) => s + 1); }, ref, ['click', 'contextmenu'], ); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useClickAway/index.en-US.md ================================================ --- nav: path: /hooks --- # useClickAway Listen for click events outside the target element. ## Examples ### Default usage ### Custom DOM ### Support multiple DOM ### Listen for other events ### Support multiple events ### Support shadow DOM ## API ```typescript type Target = Element | (() => Element) | React.MutableRefObject; type DocumentEventKey = keyof DocumentEventMap; useClickAway( onClickAway: (event: T) => void, target: Target | Target[], eventName?: DocumentEventKey | DocumentEventKey[] ); ``` ### Params | Property | Description | Type | Default | | ----------- | ---------------------------------------------- | ------------------------------------------ | ------- | | onClickAway | Trigger Function | `(event: T) => void` | - | | target | DOM elements or Ref or Function, support array | `Target` \| `Target[]` | - | | eventName | Set the event to be listened, support array | `DocumentEventKey` \| `DocumentEventKey[]` | `click` | ================================================ FILE: packages/hooks/src/useClickAway/index.ts ================================================ import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import getDocumentOrShadow from '../utils/getDocumentOrShadow'; import useEffectWithTarget from '../utils/useEffectWithTarget'; type DocumentEventKey = keyof DocumentEventMap; export default function useClickAway( onClickAway: (event: T) => void, target: BasicTarget | BasicTarget[], eventName: DocumentEventKey | DocumentEventKey[] = 'click', ) { const onClickAwayRef = useLatest(onClickAway); useEffectWithTarget( () => { const handler = (event: any) => { const targets = Array.isArray(target) ? target : [target]; if ( targets.some((item) => { const targetElement = getTargetElement(item); return !targetElement || targetElement.contains(event.target); }) ) { return; } onClickAwayRef.current(event); }; const documentOrShadow = getDocumentOrShadow(target); const eventNames = Array.isArray(eventName) ? eventName : [eventName]; eventNames.forEach((event) => documentOrShadow.addEventListener(event, handler)); return () => { eventNames.forEach((event) => documentOrShadow.removeEventListener(event, handler)); }; }, Array.isArray(eventName) ? eventName : [eventName], target, ); } ================================================ FILE: packages/hooks/src/useClickAway/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useClickAway 监听目标元素外的点击事件。 ## 代码演示 ### 基础用法 ### 自定义 DOM ### 支持多个 DOM 对象 ### 监听其它事件 ### 支持多个事件 ### 支持 shadow DOM ## API ```typescript type Target = Element | (() => Element) | React.MutableRefObject; type DocumentEventKey = keyof DocumentEventMap; useClickAway( onClickAway: (event: T) => void, target: Target | Target[], eventName?: DocumentEventKey | DocumentEventKey[] ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ----------- | ----------------------------------- | ------------------------------------------ | ------- | | onClickAway | 触发函数 | `(event: T) => void` | - | | target | DOM 节点或者 Ref 或者函数,支持数组 | `Target` \| `Target[]` | - | | eventName | 指定需要监听的事件,支持数组 | `DocumentEventKey` \| `DocumentEventKey[]` | `click` | ================================================ FILE: packages/hooks/src/useControllableValue/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import type { Options, Props } from '../index'; import useControllableValue from '../index'; describe('useControllableValue', () => { const setUp = (props?: Props, options?: Options) => renderHook(() => useControllableValue(props, options)); test('defaultValue should work', () => { const hook = setUp({ defaultValue: 1 }); expect(hook.result.current[0]).toBe(1); }); test('value should work', () => { const hook = setUp({ defaultValue: 1, value: 2 }); expect(hook.result.current[0]).toBe(2); }); test('state should be undefined', () => { const hook = setUp(); expect(hook.result.current[0]).toBeUndefined(); }); test('onChange should work', () => { let extraParam: string = ''; const props = { value: 2, onChange(v: any, extra: any) { this.value = v; extraParam = extra; }, }; const hook = setUp(props); expect(hook.result.current[0]).toBe(2); act(() => { hook.result.current[1](3, 'extraParam'); }); expect(props.value).toBe(3); expect(extraParam).toBe('extraParam'); }); test('test on state update', () => { const props: any = { value: 1, }; const { result, rerender } = setUp(props); props.value = 2; rerender(props); expect(result.current[0]).toBe(2); props.value = 3; rerender(props); expect(result.current[0]).toBe(3); }); test('test set state', async () => { const { result } = setUp({ newValue: 1, }); const [, setValue] = result.current; act(() => setValue(undefined)); expect(result.current[0]).toBeUndefined(); act(() => setValue(null)); expect(result.current[0]).toBeNull(); act(() => setValue(55)); expect(result.current[0]).toBe(55); act(() => setValue((prevState: number) => prevState + 1)); expect(result.current[0]).toBe(56); }); test('type inference should work', async () => { type Value = { foo: number; }; const props: { value: Value; defaultValue: Value; onChange: (val: Value) => void; } = { value: { foo: 123, }, defaultValue: { foo: 123, }, onChange: () => {}, }; const hook = renderHook(() => useControllableValue(props)); const [v] = hook.result.current; expect(v.foo).toBe(123); }); }); ================================================ FILE: packages/hooks/src/useControllableValue/demo/demo1.tsx ================================================ /** * title: Uncontrolled component * desc: If there is no value in props, the component manage state by self * * title.zh-CN: 非受控组件 * desc.zh-CN: 如果 props 中没有 value,则组件内部自己管理 state */ import { useControllableValue } from 'ahooks'; export default (props: any) => { const [state, setState] = useControllableValue(props, { defaultValue: '', }); return ( <> setState(e.target.value)} style={{ width: 300 }} /> ); }; ================================================ FILE: packages/hooks/src/useControllableValue/demo/demo2.tsx ================================================ /** * title: Controlled component * desc: If props has the value field, then the state is controlled by it's parent * * title.zh-CN: 受控组件 * desc.zh-CN: 如果 props 有 value 字段,则由父级接管控制 state */ import { useState } from 'react'; import { useControllableValue } from 'ahooks'; const ControllableComponent = (props: any) => { const [state, setState] = useControllableValue(props); return setState(e.target.value)} style={{ width: 300 }} />; }; const Parent = () => { const [state, setState] = useState(''); const clear = () => { setState(''); }; return ( <> ); }; export default Parent; ================================================ FILE: packages/hooks/src/useControllableValue/demo/demo3.tsx ================================================ /** * title: No value, have onChange component * desc: If there is an onChange field in props, the onChange will be trigger when state change * * title.zh-CN: 无 value,有 onChange 的组件 * desc.zh-CN: 只要 props 中有 onChange 字段,则在 state 变化时,就会触发 onChange 函数 */ import { useState } from 'react'; import { useControllableValue } from 'ahooks'; const ControllableComponent = (props: any) => { const [state, setState] = useControllableValue(props); return ( { setState(e.target.value); }} style={{ width: 300 }} /> ); }; const Parent = () => { const [state, setState] = useState(0); return ( <>
state:{state}
); }; export default Parent; ================================================ FILE: packages/hooks/src/useControllableValue/index.en-US.md ================================================ --- nav: path: /hooks --- # useControllableValue In some components, we need the state to be managed by itself or controlled by it's parent. `useControllableValue` is a Hook that helps you manage this kind of state. ## Examples ### Uncontrolled component ### Controlled component ### No value, have onChange component ## API ```typescript const [state, setState] = useControllableValue(props: Record, options?: Options); ``` ### Result | Property | Description | Type | | -------- | ----------- | --------------------------------------------------- | | state | State | - | | setState | Set state | `(value: any \| ((prevState: any) => any)) => void` | ### Params | Property | Description | Type | Default | | -------- | ---------------------- | --------------------- | ------- | | props | Component props | `Record` | - | | options | Optional configuration | `Options` | - | ### Options | Property | Description | Type | Default | | -------------------- | ------------------------------------------------------------------------------- | -------- | -------------- | | defaultValue | The default value, will be overridden by `props.defaultValue` and `props.value` | - | - | | defaultValuePropName | Custom defaultValue attribute name | `string` | `defaultValue` | | valuePropName | Custom value attribute name | `string` | `value` | | trigger | Custom trigger attribute name | `string` | `onChange` | ================================================ FILE: packages/hooks/src/useControllableValue/index.ts ================================================ import { useMemo, useRef } from 'react'; import type { SetStateAction } from 'react'; import { isFunction } from '../utils'; import useMemoizedFn from '../useMemoizedFn'; import useUpdate from '../useUpdate'; export interface Options { defaultValue?: T; defaultValuePropName?: string; valuePropName?: string; trigger?: string; } export type Props = Record; export interface StandardProps { value: T; defaultValue?: T; onChange: (val: T) => void; } function useControllableValue( props: StandardProps, ): [T, (v: SetStateAction) => void]; function useControllableValue( props?: Props, options?: Options, ): [T, (v: SetStateAction, ...args: any[]) => void]; function useControllableValue(defaultProps?: Props, options: Options = {}) { const props = defaultProps ?? {}; const { defaultValue, defaultValuePropName = 'defaultValue', valuePropName = 'value', trigger = 'onChange', } = options; const value = props[valuePropName] as T; const isControlled = Object.prototype.hasOwnProperty.call(props, valuePropName); const initialValue = useMemo(() => { if (isControlled) { return value; } if (Object.prototype.hasOwnProperty.call(props, defaultValuePropName)) { return props[defaultValuePropName]; } return defaultValue; }, []); const stateRef = useRef(initialValue); if (isControlled) { stateRef.current = value; } const update = useUpdate(); function setState(v: SetStateAction, ...args: any[]) { const r = isFunction(v) ? v(stateRef.current) : v; if (!isControlled) { stateRef.current = r; update(); } if (props[trigger]) { props[trigger](r, ...args); } } return [stateRef.current, useMemoizedFn(setState)] as const; } export default useControllableValue; ================================================ FILE: packages/hooks/src/useControllableValue/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useControllableValue 在某些组件开发时,我们需要组件的状态既可以自己管理,也可以被外部控制,`useControllableValue` 就是帮你管理这种状态的 Hook。 ## 代码演示 ### 非受控组件 ### 受控组件 ### 无 value,有 onChange 的组件 ## API ```typescript const [state, setState] = useControllableValue(props: Record, options?: Options); ``` ### Result | 参数 | 说明 | 类型 | | -------- | ----------------- | --------------------------------------------------- | | state | 状态值 | - | | setState | 修改 state 的函数 | `(value: any \| ((prevState: any) => any)) => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ------------ | --------------------- | ------ | | props | 组件的 props | `Record` | - | | options | 可选配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------------------- | ------------------------------------------------------- | -------- | -------------- | | defaultValue | 默认值,会被 `props.defaultValue` 和 `props.value` 覆盖 | - | - | | defaultValuePropName | 默认值的属性名 | `string` | `defaultValue` | | valuePropName | 值的属性名 | `string` | `value` | | trigger | 修改值时,触发的函数 | `string` | `onChange` | ================================================ FILE: packages/hooks/src/useCookieState/__tests__/index.spec.tsx ================================================ import { act, renderHook } from '@testing-library/react'; import Cookies from 'js-cookie'; import { describe, expect, test } from 'vitest'; import type { Options } from '../index'; import useCookieState from '../index'; describe('useCookieState', () => { const setUp = (key: string, options: Options) => renderHook(() => { const [state, setState] = useCookieState(key, options); return { state, setState, } as const; }); test('getKey should work', () => { const COOKIE = 'test-key'; const hook = setUp(COOKIE, { defaultValue: 'A', }); expect(hook.result.current.state).toBe('A'); act(() => { hook.result.current.setState('B'); }); expect(hook.result.current.state).toBe('B'); const anotherHook = setUp(COOKIE, { defaultValue: 'A', }); expect(anotherHook.result.current.state).toBe('B'); act(() => { anotherHook.result.current.setState('C'); }); expect(anotherHook.result.current.state).toBe('C'); expect(hook.result.current.state).toBe('B'); expect(Cookies.get(COOKIE)).toBe('C'); }); test('should support undefined', () => { const COOKIE = 'test-boolean-key-with-undefined'; const hook = setUp(COOKIE, { defaultValue: 'undefined', }); expect(hook.result.current.state).toBe('undefined'); act(() => { hook.result.current.setState(undefined); }); expect(hook.result.current.state).toBeUndefined(); const anotherHook = setUp(COOKIE, { defaultValue: 'false', }); expect(anotherHook.result.current.state).toBe('false'); expect(Cookies.get(COOKIE)).toBeUndefined(); act(() => { // @ts-ignore hook.result.current.setState(); }); expect(hook.result.current.state).toBeUndefined(); expect(Cookies.get(COOKIE)).toBeUndefined(); }); test('should support empty string', () => { Cookies.set('test-key-empty-string', ''); expect(Cookies.get('test-key-empty-string')).toBe(''); const COOKIE = 'test-key-empty-string'; const hook = setUp(COOKIE, { defaultValue: 'hello', }); expect(hook.result.current.state).toBe(''); }); test('should support function updater', () => { const COOKIE = 'test-func-updater'; const hook = setUp(COOKIE, { defaultValue: () => 'hello world', }); expect(hook.result.current.state).toBe('hello world'); act(() => { hook.result.current.setState((state) => `${state}, zhangsan`); }); expect(hook.result.current.state).toBe('hello world, zhangsan'); }); test('using the same cookie name', () => { const COOKIE_NAME = 'test-same-cookie-name'; const { result: result1 } = setUp(COOKIE_NAME, { defaultValue: 'A' }); const { result: result2 } = setUp(COOKIE_NAME, { defaultValue: 'B' }); expect(result1.current.state).toBe('A'); expect(result2.current.state).toBe('B'); act(() => { result1.current.setState('C'); }); expect(result1.current.state).toBe('C'); expect(result2.current.state).toBe('B'); expect(Cookies.get(COOKIE_NAME)).toBe('C'); act(() => { result2.current.setState('D'); }); expect(result1.current.state).toBe('C'); expect(result2.current.state).toBe('D'); expect(Cookies.get(COOKIE_NAME)).toBe('D'); }); }); ================================================ FILE: packages/hooks/src/useCookieState/demo/demo1.tsx ================================================ /** * title: Store state into Cookie * desc: Refresh this page and you will get the state from Cookie. * * title.zh-CN: 将 state 存储在 Cookie 中 * desc.zh-CN: 刷新页面后,可以看到输入框中的内容被从 Cookie 中恢复了。 */ import { useCookieState } from 'ahooks'; export default () => { const [message, setMessage] = useCookieState('useCookieStateString'); return ( setMessage(e.target.value)} style={{ width: 300 }} /> ); }; ================================================ FILE: packages/hooks/src/useCookieState/demo/demo2.tsx ================================================ /** * title: SetState can receive function * desc: Function updater is also acceptable with useCookieState's setState,similar to how useState is used. * * title.zh-CN: setState 可以接收函数 * desc.zh-CN: useCookieState 的 setState 可以接收 function updater,就像 useState 那样。 */ import { useCookieState } from 'ahooks'; export default function App() { const [value, setValue] = useCookieState('useCookieStateUpdater', { defaultValue: '0', }); return ( <>

{value}

); } ================================================ FILE: packages/hooks/src/useCookieState/demo/demo3.tsx ================================================ /** * title: Use the option property to configure Cookie * desc: 'Available options: defaultValue、expires、path、domain、secure、sameSite etc.' * * title.zh-CN: 使用 option 配置 Cookie * desc.zh-CN: 可配置属性:默认值、有效时间、路径、域名、协议、跨域等,详见 Options 文档。 */ import { useCookieState } from 'ahooks'; export default function App() { const [value, setValue] = useCookieState('useCookieStateOptions', { defaultValue: '0', path: '/', expires: (() => new Date(+new Date() + 10000))(), }); return ( <>

{value}

); } ================================================ FILE: packages/hooks/src/useCookieState/index.en-US.md ================================================ --- nav: path: /hooks --- # useCookieState A Hook that store state into Cookie. ## Examples ### Store state into Cookie ### SetState can receive function ### Use the option property to configure Cookie ## API ```typescript type State = string | undefined; type SetState = ( newValue?: State | ((prevState?: State) => State), options?: Cookies.CookieAttributes, ) => void; const [state, setState]: [State, SetState] = useCookieState( cookieKey: string, options?: Options, ); ``` If you want to delete this record from document.cookie, use `setState()` or `setState(undefined)`. ### Params | Property | Description | Type | Default | | --------- | ------------------------------ | --------- | ------- | | cookieKey | The key of Cookie | `string` | - | | options | Optional. Cookie configuration | `Options` | - | ### Result | Property | Description | Type | | -------- | ------------------- | ----------------------- | | state | Local Cookie value | `string` \| `undefined` | | setState | Update Cookie value | `SetState` | setState can update cookie options, which will be merged with the options set by `useCookieState`. `const targetOptions = { ...options, ...updateOptions }` ### Options | Property | Description | Type | Default | | ------------ | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | ----------- | | defaultValue | Optional. Default value, but not store to Cookie | `string` \| `undefined` \| `(() => (string \| undefined))` | `undefined` | | expires | Optional. Set Cookie expiration time | `number` \| `Date` | - | | path | Optional. Specify available paths | `string` | `/` | | domain | Optional. Specify available domain. Default creation domain | `string` | - | | secure | Optional. Specify whether the Cookie can only be transmitted over secure protocol as https | `boolean` | `false` | | sameSite | Optional. Specify whether the browser can send this Cookie along with cross-site requests | `strict` \| `lax` \| `none` | - | Options is same as [js-cookie attributes](https://github.com/js-cookie/js-cookie#cookie-attributes). ================================================ FILE: packages/hooks/src/useCookieState/index.ts ================================================ import Cookies from 'js-cookie'; import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isFunction, isString } from '../utils'; export type State = string | undefined; export interface Options extends Cookies.CookieAttributes { defaultValue?: State | (() => State); } function useCookieState(cookieKey: string, options: Options = {}) { const [state, setState] = useState(() => { const cookieValue = Cookies.get(cookieKey); if (isString(cookieValue)) { return cookieValue; } if (isFunction(options.defaultValue)) { return options.defaultValue(); } return options.defaultValue; }); const updateState = useMemoizedFn( ( newValue: State | ((prevState: State) => State), newOptions: Cookies.CookieAttributes = {}, ) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { defaultValue, ...restOptions } = { ...options, ...newOptions }; const value = isFunction(newValue) ? newValue(state) : newValue; setState(value); if (value === undefined) { Cookies.remove(cookieKey); } else { Cookies.set(cookieKey, value, restOptions); } }, ); return [state, updateState] as const; } export default useCookieState; ================================================ FILE: packages/hooks/src/useCookieState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useCookieState 一个可以将状态存储在 Cookie 中的 Hook 。 ## 代码演示 ### 将 state 存储在 Cookie 中 ### setState 可以接收函数 ### 使用 option 配置 Cookie ## API ```typescript type State = string | undefined; type SetState = ( newValue?: State | ((prevState?: State) => State), options?: Cookies.CookieAttributes, ) => void; const [state, setState]: [State, SetState] = useCookieState( cookieKey: string, options?: Options, ); ``` 注意:如果想从 document.cookie 中删除这条数据,可以使用 `setState()` 或 `setState(undefined)`。 ### Params | 参数 | 说明 | 类型 | 默认值 | | --------- | ------------------------ | --------- | ------ | | cookieKey | Cookie 的 key 值 | `string` | - | | options | 可选项,配置 Cookie 属性 | `Options` | - | ### Result | 参数 | 说明 | 类型 | | -------- | -------------- | ----------------------- | | state | 本地 Cookie 值 | `string` \| `undefined` | | setState | 设置 Cookie 值 | `SetState` | setState 可以更新 cookie options,会与 `useCookieState` 设置的 options 进行 merge 操作。 `const targetOptions = { ...options, ...updateOptions }` ### Options | 参数 | 说明 | 类型 | 默认值 | | ------------ | ---------------------------------------------------- | ---------------------------------------------------------- | ----------- | | defaultValue | 可选,定义 Cookie 默认值,但不同步到本地 Cookie | `string` \| `undefined` \| `(() => (string \| undefined))` | `undefined` | | expires | 可选,定义 Cookie 存储有效时间 | `number` \| `Date` | - | | path | 可选,定义 Cookie 可用的路径 | `string` | `/` | | domain | 可选,定义 Cookie 可用的域,默认为 Cookie 创建的域名 | `string` | - | | secure | 可选,Cookie 传输是否需要 https 安全协议 | `boolean` | `false` | | sameSite | 可选,Cookie 不能与跨域请求一起发送 | `strict` \| `lax` \| `none` | - | Options 与 [js-cookie attributes](https://github.com/js-cookie/js-cookie#cookie-attributes) 保持一致。 ================================================ FILE: packages/hooks/src/useCountDown/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import type { Options } from '../index'; import useCountDown from '../index'; const setup = (options: Options = {}) => renderHook((props: Options = options) => useCountDown(props)); describe('useCountDown', () => { beforeAll(() => { vi.useFakeTimers(); vi.setSystemTime(1479427200000); }); afterAll(() => { vi.useRealTimers(); }); test('should initialize correctly with undefined targetDate', () => { const { result } = setup(); const [count, formattedRes] = result.current; expect(count).toBe(0); expect(formattedRes).toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0, }); }); test('should initialize correctly with correct targetDate', () => { const { result } = setup({ targetDate: Date.now() + 5000, interval: 1000, }); const [count, formattedRes] = result.current; expect(count).toBe(5000); expect(formattedRes.seconds).toBe(5); expect(formattedRes.milliseconds).toBe(0); }); test('should work manually', () => { const { result, rerender } = setup({ interval: 100 }); rerender({ targetDate: Date.now() + 5000, interval: 1000 }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); act(() => { vi.advanceTimersByTime(4000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('should work automatically', () => { const { result } = setup({ targetDate: Date.now() + 5000, interval: 1000, }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); act(() => { vi.advanceTimersByTime(4000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('should work stop', () => { const { result, rerender } = setup({ targetDate: Date.now() + 5000, interval: 1000, }); rerender({ targetDate: Date.now() + 5000, interval: 1000, }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); rerender({ targetDate: undefined, }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('it onEnd should work', () => { const onEnd = vi.fn(); setup({ targetDate: Date.now() + 5000, interval: 1000, onEnd, }); act(() => { vi.advanceTimersByTime(6000); }); expect(onEnd).toBeCalled(); }); test('timeLeft should be 0 when target date less than current time', () => { const { result } = setup({ targetDate: Date.now() - 5000, }); expect(result.current[0]).toBe(0); }); test('should initialize correctly with undefined leftTime', () => { const { result } = setup(); const [count, formattedRes] = result.current; expect(count).toBe(0); expect(formattedRes).toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0, }); }); test('should initialize correctly with correct leftTime', () => { const { result } = setup({ leftTime: 5 * 1000, interval: 1000 }); const [count, formattedRes] = result.current; expect(count).toBe(5000); expect(formattedRes.seconds).toBe(5); expect(formattedRes.milliseconds).toBe(0); }); test('should work manually', () => { const { result, rerender } = setup({ interval: 100 }); rerender({ leftTime: 5 * 1000, interval: 1000 }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); act(() => { vi.advanceTimersByTime(4000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('should work automatically', () => { const { result } = setup({ leftTime: 5 * 1000, interval: 1000 }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); act(() => { vi.advanceTimersByTime(4000); }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('should work stop', () => { const { result, rerender } = setup({ leftTime: 5 * 1000, interval: 1000 }); rerender({ leftTime: 5 * 1000, interval: 1000 }); expect(result.current[0]).toBe(5000); expect(result.current[1].seconds).toBe(5); act(() => { vi.advanceTimersByTime(1000); }); expect(result.current[0]).toBe(4000); expect(result.current[1].seconds).toBe(4); rerender({ leftTime: undefined }); expect(result.current[0]).toBe(0); expect(result.current[1].seconds).toBe(0); }); test('it onEnd should work', () => { const onEnd = vi.fn(); setup({ leftTime: 5 * 1000, interval: 1000, onEnd }); act(() => { vi.advanceTimersByTime(6000); }); expect(onEnd).toBeCalled(); }); test('timeLeft should be 0 when leftTime less than current time', () => { const { result } = setup({ leftTime: -5 * 1000 }); expect(result.current[0]).toBe(0); }); test('run with timeLeft should not be reset after targetDate changed', async () => { let targetDate = Date.now() + 8000; const { result, rerender } = setup({ leftTime: 6000, targetDate, }); expect(result.current[0]).toBe(6000); act(() => { vi.advanceTimersByTime(2000); }); rerender({ leftTime: 6000, targetDate: targetDate, }); expect(result.current[0]).toBe(4000); targetDate = Date.now() + 9000; rerender({ leftTime: 6000, targetDate: targetDate, }); expect(result.current[0]).toBe(4000); }); }); ================================================ FILE: packages/hooks/src/useCountDown/demo/demo1.tsx ================================================ /** * title: Basic Usage * desc: Basic countdown management. * * title.zh-CN: 基础用法 * desc.zh-CN: 基础的倒计时管理。 */ import { useCountDown } from 'ahooks'; export default () => { const [, formattedRes] = useCountDown({ targetDate: `${new Date().getFullYear()}-12-31 23:59:59`, }); const { days, hours, minutes, seconds, milliseconds } = formattedRes; return (

There are {days} days {hours} hours {minutes} minutes {seconds} seconds {milliseconds}{' '} milliseconds until {new Date().getFullYear()}-12-31 23:59:59

); }; ================================================ FILE: packages/hooks/src/useCountDown/demo/demo2.tsx ================================================ /** * title: Adcanved Uasge * desc: Dynamic change targetDate, suitable for verification codes or similar scenarios. * * title.zh-CN: 进阶使用 * desc.zh-CN: 动态变更配置项, 适用于验证码或类似场景,时间结束后会触发 onEnd 回调。 */ import { useState } from 'react'; import { useCountDown } from 'ahooks'; export default () => { const [targetDate, setTargetDate] = useState(); const [countdown] = useCountDown({ targetDate, onEnd: () => { alert('End of the time'); }, }); return ( <> ); }; ================================================ FILE: packages/hooks/src/useCountDown/demo/demo3.tsx ================================================ /** * title: The rest of time * desc: A countdown to the number of milliseconds remaining. * * title.zh-CN: 剩余时间 * desc.zh-CN: 剩余时间毫秒数的倒计时 */ import React from 'react'; import { useCountDown } from 'ahooks'; const App: React.FC = () => { const [countdown] = useCountDown({ leftTime: 60 * 1000 }); return

{countdown}

; }; export default App; ================================================ FILE: packages/hooks/src/useCountDown/index.en-US.md ================================================ --- nav: path: /hooks --- # useCountDown A hook for manage countdown. ## Countdown to target time ## Dynamic config ## Config leftTime ## API ```typescript type TDate = Date | number | string | undefined; interface FormattedRes { days: number; hours: number; minutes: number; seconds: number; milliseconds: number; } const [countdown, formattedRes] = useCountDown( { leftTime, targetDate, interval, onEnd } ); ``` **Remark** The precision of useCountDown is milliseconds, which may cause the following problems - Even if the interval time is set to 1000ms, the update interval of useCountDown may not be exactly 1000ms, but around it. - In the second demo, countdown is generally 499x milliseconds at the beginning due to the execution delay of the program. If you only need to be accurate to the second, you can use it like this `Math.round(countdown / 1000)`. If both `leftTime` and `targetDate` are passed, the `targetDate` is ignored, the `leftTime` is dominant. ### Params | Property | Description | Type | Default | | ---------- | -------------------------------------------- | ------------ | ------- | | leftTime | The rest of time, in milliseconds | `number` | - | | targetDate | Target time | `TDate` | - | | interval | Time interval between ticks, in milliseconds | `number` | `1000` | | onEnd | Function to call when countdown completes | `() => void` | - | ### Return | Params | Description | Type | | --------------- | ---------------------------------------- | -------------- | | countdown | Timestamp to targetDate, in milliseconds | `number` | | formattedResult | Formatted countdown | `FormattedRes` | ## Remark `leftTime`、`targetDate`、`interval`、`onEnd` support dynamic change. ================================================ FILE: packages/hooks/src/useCountDown/index.ts ================================================ import dayjs from 'dayjs'; import { useEffect, useMemo, useState } from 'react'; import useLatest from '../useLatest'; import { isNumber } from '../utils/index'; export type TDate = dayjs.ConfigType; export interface Options { leftTime?: number; targetDate?: TDate; interval?: number; onEnd?: () => void; } export interface FormattedRes { days: number; hours: number; minutes: number; seconds: number; milliseconds: number; } const calcLeft = (target?: TDate) => { if (!target) { return 0; } // https://stackoverflow.com/questions/4310953/invalid-date-in-safari const left = dayjs(target).valueOf() - Date.now(); return left < 0 ? 0 : left; }; const parseMs = (milliseconds: number): FormattedRes => { return { days: Math.floor(milliseconds / 86400000), hours: Math.floor(milliseconds / 3600000) % 24, minutes: Math.floor(milliseconds / 60000) % 60, seconds: Math.floor(milliseconds / 1000) % 60, milliseconds: Math.floor(milliseconds) % 1000, }; }; const useCountdown = (options: Options = {}) => { const { leftTime, targetDate, interval = 1000, onEnd } = options || {}; const memoLeftTime = useMemo(() => { return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined; }, [leftTime]); const target = 'leftTime' in options ? memoLeftTime : targetDate; const [timeLeft, setTimeLeft] = useState(() => calcLeft(target)); const onEndRef = useLatest(onEnd); useEffect(() => { if (!target) { // for stop setTimeLeft(0); return; } // 立即执行一次 setTimeLeft(calcLeft(target)); const timer = setInterval(() => { const targetLeft = calcLeft(target); setTimeLeft(targetLeft); if (targetLeft === 0) { clearInterval(timer); onEndRef.current?.(); } }, interval); return () => clearInterval(timer); }, [target, interval]); const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]); return [timeLeft, formattedRes] as const; }; export default useCountdown; ================================================ FILE: packages/hooks/src/useCountDown/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useCountDown 一个用于管理倒计时的 Hook。 ## 到未来某一时间点的倒计时 ## 配置项动态变化 ## 通过 leftTime 配置剩余时间 **说明** useCountDown 的精度为毫秒,可能会造成以下几个问题 - 即使设置 interval 时间为 1000 毫秒,useCountDown 每次更新间隔也**不一定**正好是 1000 毫秒,而是 1000 毫秒左右。 - 在第二个 demo 中,countdown 开始一般是 499x 毫秒,因为程序执行有延迟。 如果你的精度只要到秒就好了,可以这样用 `Math.round(countdown / 1000)`。 如果同时传了 `leftTime` 和 `targetDate`,则会忽略 `targetDate`,以 `leftTime` 为主 ## API ```typescript type TDate = Date | number | string | undefined; interface FormattedRes { days: number; hours: number; minutes: number; seconds: number; milliseconds: number; } const [countdown, formattedRes] = useCountDown( { leftTime, targetDate, interval, onEnd } ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ---------- | -------------------- | ------------ | ------ | | leftTime | 剩余时间(毫秒) | `number` | - | | targetDate | 目标时间 | `TDate` | - | | interval | 变化时间间隔(毫秒) | `number` | `1000` | | onEnd | 倒计时结束触发 | `() => void` | - | ### Result | 参数 | 说明 | 类型 | | ------------ | -------------------- | -------------- | | countdown | 倒计时时间戳(毫秒) | `number` | | formattedRes | 格式化后的倒计时 | `FormattedRes` | ## 备注 `leftTime`、`targetDate`、`interval`、`onEnd` 支持动态变化 ================================================ FILE: packages/hooks/src/useCounter/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import type { Options } from '../index'; import useCounter from '../index'; const setUp = (init?: number, options?: Options) => renderHook(() => useCounter(init, options)); describe('useCounter', () => { test('should init counter', () => { const { result } = setUp(100); const [current] = result.current; expect(current).toBe(100); }); test('should max, min, actions work', () => { const { result } = setUp(100, { max: 10, min: 1 }); const [current, { inc, dec, reset, set }] = result.current; expect(current).toBe(10); act(() => { inc(1); }); expect(result.current[0]).toBe(10); act(() => { dec(100); }); expect(result.current[0]).toBe(1); act(() => { inc(); }); expect(result.current[0]).toBe(2); act(() => { reset(); }); expect(result.current[0]).toBe(10); act(() => { set(-1000); }); expect(result.current[0]).toBe(1); act(() => { set((c) => c + 2); }); expect(result.current[0]).toBe(3); act(() => { inc(); inc(); inc(); }); expect(result.current[0]).toBe(6); }); }); ================================================ FILE: packages/hooks/src/useCounter/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Simple example of counter management. * * title.zh-CN: 基础用法 * desc.zh-CN: 简单的 counter 管理示例。 */ import { useCounter } from 'ahooks'; export default () => { const [current, { inc, dec, set, reset }] = useCounter(100, { min: 1, max: 10 }); return (

{current} [max: 10; min: 1;]

); }; ================================================ FILE: packages/hooks/src/useCounter/index.en-US.md ================================================ --- nav: path: /hooks --- # useCounter A hook that manage counter. ## Examples ### Default usage ## API ```typescript const [current, { inc, dec, set, reset }] = useCounter(initialValue, { min, max }); ``` ### Result | Property | Description | Type | | -------- | ------------------------------------ | ------------------------------------------------------ | | current | Current value | `number` | | inc | Increment, default delta is 1 | `(delta?: number) => void` | | dec | Decrement, default delta is 1 | `(delta?: number) => void` | | set | Set current value | `(value: number` \| `((c: number) => number)) => void` | | reset | Reset current value to initial value | `() => void` | ### Params | Property | Description | Type | Default | | ------------ | ------------- | -------- | ------- | | initialValue | Initial count | `number` | `0` | | min | Min count | `number` | - | | max | Max count | `number` | - | ================================================ FILE: packages/hooks/src/useCounter/index.ts ================================================ import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isNumber } from '../utils'; export interface Options { min?: number; max?: number; } export interface Actions { inc: (delta?: number) => void; dec: (delta?: number) => void; set: (value: number | ((c: number) => number)) => void; reset: () => void; } export type ValueParam = number | ((c: number) => number); function getTargetValue(val: number, options: Options = {}) { const { min, max } = options; let target = val; if (isNumber(max)) { target = Math.min(max, target); } if (isNumber(min)) { target = Math.max(min, target); } return target; } function useCounter(initialValue: number = 0, options: Options = {}) { const { min, max } = options; const [current, setCurrent] = useState(() => { return getTargetValue(initialValue, { min, max, }); }); const setValue = (value: ValueParam) => { setCurrent((c) => { const target = isNumber(value) ? value : value(c); return getTargetValue(target, { max, min, }); }); }; const inc = (delta: number = 1) => { setValue((c) => c + delta); }; const dec = (delta: number = 1) => { setValue((c) => c - delta); }; const set = (value: ValueParam) => { setValue(value); }; const reset = () => { setValue(initialValue); }; return [ current, { inc: useMemoizedFn(inc), dec: useMemoizedFn(dec), set: useMemoizedFn(set), reset: useMemoizedFn(reset), }, ] as const; } export default useCounter; ================================================ FILE: packages/hooks/src/useCounter/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useCounter 管理计数器的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const [current, { inc, dec, set, reset }] = useCounter(initialValue, { min, max }); ``` ### Result | 参数 | 说明 | 类型 | | ------- | ------------ | ------------------------------------------------------ | | current | 当前值 | `number` | | inc | 加,默认加 1 | `(delta?: number) => void` | | dec | 减,默认减 1 | `(delta?: number) => void` | | set | 设置 current | `(value: number` \| `((c: number) => number)) => void` | | reset | 重置为默认值 | `() => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | ------ | -------- | ------ | | initialValue | 默认值 | `number` | `0` | | min | 最小值 | `number` | - | | max | 最大值 | `number` | - | ================================================ FILE: packages/hooks/src/useCreation/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import useCreation from '../index'; describe('useCreation', () => { class Foo { constructor() { this.data = Math.random(); } data: number; } const setUp = () => renderHook(() => { const [count, setCount] = useState(0); const [, setFlag] = useState({}); const foo = useCreation(() => new Foo(), [count]); return { foo, setCount, count, setFlag, }; }); test('should work', () => { const hook = setUp(); const { foo } = hook.result.current; act(() => { hook.result.current.setFlag({}); }); expect(hook.result.current.foo).toBe(foo); act(() => { hook.result.current.setCount(1); }); expect(hook.result.current.foo).not.toBe(foo); }); }); ================================================ FILE: packages/hooks/src/useCreation/demo/demo1.tsx ================================================ /** * title: Make sure only one instance is created * desc: You can click the "Rerender" button and trigger the update of this component. But the instance of Foo will not change. * * title.zh-CN: 确保实例不会被重复创建 * desc.zh-CN: 点击 "Rerender" 按钮,触发组件的更新,但 Foo 的实例会保持不变 */ import { useState } from 'react'; import { useCreation } from 'ahooks'; class Foo { constructor() { this.data = Math.random(); } data: number; } export default function () { const foo = useCreation(() => new Foo(), []); const [, setFlag] = useState({}); return ( <>

{foo.data}

); } ================================================ FILE: packages/hooks/src/useCreation/index.en-US.md ================================================ --- nav: path: /hooks --- # useCreation `useCreation` is the replacement for `useMemo` or `useRef`. `useMemo` can't guarantee the memoized value will not be recalculated, while `useCreation` can guarantee that. As the the official document of React.js says: > **You may rely on useMemo as a performance optimization, not as a semantic guarantee.** In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without `useMemo` — and then add it to optimize performance. And similar to `useRef`, you can use `useCreation` to create some constants. But `useCreation` can avoid performance hazards. ```javascript const a = useRef(new Subject()); // A new Subject instance is created in every render. const b = useCreation(() => new Subject(), []); // By using factory function, Subject is only instantiated once. ``` ## Examples ### Default usage ## API ```javascript function useCreation(factory: () => T, deps: any[]): T; ``` ### Params | Property | Description | Type | Default | | -------- | ---------------------------------------- | ----------- | ------- | | factory | A function used for creating the object. | `() => any` | - | | deps | The dependencies list. | `any[]` | - | ================================================ FILE: packages/hooks/src/useCreation/index.ts ================================================ import type { DependencyList } from 'react'; import { useRef } from 'react'; import depsAreSame from '../utils/depsAreSame'; const useCreation = (factory: () => T, deps: DependencyList) => { const { current } = useRef({ deps, obj: undefined as T, initialized: false, }); if (current.initialized === false || !depsAreSame(current.deps, deps)) { current.deps = deps; current.obj = factory(); current.initialized = true; } return current.obj; }; export default useCreation; ================================================ FILE: packages/hooks/src/useCreation/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useCreation `useCreation` 是 `useMemo` 或 `useRef` 的替代品。 因为 `useMemo` 不能保证被 memo 的值一定不会被重新计算,而 `useCreation` 可以保证这一点。以下为 React 官方文档中的介绍: > **You may rely on useMemo as a performance optimization, not as a semantic guarantee.** In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without `useMemo` — and then add it to optimize performance. 而相比于 `useRef`,你可以使用 `useCreation` 创建一些常量,这些常量和 `useRef` 创建出来的 ref 有很多使用场景上的相似,但对于复杂常量的创建,`useRef` 却容易出现潜在的性能隐患。 ```javascript const a = useRef(new Subject()); // 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了 const b = useCreation(() => new Subject(), []); // 通过 factory 函数,可以避免性能隐患 ``` ## 代码演示 ### 基础用法 ## API ```typescript function useCreation(factory: () => T, deps: any[]): T; ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ---------------------- | ----------- | ------ | | factory | 用来创建所需对象的函数 | `() => any` | - | | deps | 传入依赖变化的对象 | `any[]` | - | ================================================ FILE: packages/hooks/src/useDebounce/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useDebounce from '../index'; describe('useDebounce', () => { test('useDebounce wait:200ms', async () => { let mountedState = 0; const { result, rerender } = renderHook(() => useDebounce(mountedState, { wait: 200 })); expect(result.current).toBe(0); mountedState = 1; rerender(); await sleep(50); expect(result.current).toBe(0); mountedState = 2; rerender(); await sleep(100); expect(result.current).toBe(0); mountedState = 3; rerender(); await sleep(150); expect(result.current).toBe(0); mountedState = 4; rerender(); await act(async () => { await sleep(250); }); expect(result.current).toBe(4); }); }); ================================================ FILE: packages/hooks/src/useDebounce/debounceOptions.ts ================================================ export interface DebounceOptions { wait?: number; leading?: boolean; trailing?: boolean; maxWait?: number; } ================================================ FILE: packages/hooks/src/useDebounce/demo/demo1.tsx ================================================ /** * title: Default usage * desc: DebouncedValue will change after the input ends 500ms. * * title.zh-CN: 基础用法 * desc.zh-CN: DebouncedValue 只会在输入结束 500ms 后变化。 */ import { useState } from 'react'; import { useDebounce } from 'ahooks'; export default () => { const [value, setValue] = useState(); const debouncedValue = useDebounce(value, { wait: 500 }); return (
setValue(e.target.value)} placeholder="Typed value" style={{ width: 280 }} />

DebouncedValue: {debouncedValue}

); }; ================================================ FILE: packages/hooks/src/useDebounce/index.en-US.md ================================================ --- nav: path: /hooks --- # useDebounce A hook that deal with the debounced value. ## Examples ### Default usage ## API ```typescript const debouncedValue = useDebounce( value: any, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ---------------------------------- | --------- | ------- | | value | The value to debounce. | `any` | - | | options | Config for the debounce behaviors. | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ------------------------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to delay. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `false` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | | maxWait | The maximum time func is allowed to be delayed before it’s invoked. | `number` | - | ================================================ FILE: packages/hooks/src/useDebounce/index.ts ================================================ import { useEffect, useState } from 'react'; import useDebounceFn from '../useDebounceFn'; import type { DebounceOptions } from './debounceOptions'; function useDebounce(value: T, options?: DebounceOptions) { const [debounced, setDebounced] = useState(value); const { run } = useDebounceFn(() => { setDebounced(value); }, options); useEffect(() => { run(); }, [value]); return debounced; } export default useDebounce; ================================================ FILE: packages/hooks/src/useDebounce/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDebounce 用来处理防抖值的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const debouncedValue = useDebounce( value: any, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | -------------- | --------- | ------ | | value | 需要防抖的值 | `any` | - | | options | 配置防抖的行为 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------------------ | --------- | ------- | | wait | 超时时间,单位为毫秒 | `number` | `1000` | | leading | 是否在延迟开始前调用函数 | `boolean` | `false` | | trailing | 是否在延迟开始后调用函数 | `boolean` | `true` | | maxWait | 最大等待时间,单位为毫秒 | `number` | - | ================================================ FILE: packages/hooks/src/useDebounceEffect/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useDebounceEffect from '../index'; let hook: RenderHookResult; describe('useDebounceEffect', () => { test('useDebounceEffect should work', async () => { let mountedState = 1; const mockEffect = vi.fn(() => {}); const mockCleanUp = vi.fn(() => {}); act(() => { hook = renderHook(() => useDebounceEffect( () => { mockEffect(); return () => { mockCleanUp(); }; }, [mountedState], { wait: 200 }, ), ); }); expect(mockEffect.mock.calls.length).toBe(0); expect(mockCleanUp.mock.calls.length).toBe(0); mountedState = 2; hook.rerender(); await sleep(50); mountedState = 3; hook.rerender(); expect(mockEffect.mock.calls.length).toBe(0); expect(mockCleanUp.mock.calls.length).toBe(0); await act(async () => { await sleep(300); }); expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(0); mountedState = 4; hook.rerender(); expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(0); await act(async () => { await sleep(300); }); expect(mockEffect.mock.calls.length).toBe(2); expect(mockCleanUp.mock.calls.length).toBe(1); }); test('should cancel timeout on unmount', async () => { const mockEffect = vi.fn(() => {}); const mockCleanUp = vi.fn(() => {}); const hook2 = renderHook( (props) => useDebounceEffect( () => { mockEffect(); return () => { mockCleanUp(); }; }, [props], { wait: 200 }, ), { initialProps: 0 }, ); expect(mockEffect.mock.calls.length).toBe(0); expect(mockCleanUp.mock.calls.length).toBe(0); hook2.rerender(1); await sleep(50); expect(mockEffect.mock.calls.length).toBe(0); expect(mockCleanUp.mock.calls.length).toBe(0); await act(async () => { await sleep(300); }); expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(0); hook2.rerender(2); await act(async () => { await sleep(300); }); expect(mockEffect.mock.calls.length).toBe(2); expect(mockCleanUp.mock.calls.length).toBe(1); hook2.unmount(); expect(mockEffect.mock.calls.length).toBe(2); expect(mockCleanUp.mock.calls.length).toBe(2); }); }); ================================================ FILE: packages/hooks/src/useDebounceEffect/demo/demo1.tsx ================================================ import { useDebounceEffect } from 'ahooks'; import { useState } from 'react'; export default () => { const [value, setValue] = useState('hello'); const [records, setRecords] = useState([]); useDebounceEffect( () => { setRecords((val) => [...val, value]); }, [value], { wait: 1000, }, ); return (
setValue(e.target.value)} placeholder="Typed value" style={{ width: 280 }} />

    {records.map((record, index) => (
  • {record}
  • ))}

); }; ================================================ FILE: packages/hooks/src/useDebounceEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useDebounceEffect Debounce your `useEffect`. ## Examples ### Default usage ## API ```typescript useDebounceEffect( effect: EffectCallback, deps?: DependencyList, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ----------------------------------------------------------------------------- | ---------------- | ------- | | effect | The effect callback. | `EffectCallback` | - | | deps | The dependencies list. | `DependencyList` | - | | options | Config for the debounce behaviors. See the Options section below for details. | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ------------------------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to wait. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `false` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | | maxWait | The maximum time func is allowed to be delayed before it’s invoked. | `number` | - | ================================================ FILE: packages/hooks/src/useDebounceEffect/index.ts ================================================ import { useEffect, useState } from 'react'; import type { DependencyList, EffectCallback } from 'react'; import type { DebounceOptions } from '../useDebounce/debounceOptions'; import useDebounceFn from '../useDebounceFn'; import useUpdateEffect from '../useUpdateEffect'; function useDebounceEffect( effect: EffectCallback, deps?: DependencyList, options?: DebounceOptions, ) { const [flag, setFlag] = useState({}); const { run } = useDebounceFn(() => { setFlag({}); }, options); useEffect(() => { return run(); }, deps); useUpdateEffect(effect, [flag]); } export default useDebounceEffect; ================================================ FILE: packages/hooks/src/useDebounceEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDebounceEffect 为 `useEffect` 增加防抖的能力。 ## 代码演示 ### 基础用法 ## API ```typescript useDebounceEffect( effect: EffectCallback, deps?: DependencyList, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ---------------------------------- | ---------------- | ------ | | effect | 执行函数 | `EffectCallback` | - | | deps | 依赖数组 | `DependencyList` | - | | options | 配置防抖的行为,详见下面的 Options | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------------------ | --------- | ------- | | wait | 等待时间,单位为毫秒 | `number` | `1000` | | leading | 是否在在延迟开始前调用 | `boolean` | `false` | | trailing | 是否在在延迟结束后调用 | `boolean` | `true` | | maxWait | 最大等待时间,单位为毫秒 | `number` | - | ================================================ FILE: packages/hooks/src/useDebounceFn/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useDebounceFn from '../index'; interface ParamsObj { fn: (...arg: any) => any; deps?: any[]; wait: number; } let count = 0; const debounceFn = (gap: number) => { count += gap; }; const setUp = ({ fn, wait }: ParamsObj) => renderHook(() => useDebounceFn(fn, { wait })); let hook: RenderHookResult; describe('useDebounceFn', () => { test('run, cancel and flush should work', async () => { act(() => { hook = setUp({ fn: debounceFn, wait: 200, }); }); await act(async () => { hook.result.current.run(2); hook.result.current.run(2); hook.result.current.run(2); hook.result.current.run(2); expect(count).toBe(0); await sleep(300); expect(count).toBe(2); hook.result.current.run(4); expect(count).toBe(2); await sleep(300); expect(count).toBe(6); hook.result.current.run(4); expect(count).toBe(6); hook.result.current.cancel(); expect(count).toBe(6); await sleep(300); expect(count).toBe(6); hook.result.current.run(1); expect(count).toBe(6); hook.result.current.flush(); expect(count).toBe(7); await sleep(300); expect(count).toBe(7); }); }); }); ================================================ FILE: packages/hooks/src/useDebounceFn/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Frequent calls run, but the function is executed only after all the clicks have completed 500ms. * * title.zh-CN: 基础用法 * desc.zh-CN: 频繁调用 run,但只会在所有点击完成 500ms 后执行一次相关函数 */ import { useDebounceFn } from 'ahooks'; import { useState } from 'react'; export default () => { const [value, setValue] = useState(0); const { run } = useDebounceFn( () => { setValue(value + 1); }, { wait: 500, }, ); return (

Clicked count: {value}

); }; ================================================ FILE: packages/hooks/src/useDebounceFn/index.en-US.md ================================================ --- nav: path: /hooks --- # useDebounceFn A hook that deal with the debounced function. ## Examples ### Default usage ## API ```typescript const { run, cancel, flush } = useDebounceFn( fn: (...args: any[]) => any, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ---------------------------------- | ------------------------- | ------- | | fn | The function to debounce. | `(...args: any[]) => any` | - | | options | Config for the debounce behaviors. | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ------------------------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to delay. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `false` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | | maxWait | The maximum time func is allowed to be delayed before it’s invoked. | `number` | - | ### Result | Property | Description | Type | | -------- | ------------------------------------------------------ | ------------------------- | | run | invoke and pass parameters to fn. | `(...args: any[]) => any` | | cancel | Cancel the invocation of currently debounced function. | `() => void` | | flush | Immediately invoke currently debounced function. | `() => void` | ================================================ FILE: packages/hooks/src/useDebounceFn/index.ts ================================================ import { debounce } from '../utils/lodash-polyfill'; import { useMemo } from 'react'; import type { DebounceOptions } from '../useDebounce/debounceOptions'; import useLatest from '../useLatest'; import useUnmount from '../useUnmount'; import { isFunction } from '../utils'; import isDev from '../utils/isDev'; type noop = (...args: any[]) => any; function useDebounceFn(fn: T, options?: DebounceOptions) { if (isDev) { if (!isFunction(fn)) { console.error(`useDebounceFn expected parameter is a function, got ${typeof fn}`); } } const fnRef = useLatest(fn); const wait = options?.wait ?? 1000; const debounced = useMemo( () => debounce( (...args: Parameters): ReturnType => { return fnRef.current(...args); }, wait, options, ), [], ); useUnmount(() => { debounced.cancel(); }); return { run: debounced, cancel: debounced.cancel, flush: debounced.flush, }; } export default useDebounceFn; ================================================ FILE: packages/hooks/src/useDebounceFn/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDebounceFn 用来处理防抖函数的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const { run, cancel, flush } = useDebounceFn( fn: (...args: any[]) => any, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ------------------ | ------------------------- | ------ | | fn | 需要防抖执行的函数 | `(...args: any[]) => any` | - | | options | 配置防抖的行为 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------------------ | --------- | ------- | | wait | 等待时间,单位为毫秒 | `number` | `1000` | | leading | 是否在延迟开始前调用函数 | `boolean` | `false` | | trailing | 是否在延迟开始后调用函数 | `boolean` | `true` | | maxWait | 最大等待时间,单位为毫秒 | `number` | - | ### Result | 参数 | 说明 | 类型 | | ------ | ---------------------------------- | ------------------------- | | run | 触发执行 fn,函数参数将会传递给 fn | `(...args: any[]) => any` | | cancel | 取消当前防抖 | `() => void` | | flush | 立即调用当前防抖函数 | `() => void` | ================================================ FILE: packages/hooks/src/useDeepCompareEffect/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import useDeepCompareEffect from '../index'; describe('useDeepCompareEffect', () => { test('test deep compare', async () => { const hook = renderHook(() => { const [x, setX] = useState(0); const [y, setY] = useState({}); useDeepCompareEffect(() => { setX((prevState) => prevState + 1); }, [y]); return { x, setY }; }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({}); }); expect(hook.result.current.x).toBe(1); }); }); ================================================ FILE: packages/hooks/src/useDeepCompareEffect/demo/demo1.tsx ================================================ import { useDeepCompareEffect } from 'ahooks'; import { useEffect, useState, useRef } from 'react'; export default () => { const [, setCount] = useState(0); const effectCountRef = useRef(0); const deepCompareCountRef = useRef(0); useEffect(() => { effectCountRef.current += 1; }, [{}]); useDeepCompareEffect(() => { deepCompareCountRef.current += 1; return () => { // do something }; }, [{}]); return (

effectCount: {effectCountRef.current}

deepCompareCount: {deepCompareCountRef.current}

); }; ================================================ FILE: packages/hooks/src/useDeepCompareEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useDeepCompareEffect Usage is the same as `useEffect`, but deps are compared with [react-fast-compare](https://www.npmjs.com/package/react-fast-compare). ## Examples ### Default usage ## API ```typescript useDeepCompareEffect( effect: React.EffectCallback, deps: React.DependencyList ); ``` ================================================ FILE: packages/hooks/src/useDeepCompareEffect/index.tsx ================================================ import { useEffect } from 'react'; import { createDeepCompareEffect } from '../createDeepCompareEffect'; export default createDeepCompareEffect(useEffect); ================================================ FILE: packages/hooks/src/useDeepCompareEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDeepCompareEffect 用法与 useEffect 一致,但 deps 通过 [react-fast-compare](https://www.npmjs.com/package/react-fast-compare) 进行深比较。 ## 代码演示 ### 基础用法 ## API ```typescript useDeepCompareEffect( effect: React.EffectCallback, deps: React.DependencyList ); ``` ================================================ FILE: packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import useDeepCompareLayoutEffect from '../index'; describe('useDeepCompareLayoutEffect', () => { test('test deep compare', async () => { const hook = renderHook(() => { const [x, setX] = useState(0); const [y, setY] = useState({}); useDeepCompareLayoutEffect(() => { setX((x) => x + 1); }, [y]); return { x, setY }; }); expect(hook.result.current.x).toBe(1); await act(async () => { hook.result.current.setY({}); }); expect(hook.result.current.x).toBe(1); }); }); ================================================ FILE: packages/hooks/src/useDeepCompareLayoutEffect/demo/demo1.tsx ================================================ import { useDeepCompareLayoutEffect } from 'ahooks'; import { useLayoutEffect, useState, useRef } from 'react'; export default () => { const [, setCount] = useState(0); const effectCountRef = useRef(0); const deepCompareCountRef = useRef(0); useLayoutEffect(() => { effectCountRef.current += 1; }, [{}]); useDeepCompareLayoutEffect(() => { deepCompareCountRef.current += 1; return () => { // do something }; }, [{}]); return (

effectCount: {effectCountRef.current}

deepCompareCount: {deepCompareCountRef.current}

); }; ================================================ FILE: packages/hooks/src/useDeepCompareLayoutEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useDeepCompareLayoutEffect Usage is the same as `useLayoutEffect`, but deps are compared with [react-fast-compare](https://www.npmjs.com/package/react-fast-compare). ## Examples ### Default usage ## API ```typescript useDeepCompareLayoutEffect( effect: React.EffectCallback, deps: React.DependencyList ); ``` ================================================ FILE: packages/hooks/src/useDeepCompareLayoutEffect/index.tsx ================================================ import { useLayoutEffect } from 'react'; import { createDeepCompareEffect } from '../createDeepCompareEffect'; export default createDeepCompareEffect(useLayoutEffect); ================================================ FILE: packages/hooks/src/useDeepCompareLayoutEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDeepCompareLayoutEffect 用法与 useLayoutEffect 一致,但 deps 通过 [react-fast-compare](https://www.npmjs.com/package/react-fast-compare) 进行深比较。 ## 代码演示 ### 基础用法 ## API ```typescript useDeepCompareLayoutEffect( effect: React.EffectCallback, deps: React.DependencyList ); ``` ================================================ FILE: packages/hooks/src/useDocumentVisibility/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { afterAll, describe, expect, test, vi } from 'vitest'; import useDocumentVisibility from '../index'; const mockIsBrowser = vi.fn(); const mockDocumentVisibilityState = vi.spyOn(document, 'visibilityState', 'get'); vi.mock('../../utils/isBrowser', () => { return { __esModule: true, get default() { return mockIsBrowser(); }, }; }); afterAll(() => { vi.clearAllMocks(); }); describe('useDocumentVisibility', () => { test('isBrowser effect correct', async () => { mockDocumentVisibilityState.mockReturnValue('hidden'); // Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true }); mockIsBrowser.mockReturnValue(false); const { result } = renderHook(() => useDocumentVisibility()); expect(result.current).toBe('visible'); }); test('visibilitychange update correct ', async () => { mockDocumentVisibilityState.mockReturnValue('hidden'); // Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true }); mockIsBrowser.mockReturnValue(true); const { result } = renderHook(() => useDocumentVisibility()); expect(result.current).toBe('hidden'); mockDocumentVisibilityState.mockReturnValue('visible'); // Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true }); act(() => { document.dispatchEvent(new Event('visibilitychange')); }); expect(result.current).toBe('visible'); }); }); ================================================ FILE: packages/hooks/src/useDocumentVisibility/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Listen to document visibility change. * * title.zh-CN: 基础用法 * desc.zh-CN: 监听 document 的可见状态 */ import { useEffect } from 'react'; import { useDocumentVisibility } from 'ahooks'; export default () => { const documentVisibility = useDocumentVisibility(); useEffect(() => { console.log(`Current document visibility state: ${documentVisibility}`); }, [documentVisibility]); return
Current document visibility state: {documentVisibility}
; }; ================================================ FILE: packages/hooks/src/useDocumentVisibility/index.en-US.md ================================================ --- nav: path: /hooks --- # useDocumentVisibility A Hook can tell if the page is visible, refer to [visibilityState API](https://developer.mozilla.org/docs/Web/API/Document/visibilityState) ## Examples ## API ```typescript const documentVisibility = useDocumentVisibility(); ``` ### Result | Property | Description | Type | | ------------------ | ------------------------------- | -------------------------------------------------- | | documentVisibility | Whether the document is visible | `visible`\| `hidden` \| `prerender` \| `undefined` | ================================================ FILE: packages/hooks/src/useDocumentVisibility/index.ts ================================================ import { useState } from 'react'; import useEventListener from '../useEventListener'; import isBrowser from '../utils/isBrowser'; type VisibilityState = 'hidden' | 'visible' | 'prerender' | undefined; const getVisibility = () => { if (!isBrowser) { return 'visible'; } return document.visibilityState; }; function useDocumentVisibility(): VisibilityState { const [documentVisibility, setDocumentVisibility] = useState(getVisibility); useEventListener( 'visibilitychange', () => { setDocumentVisibility(getVisibility()); }, { target: () => document, }, ); return documentVisibility; } export default useDocumentVisibility; ================================================ FILE: packages/hooks/src/useDocumentVisibility/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDocumentVisibility 监听页面是否可见,参考 [visibilityState API](https://developer.mozilla.org/docs/Web/API/Document/visibilityState) ## 代码演示 ### 基础用法 ## API ```typescript const documentVisibility = useDocumentVisibility(); ``` ### Result | 参数 | 说明 | 类型 | | ------------------ | ------------------------------ | -------------------------------------------------- | | documentVisibility | 判断 document 是否处于可见状态 | `visible`\| `hidden` \| `prerender` \| `undefined` | ================================================ FILE: packages/hooks/src/useDrag/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { BasicTarget } from '../../utils/domTarget'; import type { Options } from '../index'; import useDrag from '../index'; const setup = (data: T, target: BasicTarget, options?: Options) => renderHook((newData: T) => useDrag(newData ? newData : data, target, options)); const events: Record void> = {}; const mockTarget = { addEventListener: vi.fn((event, callback) => { events[event] = callback; }), removeEventListener: vi.fn((event) => { Reflect.deleteProperty(events, event); }), setAttribute: vi.fn(), }; describe('useDrag', () => { beforeEach(() => { vi.clearAllMocks(); }); test('should add/remove listener on mount/unmount', () => { const { unmount } = setup(1, mockTarget as any); expect(mockTarget.addEventListener).toBeCalled(); expect(mockTarget.addEventListener.mock.calls[0][0]).toBe('dragstart'); expect(mockTarget.addEventListener.mock.calls[1][0]).toBe('dragend'); expect(mockTarget.setAttribute).toBeCalledWith('draggable', 'true'); unmount(); expect(mockTarget.removeEventListener).toBeCalled(); }); test('should trigger drag callback', () => { const onDragStart = vi.fn(); const onDragEnd = vi.fn(); const mockEvent = { dataTransfer: { setData: vi.fn(), }, }; const hook = setup(1, mockTarget as any, { onDragStart, onDragEnd, }); events.dragstart(mockEvent); expect(onDragStart).toBeCalled(); expect(mockEvent.dataTransfer.setData).toBeCalledWith('custom', '1'); events.dragend(mockEvent); expect(onDragEnd).toBeCalled(); hook.rerender(2); events.dragstart(mockEvent); expect(onDragStart).toBeCalled(); expect(mockEvent.dataTransfer.setData).toHaveBeenLastCalledWith('custom', '2'); events.dragend(mockEvent); expect(onDragEnd).toBeCalled(); }); test(`should not work when target don't support addEventListener method`, () => { Object.defineProperty(mockTarget, 'addEventListener', { get() { return false; }, }); setup(1, mockTarget as any); expect(mockTarget.setAttribute).not.toBeCalled(); }); }); ================================================ FILE: packages/hooks/src/useDrag/index.ts ================================================ import { useRef } from 'react'; import useLatest from '../useLatest'; import useMount from '../useMount'; import { isString } from '../utils'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; export interface Options { onDragStart?: (event: React.DragEvent) => void; onDragEnd?: (event: React.DragEvent) => void; dragImage?: { image: string | Element; offsetX?: number; offsetY?: number; }; } const useDrag = (data: T, target: BasicTarget, options: Options = {}) => { const optionsRef = useLatest(options); const dataRef = useLatest(data); const imageElementRef = useRef(undefined); const { dragImage } = optionsRef.current; useMount(() => { if (dragImage?.image) { const { image } = dragImage; if (isString(image)) { const imageElement = new Image(); imageElement.src = image; imageElementRef.current = imageElement; } else { imageElementRef.current = image; } } }); useEffectWithTarget( () => { const targetElement = getTargetElement(target); if (!targetElement?.addEventListener) { return; } const onDragStart = (event: React.DragEvent) => { optionsRef.current.onDragStart?.(event); event.dataTransfer.setData('custom', JSON.stringify(dataRef.current)); if (dragImage?.image && imageElementRef.current) { const { offsetX = 0, offsetY = 0 } = dragImage; event.dataTransfer.setDragImage(imageElementRef.current, offsetX, offsetY); } }; const onDragEnd = (event: React.DragEvent) => { optionsRef.current.onDragEnd?.(event); }; targetElement.setAttribute('draggable', 'true'); targetElement.addEventListener('dragstart', onDragStart as any); targetElement.addEventListener('dragend', onDragEnd as any); return () => { targetElement.removeEventListener('dragstart', onDragStart as any); targetElement.removeEventListener('dragend', onDragEnd as any); }; }, [], target, ); }; export default useDrag; ================================================ FILE: packages/hooks/src/useDrop/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import type { BasicTarget } from '../../utils/domTarget'; import type { Options } from '../index'; import useDrop from '../index'; const setup = (target: unknown, options?: Options) => renderHook(() => useDrop(target as BasicTarget, options)); const events: Record void> = {}; const mockTarget = { addEventListener: vi.fn((event: string, callback: (event?: any) => void) => { events[event] = callback; }), removeEventListener: vi.fn((event) => { Reflect.deleteProperty(events, event); }), }; const mockEvent = { dataTransfer: { getData: (format?: string) => 'mock' as unknown, get items() { return [] as unknown[]; }, get files() { return [] as unknown[]; }, }, clipboardData: { getData: (format?: string) => 'mock' as unknown, get items() { return [] as unknown[]; }, get files() { return [] as unknown[]; }, }, preventDefault: vi.fn(), stopPropagation: vi.fn(), }; describe('useDrop', () => { test(`should not work when target don't support addEventListener method`, () => { const originAddEventListener = mockTarget.addEventListener; Object.defineProperty(mockTarget, 'addEventListener', { value: false, }); setup(mockTarget); expect(Object.keys(events)).toHaveLength(0); Object.defineProperty(mockTarget, 'addEventListener', { value: originAddEventListener, }); }); test('should add/remove listener on mount/unmount', () => { const { unmount } = setup(mockTarget); const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop', 'paste']; expect(mockTarget.addEventListener).toBeCalledTimes(eventNames.length); eventNames.forEach((eventName, i) => { expect(mockTarget.addEventListener.mock.calls[i][0]).toBe(eventName); }); unmount(); expect(mockTarget.removeEventListener).toBeCalledTimes(eventNames.length); eventNames.forEach((eventName, i) => { expect(mockTarget.addEventListener.mock.calls[i][0]).toBe(eventName); }); }); test('should call callback', () => { const onDragEnter = vi.fn(); const onDragOver = vi.fn(); const onDragLeave = vi.fn(); const onDrop = vi.fn(); const onPaste = vi.fn(); setup(mockTarget, { onDragEnter, onDragOver, onDragLeave, onDrop, onPaste, }); const callbacks = [onDragEnter, onDragOver, onDragLeave, onDrop, onPaste]; const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop', 'paste']; eventNames.forEach((event) => { events[event](mockEvent); }); callbacks.forEach((callback) => expect(callback).toBeCalled()); }); test('should call onText on drop', async () => { vi.spyOn(mockEvent.dataTransfer, 'items', 'get').mockReturnValue([ { getAsString: (callback: (text: string) => void) => { callback('drop text'); }, }, ]); const onText = vi.fn(); setup(mockTarget, { onText, }); events['dragenter'](mockEvent); events['drop'](mockEvent); expect(onText.mock.calls[0][0]).toBe('drop text'); }); test('should call onFiles on drop', async () => { const file = new File(['hello'], 'hello.png'); vi.spyOn(mockEvent.dataTransfer, 'files', 'get').mockReturnValue([file]); const onFiles = vi.fn(); setup(mockTarget, { onFiles, }); events['dragenter'](mockEvent); events['drop'](mockEvent); expect(onFiles.mock.calls[0][0]).toHaveLength(1); }); test('should call onUri on drop', async () => { const url = 'https://alipay.com'; vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => { if (format === 'text/uri-list') return url; return undefined; }); const onUri = vi.fn(); setup(mockTarget, { onUri, }); events['dragenter'](mockEvent); events['drop'](mockEvent); expect(onUri.mock.calls[0][0]).toBe(url); }); test('should call onDom on drop', async () => { const data = { value: 'mock', }; vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => { if (format === 'custom') return data; return undefined; }); const onDom = vi.fn(); setup(mockTarget, { onDom, }); events['dragenter'](mockEvent); events['drop'](mockEvent); expect(onDom.mock.calls[0][0]).toMatchObject(data); // catch JSON.parse error vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => { if (format === 'custom') return {}; return undefined; }); events['dragenter'](mockEvent); events['drop'](mockEvent); expect(onDom.mock.calls[0][0]).toMatchObject({}); }); test('should call onText on paste', async () => { vi.spyOn(mockEvent.clipboardData, 'items', 'get').mockReturnValue([ { getAsString: (callback: (text: string) => void) => { callback('paste text'); }, }, ]); const onText = vi.fn(); setup(mockTarget, { onText, }); events['dragenter'](mockEvent); events['paste'](mockEvent); expect(onText.mock.calls[0][0]).toBe('paste text'); }); }); ================================================ FILE: packages/hooks/src/useDrop/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: The drop area can accept files, uri, text or one of the boxes below. * * title.zh-CN: 基础用法 * desc.zh-CN: 拖拽区域可以接受文件,链接,文字,和下方的 box 节点。 */ import { useRef, useState } from 'react'; import { useDrop, useDrag } from 'ahooks'; const DragItem = ({ data }: { data: string }) => { const dragRef = useRef(null); const [dragging, setDragging] = useState(false); useDrag(data, dragRef, { onDragStart: () => { setDragging(true); }, onDragEnd: () => { setDragging(false); }, }); return (
{dragging ? 'dragging' : `box-${data}`}
); }; export default () => { const [isHovering, setIsHovering] = useState(false); const dropRef = useRef(null); useDrop(dropRef, { onText: (text, e) => { console.log(e); alert(`'text: ${text}' dropped`); }, onFiles: (files, e) => { console.log(e, files); alert(`${files.length} file dropped`); }, onUri: (uri, e) => { console.log(e); alert(`uri: ${uri} dropped`); }, onDom: (content: string, e) => { alert(`custom: ${content} dropped`); }, onDragEnter: () => setIsHovering(true), onDragLeave: () => setIsHovering(false), }); return (
{isHovering ? 'release here' : 'drop here'}
{['1', '2', '3', '4', '5'].map((e) => ( ))}
); }; ================================================ FILE: packages/hooks/src/useDrop/demo/demo2.tsx ================================================ /** * title: Customize Image * desc: Customize image that follow the mouse pointer during dragging. * * title.zh-CN: 自定义拖拽图像 * desc.zh-CN: 自定义拖拽过程中跟随鼠标指针的图像。 */ import { useRef } from 'react'; import { useDrag } from 'ahooks'; const COMMON_STYLE: React.CSSProperties = { border: '1px solid #e8e8e8', height: '50px', lineHeight: '50px', padding: '16px', textAlign: 'center', marginRight: '16px', }; export default () => { const dragRef = useRef(null); useDrag('', dragRef, { dragImage: { image: '/logo.svg', }, }); return (
drag me
); }; ================================================ FILE: packages/hooks/src/useDrop/index.en-US.md ================================================ --- nav: path: /hooks --- # useDrop & useDrag A pair of hooks to help you manage data transfer between drag and drop > useDrop can be used alone to accept file, text or uri dropping. > > useDrag should be used along with useDrop. > > Paste into the drop area will also be treated as content drop. ## Examples ### Basic Usage ### Customize Image ## API ### useDrag ```typescript useDrag( data: any, target: (() => Element) | Element | MutableRefObject, options?: DragOptions ); ``` #### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | data | Drag data | `any` | - | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | More config | `DragOptions` | - | #### DragOptions | Property | Description | Type | Default | | ----------- | ------------------------------------------------------------- | ------------------------------ | ------- | | onDragStart | On drag start callback | `(e: React.DragEvent) => void` | - | | onDragEnd | On drag end callback | `(e: React.DragEvent) => void` | - | | dragImage | Customize image that follow the mouse pointer during dragging | `DragImageOptions` | - | #### DragImageOptions | 参数 | 说明 | 类型 | 默认值 | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------ | | image | An image Element element to use for the drag feedback image. The image will typically be an [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) element but it can also be a [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) or any other visible element | `string \| Element` | - | | offsetX | the horizontal offset within the image | `number` | 0 | | offsetY | the vertical offset within the image | `number` | 0 | ### useDrop ```typescript useDrop( target: (() => Element) | Element | MutableRefObject, options?: DropOptions ); ``` #### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | More config | `DropOptions` | - | #### DropOptions | Property | Description | Type | Default | | ----------- | ------------------------------------------- | --------------------------------------------- | ------- | | onText | The callback when text is dropped or pasted | `(text: string, e: React.DragEvent) => void` | - | | onFiles | The callback when file is dropped or pasted | `(files: File[], e: React.DragEvent) => void` | - | | onUri | The callback when uri is dropped or pasted | `(text: string, e: React.DragEvent) => void` | - | | onDom | The callback when DOM is dropped or pasted | `(content: any, e: React.DragEvent) => void` | - | | onDrop | The callback when any is dropped | `(e: React.DragEvent) => void` | - | | onPaste | The callback when any is pasted | `(e: React.DragEvent) => void` | - | | onDragEnter | On drag enter callback | `(e: React.DragEvent) => void` | - | | onDragOver | On drag over callback | `(e: React.DragEvent) => void` | - | | onDragLeave | On drag leave callback | `(e: React.DragEvent) => void` | - | ================================================ FILE: packages/hooks/src/useDrop/index.ts ================================================ import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; import { useRef } from 'react'; export interface Options { onFiles?: (files: File[], event?: React.DragEvent) => void; onUri?: (url: string, event?: React.DragEvent) => void; onDom?: (content: any, event?: React.DragEvent) => void; onText?: (text: string, event?: React.ClipboardEvent) => void; onDragEnter?: (event?: React.DragEvent) => void; onDragOver?: (event?: React.DragEvent) => void; onDragLeave?: (event?: React.DragEvent) => void; onDrop?: (event?: React.DragEvent) => void; onPaste?: (event?: React.ClipboardEvent) => void; } const useDrop = (target: BasicTarget, options: Options = {}) => { const optionsRef = useLatest(options); // https://stackoverflow.com/a/26459269 const dragEnterTarget = useRef(undefined); useEffectWithTarget( () => { const targetElement = getTargetElement(target); if (!targetElement?.addEventListener) { return; } const onData = ( dataTransfer: DataTransfer, event: React.DragEvent | React.ClipboardEvent, ) => { const uri = dataTransfer.getData('text/uri-list'); const dom = dataTransfer.getData('custom'); if (dom && optionsRef.current.onDom) { let data = dom; try { data = JSON.parse(dom); } catch { data = dom; } optionsRef.current.onDom(data, event as React.DragEvent); return; } if (uri && optionsRef.current.onUri) { optionsRef.current.onUri(uri, event as React.DragEvent); return; } if (dataTransfer.files && dataTransfer.files.length && optionsRef.current.onFiles) { optionsRef.current.onFiles(Array.from(dataTransfer.files), event as React.DragEvent); return; } if (dataTransfer.items && dataTransfer.items.length && optionsRef.current.onText) { dataTransfer.items[0].getAsString((text) => { optionsRef.current.onText!(text, event as React.ClipboardEvent); }); } }; const onDragEnter = (event: React.DragEvent) => { event.preventDefault(); event.stopPropagation(); dragEnterTarget.current = event.target; optionsRef.current.onDragEnter?.(event); }; const onDragOver = (event: React.DragEvent) => { event.preventDefault(); optionsRef.current.onDragOver?.(event); }; const onDragLeave = (event: React.DragEvent) => { if (event.target === dragEnterTarget.current) { optionsRef.current.onDragLeave?.(event); } }; const onDrop = (event: React.DragEvent) => { event.preventDefault(); onData(event.dataTransfer, event); optionsRef.current.onDrop?.(event); }; const onPaste = (event: React.ClipboardEvent) => { onData(event.clipboardData, event); optionsRef.current.onPaste?.(event); }; targetElement.addEventListener('dragenter', onDragEnter as any); targetElement.addEventListener('dragover', onDragOver as any); targetElement.addEventListener('dragleave', onDragLeave as any); targetElement.addEventListener('drop', onDrop as any); targetElement.addEventListener('paste', onPaste as any); return () => { targetElement.removeEventListener('dragenter', onDragEnter as any); targetElement.removeEventListener('dragover', onDragOver as any); targetElement.removeEventListener('dragleave', onDragLeave as any); targetElement.removeEventListener('drop', onDrop as any); targetElement.removeEventListener('paste', onPaste as any); }; }, [], target, ); }; export default useDrop; ================================================ FILE: packages/hooks/src/useDrop/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDrop & useDrag 处理元素拖拽的 Hook。 > useDrop 可以单独使用来接收文件、文字和网址的拖拽。 > > useDrag 允许一个 DOM 节点被拖拽,需要配合 useDrop 使用。 > > 向节点内触发粘贴动作也会被视为拖拽。 ## 代码演示 ### 基础用法 ### 自定义拖拽图像 ## API ### useDrag ```typescript useDrag( data: any, target: (() => Element) | Element | MutableRefObject, options?: DragOptions ); ``` #### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | --------------------- | ----------------------------------------------------------- | ------ | | data | 拖拽的内容 | `any` | - | | target | DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | 额外的配置项 | `DragOptions` | - | #### DragOptions | 参数 | 说明 | 类型 | 默认值 | | ----------- | ---------------------------------- | ------------------------------ | ------ | | onDragStart | 开始拖拽的回调 | `(e: React.DragEvent) => void` | - | | onDragEnd | 结束拖拽的回调 | `(e: React.DragEvent) => void` | - | | dragImage | 自定义拖拽过程中跟随鼠标指针的图像 | `DragImageOptions` | - | #### DragImageOptions | 参数 | 说明 | 类型 | 默认值 | | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------ | | image | 拖拽过程中跟随鼠标指针的图像。图像通常是一个 [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) 元素,但也可以是 [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) 或任何其他图像元素。 | `string \| Element` | - | | offsetX | 水平偏移 | `number` | 0 | | offsetY | 垂直偏移 | `number` | 0 | ### useDrop ```typescript useDrop( target: (() => Element) | Element | MutableRefObject, options?: DropOptions ); ``` #### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | --------------------- | ----------------------------------------------------------- | ------ | | target | DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | 额外的配置项 | `DragOptions` | - | #### DropOptions | 参数 | 说明 | 类型 | 默认值 | | ----------- | ------------------------------ | --------------------------------------------- | ------ | | onText | 拖拽/粘贴文字的回调 | `(text: string, e: React.DragEvent) => void` | - | | onFiles | 拖拽/粘贴文件的回调 | `(files: File[], e: React.DragEvent) => void` | - | | onUri | 拖拽/粘贴链接的回调 | `(text: string, e: React.DragEvent) => void` | - | | onDom | 拖拽/粘贴自定义 DOM 节点的回调 | `(content: any, e: React.DragEvent) => void` | - | | onDrop | 拖拽任意内容的回调 | `(e: React.DragEvent) => void` | - | | onPaste | 粘贴内容的回调 | `(e: React.DragEvent) => void` | - | | onDragEnter | 拖拽进入 | `(e: React.DragEvent) => void` | - | | onDragOver | 拖拽中 | `(e: React.DragEvent) => void` | - | | onDragLeave | 拖拽出去 | `(e: React.DragEvent) => void` | - | ================================================ FILE: packages/hooks/src/useDynamicList/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { afterAll, afterEach, describe, expect, test, vi } from 'vitest'; import useDynamicList from '../index'; describe('useDynamicList', () => { const setUp = (props: any): any => renderHook(() => useDynamicList(props)); const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); afterEach(() => { warnSpy.mockReset(); }); afterAll(() => { warnSpy.mockRestore(); }); test('getKey should work', () => { const hook = setUp([1, 2, 3]); expect(hook.result.current.list[0]).toBe(1); expect(hook.result.current.getKey(0)).toBe(0); expect(hook.result.current.getKey(1)).toBe(1); expect(hook.result.current.getKey(2)).toBe(2); }); test('methods should work', () => { const hook = setUp([ { name: 'aaa', age: 18 }, { name: 'bbb', age: 19 }, { name: 'ccc', age: 20 }, ]); expect(hook.result.current.list[0].age).toBe(18); expect(hook.result.current.list[1].age).toBe(19); expect(hook.result.current.list[2].age).toBe(20); expect(hook.result.current.getKey(0)).toBe(0); expect(hook.result.current.getKey(1)).toBe(1); expect(hook.result.current.getKey(2)).toBe(2); // unshift act(() => { hook.result.current.unshift({ name: 'ddd', age: 21 }); }); expect(hook.result.current.list[0].name).toBe('ddd'); expect(hook.result.current.getKey(0)).toBe(3); // push act(() => { hook.result.current.push({ name: 'ddd', age: 21 }); }); expect(hook.result.current.list[4].name).toBe('ddd'); expect(hook.result.current.getKey(0)).toBe(3); expect(hook.result.current.getKey(4)).toBe(4); // insert act(() => { hook.result.current.insert(1, { name: 'eee', age: 22 }); }); expect(hook.result.current.list[1].name).toBe('eee'); expect(hook.result.current.getKey(1)).toBe(5); // merge act(() => { hook.result.current.merge(0, [1, 2, 3, 4]); }); expect(hook.result.current.list[0]).toBe(1); expect(hook.result.current.getKey(0)).toBe(6); // move act(() => { hook.result.current.move(0, 1); }); expect(hook.result.current.list[0]).toBe(2); expect(hook.result.current.getKey(0)).toBe(7); // move without changes act(() => { hook.result.current.move(2, 2); }); expect(hook.result.current.list[0]).toBe(2); expect(hook.result.current.getKey(0)).toBe(7); // shift act(() => { hook.result.current.shift(); }); expect(hook.result.current.list[0]).toBe(1); expect(hook.result.current.getKey(0)).toBe(6); expect(hook.result.current.list.length).toBe(9); // pop act(() => { hook.result.current.pop(); }); expect(hook.result.current.list.length).toBe(8); // replace act(() => { hook.result.current.replace(7, { value: 8 }); }); expect(hook.result.current.list[7].value).toBe(8); // remove act(() => { hook.result.current.remove(7); }); expect(hook.result.current.list.length).toBe(7); // batch remove act(() => { hook.result.current.batchRemove(1); }); expect(warnSpy).toHaveBeenCalledWith( '`indexes` parameter of `batchRemove` function expected to be an array, but got "number".', ); act(() => { hook.result.current.batchRemove([0, 1, 2]); }); expect(hook.result.current.list.length).toBe(4); }); test('same items should have different keys', () => { const hook = setUp([1, 1, 1, 1]); expect(hook.result.current.getKey(0)).toBe(0); expect(hook.result.current.getKey(1)).toBe(1); expect(hook.result.current.getKey(2)).toBe(2); expect(hook.result.current.getKey(3)).toBe(3); act(() => { hook.result.current.push(1); }); expect(hook.result.current.getKey(4)).toBe(4); const testObj = {}; act(() => { hook.result.current.push({}); hook.result.current.push(testObj); hook.result.current.push(testObj); }); expect(hook.result.current.getKey(5)).toBe(5); expect(hook.result.current.getKey(6)).toBe(6); expect(hook.result.current.getKey(7)).toBe(7); }); test('initialValue changes', () => { const hook = renderHook(({ initialValue }) => useDynamicList(initialValue), { initialProps: { initialValue: [1], }, }); expect(hook.result.current.list[0]).toBe(1); expect(hook.result.current.getKey(0)).toBe(0); act(() => { hook.result.current.resetList([2]); }); expect(hook.result.current.list[0]).toBe(2); expect(hook.result.current.getKey(0)).toBe(1); act(() => { hook.result.current.resetList([3]); }); expect(hook.result.current.list[0]).toBe(3); expect(hook.result.current.getKey(0)).toBe(2); }); test('sortList', () => { const hook = setUp([1, 2, 3, 4]); const formData = [ { name: 'my bro', age: '23', memo: "he's my bro", }, { name: 'my sis', age: '21', memo: "she's my sis", }, null, { name: '新增行', age: '25', }, ]; let sorted = hook.result.current.sortList(formData); expect(sorted.length).toBe(3); expect(sorted[0].name).toBe('my bro'); act(() => { hook.result.current.move(3, 0); }); sorted = hook.result.current.sortList(formData); expect(sorted[0].name).toBe('新增行'); }); }); ================================================ FILE: packages/hooks/src/useDynamicList/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Dynamic list management * * title.zh-CN: 基础用法 * desc.zh-CN: 管理动态列表 */ import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { useDynamicList } from 'ahooks'; import { Button, Input, Space } from 'antd'; export default () => { const { list, remove, batchRemove, getKey, insert, replace } = useDynamicList(['David', 'Jack']); const listIndexes = list.map((item, index) => index); const Row = (index: number, item: any) => (
replace(index, e.target.value)} value={item} /> {list.length > 1 && ( { remove(index); }} /> )} { insert(index + 1, ''); }} />
); return ( <> {list.map((ele, index) => Row(index, ele))}
{JSON.stringify([list])}
); }; ================================================ FILE: packages/hooks/src/useDynamicList/demo/demo2.tsx ================================================ /** * title: Used in antd Form * desc: Used in antd Form, a component can be packaged independently, like DynamicInputs in the example. * * title.zh-CN: 在 antd Form 中使用 * desc.zh-CN: 在 antd Form 中使用,可以独立封装一个组件,比如例子中的 DynamicInputs。 */ import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { useDynamicList } from 'ahooks'; import { Button, Form, Input } from 'antd'; import { useEffect, useState } from 'react'; const DynamicInputs = ({ value = [], onChange, }: { value?: string[]; onChange?: (value: string[]) => void; }) => { const { list, remove, getKey, insert, replace, resetList } = useDynamicList(value); useEffect(() => { // If value change manual, reset list if (value !== list) { resetList(value); } }, [value]); useEffect(() => { onChange?.(list); }, [list]); const Row = (index: number, item: any) => (
replace(index, e.target.value)} value={item} /> {list.length > 1 && ( { remove(index); }} /> )} { insert(index + 1, ''); }} />
); return <>{list.map((ele, index) => Row(index, ele))}; }; export default () => { const [form] = Form.useForm(); const [result, setResult] = useState(''); return ( <>

{result}

); }; ================================================ FILE: packages/hooks/src/useDynamicList/demo/demo3.tsx ================================================ /** * title: Used in antd Form * desc: Pay attention to the use of sortList. The data of antd Form is not sorted correctly. sortList can be used to calibrate the sorting. * * title.zh-CN: 在 antd Form 中使用的另一种写法 * desc.zh-CN: 注意 sortList 的使用,antd Form 获取的数据排序不对,通过 sortList 可以校准排序。 */ import { useState } from 'react'; import { Form, Button, Input } from 'antd'; import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons'; import { useDynamicList } from 'ahooks'; export default () => { const { list, remove, getKey, insert, resetList, sortList } = useDynamicList(['David', 'Jack']); const [form] = Form.useForm(); const [result, setResult] = useState(''); const Row = (index: number, item: any) => (
{list.length > 1 && ( { remove(index); }} /> )} { insert(index + 1, ''); }} />
); return ( <>
{list.map((ele, index) => Row(index, ele))}
{result}
); }; ================================================ FILE: packages/hooks/src/useDynamicList/demo/demo4.tsx ================================================ /** * title: Draggable dynamic table * desc: Using antd Table to build dynamic table form. * * title.zh-CN: 可拖拽的动态表格 * desc.zh-CN: 使用 antd table 构建动态表格 */ import { DragOutlined } from '@ant-design/icons'; import { Button, Form, Input, Table } from 'antd'; import { useState } from 'react'; import ReactDragListView from 'react-drag-listview'; import { useDynamicList } from 'ahooks'; interface Item { name?: string; age?: string; memo?: string; } export default () => { const { list, remove, getKey, move, push, sortList } = useDynamicList([ { name: 'my bro', age: '23', memo: "he's my bro" }, { name: 'my sis', age: '21', memo: "she's my sis" }, {}, ]); const [form] = Form.useForm(); const [result, setResult] = useState(''); const columns = [ { title: 'Name', dataIndex: 'name', key: 'name', render: (text: string, row: Item, index: number) => ( <> ), }, { title: 'Age', dataIndex: 'age', key: 'age', render: (text: string, row: Item, index: number) => ( ), }, { key: 'memo', title: 'Memo', dataIndex: 'memo', render: (text: string, row: Item, index: number) => ( <> ), }, ]; return (
{/* @ts-ignore - ReactDragListView types issue */} move(oldIndex, newIndex)} handleSelector={'span[aria-label="drag"]'} >
getKey(index || 0).toString()} pagination={false} style={{ overflow: 'auto' }} />
{result && `content: ${result}`}
); }; ================================================ FILE: packages/hooks/src/useDynamicList/index.en-US.md ================================================ --- nav: path: /hooks --- # useDynamicList A hook that helps you manage dynamic list and generate unique key for each item. ## Examples ### Basic usage ### Using with antd Form ### Another way of writing used in antd Form ### Draggable dynamic table ## API ```typescript const result: Result = useDynamicList(initialValue: T[]); ``` ### Result | Property | Description | Type | Remarks | | --------- | ---------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ | | list | Current list | `T[]` | - | | resetList | Reset list current data | `(list: T[]) => void` | - | | insert | Add item at specific position | `(index: number, item: T) => void` | - | | merge | Merge items into specific position | `(index: number, items: T[]) => void` | - | | replace | Replace item at specific position | `(index: number, item: T) => void` | - | | remove | Delete specific item | `(index: number) => void` | - | | move | Move item from old index to new index | `(oldIndex: number, newIndex: number) => void` | - | | getKey | Get the uuid of specific item | `(index: number) => number` | - | | getIndex | Retrieve index from uuid | `(key: number) => number` | - | | sortList | Sort the form data(using with antd form) | `(list: T[]) => T[]` | see[`Another way of writing used in antd Form`](#another-way-of-writing-used-in-antd-form) | | push | Push new item at the end of list | `(item: T) => void` | - | | pop | Remove the last item from the list | `() => void` | - | | unshift | Add new item at the front of the list | `(item: T) => void` | - | | shift | Remove the first item from the list | `() => void` | - | ### Params | Property | Description | Type | Default | | ------------ | ------------------------- | ----- | ------- | | initialValue | Initial value of the list | `T[]` | `[]` | ================================================ FILE: packages/hooks/src/useDynamicList/index.ts ================================================ import { useCallback, useRef, useState } from 'react'; import isDev from '../utils/isDev'; const useDynamicList = (initialList: T[] = []) => { const counterRef = useRef(-1); const keyList = useRef([]); const setKey = useCallback((index: number) => { counterRef.current += 1; keyList.current.splice(index, 0, counterRef.current); }, []); const [list, setList] = useState(() => { initialList.forEach((_, index) => { setKey(index); }); return initialList; }); const resetList = useCallback((newList: T[]) => { keyList.current = []; setList(() => { newList.forEach((_, index) => { setKey(index); }); return newList; }); }, []); const insert = useCallback((index: number, item: T) => { setList((l) => { const temp = [...l]; temp.splice(index, 0, item); setKey(index); return temp; }); }, []); const getKey = useCallback((index: number) => keyList.current[index], []); const getIndex = useCallback( (key: number) => keyList.current.findIndex((ele) => ele === key), [], ); const merge = useCallback((index: number, items: T[]) => { setList((l) => { const temp = [...l]; items.forEach((_, i) => { setKey(index + i); }); temp.splice(index, 0, ...items); return temp; }); }, []); const replace = useCallback((index: number, item: T) => { setList((l) => { const temp = [...l]; temp[index] = item; return temp; }); }, []); const remove = useCallback((index: number) => { setList((l) => { const temp = [...l]; temp.splice(index, 1); // remove keys if necessary try { keyList.current.splice(index, 1); } catch (e) { console.error(e); } return temp; }); }, []); const batchRemove = useCallback((indexes: number[]) => { if (!Array.isArray(indexes)) { if (isDev) { console.error( `\`indexes\` parameter of \`batchRemove\` function expected to be an array, but got "${typeof indexes}".`, ); } return; } if (!indexes.length) { return; } setList((prevList) => { const newKeyList: number[] = []; const newList = prevList.filter((item, index) => { const shouldKeep = !indexes.includes(index); if (shouldKeep) { newKeyList.push(getKey(index)); } return shouldKeep; }); keyList.current = newKeyList; return newList; }); }, []); const move = useCallback((oldIndex: number, newIndex: number) => { if (oldIndex === newIndex) { return; } setList((l) => { const newList = [...l]; const temp = newList.filter((_, index: number) => index !== oldIndex); temp.splice(newIndex, 0, newList[oldIndex]); // move keys if necessary try { const keyTemp = keyList.current.filter((_, index: number) => index !== oldIndex); keyTemp.splice(newIndex, 0, keyList.current[oldIndex]); keyList.current = keyTemp; } catch (e) { console.error(e); } return temp; }); }, []); const push = useCallback((item: T) => { setList((l) => { setKey(l.length); return l.concat([item]); }); }, []); const pop = useCallback(() => { // remove keys if necessary try { keyList.current = keyList.current.slice(0, keyList.current.length - 1); } catch (e) { console.error(e); } setList((l) => l.slice(0, l.length - 1)); }, []); const unshift = useCallback((item: T) => { setList((l) => { setKey(0); return [item].concat(l); }); }, []); const shift = useCallback(() => { // remove keys if necessary try { keyList.current = keyList.current.slice(1, keyList.current.length); } catch (e) { console.error(e); } setList((l) => l.slice(1, l.length)); }, []); const sortList = useCallback( (result: T[]) => result .map((item, index) => ({ key: index, item })) // add index into obj .sort((a, b) => getIndex(a.key) - getIndex(b.key)) // sort based on the index of table .filter((item) => !!item.item) // remove undefined(s) .map((item) => item.item), // retrive the data [], ); return { list, insert, merge, replace, remove, batchRemove, getKey, getIndex, move, push, pop, unshift, shift, sortList, resetList, }; }; export default useDynamicList; ================================================ FILE: packages/hooks/src/useDynamicList/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useDynamicList 一个帮助你管理动态列表状态,并能生成唯一 key 的 Hook。 ## 代码演示 ### 基础用法 ### 在 antd Form 中使用 ### 在 antd Form 中使用的另一种写法 ### 可拖拽的动态表格 ## API ```typescript const result: Result = useDynamicList(initialList?: T[]); ``` ### Result | 参数 | 说明 | 类型 | 备注 | | --------- | ---------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------- | | list | 当前的列表 | `T[]` | - | | resetList | 重新设置 list 的值 | `(list: T[]) => void` | - | | insert | 在指定位置插入元素 | `(index: number, item: T) => void` | - | | merge | 在指定位置插入多个元素 | `(index: number, items: T[]) => void` | - | | replace | 替换指定元素 | `(index: number, item: T) => void` | - | | remove | 删除指定元素 | `(index: number) => void` | - | | move | 移动元素 | `(oldIndex: number, newIndex: number) => void` | - | | getKey | 获得某个元素的 uuid | `(index: number) => number` | - | | getIndex | 获得某个 key 的 index | `(key: number) => number` | - | | push | 在列表末尾添加元素 | `(item: T) => void` | - | | pop | 移除末尾元素 | `() => void` | - | | unshift | 在列表起始位置添加元素 | `(item: T) => void` | - | | shift | 移除起始位置元素 | `() => void` | - | | sortList | 校准排序 | `(list: T[]) => T[]` | 使用方法详见 [在 antd Form 中使用的另一种写法](#在-antd-form-中使用的另一种写法) | ### 参数 | 参数 | 说明 | 类型 | 默认值 | | ----------- | ------------ | ----- | ------ | | initialList | 列表的初始值 | `T[]` | `[]` | ================================================ FILE: packages/hooks/src/useEventEmitter/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import useEventEmitter from '../index'; describe('useEventEmitter', () => { const setUp = () => renderHook(() => { const event$ = useEventEmitter(); const [count, setCount] = useState(0); event$.useSubscription((val) => { setCount((c) => c + val); }); event$.useSubscription((val) => { setCount((c) => c + val + 10); }); return { event$, count, }; }); test('emit and subscribe should work', () => { const hook = setUp(); act(() => { hook.result.current.event$.emit(1); }); expect(hook.result.current.count).toBe(12); act(() => { hook.result.current.event$.emit(2); }); expect(hook.result.current.count).toBe(26); }); }); ================================================ FILE: packages/hooks/src/useEventEmitter/demo/demo1.tsx ================================================ /** * title: Parent component shares a event * desc: The parent component creates a `focus$` event emitter, and passes it to its children. When calling `focus$.emit` in MessageBox, InputBox will get notified. * * title.zh-CN: 父组件向子组件共享事件 * desc.zh-CN: 父组件创建了一个 `focus$` 事件,并且将它传递给了两个子组件。在 MessageBox 中调用 `focus$.emit` ,InputBox 组件就可以收到通知。 */ import { useRef, type FC } from 'react'; import { useEventEmitter } from 'ahooks'; import { EventEmitter } from 'ahooks/lib/useEventEmitter'; const MessageBox: FC<{ focus$: EventEmitter; }> = function (props) { return (

You received a message

); }; const InputBox: FC<{ focus$: EventEmitter; }> = function (props) { const inputRef = useRef(null); props.focus$.useSubscription(() => { inputRef.current?.focus(); }); return ( ); }; export default function () { const focus$ = useEventEmitter(); return ( <> ); } ================================================ FILE: packages/hooks/src/useEventEmitter/index.en-US.md ================================================ --- nav: path: /hooks --- # useEventEmitter Sometimes it is difficult to pass events between multiple components. By using EventEmitter, this can be simplified. To get an instance of `EventEmitter`, you can call `useEventEmitter` in React components. ```js const event$ = useEventEmitter(); ``` > If the component renders multiple times, the return value of `useEventEmitter` in every render process will stay unchanged and no extra `EventEmitter` instance will be created. Then we can share `event$` to other components via `props` or `Context`. To push a event, just call the `emit` method of `EventEmitter`. To subscribe to a series of events, call the `useSubscription` method. ```js event$.emit('hello'); ``` ```js event$.useSubscription(val => { console.log(val); }); ``` > `useSubscription` will automatically register the subscription and unsubscription. If you want to let the child component notify the parent component, you can just use `props` to pass a `onEvent` function. And if you want to let the parent component notify the child component, you can use `forwardRef` to retrieve the ref of child component. `useEventEmitter` is most suitable for event management among multiple components or between two components which are far away. ## Examples ### Parent component shares a event ## API ### Params ```typescript const result: Result = useEventEmitter(); ``` ### Result | Property | Description | Type | | --------------- | ----------------------------- | -------------------------------------- | | emit | Emit a new event. | `(val: T) => void` | | useSubscription | Subscribe to a event emitter. | `(callback: (val: T) => void) => void` | ================================================ FILE: packages/hooks/src/useEventEmitter/index.ts ================================================ import { useRef, useEffect } from 'react'; type Subscription = (val: T) => void; export class EventEmitter { private subscriptions = new Set>(); emit = (val: T) => { for (const subscription of this.subscriptions) { subscription(val); } }; useSubscription = (callback: Subscription) => { // eslint-disable-next-line react-hooks/rules-of-hooks const callbackRef = useRef>(undefined); callbackRef.current = callback; // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { function subscription(val: T) { if (callbackRef.current) { callbackRef.current(val); } } this.subscriptions.add(subscription); return () => { this.subscriptions.delete(subscription); }; }, []); }; } function useEventEmitter() { const ref = useRef>(undefined); if (!ref.current) { ref.current = new EventEmitter(); } return ref.current; } export default useEventEmitter; ================================================ FILE: packages/hooks/src/useEventEmitter/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useEventEmitter 在多个组件之间进行事件通知有时会让人非常头疼,借助 EventEmitter ,可以让这一过程变得更加简单。 在组件中调用 `useEventEmitter` 可以获得一个 `EventEmitter` 的实例: ```js const event$ = useEventEmitter(); ``` > 在组件多次渲染时,每次渲染调用 `useEventEmitter` 得到的返回值会保持不变,不会重复创建 `EventEmitter` 的实例。 通过 `props` 或者 `Context` ,可以将 `event$` 共享给其他组件。然后在其他组件中,可以调用 `EventEmitter` 的 `emit` 方法,推送一个事件,或是调用 `useSubscription` 方法,订阅事件。 ```js event$.emit('hello'); ``` ```js event$.useSubscription(val => { console.log(val); }); ``` > `useSubscription` 会在组件创建时自动注册订阅,并在组件销毁时自动取消订阅。 对于**子组件**通知**父组件**的情况,我们仍然推荐直接使用 `props` 传递一个 `onEvent` 函数。而对于**父组件**通知**子组件**的情况,可以使用 `forwardRef` 获取子组件的 ref ,再进行子组件的方法调用。 `useEventEmitter` 适合的是在**距离较远**的组件之间进行事件通知,或是在**多个**组件之间共享事件通知。 ## 代码演示 ### 父组件向子组件共享事件 ## API ```typescript const result: Result = useEventEmitter(); ``` ### Result | 参数 | 说明 | 类型 | | --------------- | ---------------- | -------------------------------------- | | emit | 发送一个事件通知 | `(val: T) => void` | | useSubscription | 订阅事件 | `(callback: (val: T) => void) => void` | ================================================ FILE: packages/hooks/src/useEventListener/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import useEventListener from '../index'; describe('useEventListener', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); }); test('test on click listener', async () => { let state: number = 0; const onClick = () => { state++; }; const { rerender, unmount } = renderHook(() => useEventListener('click', onClick, { target: () => container }), ); document.body.click(); expect(state).toBe(0); rerender(); container.click(); expect(state).toBe(1); unmount(); document.body.click(); expect(state).toBe(1); }); test('test on event list listener', async () => { let state: number = 0; const onClick = () => { state++; }; const onKeydown = () => { state++; }; const { rerender, unmount } = renderHook( () => ( useEventListener('click', onClick, { target: () => container }), useEventListener('keydown', onKeydown, { target: () => container }) ), ); document.body.click(); document.body.dispatchEvent(new KeyboardEvent('keydown')); expect(state).toBe(0); rerender(); container.click(); container.dispatchEvent(new KeyboardEvent('keydown')); expect(state).toBe(2); unmount(); document.body.click(); document.body.dispatchEvent(new KeyboardEvent('keydown')); expect(state).toBe(2); }); test('test "enable" parameter', () => { let state = 0; let enable = true; const onClick = () => state++; const { rerender, unmount } = renderHook(() => useEventListener('click', onClick, { target: () => container, enable }), ); document.body.click(); expect(state).toBe(0); container.click(); expect(state).toBe(1); enable = false; rerender(); container.click(); expect(state).toBe(1); unmount(); }); }); ================================================ FILE: packages/hooks/src/useEventListener/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Click the button to preview. * * title.zh-CN: 基础用法 * desc.zh-CN: 点击按钮查看效果。 */ import { useState, useRef } from 'react'; import { useEventListener } from 'ahooks'; export default () => { const [value, setValue] = useState(0); const ref = useRef(null); useEventListener( 'click', () => { setValue(value + 1); }, { target: ref }, ); return ( ); }; ================================================ FILE: packages/hooks/src/useEventListener/demo/demo2.tsx ================================================ /** * title: Listen keydown * desc: Press any key to preview. * * title.zh-CN: 监听 keydown 事件 * desc.zh-CN: 按下键盘查看效果。 */ import { useState } from 'react'; import { useEventListener } from 'ahooks'; export default () => { const [value, setValue] = useState(''); useEventListener('keydown', (ev) => { setValue(ev.code); }); return

Your press key is {value}

; }; ================================================ FILE: packages/hooks/src/useEventListener/demo/demo3.tsx ================================================ /** * title: Listen to multiple events. * desc: Mouse hover or over the button to preview. * * title.zh-CN: 监听多个事件 * desc.zh-CN: 鼠标移入移出按钮查看效果。 */ import { useRef, useState } from 'react'; import { useEventListener } from 'ahooks'; export default () => { const ref = useRef(null); const [value, setValue] = useState(''); useEventListener( ['mouseenter', 'mouseleave'], (ev) => { setValue(ev.type); }, { target: ref }, ); return ( ); }; ================================================ FILE: packages/hooks/src/useEventListener/index.en-US.md ================================================ --- nav: path: /hooks --- # useEventListener Use addEventListener elegant by Hook. ## Examples ### Default usage ### Listen for keydown ### Listen for multiple events ## API ```typescript useEventListener( eventName: string, handler: (ev: Event) => void, options?: Options, ); ``` ### Property | Property | Description | type | default | | --------- | ---------------------- | ---------------------- | ------- | | eventName | Event name | `string` \| `string[]` | - | | handler | Callback function | `(ev: Event) => void` | - | | options | More options(optional) | `Options` | - | ### Options | Property | Description | type | default | | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------- | | target | DOM element or ref | `(() => Element)` \| `Element` \| `React.MutableRefObject` \| `Window` \| `Document` | `window` | | capture | Optional, a Boolean indicating that events of this type will be dispatched to the registered listener before being dispatched to any EventTarget beneath it in the DOM tree. | `boolean` | `false` | | once | Optional, A Boolean indicating that the listener should be invoked at most once after being added. If true, the listener would be automatically removed when invoked. | `boolean` | `false` | | passive | Optional, A Boolean which, if true, indicates that the function specified by listener will never call preventDefault(). If a passive listener does call preventDefault(), the user agent will do nothing other than generate a console warning. | `boolean` | `false` | | enable | Optional, Whether to enable listening. | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useEventListener/index.ts ================================================ import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; type noop = (...p: any) => void; export type Target = BasicTarget; type Options = { target?: T; capture?: boolean; once?: boolean; passive?: boolean; enable?: boolean; }; function useEventListener( eventName: K, handler: (ev: HTMLElementEventMap[K]) => void, options?: Options, ): void; function useEventListener( eventName: K, handler: (ev: ElementEventMap[K]) => void, options?: Options, ): void; function useEventListener( eventName: K, handler: (ev: DocumentEventMap[K]) => void, options?: Options, ): void; function useEventListener( eventName: K, handler: (ev: WindowEventMap[K]) => void, options?: Options, ): void; function useEventListener( eventName: string | string[], handler: (event: Event) => void, options?: Options, ): void; function useEventListener(eventName: string | string[], handler: noop, options: Options): void; function useEventListener(eventName: string | string[], handler: noop, options: Options = {}) { const { enable = true } = options; const handlerRef = useLatest(handler); useEffectWithTarget( () => { if (!enable) { return; } const targetElement = getTargetElement(options.target, window); if (!targetElement?.addEventListener) { return; } const eventListener = (event: Event) => { return handlerRef.current(event); }; const eventNameArray = Array.isArray(eventName) ? eventName : [eventName]; eventNameArray.forEach((event) => { targetElement.addEventListener(event, eventListener, { capture: options.capture, once: options.once, passive: options.passive, }); }); return () => { eventNameArray.forEach((event) => { targetElement.removeEventListener(event, eventListener, { capture: options.capture, }); }); }; }, [eventName, options.capture, options.once, options.passive, enable], options.target, ); } export default useEventListener; ================================================ FILE: packages/hooks/src/useEventListener/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useEventListener 优雅的使用 addEventListener。 ## 代码演示 ### 基础用法 ### 监听 keydown 事件 ### 监听多个事件 ## API ```typescript useEventListener( eventName: string, handler: (ev: Event) => void, options?: Options, ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | --------- | ---------- | ---------------------- | ------ | | eventName | 事件名称 | `string` \| `string[]` | - | | handler | 处理函数 | `(ev: Event) => void` | - | | options | 设置(可选) | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------- | | target | DOM 节点或者 ref | `(() => Element)` \| `Element` \| `React.MutableRefObject` \| `Window` \| `Document` | `window` | | capture | 可选项,listener 会在该类型的事件捕获阶段传播到该 EventTarget 时触发。 | `boolean` | `false` | | once | 可选项,listener 在添加之后最多只调用一次。如果是 true,listener 会在其被调用之后自动移除。 | `boolean` | `false` | | passive | 可选项,设置为 true 时,表示 listener 永远不会调用 preventDefault() 。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告。 | `boolean` | `false` | | enable | 可选项,是否开启监听。 | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useEventTarget/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useEventTarget from '../index'; describe('useEventTarget', () => { test('should work without initial value', async () => { const hook = renderHook(() => useEventTarget()); expect(hook.result.current[0]).toBeUndefined(); act(() => { hook.result.current[1].onChange({ target: { value: 'abc' } }); }); expect(hook.result.current[0]).toBe('abc'); }); test('should work with initial value', async () => { const hook = renderHook(() => useEventTarget({ initialValue: 'abc' })); expect(hook.result.current[0]).toBe('abc'); act(() => { hook.result.current[1].onChange({ target: { value: 'def' } }); }); expect(hook.result.current[0]).toBe('def'); act(() => { hook.result.current[1].reset(); }); expect(hook.result.current[0]).toBe('abc'); }); test('should work with transformer', () => { const hook = renderHook(() => useEventTarget({ transformer: (str: string) => str.toUpperCase(), }), ); expect(hook.result.current[0]).toBeUndefined(); act(() => { hook.result.current[1].onChange({ target: { value: 'def' } }); }); expect(hook.result.current[0]).toBe('DEF'); }); test('should be able to transform to any type', () => { const hook = renderHook(() => useEventTarget({ transformer: (num: number) => String(num), }), ); expect(hook.result.current[0]).toBeUndefined(); act(() => { hook.result.current[1].onChange({ target: { value: 123 } }); }); expect(hook.result.current[0]).toBe('123'); }); }); ================================================ FILE: packages/hooks/src/useEventTarget/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Controlled input component,support reset. * * title.zh-CN: 基础用法 * desc.zh-CN: 受控的 input,支持 reset。 */ import { useEventTarget } from 'ahooks'; export default () => { const [value, { reset, onChange }] = useEventTarget({ initialValue: 'this is initial value' }); return (
); }; ================================================ FILE: packages/hooks/src/useEventTarget/demo/demo2.tsx ================================================ /** * title: Custom transformer function * desc: Controlled input component with number input only * * title.zh-CN: 自定义转换函数 * desc.zh-CN: 只能输入数字的 input 组件 */ import { useEventTarget } from 'ahooks'; export default () => { const [value, { onChange, reset }] = useEventTarget({ initialValue: '', transformer: (val: string) => val.replace(/[^\d]/g, ''), }); return (
); }; ================================================ FILE: packages/hooks/src/useEventTarget/index.en-US.md ================================================ --- nav: path: /hooks --- # useEventTarget A hook that encapsulates `onChange` and `value` logic for form controls that obtains value through `event.target.value`. It also supports custom transformer and reset functionalities. ## Example ### Basic Usage ### Custom transformer ## API ```typescript const [value, { onChange, reset }] = useEventTarget(Options); ``` ### Result | Property | Description | Type | | -------- | ------------------------------------------- | --------------------------------------- | | value | component value | `T` | | onChange | callback when value changes | `(e: { target: { value: T } }) => void` | | reset | function to reset the value to initialValue | `() => void` | ### Options | Property | Description | Type | Default | | ------------ | ------------------------------------------ | ----------------- | ------- | | initialValue | initial value | `T` | - | | transformer | custom transform function applied to value | `(value: U) => T` | - | ================================================ FILE: packages/hooks/src/useEventTarget/index.ts ================================================ import { useCallback, useState } from 'react'; import useLatest from '../useLatest'; import { isFunction } from '../utils'; interface EventTarget { target: { value: U; }; } export interface Options { initialValue?: T; transformer?: (value: U) => T; } function useEventTarget(options?: Options) { const { initialValue, transformer } = options || {}; const [value, setValue] = useState(initialValue); const transformerRef = useLatest(transformer); const reset = useCallback(() => setValue(initialValue), []); const onChange = useCallback((e: EventTarget) => { const _value = e.target.value; if (isFunction(transformerRef.current)) { return setValue(transformerRef.current(_value)); } // no transformer => U and T should be the same return setValue(_value as unknown as T); }, []); return [ value, { onChange, reset, }, ] as const; } export default useEventTarget; ================================================ FILE: packages/hooks/src/useEventTarget/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useEventTarget 常见表单控件(通过 e.target.value 获取表单值) 的 onChange 跟 value 逻辑封装,支持自定义值转换和重置功能。 ## 代码演示 ### 基础用法 ### 自定义转换函数 ## API ```typescript const [value, { onChange, reset }] = useEventTarget(Options); ``` ### Result | 参数 | 说明 | 类型 | | -------- | ---------------------------- | --------------------------------------- | | value | 表单控件的值 | `T` | | onChange | 表单控件值发生变化时候的回调 | `(e: { target: { value: T } }) => void` | | reset | 重置函数 | `() => void` | ### Options | 参数 | 说明 | 类型 | 默认值 | | ------------ | ---------------------------- | ----------------- | ------ | | initialValue | 可选项, 初始值 | `T` | - | | transformer | 可选项,可自定义回调值的转化 | `(value: U) => T` | - | ================================================ FILE: packages/hooks/src/useExternal/__tests__/index.spec.ts ================================================ import { act, fireEvent, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import useExternal, { type Options } from '../index'; const setup = (path: string, options?: Options) => renderHook(() => useExternal(path, options)); describe('useExternal', () => { beforeEach(() => { document.body.innerHTML = ''; document.head.innerHTML = ''; }); test('should load a script', () => { const path = 'https://ahooks.js.org/useExternal/test-external-script.js'; const { result } = setup(path, { js: { async: true, }, }); const script = document.querySelector('script') as HTMLScriptElement; expect(result.current).toBe('loading'); act(() => { fireEvent.load(script); }); expect(result.current).toBe('ready'); }); test('should load a css', () => { const path = 'https://ahooks.js.org/useExternal/bootstrap-badge.css'; const { result } = setup(path, { css: { media: 'all', }, }); const link = document.querySelector('link') as HTMLLinkElement; expect(result.current).toBe('loading'); act(() => { fireEvent.load(link); }); expect(result.current).toBe('ready'); }); test('status should be unset without path', () => { const { result } = setup(''); expect(result.current).toBe('unset'); }); test('status should be error when load failed', async () => { const { result } = setup('xx.js'); const script = document.querySelector('script') as HTMLScriptElement; act(() => { fireEvent.error(script); }); expect(result.current).toBe('error'); }); test('should throw error when provide unsupported type', () => { const mockSpy = vi.spyOn(console, 'error').mockImplementationOnce(() => {}); setup('ahooks.ts'); expect(mockSpy).toBeCalled(); }); test('should not load again when the js exists', () => { const path = 'a.js'; const hook1 = setup(path); const script = document.querySelector('script') as HTMLScriptElement; act(() => { fireEvent.load(script); }); expect(hook1.result.current).toBe('ready'); const hook2 = setup(path); expect(hook2.result.current).toBe('ready'); }); test('should not load again when the css exists', () => { const path = 'a.css'; const hook1 = setup(path); const link = document.querySelector('link') as HTMLLinkElement; act(() => { fireEvent.load(link); }); expect(hook1.result.current).toBe('ready'); const hook2 = setup(path); expect(hook2.result.current).toBe('ready'); }); test('should remove when not use', () => { const { unmount } = setup('b.js'); const script = document.querySelector('script') as HTMLScriptElement; act(() => { fireEvent.load(script); }); unmount(); expect(document.querySelector('script')).toBeNull(); }); test('should not remove when keepWhenUnused is true', () => { // https://github.com/alibaba/hooks/discussions/2163 const { result, unmount } = setup('b.js', { keepWhenUnused: true, }); const script = document.querySelector('script') as HTMLScriptElement; act(() => { fireEvent.load(script); }); unmount(); expect(result.current).toBe('ready'); }); test('css preload should work in IE Edge', () => { Object.defineProperty(HTMLLinkElement.prototype, 'hideFocus', { value: true, }); setup('b.css'); const link = document.querySelector('link') as HTMLLinkElement; act(() => { fireEvent.load(link); }); expect(link.rel).toBe('preload'); expect(link.as).toBe('style'); }); }); ================================================ FILE: packages/hooks/src/useExternal/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Load js file, such as [test-external-script.js](/useExternal/test-external-script.js) * * title.zh-CN: 基础用法 * desc.zh-CN: 加载 js 文件,例如引入 [test-external-script.js](/useExternal/test-external-script.js) */ import { useExternal } from 'ahooks'; export default () => { const status = useExternal('/useExternal/test-external-script.js', { js: { async: true, }, }); return ( <>

Status: {status}

Response: {status === 'ready' ? (window as any).TEST_SCRIPT?.start() : '-'}

); }; ================================================ FILE: packages/hooks/src/useExternal/demo/demo2.tsx ================================================ /** * title: Load style dynamically * desc: Load css file, such as [bootstrap-badge.css](/useExternal/bootstrap-badge.css) * * title.zh-CN: 动态加载样式 * desc.zh-CN: 加载 css 文件,例如引入 [bootstrap-badge.css](/useExternal/bootstrap-badge.css) */ import { useExternal } from 'ahooks'; import { useState } from 'react'; export default () => { const [path, setPath] = useState('/useExternal/bootstrap-badge.css'); const status = useExternal(path); return ( <>

Status: {status}

Primary Secondary Success Danger Warning Info Light Dark

); }; ================================================ FILE: packages/hooks/src/useExternal/demo/demo3.tsx ================================================ /** * title: Load style dynamically * desc: Load css file, such as [bootstrap-badge.css](/useExternal/bootstrap-badge.css) * * title.zh-CN: 动态加载样式 * desc.zh-CN: 加载 css 文件,例如引入 [bootstrap-badge.css](/useExternal/bootstrap-badge.css) */ import { useExternal } from 'ahooks'; import { useState } from 'react'; export default () => { const [path, setPath] = useState(''); const status = useExternal(path); const [path2, setPath2] = useState(''); const status2 = useExternal(path2); return ( <>
Primary Secondary Success Danger Warning Info Light Dark

第一个

Status: {status}


第二个

Status: {status2}

); }; ================================================ FILE: packages/hooks/src/useExternal/index.en-US.md ================================================ --- nav: path: /hooks --- # useExternal Dynamically load JS or CSS, useExternal can ensure that the resource are globally unique. ## Example ### Basic Usage ### Load CSS ## API ```typescript const status = useExternal(path: string, options?: Options); ``` ### Result | Params | Description | Type | | ------ | -------------------------------------------------------------------------------------------- | -------- | | status | The progress of loading the external resources, support `unset`, `loading`, `ready`, `error` | `string` | ### Params | Params | Description | Type | Default | | ------ | --------------------------------- | -------- | ------- | | path | The url of the external resources | `string` | - | ### Options | Params | Description | Type | Default | | -------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------- | ------- | | type | The type of external resources which need to load, support `js`/`css`, if no type, it will deduced according to path | `string` | - | | js | Attributes supported by `script` | `HTMLScriptElement` | - | | css | Attributes supported by `link` | `HTMLStyleElement` | - | | keepWhenUnused | Allow resources to remain after they have lost their references | `boolean` | `false` | ================================================ FILE: packages/hooks/src/useExternal/index.ts ================================================ import { useEffect, useRef, useState } from 'react'; type JsOptions = { type: 'js'; js?: Partial; keepWhenUnused?: boolean; }; type CssOptions = { type: 'css'; css?: Partial; keepWhenUnused?: boolean; }; type DefaultOptions = { type?: never; js?: Partial; css?: Partial; keepWhenUnused?: boolean; }; export type Options = JsOptions | CssOptions | DefaultOptions; // {[path]: count} // remove external when no used const EXTERNAL_USED_COUNT: Record = {}; export type Status = 'unset' | 'loading' | 'ready' | 'error'; interface LoadResult { ref: Element; status: Status; } type LoadExternal = (path: string, props?: Partial) => LoadResult; const loadScript: LoadExternal = (path, props = {}) => { const script = document.querySelector(`script[src="${path}"]`); if (!script) { const newScript = document.createElement('script'); newScript.src = path; Object.keys(props).forEach((key) => { (newScript as any)[key] = (props as any)[key]; }); newScript.setAttribute('data-status', 'loading'); document.body.appendChild(newScript); return { ref: newScript, status: 'loading', }; } return { ref: script, status: (script.getAttribute('data-status') as Status) || 'ready', }; }; const loadCss: LoadExternal = (path, props = {}) => { const css = document.querySelector(`link[href="${path}"]`); if (!css) { const newCss = document.createElement('link'); newCss.rel = 'stylesheet'; newCss.href = path; Object.keys(props).forEach((key) => { (newCss as any)[key] = (props as any)[key]; }); // IE9+ const isLegacyIECss = 'hideFocus' in newCss; // use preload in IE Edge (to detect load errors) if (isLegacyIECss && newCss.relList) { newCss.rel = 'preload'; newCss.as = 'style'; } newCss.setAttribute('data-status', 'loading'); document.head.appendChild(newCss); return { ref: newCss, status: 'loading', }; } return { ref: css, status: (css.getAttribute('data-status') as Status) || 'ready', }; }; const useExternal = (path?: string, options?: Options) => { const [status, setStatus] = useState(path ? 'loading' : 'unset'); const ref = useRef(undefined); useEffect(() => { if (!path) { setStatus('unset'); return; } const pathname = path.replace(/[|#].*$/, ''); if (options?.type === 'css' || (!options?.type && /(^css!|\.css$)/.test(pathname))) { const result = loadCss(path, options?.css); ref.current = result.ref; setStatus(result.status); } else if (options?.type === 'js' || (!options?.type && /(^js!|\.js$)/.test(pathname))) { const result = loadScript(path, options?.js); ref.current = result.ref; setStatus(result.status); } else { // do nothing console.error( "Cannot infer the type of external resource, and please provide a type ('js' | 'css'). " + 'Refer to the https://ahooks.js.org/hooks/dom/use-external/#options', ); } if (!ref.current) { return; } if (EXTERNAL_USED_COUNT[path] === undefined) { EXTERNAL_USED_COUNT[path] = 1; } else { EXTERNAL_USED_COUNT[path] += 1; } const handler = (event: Event) => { const targetStatus = event.type === 'load' ? 'ready' : 'error'; ref.current?.setAttribute('data-status', targetStatus); setStatus(targetStatus); }; ref.current.addEventListener('load', handler); ref.current.addEventListener('error', handler); return () => { ref.current?.removeEventListener('load', handler); ref.current?.removeEventListener('error', handler); EXTERNAL_USED_COUNT[path] -= 1; if (EXTERNAL_USED_COUNT[path] === 0 && !options?.keepWhenUnused) { ref.current?.remove(); } ref.current = undefined; }; }, [path]); return status; }; export default useExternal; ================================================ FILE: packages/hooks/src/useExternal/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useExternal 动态注入 JS 或 CSS 资源,useExternal 可以保证资源全局唯一。 ## 代码演示 ### 基础用法 ### 加载 CSS ## API ```typescript const status = useExternal(path: string, options?: Options); ``` ### Result | 参数 | 说明 | 类型 | | ------ | ---------------------------------------------------------------------------------- | -------- | | status | 加载状态,`unset`(未设置), `loading`(加载中), `ready`(加载完成), `error`(加载失败) | `string` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ---- | ----------------- | -------- | ------ | | path | 外部资源 url 地址 | `string` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------------- | ----------------------------------------------------------------- | ------------------- | ------- | | type | 需引入外部资源的类型,支持 `js`/`css`,如果不传,则根据 path 推导 | `string` | - | | js | `script` 标签支持的属性 | `HTMLScriptElement` | - | | css | `link` 标签支持的属性 | `HTMLStyleElement` | - | | keepWhenUnused | 在不持有资源的引用后,仍然保留资源 | `boolean` | `false` | ================================================ FILE: packages/hooks/src/useFavicon/__tests__/index.spec.tsx ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useFavicon from '../index'; describe('useFavicon', () => { test('should set the favicon', () => { expect(document.querySelector("link[rel*='icon']")).toBeNull(); renderHook(() => useFavicon('favicon.ico')); expect(document.querySelector("link[rel*='icon']")).not.toBeNull(); }); test('should support svg/png/ico/gif', () => { const { rerender } = renderHook((url: string) => useFavicon(url)); const suffixes = ['svg', 'png', 'ico', 'gif'] as const; const imgTypeMap = { svg: 'image/svg+xml', ico: 'image/x-icon', gif: 'image/gif', png: 'image/png', } as const; suffixes.forEach((suffix) => { const url = `favicon.${suffix}`; rerender(url); const link = document.querySelector("link[rel*='icon']") as HTMLLinkElement; expect(link.getAttribute('type')).toBe(imgTypeMap[suffix]); expect(link.getAttribute('href')).toBe(url); expect(link.getAttribute('rel')).toBe('shortcut icon'); }); }); }); ================================================ FILE: packages/hooks/src/useFavicon/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Set favicon * * title.zh-CN: 基础用法 * desc.zh-CN: 设置 favicon */ import { useState } from 'react'; import { useFavicon } from 'ahooks'; export const DEFAULT_FAVICON_URL = 'https://ahooks.js.org/simple-logo.svg'; export const GOOGLE_FAVICON_URL = 'https://www.google.com/favicon.ico'; export default () => { const [url, setUrl] = useState(DEFAULT_FAVICON_URL); useFavicon(url); return ( <>

Current Favicon: {url}

); }; ================================================ FILE: packages/hooks/src/useFavicon/index.en-US.md ================================================ --- nav: path: /hooks --- # useFavicon A hook that set the favicon of the page. ## Example ### Basic Usage ## API ```typescript useFavicon(href: string); ``` ### Params | Params | Description | Type | Default | | ------ | -------------------------------------------- | -------- | ------- | | href | favicon URL, support `svg`/`png`/`ico`/`gif` | `string` | - | ## FAQ ### It doesn't work in Safari? Safari cannot set the favicon dynamically. > Apple intentionally do not want the ability to script favicons. See https://bugs.webkit.org/show_bug.cgi?id=95979#c2 Related issues: [#2126](https://github.com/alibaba/hooks/issues/2126) ================================================ FILE: packages/hooks/src/useFavicon/index.ts ================================================ import { useEffect } from 'react'; const ImgTypeMap = { SVG: 'image/svg+xml', ICO: 'image/x-icon', GIF: 'image/gif', PNG: 'image/png', }; type ImgTypes = keyof typeof ImgTypeMap; const useFavicon = (href: string) => { useEffect(() => { if (!href) { return; } const cutUrl = href.split('.'); const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes; const link = document.querySelector("link[rel*='icon']") || document.createElement('link'); link.type = ImgTypeMap[imgSuffix]; link.href = href; link.rel = 'shortcut icon'; document.getElementsByTagName('head')[0].appendChild(link); }, [href]); }; export default useFavicon; ================================================ FILE: packages/hooks/src/useFavicon/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useFavicon 设置页面的 favicon。 ## 代码演示 ### 基础用法 ## API ```typescript useFavicon(href: string); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ---- | ----------------------------------------------------- | -------- | ------ | | href | favicon 地址, 支持 `svg`/`png`/`ico`/`gif` 后缀的图片 | `string` | - | ## FAQ ### 在 Safari 中不工作? Safari 无法动态设置 favicon。 > Apple intentionally do not want the ability to script favicons. See https://bugs.webkit.org/show_bug.cgi?id=95979#c2 相关 issue:[#2126](https://github.com/alibaba/hooks/issues/2126) ================================================ FILE: packages/hooks/src/useFocusWithin/__tests__/index.spec.tsx ================================================ import { cleanup, fireEvent, render } from '@testing-library/react'; import { useRef } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import useFocusWithin, { type Options } from '../index'; const setup = (options?: Options) => { const TestComponent = () => { const ref = useRef(null); const isFocusWithin = useFocusWithin(ref, options); return (

isFocusWithin: {JSON.stringify(isFocusWithin)}

); }; return render(); }; describe('useFocusWithin', () => { beforeEach(() => { cleanup(); vi.clearAllMocks(); }); test('should call onFocus/onBlur', () => { const onFocus = vi.fn(); const onBlur = vi.fn(); const result = setup({ onFocus, onBlur }); fireEvent.focusIn(result.getAllByLabelText('First Name')[0]); expect(onFocus).toBeCalled(); fireEvent.focusOut(result.getAllByLabelText('First Name')[0]); expect(onBlur).toBeCalled(); }); test('should call onChange', () => { const onChange = vi.fn(); const result = setup({ onChange }); fireEvent.focusIn(result.getAllByLabelText('First Name')[0]); expect(onChange).toBeCalledWith(true); fireEvent.focusOut(result.getAllByLabelText('First Name')[0]); expect(onChange).toHaveBeenLastCalledWith(false); }); }); ================================================ FILE: packages/hooks/src/useFocusWithin/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Use ref to set area that needs monitoring. The focus can be switched by click the outside with the mouse, or using keys such as `tab` on the keyboard. * * title.zh-CN: 基础用法 * desc.zh-CN: 使用 ref 设置需要监听的区域。可以通过鼠标点击外部区域,或者使用键盘的 `tab` 等按键来切换焦点。 */ import { useRef } from 'react'; import { useFocusWithin } from 'ahooks'; import { message } from 'antd'; export default () => { const ref = useRef(null); const isFocusWithin = useFocusWithin(ref, { onFocus: () => { message.info('focus'); }, onBlur: () => { message.info('blur'); }, }); return (

isFocusWithin: {JSON.stringify(isFocusWithin)}

); }; ================================================ FILE: packages/hooks/src/useFocusWithin/demo/demo2.tsx ================================================ /** * title: Pass in DOM element * desc: Pass in a function that returns the DOM element. * * title.zh-CN: 传入 DOM 元素 * desc.zh-CN: 传入 function 并返回一个 dom 元素。 */ import { useFocusWithin } from 'ahooks'; export default () => { const isFocusWithin = useFocusWithin(() => document.getElementById('focus-area')); return (

isFocusWithin: {JSON.stringify(isFocusWithin)}

); }; ================================================ FILE: packages/hooks/src/useFocusWithin/index.en-US.md ================================================ --- nav: path: /hooks --- # useFocusWithin Monitor whether the current focus is within a certain area, Same as css attribute [:focus-within](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within). ## Examples ### Default usage ### Pass in DOM element ## API ```typescript const isFocusWithin = useFocusWithin( target, { onFocus, onBlur, onChange } ); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | More config | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | --------------------------------------- | ---------------------------------- | ------- | | onFocus | Callback to be executed on focus | `(e: FocusEvent) => void` | - | | onBlur | Callback to be executed on blur | `(e: FocusEvent) => void` | - | | onChange | Callback to be executed on focus change | `(isFocusWithin: boolean) => void` | - | ### Result | Property | Description | Type | | ------------- | ---------------------------------------- | --------- | | isFocusWithin | Whether the focus is in the current area | `boolean` | ================================================ FILE: packages/hooks/src/useFocusWithin/index.tsx ================================================ import { useState } from 'react'; import useEventListener from '../useEventListener'; import type { BasicTarget } from '../utils/domTarget'; export interface Options { onFocus?: (e: FocusEvent) => void; onBlur?: (e: FocusEvent) => void; onChange?: (isFocusWithin: boolean) => void; } export default function useFocusWithin(target: BasicTarget, options?: Options) { const [isFocusWithin, setIsFocusWithin] = useState(false); const { onFocus, onBlur, onChange } = options || {}; useEventListener( 'focusin', (e: FocusEvent) => { if (!isFocusWithin) { onFocus?.(e); onChange?.(true); setIsFocusWithin(true); } }, { target, }, ); useEventListener( 'focusout', (e: FocusEvent) => { if (isFocusWithin && !(e.currentTarget as Element)?.contains?.(e.relatedTarget as Element)) { onBlur?.(e); onChange?.(false); setIsFocusWithin(false); } }, { target, }, ); return isFocusWithin; } ================================================ FILE: packages/hooks/src/useFocusWithin/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useFocusWithin 监听当前焦点是否在某个区域之内,同 css 属性 [:focus-within](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-within)。 ## 代码演示 ### 基础用法 ### 传入 DOM 元素 ## API ```typescript const isFocusWithin = useFocusWithin( target, { onFocus, onBlur, onChange } ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | --------------------- | ----------------------------------------------------------- | ------ | | target | DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | 额外的配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | -------------- | ---------------------------------- | ------ | | onFocus | 获取焦点时触发 | `(e: FocusEvent) => void` | - | | onBlur | 失去焦点时触发 | `(e: FocusEvent) => void` | - | | onChange | 焦点变化时触发 | `(isFocusWithin: boolean) => void` | - | ### Result | 参数 | 说明 | 类型 | | ------------- | ------------------ | --------- | | isFocusWithin | 焦点是否在当前区域 | `boolean` | ================================================ FILE: packages/hooks/src/useFullscreen/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import screenfull from 'screenfull'; import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { BasicTarget } from '../../utils/domTarget'; import useFullscreen, { type Options } from '../index'; // Mock screenfull vi.mock('screenfull', () => ({ default: { isEnabled: true, element: null, request: vi.fn(), exit: vi.fn(), on: vi.fn(), off: vi.fn(), }, })); const mockScreenfull = screenfull as any; let globalHook: any; let targetEl: any; let changeCallback: any; const setup = (target: BasicTarget, options?: Options) => { globalHook = renderHook(() => useFullscreen(target, options)); return globalHook; }; describe('useFullscreen', () => { beforeEach(() => { targetEl = document.createElement('div'); document.body.appendChild(targetEl); // Reset screenfull mocks mockScreenfull.element = null; mockScreenfull.on.mockImplementation((event: string, callback: any) => { if (event === 'change') { changeCallback = callback; } }); mockScreenfull.off.mockImplementation(() => {}); mockScreenfull.request.mockImplementation((el: any) => { mockScreenfull.element = el; return Promise.resolve(); }); mockScreenfull.exit.mockImplementation(() => { mockScreenfull.element = null; return Promise.resolve(); }); vi.clearAllMocks(); }); afterEach(() => { document.body.removeChild(targetEl); globalHook?.unmount(); changeCallback = null; }); afterAll(() => { vi.resetAllMocks(); }); test('enterFullscreen/exitFullscreen should be work', () => { const { result } = setup(targetEl); const { enterFullscreen, exitFullscreen } = result.current[1]; enterFullscreen(); expect(mockScreenfull.request).toBeCalledWith(targetEl); act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(true); exitFullscreen(); expect(mockScreenfull.exit).toBeCalled(); act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(false); }); test('toggleFullscreen should be work', () => { const { result } = setup(targetEl); const { toggleFullscreen } = result.current[1]; toggleFullscreen(); expect(mockScreenfull.request).toBeCalledWith(targetEl); act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(true); toggleFullscreen(); expect(mockScreenfull.exit).toBeCalled(); act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(false); }); test('onExit/onEnter should be called', () => { const onExit = vi.fn(); const onEnter = vi.fn(); const { result } = setup(targetEl, { onExit, onEnter, }); const { toggleFullscreen } = result.current[1]; toggleFullscreen(); act(() => { if (changeCallback) changeCallback(); }); expect(onEnter).toBeCalled(); toggleFullscreen(); act(() => { if (changeCallback) changeCallback(); }); expect(onExit).toBeCalled(); }); test('onExit/onEnter should not be called', () => { const onExit = vi.fn(); const onEnter = vi.fn(); const { result } = setup(targetEl, { onExit, onEnter, }); const { exitFullscreen, enterFullscreen } = result.current[1]; // `onExit` should not be called when not full screen exitFullscreen(); act(() => { if (changeCallback) changeCallback(); }); expect(onExit).not.toBeCalled(); // Enter full screen enterFullscreen(); act(() => { if (changeCallback) changeCallback(); }); expect(onEnter).toBeCalled(); onEnter.mockReset(); // `onEnter` should not be called when full screen enterFullscreen(); expect(onEnter).not.toBeCalled(); }); test('pageFullscreen should be work', () => { const PAGE_FULLSCREEN_CLASS_NAME = 'test-page-fullscreen'; const PAGE_FULLSCREEN_Z_INDEX = 101; const onExit = vi.fn(); const onEnter = vi.fn(); const { result } = setup(targetEl, { onExit, onEnter, pageFullscreen: { className: PAGE_FULLSCREEN_CLASS_NAME, zIndex: PAGE_FULLSCREEN_Z_INDEX, }, }); const { toggleFullscreen } = result.current[1]; const getStyleEl = () => targetEl.querySelector('style'); act(() => toggleFullscreen()); expect(result.current[0]).toBe(true); expect(onEnter).toBeCalled(); expect(targetEl.classList.contains(PAGE_FULLSCREEN_CLASS_NAME)).toBeTruthy(); expect(getStyleEl()).not.toBeNull(); expect(getStyleEl()?.textContent).toContain(`z-index: ${PAGE_FULLSCREEN_Z_INDEX}`); expect(getStyleEl()?.getAttribute('id')).toBe(PAGE_FULLSCREEN_CLASS_NAME); act(() => toggleFullscreen()); expect(result.current[0]).toBe(false); expect(onExit).toBeCalled(); expect(targetEl.classList.contains(PAGE_FULLSCREEN_CLASS_NAME)).toBeFalsy(); expect(getStyleEl()).toBeNull(); expect(getStyleEl()?.textContent).toBeUndefined(); expect(getStyleEl()?.getAttribute('id')).toBeUndefined(); }); test('enterFullscreen should not work when target is not element', () => { const onEnter = vi.fn(); const { result } = setup(null, { onEnter }); const { enterFullscreen } = result.current[1]; enterFullscreen(); expect(mockScreenfull.request).not.toBeCalled(); expect(onEnter).not.toBeCalled(); }); test('should remove event listener when unmount', () => { const { unmount } = setup(targetEl); expect(mockScreenfull.on).toBeCalledWith('change', expect.any(Function)); unmount(); expect(mockScreenfull.off).toBeCalledWith('change', expect.any(Function)); }); test('`isFullscreen` should be false when use `document.exitFullscreen`', () => { const { result } = setup(targetEl); const { enterFullscreen } = result.current[1]; enterFullscreen(); act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(true); // Simulate external exit fullscreen mockScreenfull.element = null; act(() => { if (changeCallback) changeCallback(); }); expect(result.current[0]).toBe(false); }); test('mutli element full screen should be correct', () => { const targetEl2 = document.createElement('p'); document.body.appendChild(targetEl2); // Store separate change callbacks for each hook let changeCallback1: any = null; let changeCallback2: any = null; // Override mock to track multiple callbacks mockScreenfull.on.mockImplementation((event: string, callback: any) => { if (event === 'change') { if (!changeCallback1) { changeCallback1 = callback; } else if (!changeCallback2) { changeCallback2 = callback; } } }); const hook = setup(targetEl); const hook2 = setup(targetEl2); // target1 full screen hook.result.current[1].enterFullscreen(); expect(mockScreenfull.element).toBe(targetEl); act(() => { if (changeCallback1) changeCallback1(); if (changeCallback2) changeCallback2(); }); expect(hook.result.current[0]).toBe(true); // target2 full screen (this should make target1 not fullscreen) hook2.result.current[1].enterFullscreen(); expect(mockScreenfull.element).toBe(targetEl2); act(() => { if (changeCallback1) changeCallback1(); if (changeCallback2) changeCallback2(); }); expect(hook.result.current[0]).toBe(false); expect(hook2.result.current[0]).toBe(true); // target2 exit full screen (no element is fullscreen now) hook2.result.current[1].exitFullscreen(); expect(mockScreenfull.element).toBe(null); act(() => { if (changeCallback1) changeCallback1(); if (changeCallback2) changeCallback2(); }); expect(hook.result.current[0]).toBe(false); expect(hook2.result.current[0]).toBe(false); document.body.removeChild(targetEl2); }); }); ================================================ FILE: packages/hooks/src/useFullscreen/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Use ref to set elements that need full screen * * title.zh-CN: 基础用法 * desc.zh-CN: 使用 ref 设置需要全屏的元素 */ import { useRef } from 'react'; import { useFullscreen } from 'ahooks'; export default () => { const ref = useRef(null); const [isFullscreen, { enterFullscreen, exitFullscreen, toggleFullscreen }] = useFullscreen(ref); return (
{isFullscreen ? 'Fullscreen' : 'Not fullscreen'}
); }; ================================================ FILE: packages/hooks/src/useFullscreen/demo/demo2.tsx ================================================ /** * title: Image full screen * * title.zh-CN: 图片全屏 */ import { useFullscreen } from 'ahooks'; // @ts-ignore - Image import import img from './react-hooks.jpg'; export default () => { const [, { enterFullscreen }] = useFullscreen(() => document.getElementById('fullscreen-img')); return (
); }; ================================================ FILE: packages/hooks/src/useFullscreen/demo/demo3.tsx ================================================ /** * title: Page full screen * * title.zh-CN: 页面全屏 */ import { useRef } from 'react'; import { useFullscreen } from 'ahooks'; export default () => { const ref = useRef(null); const [isFullscreen, { toggleFullscreen, enterFullscreen, exitFullscreen }] = useFullscreen(ref, { pageFullscreen: true, }); return (
{isFullscreen ? 'Fullscreen' : 'Not fullscreen'}
); }; ================================================ FILE: packages/hooks/src/useFullscreen/demo/demo4.tsx ================================================ /** * title: Coexist with other full screen operations * desc: The element's full screen may be modified by other scripts, don't worry, ahooks can work with them. * * title.zh-CN: 与其它全屏操作共存 * desc.zh-CN: 元素的全屏情况可能被其它脚本修改,不用担心,ahooks 可以与它们共存。 */ import { useRef } from 'react'; import { useFullscreen } from 'ahooks'; function vanillaToggleFullscreen(element: HTMLElement) { const isFullscreen = !!document.fullscreenElement; if (isFullscreen) { document.exitFullscreen(); } else { element.requestFullscreen(); } } export default () => { const ref = useRef(null); const [isFullscreen, { toggleFullscreen }] = useFullscreen(ref); return (
{isFullscreen ? 'Fullscreen' : 'Not fullscreen'}
); }; ================================================ FILE: packages/hooks/src/useFullscreen/index.en-US.md ================================================ --- nav: path: /hooks --- # useFullscreen manages DOM full screen. ## Examples ### Default usage ### Image full screen ### Page full screen ### Coexist with other full screen operations ## API ```typescript const [isFullscreen, { enterFullscreen, exitFullscreen, toggleFullscreen, isEnabled, }] = useFullScreen( target, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | target | DOM element or ref | `Element` \| `() => Element` \| `MutableRefObject` | - | | options | Setting | `Options` | - | ### Options | Property | Description | Type | Default | | -------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------- | | onExit | Exit full screen trigger | `() => void` | - | | onEnter | Enter full screen trigger | `() => void` | - | | pageFullscreen | Whether to enable full screen of page. If its type is object, it can set `className` and `z-index` of the full screen element | `boolean` \| `{ className?: string, zIndex?: number }` | `false` | ### Result | Property | Description | Type | | ---------------- | -------------------- | ------------ | | isFullscreen | Is full screen | `boolean` | | enterFullscreen | Enter full screen | `() => void` | | exitFullscreen | Exit full screen | `() => void` | | toggleFullscreen | Toggle full screen | `() => void` | | isEnabled | Is enable screenfull | `boolean` | ================================================ FILE: packages/hooks/src/useFullscreen/index.ts ================================================ import { useEffect, useState, useRef } from 'react'; import screenfull from 'screenfull'; import useLatest from '../useLatest'; import useMemoizedFn from '../useMemoizedFn'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import { isBoolean } from '../utils'; export interface PageFullscreenOptions { className?: string; zIndex?: number; } export interface Options { onExit?: () => void; onEnter?: () => void; pageFullscreen?: boolean | PageFullscreenOptions; } const useFullscreen = (target: BasicTarget, options?: Options) => { const { onExit, onEnter, pageFullscreen = false } = options || {}; const { className = 'ahooks-page-fullscreen', zIndex = 999999 } = isBoolean(pageFullscreen) || !pageFullscreen ? {} : pageFullscreen; const onExitRef = useLatest(onExit); const onEnterRef = useLatest(onEnter); // The state of full screen may be changed by other scripts/components, // so the initial value needs to be computed dynamically. const [state, setState] = useState(getIsFullscreen); const stateRef = useRef(getIsFullscreen()); function getIsFullscreen() { return ( screenfull.isEnabled && !!screenfull.element && screenfull.element === getTargetElement(target) ); } const invokeCallback = (fullscreen: boolean) => { if (fullscreen) { onEnterRef.current?.(); } else { onExitRef.current?.(); } }; const updateFullscreenState = (fullscreen: boolean) => { // Prevent repeated calls when the state is not changed. if (stateRef.current !== fullscreen) { invokeCallback(fullscreen); setState(fullscreen); stateRef.current = fullscreen; } }; const onScreenfullChange = () => { const fullscreen = getIsFullscreen(); updateFullscreenState(fullscreen); }; const togglePageFullscreen = (fullscreen: boolean) => { const el = getTargetElement(target); if (!el) { return; } let styleElem = document.getElementById(className); if (fullscreen) { el.classList.add(className); if (!styleElem) { styleElem = document.createElement('style'); styleElem.setAttribute('id', className); styleElem.textContent = ` .${className} { position: fixed; left: 0; top: 0; right: 0; bottom: 0; width: 100% !important; height: 100% !important; z-index: ${zIndex}; }`; el.appendChild(styleElem); } } else { el.classList.remove(className); if (styleElem) { styleElem.remove(); } } updateFullscreenState(fullscreen); }; const enterFullscreen = () => { const el = getTargetElement(target); if (!el) { return; } if (pageFullscreen) { togglePageFullscreen(true); return; } if (screenfull.isEnabled) { try { screenfull.request(el); } catch (error) { console.error(error); } } }; const exitFullscreen = () => { const el = getTargetElement(target); if (!el) { return; } if (pageFullscreen) { togglePageFullscreen(false); return; } if (screenfull.isEnabled && screenfull.element === el) { screenfull.exit(); } }; const toggleFullscreen = () => { if (state) { exitFullscreen(); } else { enterFullscreen(); } }; useEffect(() => { if (!screenfull.isEnabled || pageFullscreen) { return; } screenfull.on('change', onScreenfullChange); return () => { screenfull.off('change', onScreenfullChange); }; }, []); return [ state, { enterFullscreen: useMemoizedFn(enterFullscreen), exitFullscreen: useMemoizedFn(exitFullscreen), toggleFullscreen: useMemoizedFn(toggleFullscreen), isEnabled: screenfull.isEnabled, }, ] as const; }; export default useFullscreen; ================================================ FILE: packages/hooks/src/useFullscreen/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useFullscreen 管理 DOM 全屏的 Hook。 ## 代码演示 ### 基础用法 ### 图片全屏 ### 页面全屏 ### 与其它全屏操作共存 ## API ```typescript const [isFullscreen, { enterFullscreen, exitFullscreen, toggleFullscreen, isEnabled, }] = useFullscreen( target, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ---------------- | ----------------------------------------------------------- | ------ | | target | DOM 节点或者 ref | `Element` \| `() => Element` \| `MutableRefObject` | - | | options | 设置 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------ | ------- | | onExit | 退出全屏触发 | `() => void` | - | | onEnter | 全屏触发 | `() => void` | - | | pageFullscreen | 是否是页面全屏。当参数类型为对象时,可以设置全屏元素的类名和 `z-index` | `boolean` \| `{ className?: string, zIndex?: number }` | `false` | ### Result | 参数 | 说明 | 类型 | | ---------------- | ------------ | ------------ | | isFullscreen | 是否全屏 | `boolean` | | enterFullscreen | 设置全屏 | `() => void` | | exitFullscreen | 退出全屏 | `() => void` | | toggleFullscreen | 切换全屏 | `() => void` | | isEnabled | 是否支持全屏 | `boolean` | ================================================ FILE: packages/hooks/src/useFusionTable/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useFusionTable from '../index'; type Result = { total: number; list: any[]; }; let count = 0; const total = 40; const getTableData = async ({ current, pageSize, }: { current: number; pageSize: number; }): Promise => { if (count * current >= total) { return { total, list: [], }; } await sleep(1000); count++; const list = new Array(pageSize).fill(1).map((item, i) => { const index = current * pageSize + i; return { id: index, name: 'test', }; }); return { total, list, }; }; let values = {}; const mockField = { getNames() { return []; }, setValues(v: any) { values = v; }, getValues() { return values; }, resetToDefault() { values = {}; }, validate(names: any, callback: (err: any, values: any) => void) { callback(null, values); }, }; const setup = (service: any, options: any = {}) => renderHook(() => useFusionTable(service, options)); describe('useFusionTable', () => { beforeEach(() => { count = 0; values = {}; }); beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.useRealTimers(); }); test('should get table & pagination props', async () => { const { result } = setup(getTableData); await act(async () => { vi.runAllTimers(); }); expect(result.current.tableProps.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.tableProps.loading).toBe(false); expect(result.current.tableProps.dataSource).toHaveLength(10); expect(result.current.paginationProps.current).toBe(1); expect(result.current.paginationProps.total).toBe(total); }); test('should get table data when page change', async () => { const { result } = setup(getTableData); const current = 2; await act(async () => { vi.runAllTimers(); }); await act(async () => { vi.advanceTimersByTime(1000); }); act(() => { result.current.paginationProps.onChange(current); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.paginationProps.current).toBe(current); expect(result.current.paginationProps.total).toBe(total); }); test('search should work when set field instance', async () => { const { result } = setup(getTableData, { field: mockField }); mockField.setValues({ name: 'ahooks', }); result.current.search.submit(); await act(async () => { vi.runAllTimers(); }); expect(result.current.loading).toBe(true); expect(result.current.params[1]).toMatchObject({ name: 'ahooks' }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); result.current.search.reset(); expect(result.current.params[1]).toMatchObject({}); await act(async () => { vi.runAllTimers(); }); expect(result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); }); test('defaultParams should be work', async () => { const { result } = setup(getTableData, { defaultParams: [{ current: 2, pageSize: 20 }], }); await act(async () => { vi.runAllTimers(); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.tableProps.dataSource).toHaveLength(20); expect(result.current.paginationProps.current).toBe(2); }); test('cache should be work', async () => { const options = { field: mockField, cacheKey: 'cache', defaultParams: [ { current: 2, pageSize: 5, }, { name: 'hello', phone: '123' }, ], }; const hook = setup(getTableData, options); await act(async () => { vi.runAllTimers(); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.tableProps.dataSource).toHaveLength(5); expect(hook.result.current.loading).toBe(false); hook.unmount(); const hook2 = setup(getTableData, options); await act(async () => { vi.runAllTimers(); }); expect(hook2.result.current.loading).toBe(false); expect(hook2.result.current.tableProps.dataSource).toHaveLength(5); }); test('onSort should be work', async () => { const { result } = setup(getTableData); act(() => { result.current.tableProps.onSort('dataIndex', 'asc'); }); expect(result.current.loading).toBe(true); expect(result.current.params[0]?.sorter).toMatchObject({ field: 'dataIndex', order: 'asc' }); }); test('onFilter should be work', async () => { const { result } = setup(getTableData); const filterParams = { version: 3, }; act(() => { result.current.tableProps.onFilter(filterParams); }); expect(result.current.loading).toBe(true); expect(result.current.params[0]?.filters).toMatchObject(filterParams); }); }); ================================================ FILE: packages/hooks/src/useFusionTable/demo/cache.tsx ================================================ import { useState } from 'react'; import { Table, Pagination, Field, Form, Input, Button } from '@alifd/next'; import { useFusionTable } from 'ahooks'; import ReactJson from 'react-json-view'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = async ( { current, pageSize, filters, sorter, }: { current: number; pageSize: number; filters: any; sorter: any }, formData: Object, ): Promise => { console.log(sorter, filters); let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`) .then((res) => res.json()) .then((res) => ({ total: 55, list: res.results, })); }; const AppList = () => { const field = Field.useField({} as any); const { tableProps, paginationProps, params, search } = useFusionTable(getTableData, { defaultPageSize: 5, field, cacheKey: 'tableProps', }); const { filters = {} } = params[0] || {}; const { type, changeType, submit, reset } = search || {}; const searchFrom = (
{type === 'advance' && ( <> )} Search
); return (
{searchFrom}

Current Table:

Current Form:

); }; const Demo = () => { const [show, setShow] = useState(true); return (
{show && }
); }; export default Demo; ================================================ FILE: packages/hooks/src/useFusionTable/demo/form.tsx ================================================ import { Table, Pagination, Field, Form, Input, Button, Icon } from '@alifd/next'; import { useFusionTable } from 'ahooks'; import ReactJson from 'react-json-view'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize }: { current: number; pageSize: number }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`) .then((res) => res.json()) .then((res) => ({ total: 55, list: res.results.slice(0, 10), })); }; const AppList = () => { const field = Field.useField({} as any); const { paginationProps, tableProps, search, loading, params } = useFusionTable(getTableData, { field, }); const { type, changeType, submit, reset } = search; const advanceSearchForm = (
Search
); const searchForm = (
} placeholder="enter name" onPressEnter={submit} />
); return ( <> {type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; export default AppList; ================================================ FILE: packages/hooks/src/useFusionTable/demo/init.tsx ================================================ import { Button, Field, Form, Icon, Input, Pagination, Select, Table } from '@alifd/next'; import { useFusionTable } from 'ahooks'; import ReactJson from 'react-json-view'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize }: { current: number; pageSize: number }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`) .then((res) => res.json()) .then((res) => ({ total: 55, list: res.results.slice(0, 10), })); }; const AppList = () => { const field = Field.useField({} as any); const { paginationProps, tableProps, search, loading, params } = useFusionTable(getTableData, { field, defaultParams: [ { current: 2, pageSize: 5 }, { name: 'hello', email: 'abc@gmail.com', gender: 'female' }, ], defaultType: 'advance', }); const { type, changeType, submit, reset } = search; const advanceSearchForm = (
Search
); const searchForm = (
} placeholder="enter name" onPressEnter={submit} />
); return ( <> {type === 'simple' ? searchForm : advanceSearchForm}

Current Table:

Current Form:

); }; export default AppList; ================================================ FILE: packages/hooks/src/useFusionTable/demo/table.tsx ================================================ import { Pagination, Table } from '@alifd/next'; import { useFusionTable } from 'ahooks'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ({ current, pageSize, }: { current: number; pageSize: number; }): Promise => { const query = `page=${current}&size=${pageSize}`; return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`) .then((res) => res.json()) .then((res) => ({ total: 55, list: res.results.slice(0, 10), })); }; const AppList = () => { const { paginationProps, tableProps } = useFusionTable(getTableData); return ( <>
); }; export default AppList; ================================================ FILE: packages/hooks/src/useFusionTable/demo/validate.tsx ================================================ import { Table, Pagination, Field, Form, Input, Icon } from '@alifd/next'; import { useFusionTable } from 'ahooks'; import ReactJson from 'react-json-view'; interface Item { name: { last: string; }; email: string; phone: string; gender: 'male' | 'female'; } interface Result { total: number; list: Item[]; } const getTableData = ( { current, pageSize }: { current: number; pageSize: number }, formData: Object, ): Promise => { let query = `page=${current}&size=${pageSize}`; Object.entries(formData).forEach(([key, value]) => { if (value) { query += `&${key}=${value}`; } }); return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`) .then((res) => res.json()) .then((res) => ({ total: 55, list: res.results.slice(0, 10), })); }; const AppList = () => { const field = Field.useField({} as any); const { paginationProps, tableProps, search, params } = useFusionTable(getTableData, { field, defaultParams: [{ current: 1, pageSize: 10 }, { name: 'hello' }], }); const { submit } = search; const searchForm = (
} placeholder="enter name" onPressEnter={submit} {...field.init('name', { rules: [{ required: true }] })} />
); return ( <> {searchForm}

Current Table:

Current Form:

); }; export default AppList; ================================================ FILE: packages/hooks/src/useFusionTable/fusionAdapter.ts ================================================ import type { AntdFormUtils } from '../useAntdTable/types'; import type { Field } from './types'; export const fieldAdapter = (field: Field) => ({ getFieldInstance: (name: string) => field.getNames().includes(name), setFieldsValue: field.setValues, getFieldsValue: field.getValues, resetFields: field.resetToDefault, validateFields: (fields, callback) => { field.validate(fields, callback); }, }) as AntdFormUtils; export const resultAdapter = (result: any) => { const tableProps = { dataSource: result.tableProps.dataSource, loading: result.tableProps.loading, onSort: (dataIndex: string, order: string) => { result.tableProps.onChange( { current: result.pagination.current, pageSize: result.pagination.pageSize }, result.params[0]?.filters, { field: dataIndex, order, }, ); }, onFilter: (filterParams: Record) => { result.tableProps.onChange( { current: result.pagination.current, pageSize: result.pagination.pageSize }, filterParams, result.params[0]?.sorter, ); }, }; const paginationProps = { onChange: result.pagination.changeCurrent, onPageSizeChange: result.pagination.changePageSize, current: result.pagination.current, pageSize: result.pagination.pageSize, total: result.pagination.total, }; return { ...result, tableProps, paginationProps, }; }; ================================================ FILE: packages/hooks/src/useFusionTable/index.en-US.md ================================================ --- nav: path: /hooks --- # useFusionTable useFusionTable encapsulates the commonly used [Fusion Form](https://fusion.design/pc/component/basic/form) and [Fusion Table](https://fusion.design/pc/component/basic/table) data binding logic. `useFusionTable` is implemented based on `useRequest`. Before using it, you need to understand a few points that are different from `useRequest`: 1. `service` receives two parameters, the first parameter is the paging data `{ current, pageSize, sorter, filters }`, and the second parameter is the form data. 2. The data structure returned by `service` must be `{ total: number, list: Item[] }`. 3. Additional `tableProps`、`paginationProps` and `search` fields will be returned to manage tables and forms. 4. When `refreshDeps` changes, it will reset `current` to the first page and re-initiate the request. ## Examples ### Table management `useFusionTable` will automatically manage the pagination data of `Table`, you only need to pass the returned `tableProps` and `paginationProps` to the corresponding components. ```tsx | pure ```
### Form and Table data binding When `useFusionTable` receives the `field` instance, it will return a search object to handle form related events. - `search.type` supports switching between `simple` and `advance` - `search.changeType`, switch form type - `search.submit` submit form - `search.reset` reset the current form In the following example, you can experience the data binding between form and table. ### Default Params `useFusionTable` sets the initial value through `defaultParams`, `defaultParams` is an array, the first item is paging related parameters, and the second item is form related data. If there is a second value, we will initialize the form for you! It should be noted that the initial form data can be filled with all the form data of `simple` and `advance`, and we will help you select the form data of the currently activated type. The following example sets paging data and form data during initialization. ### Form Validation Before the form is submitted, we will automatically validate the form data. If the verification fails, the request will not be initiated. ### Data Caching By setting `cacheKey`, we can apply the data caching for the `Form` and `Table` . ## API All parameters and returned results of `useRequest` are applicable to `useFusionTable`, so we won't repeat them here. ```typescript type Data = { total: number; list: any[] }; type Params = [{ current: number; pageSize: number, filter?: any, sorter?: any }, { [key: string]: any }]; const { ..., tableProps: { dataSource: TData['list']; loading: boolean; onSort: (dataIndex: string, order: string) => void; onFilter: (filterParams: any) => void; }; paginationProps: { onChange: (current: number) => void; onPageSizeChange: (size: number) => void; current: number; pageSize: number; total: number; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } = useFusionTable( service: (...args: TParams) => Promise, { ..., field?: any; defaultType?: 'simple' | 'advance'; defaultParams?: TParams, defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | Property | Description | Type | | ----------------- | ----------------------------------------------- | --------------------- | | tableProps | The data required by the `Table` component | - | | paginationProps | The data required by the `Pagination` component | - | | search.type | Current form type | `simple` \| `advance` | | search.changeType | Switch form type | `() => void` | | search.submit | Submit form | `() => void` | | search.reset | Reset the current form | `() => void` | ### Params | Property | Description | Type | Default | | --------------- | ------------------------------------------------------------------------------------------ | ------------------------ | -------- | | field | `Form` instance | - | - | | defaultType | Default form type | `simple` \| `advance` | `simple` | | defaultParams | Default parameters, the first item is paging data, the second item is form data | `[pagination, formData]` | - | | defaultPageSize | Default page size | `number` | `10` | | refreshDeps | Changes in `refreshDeps` will reset current to the first page and re-initiate the request. | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/useFusionTable/index.tsx ================================================ import useAntdTable from '../useAntdTable'; import type { Data, Params, Service } from '../useAntdTable/types'; import { fieldAdapter, resultAdapter } from './fusionAdapter'; import type { FusionTableOptions, FusionTableResult } from './types'; const useFusionTable = ( service: Service, options: FusionTableOptions = {}, ): FusionTableResult => { const ret = useAntdTable(service, { ...options, form: options.field ? fieldAdapter(options.field) : undefined, }); return resultAdapter(ret); }; export default useFusionTable; ================================================ FILE: packages/hooks/src/useFusionTable/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useFusionTable 封装了常用的 [Fusion Form](https://fusion.design/pc/component/basic/form) 与 [Fusion Table](https://fusion.design/pc/component/basic/table) 联动逻辑。 > 🌈「Table 场景解决方案」上线啦!点击图片查看常用的表格场景,区块代码一键下载到本地,快速复用! [使用文档](https://fusion.design/help.html#/dnzud5) [![](https://img.alicdn.com/tfs/TB1bEbWbQcx_u4jSZFlXXXnUFXa-2326-498.png)](https://fusion.design/pc/block?category=Table) `useFusionTable` 基于 `useRequest` 实现,在使用之前,你需要了解它与 `useRequest` 不同的几个点: 1. `service` 接收两个参数,第一个参数为分页数据 `{ current, pageSize, sorter, filters }`,第二个参数为表单数据。 2. `service` 返回的数据结构为 `{ total: number, list: Item[] }`。 3. 会额外返回 `tableProps`、`paginationProps` 和 `search` 字段,管理表格和表单。 4. `refreshDeps` 变化,会重置 `current` 到第一页,并重新发起请求。 ## 代码演示 ### Table 管理 `useFusionTable` 会自动管理 `Table` 分页数据,你只需要把返回的 `tableProps` 与 `paginationProps` 传递给相应组件即可。 ```tsx | pure
```
### Form 与 Table 联动 `useFusionTable` 接收 `field` 实例后,会返回 search 对象,用来处理表单相关事件。 - `search.type` 支持 `simple` 和 `advance` 两个表单切换 - `search.changeType`,切换表单类型 - `search.submit` 提交表单行为 - `search.reset` 重置当前表单 以下示例你可以体验表单与表格联动。 ### 初始化数据 `useFusionTable` 通过 `defaultParams` 设置初始化值,`defaultParams` 是一个数组,第一项为分页相关参数,第二项为表单相关数据。如果有第二个值,我们会帮您初始化表单! 需要注意的是,初始化的表单数据可以填写 `simple` 和 `advance` 全量的表单数据,我们会帮您挑选当前激活的类型中的表单数据。 以下示例在初始化时设置了分页数据和表单数据。 ### 表单验证 表单提交之前,我们会自动校验表单数据,如果验证不通过,则不会发起请求。 ### 数据缓存 通过设置 `cacheKey`,我们可以实现 `Form` 与 `Table` 数据缓存。 ## API `useRequest` 所有参数和返回结果均适用于 `useFusionTable`,此处不再赘述。 ```typescript type Data = { total: number; list: any[] }; type Params = [{ current: number; pageSize: number, filter?: any, sorter?: any }, { [key: string]: any }]; const { ..., tableProps: { dataSource: TData['list']; loading: boolean; onSort: (dataIndex: string, order: string) => void; onFilter: (filterParams: any) => void; }; paginationProps: { onChange: (current: number) => void; onPageSizeChange: (size: number) => void; current: number; pageSize: number; total: number; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } = useFusionTable( service: (...args: TParams) => Promise, { ..., field?: any; defaultType?: 'simple' | 'advance'; defaultParams?: TParams, defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | 参数 | 说明 | 类型 | | ----------------- | ------------------------------------------------------------- | --------------------- | | tableProps | `Table` 组件需要的数据,直接透传给 `Table` 组件即可 | - | | paginationProps | `Pagination` 组件需要的数据,直接透传给 `Pagination` 组件即可 | - | | search.type | 当前表单类型 | `simple` \| `advance` | | search.changeType | 切换表单类型 | `() => void` | | search.submit | 提交表单 | `() => void` | | search.reset | 重置当前表单 | `() => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | --------------- | ------------------------------------------------------------- | ------------------------ | -------- | | field | `Form` 实例 | - | - | | defaultType | 默认表单类型 | `simple` \| `advance` | `simple` | | defaultParams | 默认参数,第一项为分页数据,第二项为表单数据 | `[pagination, formData]` | - | | defaultPageSize | 默认分页数量 | `number` | `10` | | refreshDeps | `refreshDeps` 变化,会重置 current 到第一页,并重新发起请求。 | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/useFusionTable/types.ts ================================================ import type { AntdTableOptions, AntdTableResult, Data, Params } from '../useAntdTable/types'; export interface Field { getFieldInstance?: (name: string) => Record; setValues: (value: Record) => void; getValues: (...args: any) => Record; reset: (...args: any) => void; validate: (fields: any, callback: (errors: any, values: any) => void) => void; [key: string]: any; } export interface FusionTableResult extends Omit, 'tableProps'> { paginationProps: { onChange: (current: number) => void; onPageSizeChange: (size: number) => void; current: number; pageSize: number; total: number; }; tableProps: { dataSource: TData['list']; loading: boolean; onSort: (dataIndex: string, order: string) => void; onFilter: (filterParams: any) => void; }; search: { type: 'simple' | 'advance'; changeType: () => void; submit: () => void; reset: () => void; }; } export interface FusionTableOptions extends Omit, 'form'> { field?: Field; } ================================================ FILE: packages/hooks/src/useGetState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useGetState from '../index'; describe('useGetState', () => { const setUp = (initialValue: T) => renderHook(() => { const [state, setState, getState] = useGetState(initialValue); return { state, setState, getState, } as const; }); test('should support initialValue', () => { const hook = setUp(() => 0); expect(hook.result.current.state).toBe(0); }); test('should support update', () => { const hook = setUp(0); act(() => { hook.result.current.setState(1); }); expect(hook.result.current.getState()).toBe(1); }); test('should getState frozen', () => { const hook = setUp(0); const prevGetState = hook.result.current.getState; act(() => { hook.result.current.setState(1); }); expect(hook.result.current.getState).toBe(prevGetState); }); }); ================================================ FILE: packages/hooks/src/useGetState/demo/demo1.tsx ================================================ /** * title: Open console to view logs * desc: The counter prints the value every 3 seconds * * title.zh-CN: 打开控制台查看输出 * desc.zh-CN: 计数器每 3 秒打印一次值 */ import { useEffect } from 'react'; import { useGetState } from 'ahooks'; export default () => { const [count, setCount, getCount] = useGetState(0); useEffect(() => { const interval = setInterval(() => { console.log('interval count', getCount()); }, 3000); return () => { clearInterval(interval); }; }, []); return ; }; ================================================ FILE: packages/hooks/src/useGetState/index.en-US.md ================================================ --- nav: path: /hooks --- # useGetState Add a getter method to the return value of `React.useState` to get the latest value ## Examples ### Default usage ## TypeScript definition ```typescript import { Dispatch, SetStateAction } from 'react'; type GetStateAction = () => S; function useGetState(initialState: S | (() => S)): [S, Dispatch>, GetStateAction]; function useGetState(): [S | undefined, Dispatch>, GetStateAction]; ``` ## API ```typescript const [state, setState, getState] = useGetState(initialState) ``` ================================================ FILE: packages/hooks/src/useGetState/index.ts ================================================ import type { Dispatch, SetStateAction } from 'react'; import { useState, useCallback } from 'react'; import useLatest from '../useLatest'; type GetStateAction = () => S; function useGetState( initialState: S | (() => S), ): [S, Dispatch>, GetStateAction]; function useGetState(): [ S | undefined, Dispatch>, GetStateAction, ]; function useGetState(initialState?: S) { const [state, setState] = useState(initialState); const stateRef = useLatest(state); const getState = useCallback(() => stateRef.current, []); return [state, setState, getState]; } export default useGetState; ================================================ FILE: packages/hooks/src/useGetState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useGetState 给 `React.useState` 增加了一个 getter 方法,以获取当前最新值。 ## 代码演示 ### 基础用法 ## 类型定义 ```typescript import { Dispatch, SetStateAction } from 'react'; type GetStateAction = () => S; function useGetState(initialState: S | (() => S)): [S, Dispatch>, GetStateAction]; function useGetState(): [S | undefined, Dispatch>, GetStateAction]; ``` ## API ```typescript const [state, setState, getState] = useGetState(initialState) ``` ================================================ FILE: packages/hooks/src/useHistoryTravel/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useHistoryTravel from '../index'; describe('useHistoryTravel', () => { test('should work without initial value', async () => { const hook = renderHook(() => useHistoryTravel()); expect(hook.result.current.value).toBeUndefined(); act(() => { hook.result.current.setValue('test'); }); expect(hook.result.current.value).toBe('test'); }); test('should work with null and undefined without initial value', async () => { const nullHook = renderHook(() => useHistoryTravel()); expect(nullHook.result.current.value).toBeUndefined(); act(() => { nullHook.result.current.setValue(null); }); expect(nullHook.result.current.value).toBeNull(); const undefHook = renderHook(() => useHistoryTravel()); expect(undefHook.result.current.value).toBeUndefined(); act(() => { undefHook.result.current.setValue('def'); }); act(() => { undefHook.result.current.setValue(undefined); }); expect(undefHook.result.current.value).toBeUndefined(); expect(undefHook.result.current.backLength).toBe(2); }); test('should work with initial value', async () => { const hook = renderHook(() => useHistoryTravel('abc')); expect(hook.result.current.value).toBe('abc'); act(() => { hook.result.current.setValue('def'); }); expect(hook.result.current.value).toBe('def'); }); test('should work with null and undefined with initial value', async () => { const nullHook = renderHook(() => useHistoryTravel('abc')); act(() => { nullHook.result.current.setValue(null); }); expect(nullHook.result.current.value).toBeNull(); const undefHook = renderHook(() => useHistoryTravel('abc')); act(() => { undefHook.result.current.setValue(undefined); }); expect(undefHook.result.current.value).toBeUndefined(); expect(undefHook.result.current.backLength).toBe(1); }); test('back and forward should work', () => { const hook = renderHook(() => useHistoryTravel()); act(() => { hook.result.current.setValue('ddd'); }); act(() => { hook.result.current.setValue('abc'); }); expect(hook.result.current.value).toBe('abc'); act(() => { hook.result.current.setValue('def'); }); expect(hook.result.current.value).toBe('def'); act(() => { hook.result.current.back(); }); expect(hook.result.current.value).toBe('abc'); act(() => { hook.result.current.forward(); }); expect(hook.result.current.value).toBe('def'); }); test('go should work for negative step', () => { const hook = renderHook(() => useHistoryTravel('init')); act(() => { hook.result.current.setValue('abc'); }); act(() => { hook.result.current.setValue('def'); }); act(() => { hook.result.current.setValue('hij'); }); act(() => { hook.result.current.go(-2); }); expect(hook.result.current.value).toBe('abc'); act(() => { hook.result.current.go(-100); }); expect(hook.result.current.value).toBe('init'); }); test('go should work for positive step', () => { const hook = renderHook(() => useHistoryTravel('init')); act(() => { hook.result.current.setValue('abc'); }); act(() => { hook.result.current.setValue('def'); }); act(() => { hook.result.current.setValue('hij'); }); act(() => { hook.result.current.go(-3); }); expect(hook.result.current.value).toBe('init'); act(() => { hook.result.current.go(2); }); expect(hook.result.current.value).toBe('def'); act(() => { hook.result.current.go(100); }); expect(hook.result.current.value).toBe('hij'); }); test('reset should reset state to initial by default', () => { const hook = renderHook(() => useHistoryTravel('init')); act(() => { hook.result.current.setValue('abc'); }); act(() => { hook.result.current.setValue('def'); }); act(() => { hook.result.current.setValue('hij'); }); act(() => { hook.result.current.go(-1); }); expect(hook.result.current.backLength).toBe(2); expect(hook.result.current.forwardLength).toBe(1); act(() => { hook.result.current.reset(); }); expect(hook.result.current.value).toBe('init'); expect(hook.result.current.backLength).toBe(0); expect(hook.result.current.forwardLength).toBe(0); }); test('reset should reset state to new initial if provided', () => { const hook = renderHook(() => useHistoryTravel('init')); act(() => { hook.result.current.setValue('abc'); }); act(() => { hook.result.current.setValue('def'); }); act(() => { hook.result.current.setValue('hij'); }); act(() => { hook.result.current.go(-1); }); expect(hook.result.current.backLength).toBe(2); expect(hook.result.current.forwardLength).toBe(1); act(() => { hook.result.current.reset('new init'); }); expect(hook.result.current.value).toBe('new init'); expect(hook.result.current.backLength).toBe(0); expect(hook.result.current.forwardLength).toBe(0); }); test('reset new initial value should work with undefined', () => { const hook = renderHook(() => useHistoryTravel('init')); act(() => { hook.result.current.setValue('abc'); }); act(() => { hook.result.current.setValue('def'); }); act(() => { hook.result.current.setValue('hij'); }); act(() => { hook.result.current.go(-1); }); expect(hook.result.current.backLength).toBe(2); expect(hook.result.current.forwardLength).toBe(1); act(() => { hook.result.current.reset(undefined); }); expect(hook.result.current.value).toBeUndefined(); expect(hook.result.current.backLength).toBe(0); expect(hook.result.current.forwardLength).toBe(0); }); test('should work without max length', async () => { const hook = renderHook(() => useHistoryTravel()); expect(hook.result.current.backLength).toBe(0); for (let i = 1; i <= 100; i++) { act(() => { hook.result.current.setValue(i); }); } expect(hook.result.current.forwardLength).toBe(0); expect(hook.result.current.backLength).toBe(100); expect(hook.result.current.value).toBe(100); }); test('should work with max length', async () => { const hook = renderHook(() => useHistoryTravel(0, 10)); expect(hook.result.current.backLength).toBe(0); for (let i = 1; i <= 100; i++) { act(() => { hook.result.current.setValue(i); }); } expect(hook.result.current.forwardLength).toBe(0); expect(hook.result.current.backLength).toBe(10); expect(hook.result.current.value).toBe(100); act(() => { hook.result.current.go(-5); }); expect(hook.result.current.forwardLength).toBe(5); expect(hook.result.current.backLength).toBe(5); expect(hook.result.current.value).toBe(95); act(() => { hook.result.current.go(5); }); expect(hook.result.current.forwardLength).toBe(0); expect(hook.result.current.backLength).toBe(10); expect(hook.result.current.value).toBe(100); act(() => { hook.result.current.go(-50); }); expect(hook.result.current.forwardLength).toBe(10); expect(hook.result.current.backLength).toBe(0); expect(hook.result.current.value).toBe(90); }); }); ================================================ FILE: packages/hooks/src/useHistoryTravel/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Redo and undo operations,click back and forward after input something. * * title.zh-CN: 基础用法 * desc.zh-CN: 撤销跟重做操作,输入内容后,点击 back 和 forward。 */ import { useHistoryTravel } from 'ahooks'; export default () => { const { value, setValue, backLength, forwardLength, back, forward } = useHistoryTravel(); return (
setValue(e.target.value)} />
); }; ================================================ FILE: packages/hooks/src/useHistoryTravel/demo/demo2.tsx ================================================ /** * title: TodoList * desc: Redo and undo operations * * title.zh-CN: 可撤销恢复的 Todo List * desc.zh-CN: 可以实现撤销恢复等操作。 */ import { useHistoryTravel } from 'ahooks'; import { useState } from 'react'; export default () => { const { value = [], setValue, backLength, forwardLength, back, forward, go, reset, } = useHistoryTravel(['do homework']); const [inputValue, setInputValue] = useState(''); const [step, setStep] = useState(-1); const onAdd = () => { setValue([...value, inputValue]); setInputValue(''); }; const onGo = () => { go(step); setStep(0); }; const onReset = () => { reset(); setStep(0); setInputValue(''); }; return (

TODO List

    {value.map((it, index) => (
  • {it}
  • ))}
setInputValue(e.target.value)} placeholder="Please enter TODO name" style={{ width: 200, marginRight: 8 }} />
setStep(e.target.value as any)} max={forwardLength} min={backLength * -1} style={{ marginRight: 8, width: 60 }} />
); }; ================================================ FILE: packages/hooks/src/useHistoryTravel/demo/demo3.tsx ================================================ /** * title: Limit maximum history length * desc: Limit the maximum number of history records to avoid excessive memory consumption. * * title.zh-CN: 限制历史记录最大长度 * desc.zh-CN: 限制最大历史记录数量,避免过度占用内存。 */ import { useHistoryTravel } from 'ahooks'; export default () => { const maxLength = 3; const { value, setValue, backLength, forwardLength, back, forward } = useHistoryTravel( '', maxLength, ); return (
maxLength: {maxLength}
backLength: {backLength}
forwardLength: {forwardLength}
setValue(e.target.value)} />
); }; ================================================ FILE: packages/hooks/src/useHistoryTravel/index.en-US.md ================================================ --- nav: path: /hooks --- # useHistoryTravel A hook to manage state change history. It provides encapsulation methods to travel through the history. ## Examples ### Basic usage ### Todo List ### Limit maximum history length ## API ```typescript const { value, setValue, backLength, forwardLength, go, back, forward } = useHistoryTravel(initialValue?: T, maxLength: number = 0 ); ``` ### Params | Property | Description | Type | Default | | ------------ | ------------------------------------------------------------------------------------------------------------------------- | -------- | ----------- | | initialValue | Optional initial value | `T` | - | | maxLength | Optional limit the maximum length of history records. If the maximum length is exceeded, the first record will be deleted | `number` | 0 unlimited | ### Result | Property | Description | Type | | ------------- | --------------------------------------------------------------------------------- | ------------------------------- | | value | Current value | `T` | | setValue | Set value | `(value: T) => void` | | backLength | The length of backward history | `number` | | forwardLength | The length of forward history | `number` | | go | Move between the history, move backward on step < 0,and move forward on step > 0 | `(step: number) => void` | | back | Move one step backward | `() => void` | | foward | Move one step forward | `() => void` | | reset | Reset history to initial value by default or provide a new initial value. | `(newInitialValue?: T) => void` | ================================================ FILE: packages/hooks/src/useHistoryTravel/index.ts ================================================ import { useRef, useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isNumber } from '../utils'; interface IData { present?: T; past: T[]; future: T[]; } const dumpIndex = (step: number, arr: T[]) => { let index = step > 0 ? step - 1 // move forward : arr.length + step; // move backward if (index >= arr.length - 1) { index = arr.length - 1; } if (index < 0) { index = 0; } return index; }; const split = (step: number, targetArr: T[]) => { const index = dumpIndex(step, targetArr); return { _current: targetArr[index], _before: targetArr.slice(0, index), _after: targetArr.slice(index + 1), }; }; export default function useHistoryTravel(initialValue?: T, maxLength: number = 0) { const [history, setHistory] = useState>({ present: initialValue, past: [], future: [], }); const { present, past, future } = history; const initialValueRef = useRef(initialValue); const reset = (...params: any[]) => { const _initial = params.length > 0 ? params[0] : initialValueRef.current; initialValueRef.current = _initial; setHistory({ present: _initial, future: [], past: [], }); }; const updateValue = (val: T) => { const _past = [...past, present]; const maxLengthNum = isNumber(maxLength) ? maxLength : Number(maxLength); // maximum number of records exceeded if (maxLengthNum > 0 && _past.length > maxLengthNum) { //delete first _past.splice(0, 1); } setHistory({ present: val, future: [], past: _past, }); }; const _forward = (step: number = 1) => { if (future.length === 0) { return; } const { _before, _current, _after } = split(step, future); setHistory({ past: [...past, present, ..._before], present: _current, future: _after, }); }; const _backward = (step: number = -1) => { if (past.length === 0) { return; } const { _before, _current, _after } = split(step, past); setHistory({ past: _before, present: _current, future: [..._after, present, ...future], }); }; const go = (step: number) => { const stepNum = isNumber(step) ? step : Number(step); if (stepNum === 0) { return; } if (stepNum > 0) { return _forward(stepNum); } _backward(stepNum); }; return { value: present, backLength: past.length, forwardLength: future.length, setValue: useMemoizedFn(updateValue), go: useMemoizedFn(go), back: useMemoizedFn(() => { go(-1); }), forward: useMemoizedFn(() => { go(1); }), reset: useMemoizedFn(reset), }; } ================================================ FILE: packages/hooks/src/useHistoryTravel/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useHistoryTravel 管理状态历史变化记录,方便在历史记录中前进与后退。 ## 代码演示 ### 基础用法 ### 可撤销恢复的 Todo List ### 限制历史记录最大长度 ## API ```typescript const { value, setValue, backLength, forwardLength, go, back, forward } = useHistoryTravel(initialValue?: T, maxLength: number = 0); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | --------------------------------------------------------- | -------- | -------- | | initialValue | 可选,初始值 | `any` | - | | maxLength | 可选,限制历史记录最大长度,超过最大长度后将删除第一个记录 | `number` | 0 不限制 | ### Result | 参数 | 说明 | 类型 | | ------------- | --------------------------------------------- | ------------------------------- | | value | 当前值 | `T` | | setValue | 设置 value | `(value: T) => void` | | backLength | 可回退历史长度 | `number` | | forwardLength | 可前进历史长度 | `number` | | go | 前进步数, step < 0 为后退, step > 0 时为前进 | `(step: number) => void` | | back | 向后回退一步 | `() => void` | | foward | 向前前进一步 | `() => void` | | reset | 重置到初始值,或提供一个新的初始值 | `(newInitialValue?: T) => void` | ================================================ FILE: packages/hooks/src/useHover/__tests__/index.spec.tsx ================================================ import { act, fireEvent, render, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useHover from '../index'; describe('useHover', () => { test('should work', () => { const { getByText } = render(); let trigger = 0; const { result } = renderHook(() => useHover(getByText('Hover'), { onEnter: () => { trigger++; }, onLeave: () => { trigger++; }, }), ); expect(result.current).toBe(false); act(() => void fireEvent.mouseEnter(getByText('Hover'))); expect(result.current).toBe(true); expect(trigger).toBe(1); act(() => void fireEvent.mouseLeave(getByText('Hover'))); expect(result.current).toBe(false); expect(trigger).toBe(2); }); }); ================================================ FILE: packages/hooks/src/useHover/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Use ref to set element that needs monitoring. * * title.zh-CN: 基础用法 * desc.zh-CN: 使用 ref 设置需要监听的元素。 */ import { useRef } from 'react'; import { useHover } from 'ahooks'; export default () => { const ref = useRef(null); const isHovering = useHover(ref); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ================================================ FILE: packages/hooks/src/useHover/demo/demo2.tsx ================================================ /** * title: Pass in DOM element * desc: Pass in a function that returns the DOM element. * * title.zh-CN: 传入 DOM 元素 * desc.zh-CN: 传入 function 并返回一个 dom 元素。 */ import { useHover } from 'ahooks'; export default () => { const isHovering = useHover(() => document.getElementById('hover-div'), { onEnter: () => { console.log('onEnter'); }, onLeave: () => { console.log('onLeave'); }, onChange: (isHover) => { console.log('onChange', isHover); }, }); return
{isHovering ? 'hover' : 'leaveHover'}
; }; ================================================ FILE: packages/hooks/src/useHover/index.en-US.md ================================================ --- nav: path: /hooks --- # useHover A hook that tracks whether the element is being hovered. ## Examples ### Default usage ### Pass in DOM element ## API ```javascript const isHovering = useHover( target, { onEnter, onLeave, onChange } ); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | More config | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | --------------------------------------- | ------------------------------- | ------- | | onEnter | Callback to be executed on mouse hover | `() => void` | - | | onLeave | Callback to be executed on mouse leave | `() => void` | - | | onChange | Callback to be executed on hover change | `(isHovering: boolean) => void` | - | ### Result | Property | Description | Type | | ---------- | ------------------------------------ | --------- | | isHovering | Whether the element is being hovered | `boolean` | ================================================ FILE: packages/hooks/src/useHover/index.ts ================================================ import useBoolean from '../useBoolean'; import useEventListener from '../useEventListener'; import type { BasicTarget } from '../utils/domTarget'; export interface Options { onEnter?: () => void; onLeave?: () => void; onChange?: (isHovering: boolean) => void; } export default (target: BasicTarget, options?: Options): boolean => { const { onEnter, onLeave, onChange } = options || {}; const [state, { setTrue, setFalse }] = useBoolean(false); useEventListener( 'mouseenter', () => { onEnter?.(); setTrue(); onChange?.(true); }, { target, }, ); useEventListener( 'mouseleave', () => { onLeave?.(); setFalse(); onChange?.(false); }, { target, }, ); return state; }; ================================================ FILE: packages/hooks/src/useHover/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useHover 监听 DOM 元素是否有鼠标悬停。 ## 代码演示 ### 基础用法 ### 传入 DOM 元素 ## API ```javascript const isHovering = useHover( target, { onEnter, onLeave, onChange } ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | --------------------- | ----------------------------------------------------------- | ------ | | target | DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | options | 额外的配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | -------------------- | ------------------------------- | ------ | | onEnter | hover 时触发 | `() => void` | - | | onLeave | 取消 hover 时触发 | `() => void` | - | | onChange | hover 状态变化时触发 | `(isHovering: boolean) => void` | - | ### Result | 参数 | 说明 | 类型 | | ---------- | ---------------------- | --------- | | isHovering | 鼠标元素是否处于 hover | `boolean` | ================================================ FILE: packages/hooks/src/useInViewport/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useInViewport from '../index'; const targetEl = document.createElement('div'); document.body.appendChild(targetEl); const observe = vi.fn(); const disconnect = vi.fn(); const mockIntersectionObserver = vi.fn().mockReturnValue({ observe, disconnect, }); window.IntersectionObserver = mockIntersectionObserver; describe('useInViewport', () => { test('should work when target is in viewport', async () => { const { result } = renderHook(() => useInViewport(targetEl)); const calls = mockIntersectionObserver.mock.calls; const [onChange] = calls[calls.length - 1]; act(() => { onChange([ { targetEl, isIntersecting: true, intersectionRatio: 0.5, }, ]); }); const [inViewport, ratio] = result.current; expect(inViewport).toBeTruthy(); expect(ratio).toBe(0.5); }); test('should work when target array is in viewport and has a callback', async () => { const targetEls: HTMLDivElement[] = []; const callback = vi.fn(); for (let i = 0; i < 2; i++) { const target = document.createElement('div'); document.body.appendChild(target); targetEls.push(target); } const getValue = (isIntersecting: any, intersectionRatio: any) => ({ isIntersecting, intersectionRatio, }); const { result } = renderHook(() => useInViewport(targetEls, { callback })); const calls = mockIntersectionObserver.mock.calls; const [observerCallback] = calls[calls.length - 1]; const target = getValue(false, 0); act(() => observerCallback([target])); expect(callback).toHaveBeenCalledWith(target); expect(result.current[0]).toBe(false); expect(result.current[1]).toBe(0); const target1 = getValue(true, 0.5); act(() => observerCallback([target1])); expect(callback).toHaveBeenCalledWith(target1); expect(result.current[0]).toBe(true); expect(result.current[1]).toBe(0.5); }); test('should not work when target is null', async () => { const previousCallsLength = mockIntersectionObserver.mock.calls.length; renderHook(() => useInViewport(null)); const currentCallsLength = mockIntersectionObserver.mock.calls.length; expect(currentCallsLength).toBe(previousCallsLength); }); test('should disconnect when unmount', async () => { mockIntersectionObserver.mockReturnValue({ observe: () => null, disconnect, }); const { unmount } = renderHook(() => useInViewport(targetEl)); unmount(); expect(disconnect).toBeCalled(); }); }); ================================================ FILE: packages/hooks/src/useInViewport/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Observe if the element is visible. * * title.zh-CN: 基础用法 * desc.zh-CN: 监听元素是否在可见区域内 */ import { useRef } from 'react'; import { useInViewport } from 'ahooks'; export default () => { const ref = useRef(null); const [inViewport] = useInViewport(ref); return (
scroll here
observer dom
inViewport: {inViewport ? 'visible' : 'hidden'}
); }; ================================================ FILE: packages/hooks/src/useInViewport/demo/demo2.tsx ================================================ /** * title: Observe element visible area ratio * desc: Pass in `options.threshold`, you can control the ratio to be triggered when the visible area reach every threshold.
`options.root` can control the parent element, in this example, visible will not change relative to the browser viewport. * * title.zh-CN: 监听元素可见区域比例 * desc.zh-CN: 传入 `options.threshold`, 可以控制在可见区域达到该比例时触发 ratio 更新。
`options.root` 可以控制相对父级元素,在这个例子中,不会相对浏览器视窗变化。 */ import { useInViewport } from 'ahooks'; export default () => { const [inViewport, ratio] = useInViewport(() => document.getElementById('children'), { threshold: [0, 0.25, 0.5, 0.75, 1], root: () => document.getElementById('parent'), }); return (
scroll here
observer dom

inViewport: {inViewport ? 'visible' : 'hidden'}

ratio: {ratio}

); }; ================================================ FILE: packages/hooks/src/useInViewport/demo/demo3.tsx ================================================ /** * title: Listening content scrolling selection menu * desc: Pass the `callback` that is triggered when the callback of `IntersectionObserver` is called, so you can do some customization. * * title.zh-CN: 监听内容滚动选中菜单 * desc.zh-CN: 传入 `callback`, 使得 `IntersectionObserver` 的回调被调用时,用户可以做一些自定义操作。 */ import { useInViewport, useMemoizedFn } from 'ahooks'; import { useRef, useState } from 'react'; const menus = ['menu-1', 'menu-2', 'menu-3']; const content = { 'menu-1': 'Content for menus 1', 'menu-2': 'Content for menus 2', 'menu-3': 'Content for menus 3', }; export default () => { const menuRef = useRef([]); const [activeMenu, setActiveMenu] = useState(menus[0]); const callback = useMemoizedFn((entry) => { if (entry.isIntersecting) { const active = entry.target.getAttribute('id') || ''; setActiveMenu(active); } }); const handleMenuClick = (index: number) => { const contentEl = document.getElementById('content-scroll'); const top = menuRef.current[index]?.offsetTop; contentEl?.scrollTo({ top, behavior: 'smooth', }); }; useInViewport(menuRef.current, { callback, root: () => document.getElementById('parent-scroll'), rootMargin: '-50% 0px -50% 0px', }); return (
    {menus.map((menu, index) => (
  • handleMenuClick(index)} style={{ padding: '10px', cursor: 'pointer', textAlign: 'center', transition: 'background-color 0.2s ease-in-out', backgroundColor: activeMenu === menu ? '#e0e0e0' : '', }} > {menu}
  • ))}
{menus.map((menu, index) => (
{ menuRef.current[index] = el; }} key={menu} id={menu} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', fontSize: 16, }} > {content[menu as keyof typeof content]}
))}
); }; ================================================ FILE: packages/hooks/src/useInViewport/index.en-US.md ================================================ --- nav: path: /hooks --- # useInViewport Observe whether the element is in the visible area, and the visible area ratio of the element. More information refer to [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). ## Examples ### Default usage ### Observe the visible area ratio of element ### Listening content scrolling selection menu ## API ```typescript type Target = Element | (() => Element) | React.MutableRefObject; const [inViewport, ratio] = useInViewport( target: Target | Target[], options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ---------------------------------- | ------------------------ | ------- | | target | DOM elements or Ref, support array | `Target` \| `Target[]` | - | | options | Setting | `Options` \| `undefined` | - | ### Options More information refer to [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) | Property | Description | Type | Default | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------- | | threshold | Either a single number or an array of numbers which indicate at what percentage of the target's visibility the ratio should be executed | `number` \| `number[]` | - | | rootMargin | Margin around the root | `string` | - | | root | The element that is used as the viewport for checking visibility of the target. Must be the ancestor of the target. Defaults to the browser viewport if not specified or if null. | `Element` \| `Document` \| `() => (Element/Document)` \| `MutableRefObject` | - | | callback | Triggered when the callback of `IntersectionObserver` is called | `(entry: IntersectionObserverEntry) => void` | - | ### Result | Property | Description | Type | | ---------- | ---------------------------------------------------------------------------------------- | ------------------------ | | inViewport | Is visible | `boolean` \| `undefined` | | ratio | Current visible ratio, updated every time the node set by `options.threshold` is reached | `number` \| `undefined` | ================================================ FILE: packages/hooks/src/useInViewport/index.ts ================================================ import 'intersection-observer'; import { useState } from 'react'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; type CallbackType = (entry: IntersectionObserverEntry) => void; export interface Options { rootMargin?: string; threshold?: number | number[]; root?: BasicTarget; callback?: CallbackType; } function useInViewport(target: BasicTarget | BasicTarget[], options?: Options) { const { callback, ...option } = options || {}; const [state, setState] = useState(); const [ratio, setRatio] = useState(); useEffectWithTarget( () => { const targets = Array.isArray(target) ? target : [target]; const els = targets.map((element) => getTargetElement(element)).filter(Boolean); if (!els.length) { return; } const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { setRatio(entry.intersectionRatio); setState(entry.isIntersecting); callback?.(entry); } }, { ...option, root: getTargetElement(options?.root), }, ); els.forEach((el) => observer.observe(el!)); return () => { observer.disconnect(); }; }, [options?.rootMargin, options?.threshold, callback], target, ); return [state, ratio] as const; } export default useInViewport; ================================================ FILE: packages/hooks/src/useInViewport/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useInViewport 观察元素是否在可见区域,以及元素可见比例。更多信息参考 [Intersection Observer API](https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API)。 ## 代码演示 ### 基础用法 ### 监听元素可见区域比例 ### 监听内容滚动选中菜单 ## API ```typescript type Target = Element | (() => Element) | React.MutableRefObject; const [inViewport, ratio] = useInViewport( target: Target | Target[], options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | -------------------------- | ------------------------ | ------ | | target | DOM 节点或者 Ref,支持数组 | `Target` \| `Target[]` | - | | options | 设置 | `Options` \| `undefined` | - | ### Options 更多信息参考 [Intersection Observer API](https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API) | 参数 | 说明 | 类型 | 默认值 | | ---------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------ | | threshold | 可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 ratio 会被更新 | `number` \| `number[]` | - | | rootMargin | 根(root)元素的外边距 | `string` | - | | root | 指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素,如果未指定或者为 null,则默认为浏览器视窗。 | `Element` \| `Document` \| `() => (Element/Document)` \| `MutableRefObject` | - | | callback | `IntersectionObserver` 的回调被调用时触发 | `(entry: IntersectionObserverEntry) => void` | - | ### Result | 参数 | 说明 | 类型 | | ---------- | ----------------------------------------------------------- | ------------------------ | | inViewport | 是否可见 | `boolean` \| `undefined` | | ratio | 当前可见比例,在每次到达 `options.threshold` 设置节点时更新 | `number` \| `undefined` | ================================================ FILE: packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useInfiniteScroll from '..'; import type { Data, InfiniteScrollOptions, Service } from '../types'; let count = 0; export async function mockRequest() { await sleep(1000); if (count >= 1) { return { list: [4, 5, 6] }; } count++; return { list: [1, 2, 3], nextId: count, }; } const targetEl = document.createElement('div'); // set target property function setTargetInfo(key: 'scrollTop', value: any) { Object.defineProperty(targetEl, key, { value, configurable: true, }); } const setup = (service: Service, options?: InfiniteScrollOptions) => renderHook(() => useInfiniteScroll(service, options)); describe('useInfiniteScroll', () => { let mockRaf: ReturnType; beforeEach(() => { count = 0; }); beforeAll(() => { vi.useFakeTimers(); // Mock requestAnimationFrame to execute callbacks immediately mockRaf = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((cb: FrameRequestCallback) => { cb(0); return 0; }) as ReturnType; }); afterAll(() => { mockRaf.mockRestore(); vi.useRealTimers(); }); test('should auto load', async () => { const { result } = setup(mockRequest); expect(result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); }); test('loadMore should be work', async () => { const { result } = setup(mockRequest, { manual: true }); const { loadMore, loading } = result.current; expect(loading).toBe(false); act(() => { loadMore(); }); expect(result.current.loadingMore).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loadingMore).toBe(false); }); test('noMore should be true when isNoMore is true', async () => { const { result } = setup(mockRequest, { isNoMore: (d) => d?.nextId === undefined, }); const { loadMore } = result.current; await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.noMore).toBe(false); act(() => loadMore()); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.noMore).toBe(true); }); test('should auto load when scroll to bottom', async () => { const events: Record = {}; const mockAddEventListener = vi .spyOn(targetEl, 'addEventListener') .mockImplementation((eventName: string, callback: any) => { events[eventName] = callback; }); const { result } = setup(mockRequest, { target: targetEl, isNoMore: (d) => d?.nextId === undefined, }); // not work when loading expect(result.current.loading).toBe(true); events['scroll'](); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150); const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 300); setTargetInfo('scrollTop', 100); act(() => { events['scroll'](); }); expect(result.current.loadingMore).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loadingMore).toBe(false); // not work when no more expect(result.current.noMore).toBe(true); act(() => { events['scroll'](); }); expect(result.current.loadingMore).toBe(false); // get list by order expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]); mockAddEventListener.mockRestore(); scrollHeightSpy.mockRestore(); clientHeightSpy.mockRestore(); }); test('should auto load when scroll to top', async () => { const events: Record = {}; const mockAddEventListener = vi .spyOn(targetEl, 'addEventListener') .mockImplementation((eventName: string, callback: any) => { events[eventName] = callback; }); // Mock scrollTo using Object.defineProperty Object.defineProperty(targetEl, 'scrollTo', { value: (x: number, y: number) => { setTargetInfo('scrollTop', y); }, writable: true, }); const { result } = setup(mockRequest, { target: targetEl, direction: 'top', isNoMore: (d) => d?.nextId === undefined, }); // not work when loading expect(result.current.loading).toBe(true); events['scroll'](); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); // mock first scroll const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150); const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 500); setTargetInfo('scrollTop', 300); act(() => { events['scroll'](); }); // mock scroll upward setTargetInfo('scrollTop', 50); act(() => { events['scroll'](); }); expect(result.current.loadingMore).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loadingMore).toBe(false); //reverse order expect(result.current.data?.list).toMatchObject([4, 5, 6, 1, 2, 3]); // not work when no more expect(result.current.noMore).toBe(true); act(() => { events['scroll'](); }); expect(result.current.loadingMore).toBe(false); mockAddEventListener.mockRestore(); scrollHeightSpy.mockRestore(); clientHeightSpy.mockRestore(); }); test('reload should be work', async () => { const fn = vi.fn(() => Promise.resolve({ list: [] })); const { result } = setup(fn); const { reload } = result.current; expect(fn).toBeCalledTimes(1); act(() => reload()); expect(fn).toBeCalledTimes(2); await act(async () => { Promise.resolve(); }); }); test('reload should be triggered when reloadDeps change', async () => { const fn = vi.fn(() => Promise.resolve({ list: [] })); const { result } = renderHook(() => { const [value, setValue] = useState(''); const res = useInfiniteScroll(fn, { reloadDeps: [value], }); return { ...res, setValue, }; }); expect(fn).toBeCalledTimes(1); act(() => { result.current.setValue('ahooks'); }); expect(fn).toBeCalledTimes(2); await act(async () => { Promise.resolve(); }); }); test('reload data should be latest', async () => { let listCount = 5; const mockRequestFn = async () => { await sleep(1000); return { list: Array.from({ length: listCount, }).map((_, index) => index + 1), nextId: listCount, hasMore: listCount > 2, }; }; const { result } = setup(mockRequestFn); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.data).toMatchObject({ list: [1, 2, 3, 4, 5], nextId: 5 }); listCount = 3; await act(async () => { result.current.reload(); vi.advanceTimersByTime(1000); }); expect(result.current.data).toMatchObject({ list: [1, 2, 3], nextId: 3 }); }); test('mutate should be work', async () => { const { result } = setup(mockRequest); const { mutate } = result.current; await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.data).toMatchObject({ list: [1, 2, 3], nextId: 1 }); const newData = { list: [1, 2], nextId: 1, }; act(() => mutate(newData)); expect(result.current.data).toMatchObject(newData); }); test('cancel should be work', () => { const onSuccess = vi.fn(); const { result } = setup(mockRequest, { onSuccess, }); const { cancel } = result.current; expect(result.current.loading).toBe(true); act(() => cancel()); expect(result.current.loading).toBe(false); expect(onSuccess).not.toBeCalled(); }); test('onBefore/onSuccess/onFinally should be called', async () => { const onBefore = vi.fn(); const onSuccess = vi.fn(); const onFinally = vi.fn(); setup(mockRequest, { onBefore, onSuccess, onFinally, }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(onBefore).toBeCalled(); expect(onSuccess).toBeCalled(); expect(onFinally).toBeCalled(); }); test('onError should be called when throw error', async () => { const onError = vi.fn(); const mockRequestError = () => { return Promise.reject('error'); }; setup(mockRequestError, { onError, }); await act(async () => { Promise.resolve(); }); expect(onError).toBeCalled(); }); test('loadMoreAsync should be work', async () => { const { result } = setup(mockRequest, { manual: true, }); const { loadMoreAsync } = result.current; act(() => { loadMoreAsync().then((res) => { expect(res).toMatchObject({ list: [1, 2, 3], nextId: 1 }); expect(result.current.loading).toBe(false); }); }); await act(async () => { vi.advanceTimersByTime(1000); }); }); test('reloadAsync should be work', async () => { const fn = vi.fn(() => Promise.resolve({ list: [] })); const { result } = setup(fn); const { reloadAsync } = result.current; expect(fn).toBeCalledTimes(1); act(() => { reloadAsync().then(() => { expect(fn).toBeCalledTimes(2); }); }); await act(async () => { Promise.resolve(); }); }); test('loading should be true when reload after loadMore', async () => { const { result } = setup(mockRequest); expect(result.current.loading).toBeTruthy(); const { reload, loadMore } = result.current; await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBeFalsy(); act(() => { loadMore(); reload(); }); expect(result.current.loading).toBeTruthy(); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBeFalsy(); }); test('loading should be true when reloadAsync after loadMore', async () => { const { result } = setup(mockRequest); expect(result.current.loading).toBeTruthy(); const { reloadAsync, loadMore } = result.current; await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBeFalsy(); act(() => { loadMore(); reloadAsync(); }); expect(result.current.loading).toBeTruthy(); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBeFalsy(); }); test('list can be null or undefined', async () => { // @ts-ignore const { result } = setup(async () => { await sleep(1000); count++; return { list: Math.random() < 0.5 ? null : undefined, nextId: count, }; }); expect(result.current.loading).toBeTruthy(); const { loadMore } = result.current; await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBeFalsy(); act(() => { loadMore(); }); }); test('error result', async () => { const { result } = setup(async () => { throw new Error('error message'); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.error?.message).toBe('error message'); }); test('reloadAsync should reset data and restart from page=1', async () => { const PAGE_SIZE = 2; // Vitest 的 mock const getLoadMoreListMock = vi.fn((page: number, pageSize: number) => { const start = (page - 1) * pageSize + 1; const list = Array.from({ length: pageSize }, (_, i) => start + i); return Promise.resolve({ list }); }); const { result } = renderHook(() => useInfiniteScroll((d) => { const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1; return getLoadMoreListMock(page, PAGE_SIZE); }), ); await act(async () => { await result.current.loadMoreAsync(); }); expect(getLoadMoreListMock).toHaveBeenLastCalledWith(1, PAGE_SIZE); expect(result.current.data?.list.length).toBe(2); await act(async () => { await result.current.loadMoreAsync(); }); expect(getLoadMoreListMock).toHaveBeenLastCalledWith(2, PAGE_SIZE); expect(result.current.data?.list.length).toBe(4); await act(async () => { await result.current.reloadAsync(); }); expect(getLoadMoreListMock).toHaveBeenLastCalledWith(1, PAGE_SIZE); expect(result.current.data?.list.length).toBe(2); expect(result.current.data?.list).toEqual([1, 2]); }); test('service should be called only once when scrolling to bottom multiple times quickly', async () => { const mockService = vi.fn(async () => { await sleep(1000); return { list: [1, 2, 3], nextId: 1 }; }); const events: Record = {}; const mockAddEventListener = vi .spyOn(targetEl, 'addEventListener') .mockImplementation((eventName: string, callback: any) => { events[eventName] = callback; }); const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150); const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 100); const { result } = setup(mockService, { target: targetEl, isNoMore: (d) => d?.nextId === undefined, }); // Wait for initial load to complete await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); expect(mockService).toHaveBeenCalledTimes(1); // Set scroll position to bottom (scrollHeight - scrollTop <= clientHeight + threshold) // 150 - 50 = 100 <= 100 + 100 = 200, so it should trigger loadMore setTargetInfo('scrollTop', 50); // Trigger scroll event multiple times quickly (before first request completes) act(() => { events['scroll'](); }); // Service should be called once more (total 2 times: initial + loadMore) expect(mockService).toHaveBeenCalledTimes(2); // Trigger more scroll events while loading act(() => { events['scroll'](); }); act(() => { events['scroll'](); }); act(() => { events['scroll'](); }); // Service should still only be called twice (no additional calls during loading) expect(mockService).toHaveBeenCalledTimes(2); mockAddEventListener.mockRestore(); scrollHeightSpy.mockRestore(); clientHeightSpy.mockRestore(); }); }); ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/default.tsx ================================================ import { useInfiniteScroll } from 'ahooks'; interface Result { list: string[]; nextId: string | undefined; } const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13']; function getLoadMoreList(nextId: string | undefined, limit: number): Promise { let start = 0; if (nextId) { start = resultData.findIndex((i) => i === nextId); } const end = start + limit; const list = resultData.slice(start, end); const nId = resultData.length >= end ? resultData[end] : undefined; return new Promise((resolve) => { setTimeout(() => { resolve({ list, nextId: nId, }); }, 1000); }); } export default () => { const { data, loading, loadMore, loadingMore } = useInfiniteScroll((d) => getLoadMoreList(d?.nextId, 4), ); return (
{loading ? (

loading

) : (
{data?.list?.map((item) => (
item-{item}
))}
)}
{data?.nextId && ( )} {!data?.nextId && No more data}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/mutate.tsx ================================================ import { useInfiniteScroll, useRequest } from 'ahooks'; interface Result { list: string[]; nextId: string | undefined; } const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13']; function getLoadMoreList(nextId: string | undefined, limit: number): Promise { let start = 0; if (nextId) { start = resultData.findIndex((i) => i === nextId); } const end = start + limit; const list = resultData.slice(start, end); const nId = resultData.length >= end ? resultData[end] : undefined; return new Promise((resolve) => { setTimeout(() => { resolve({ list, nextId: nId, }); }, 1000); }); } function deleteItem(id: string) { return new Promise((resolve) => { setTimeout(() => { resolve(); }, 1000); }); } export default () => { const { data, loading, loadMore, loadingMore, mutate } = useInfiniteScroll((d) => getLoadMoreList(d?.nextId, 4), ); const { loading: deleteLading, params: deleteParams, run: remove, } = useRequest(deleteItem, { manual: true, onSuccess: (_, [id]) => { if (data) { const index = data.list.findIndex((i) => i === id); data?.list.splice(index, 1); mutate({ ...data }); } }, }); return (
{loading ? (

loading

) : (
{data?.list?.map((item) => (
item-{item}
))}
)}
{data?.nextId && ( )} {!data?.nextId && No more data}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/pagination.tsx ================================================ import { useInfiniteScroll } from 'ahooks'; interface Result { list: string[]; total: number; } const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13']; function getLoadMoreList(page: number, pageSize: number): Promise { const start = (page - 1) * pageSize; const end = page * pageSize; const list = resultData.slice(start, end); return new Promise((resolve) => { setTimeout(() => { resolve({ list, total: resultData.length, }); }, 1000); }); } const PAGE_SIZE = 4; export default () => { const { data, loading, loadMore, loadingMore } = useInfiniteScroll((d) => { const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1; return getLoadMoreList(page, PAGE_SIZE); }); const hasMore = data && data.list.length < data.total; return (
{loading ? (

loading

) : (
{data?.list?.map((item) => (
item-{item}
))}
)}
{hasMore && ( )} {!hasMore && No more data}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/reload.tsx ================================================ import { useState } from 'react'; import { useInfiniteScroll } from 'ahooks'; interface Result { list: string[]; nextId: string | undefined; } const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13']; function getLoadMoreList( nextId: string | undefined, limit: number, keyword: string, ): Promise { let start = 0; if (nextId) { start = resultData.findIndex((i) => i === nextId); } const end = start + limit; const list = resultData.slice(start, end); const nId = resultData.length >= end ? resultData[end] : undefined; return new Promise((resolve) => { setTimeout(() => { resolve({ list, nextId: nId, }); }, 1000); }); } export default () => { const [keyword, setKeyword] = useState(''); const { data, loading, loadMore, loadingMore, reload } = useInfiniteScroll((d) => getLoadMoreList(d?.nextId, 4, keyword), ); return (
setKeyword(e.target.value)} />
{loading ? (

loading

) : (
{data?.list?.map((item) => (
item-{item}
))}
)}
{data?.nextId && ( )} {!data?.nextId && No more data}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/scroll.tsx ================================================ import { useRef } from 'react'; import { useInfiniteScroll } from 'ahooks'; interface Result { list: string[]; nextId: string | undefined; } const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13']; function getLoadMoreList(nextId: string | undefined, limit: number): Promise { let start = 0; if (nextId) { start = resultData.findIndex((i) => i === nextId); } const end = start + limit; const list = resultData.slice(start, end); const nId = resultData.length >= end ? resultData[end] : undefined; return new Promise((resolve) => { setTimeout(() => { resolve({ list, nextId: nId, }); }, 1000); }); } export default () => { const ref = useRef(null); const { data, loading, loadMore, loadingMore, noMore } = useInfiniteScroll( (d) => getLoadMoreList(d?.nextId, 4), { target: ref, isNoMore: (d) => d?.nextId === undefined, }, ); return (
{loading ? (

loading

) : (
{data?.list?.map((item) => (
item-{item}
))}
)}
{!noMore && ( )} {noMore && No more data}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx ================================================ import { useRef } from 'react'; import { useInfiniteScroll } from 'ahooks'; interface Result { list: string[]; nextId: string | undefined; } const resultData = [ '15', '14', '13', '12', '11', '10', '9', '8', '7', '6', '5', '4', '3', '2', '1', '0', ]; function getLoadMoreList(nextId: string | undefined, limit: number): Promise { let start = 0; if (nextId) { start = resultData.findIndex((i) => i === nextId); } const end = start + limit; const list = resultData.slice(start, end).reverse(); const nId = resultData.length >= end ? resultData[end] : undefined; return new Promise((resolve) => { setTimeout(() => { resolve({ list, nextId: nId, }); }, 1000); }); } export default () => { const ref = useRef(null); const isFirstIn = useRef(true); const { data, loading, loadMore, loadingMore, noMore } = useInfiniteScroll( (d) => getLoadMoreList(d?.nextId, 5), { target: ref, direction: 'top', threshold: 0, isNoMore: (d) => d?.nextId === undefined, onSuccess() { if (isFirstIn.current) { isFirstIn.current = false; setTimeout(() => { const el = ref.current; if (el) { el.scrollTo(0, 999999); } }); } }, }, ); return (
{loading ? (

loading

) : (
{!noMore && ( )} {noMore && No more data}
{data?.list?.map((item) => (
item-{item}
))}
)}
); }; ================================================ FILE: packages/hooks/src/useInfiniteScroll/index.en-US.md ================================================ --- nav: path: /hooks --- # useInfiniteScroll useInfiniteScroll encapsulates the common infinite scroll logic. ```js const { data, loading, loadingMore, loadMore } = useInfiniteScroll(service); ``` The first parameter `service` of useInfiniteScroll is an asynchronous function. The input and output parameters of this function have the following conventions: 1. The data returned by `service` must contain a `list` array, the type is `{ list: any[], ...rest }` 2. The input parameter of `service` is the latest merged `data` If the data returned for the first request is `{ list: [1, 2, 3], nextId: 4 }`, the data returned for the second time is `{ list: [4, 5, 6], nextId: 7 }` , then we will automatically merge `list`, and the merged `data` will be `{ list: [1, 2, 3, 4, 5, 6], nextId: 7 }`. ## Basic usage In the first example, we demonstrate the implementation of a most basic infinite scroll. ## Pagination In the data fixation scenario, we sometimes use `page` and `pageSize` to request new data. ## Scrolling to automatically load In the infinite scrolling scenario, the most common case is to automatically load when scrolling to the bottom. By configuring the following properties, you can achieve scrolling to automatically load. - `options.target` specifies the parent element, The parent element needs to set a fixed height and support internal scrolling - `options.isNoMore` determines if there is no more data - `options.direction` determines the direction of scrolling, the default is `bottom` the scroll to bottom demo the scroll to top demo ## Data reset The data can be reset by `reload`. The following example shows that after the `filter` changes, the data is reset to the first page. The above code can be implemented with `reloadDeps` syntax sugar. When `reloadDeps` changes, `reload` will be triggered automatically. ```ts const result = useInfiniteScroll(service, { reloadDeps: [keyword] }); ``` ## Data mutation With `mutate`, we can directly modify the current `data`. The following example demonstrates deleting a record from the data. ## API ```ts export type Data = { list: any[];[key: string]: any; }; export type Service = (currentData?: TData) => Promise; const { data: TData; loading: boolean; loadingMore: boolean; error?: Error; noMore: boolean; loadMore: () => void; loadMoreAsync: () => Promise; reload: () => void; reloadAsync: () => Promise; cancel: () => void; mutate: (data?: TData) => void; } = useInfiniteScroll( service: (currentData?: TData) => Promise, { target?: BasicTarget; isNoMore?: (data?: TData) => boolean; threshold?: number; manual?: boolean; reloadDeps?: DependencyList; onBefore?: () => void; onSuccess?: (data: TData) => void; onError?: (e: Error) => void; onFinally?: (data?: TData, e?: Error) => void; } ); ``` ### Result | Property | Description | Type | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | data | The data returned by the service, where the `list` attribute is the aggregated data | `TData` \| `undefined` | | loading | Is the first request in progress | `boolean` | | loadingMore | Is more data request in progress | `boolean` | | noMore | Whether there is no more data, it will take effect after configuring `options.isNoMore` | `boolean` | | error | Request error message | `Error` | | loadMore | Load more data, it will automatically catch the exception, and handle it through `options.onError` | `() => void` | | loadMoreAsync | Load more data, which is consistent with the behavior of `loadMore`, but returns Promise, so you need to handle the exception yourself | `() => Promise` | | reload | Load the first page of data, it will automatically catch the exception, and handle it through `options.onError` | `() => void` | | reloadAsync | Load the first page of data, which is consistent with the behavior of `reload`, but returns Promise, so you need to handle the exception yourself | `() => Promise` | | mutate | Modify `data` directly | `(data?: TData) => void` | | cancel | Ignore the current promise response | `() => void` | ### Options | Property | Description | Type | Default | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- | | target | specifies the parent element. If it exists, it will trigger the `loadMore` when scrolling to the bottom. Needs to work with `isNoMore` to know when there is no more data to load. **when target is document, it is defined as the entire viewport** | `() => Element` \| `Element` \| `MutableRefObject` | - | | isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - | | threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` | | direction | The direction of the scrolling | `bottom` \|`top` | `bottom` | | reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - | | manual |
  • The default is `false`. That is, the service is automatically executed during initialization.
  • If set to `true`, you need to manually call `run` or `runAsync` to trigger execution
| `boolean` | `false` | | onBefore | Triggered before service execution | `() => void` | - | | onSuccess | Triggered when service resolve | `(data: TData) => void` | - | | onError | Triggered when service reject | `(e: Error) => void` | - | | onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - | ================================================ FILE: packages/hooks/src/useInfiniteScroll/index.tsx ================================================ import { useMemo, useRef, useState } from 'react'; import useEventListener from '../useEventListener'; import useMemoizedFn from '../useMemoizedFn'; import useRequest from '../useRequest'; import useUpdateEffect from '../useUpdateEffect'; import { getTargetElement } from '../utils/domTarget'; import { getClientHeight, getScrollHeight, getScrollTop } from '../utils/rect'; import type { Data, InfiniteScrollOptions, Service } from './types'; const useInfiniteScroll = ( service: Service, options: InfiniteScrollOptions = {}, ) => { const { target, isNoMore, threshold = 100, direction = 'bottom', reloadDeps = [], manual, onBefore, onSuccess, onError, onFinally, } = options; const [finalData, setFinalData] = useState(); const [loadingMore, setLoadingMore] = useState(false); const isScrollToTop = direction === 'top'; // lastScrollTop is used to determine whether the scroll direction is up or down const lastScrollTop = useRef(undefined); // scrollBottom is used to record the distance from the bottom of the scroll bar const scrollBottom = useRef(0); const noMore = useMemo(() => { if (!isNoMore) { return false; } return isNoMore(finalData); }, [finalData]); const { loading, error, run, runAsync, cancel } = useRequest( async (lastData?: TData) => { const currentData = await service(lastData); return { currentData, lastData }; }, { manual, onFinally: (_, d, e) => { setLoadingMore(false); onFinally?.(d?.currentData, e); }, onBefore: () => onBefore?.(), onSuccess: (d) => { if (!d.lastData) { setFinalData({ ...d.currentData, list: [...(d.currentData.list ?? [])], }); } else { setFinalData({ ...d.currentData, list: isScrollToTop ? [...d.currentData.list, ...(d.lastData.list ?? [])] : [...(d.lastData.list ?? []), ...d.currentData.list], }); } setTimeout(() => { // use requestAnimationFrame to ensure the scroll position is updated (To ensure compatibility react 19) requestAnimationFrame(() => { if (isScrollToTop) { let el = getTargetElement(target); el = el === document ? document.documentElement : el; if (el) { const scrollHeight = getScrollHeight(el); (el as Element).scrollTo(0, scrollHeight - scrollBottom.current); } } else { // eslint-disable-next-line @typescript-eslint/no-use-before-define scrollMethod(); } }); }); onSuccess?.(d.currentData); }, onError: (e) => onError?.(e), }, ); const loadMore = useMemoizedFn(() => { if (noMore) { return; } setLoadingMore(true); run(finalData); }); const runAsyncForCurrent = async (data?: TData) => { const res = await runAsync(data); return res.currentData; }; const loadMoreAsync = useMemoizedFn(() => { if (noMore) { return Promise.reject(); } setLoadingMore(true); return runAsyncForCurrent(finalData); }); const reload = () => { setLoadingMore(false); return run(); }; const reloadAsync = () => { setLoadingMore(false); return runAsyncForCurrent(); }; const scrollMethod = () => { const el = getTargetElement(target); if (!el) { return; } const targetEl = el === document ? document.documentElement : el; const scrollTop = getScrollTop(targetEl); const scrollHeight = getScrollHeight(targetEl); const clientHeight = getClientHeight(targetEl); if (isScrollToTop) { if ( lastScrollTop.current !== undefined && lastScrollTop.current > scrollTop && scrollTop <= threshold ) { loadMore(); } lastScrollTop.current = scrollTop; scrollBottom.current = scrollHeight - scrollTop; } else if (scrollHeight - scrollTop <= clientHeight + threshold) { loadMore(); } }; useEventListener( 'scroll', () => { if (loading || loadingMore) { return; } scrollMethod(); }, { target }, ); useUpdateEffect(() => { run(); }, [...reloadDeps]); return { data: finalData, loading: !loadingMore && loading, error, loadingMore, noMore, loadMore, loadMoreAsync, reload: useMemoizedFn(reload), reloadAsync: useMemoizedFn(reloadAsync), mutate: setFinalData, cancel, }; }; export default useInfiniteScroll; ================================================ FILE: packages/hooks/src/useInfiniteScroll/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useInfiniteScroll useInfiniteScroll 封装了常见的无限滚动逻辑。 ```js const { data, loading, loadingMore, loadMore } = useInfiniteScroll(service); ``` useInfiniteScroll 的第一个参数 `service` 是一个异步函数,对这个函数的入参和出参有如下约定: 1. `service` 返回的数据必须包含 `list` 数组,类型为 `{ list: any[], ...rest }` 2. `service` 的入参为整合后的最新 `data` 假如第一次请求返回数据为 `{ list: [1, 2, 3], nextId: 4 }`, 第二次返回的数据为 `{ list: [4, 5, 6], nextId: 7 }`, 则我们会自动合并 `list`,整合后的 `data` 为 `{ list: [1, 2, 3, 4, 5, 6], nextId: 7 }`。 ## 基础用法 第一个例子我们演示最基本的无限滚动写法。 ## 分页 在数据固定场景下,我们有时候会用 `page` 和 `pageSize` 来请求新的分页数据。 ## 滚动自动加载 在无限滚动场景中,我们最常见的是滚动到底部时自动加载。通过配置以下几个属性,即可实现滚动自动加载。 - `options.target` 指定父级元素(父级元素需设置固定高度,且支持内部滚动) - `options.isNoMore` 判断是不是没有更多数据了 - `options.direction` 滚动的方向,默认为向下滚动 向下滚动示例 向上滚动示例 ## 数据重置 通过 `reload` 即可实现数据重置,下面示例我们演示在 `filter` 变化后,重置数据到第一页。 以上代码可以通过 `reloadDeps` 语法糖实现,当 `reloadDeps` 变化时,会自动触发 `reload`。 ```ts const result = useInfiniteScroll(service, { reloadDeps: [keyword] }); ``` ## 数据突变 通过 `mutate`,我们可以直接修改当前 `data`。下面示例演示了删除某条数据。 ## API ```ts export type Data = { list: any[];[key: string]: any; }; export type Service = (currentData?: TData) => Promise; const { data: TData; loading: boolean; loadingMore: boolean; error?: Error; noMore: boolean; loadMore: () => void; loadMoreAsync: () => Promise; reload: () => void; reloadAsync: () => Promise; cancel: () => void; mutate: (data?: TData) => void; } = useInfiniteScroll( service: (currentData?: TData) => Promise, { target?: BasicTarget; isNoMore?: (data?: TData) => boolean; threshold?: number; manual?: boolean; reloadDeps?: DependencyList; onBefore?: () => void; onSuccess?: (data: TData) => void; onError?: (e: Error) => void; onFinally?: (data?: TData, e?: Error) => void; } ); ``` ### Result | 参数 | 说明 | 类型 | | ------------- | -------------------------------------------------------------------------- | ------------------------ | | data | service 返回的数据,其中的 `list` 属性为聚合后数据 | `TData` \| `undefined` | | loading | 是否正在进行首次请求 | `boolean` | | loadingMore | 是否正在进行更多数据请求 | `boolean` | | noMore | 是否没有更多数据了,配置 `options.isNoMore` 后生效 | `boolean` | | error | 请求错误消息 | `Error` | | loadMore | 加载更多数据,会自动捕获异常,通过 `options.onError` 处理 | `() => void` | | loadMoreAsync | 加载更多数据,与 `loadMore` 行为一致,但返回的是 Promise,需要自行处理异常 | `() => Promise` | | reload | 加载第一页数据,会自动捕获异常,通过 `options.onError` 处理 | `() => void` | | reloadAsync | 加载第一页数据,与 `reload` 行为一致,但返回的是 Promise,需要自行处理异常 | `() => Promise` | | mutate | 直接修改 `data` | `(data?: TData) => void` | | cancel | 忽略当前 Promise 的响应 | `() => void` | ### Options | 参数 | 说明 | 类型 | 默认值 | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- | | target | 父级容器,如果存在,则在滚动到底部时,自动触发 `loadMore`。需要配合 `isNoMore` 使用,以便知道什么时候到最后一页了。 **当 target 为 document 时,定义为整个视口** | `() => Element` \| `Element` \| `MutableRefObject` | - | | isNoMore | 是否有最后一页的判断逻辑,入参为当前聚合后的 `data` | `(data?: TData) => boolean` | - | | threshold | 下拉自动加载,距离底部距离阈值 | `number` | `100` | | direction | 滚动的方向 | `bottom` \| `top` | `bottom` | | reloadDeps | 变化后,会自动触发 `reload` | `any[]` | - | | manual |
  • 默认 `false`。 即在初始化时自动执行 service。
  • 如果设置为 `true`,则需要手动调用 `reload` 或 `reloadAsync` 触发执行。
| `boolean` | `false` | | onBefore | service 执行前触发 | `() => void` | - | | onSuccess | service resolve 时触发 | `(data: TData) => void` | - | | onError | service reject 时触发 | `(e: Error) => void` | - | | onFinally | service 执行完成时触发 | `(data?: TData, e?: Error) => void` | - | ================================================ FILE: packages/hooks/src/useInfiniteScroll/types.ts ================================================ import type { DependencyList } from 'react'; import type { BasicTarget } from '../utils/domTarget'; export type Data = { list: any[]; [key: string]: any }; export type Service = (currentData?: TData) => Promise; export interface InfiniteScrollResult { data: TData; loading: boolean; loadingMore: boolean; error?: Error; noMore: boolean; loadMore: () => void; loadMoreAsync: () => Promise; reload: () => void; reloadAsync: () => Promise; cancel: () => void; mutate: (data?: TData) => void; } export interface InfiniteScrollOptions { target?: BasicTarget; isNoMore?: (data?: TData) => boolean; threshold?: number; direction?: 'bottom' | 'top'; manual?: boolean; reloadDeps?: DependencyList; onBefore?: () => void; onSuccess?: (data: TData) => void; onError?: (e: Error) => void; onFinally?: (data?: TData, e?: Error) => void; } ================================================ FILE: packages/hooks/src/useInterval/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useInterval from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay: number | undefined; options?: { immediate: boolean }; } const setUp = ({ fn, delay, options }: ParamsObj) => renderHook(() => useInterval(fn, delay, options)); describe('useInterval', () => { vi.useFakeTimers(); vi.spyOn(global, 'clearInterval'); test('interval should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: 20 }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(70); expect(callback).toHaveBeenCalledTimes(3); }); test('interval should stop', () => { const callback = vi.fn(); setUp({ fn: callback, delay: undefined }); vi.advanceTimersByTime(50); expect(callback).toHaveBeenCalledTimes(0); setUp({ fn: callback, delay: -2 }); vi.advanceTimersByTime(50); expect(callback).toHaveBeenCalledTimes(0); }); test('immediate in options should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: 20, options: { immediate: true } }); expect(callback).toBeCalled(); expect(callback).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(50); expect(callback).toHaveBeenCalledTimes(3); }); test('interval should be clear', () => { const callback = vi.fn(); const hook = setUp({ fn: callback, delay: 20 }); expect(callback).not.toBeCalled(); hook.result.current(); vi.advanceTimersByTime(70); // not to be called expect(callback).toHaveBeenCalledTimes(0); expect(clearInterval).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useInterval/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Execute once per 1000ms. * * title.zh-CN: 基础用法 * desc.zh-CN: 每1000ms,执行一次 */ import { useState } from 'react'; import { useInterval } from 'ahooks'; export default () => { const [count, setCount] = useState(0); useInterval(() => { setCount(count + 1); }, 1000); return
count: {count}
; }; ================================================ FILE: packages/hooks/src/useInterval/demo/demo2.tsx ================================================ /** * title: Advanced usage * desc: Modify the delay to realize the timer interval change and pause. * * title.zh-CN: 进阶使用 * desc.zh-CN: 动态修改 delay 以实现定时器间隔变化与暂停。 */ import { useState } from 'react'; import { useInterval } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [interval, setInterval] = useState(1000); const clear = useInterval(() => { setCount(count + 1); }, interval); return (

count: {count}

interval: {interval}

); }; ================================================ FILE: packages/hooks/src/useInterval/index.en-US.md ================================================ --- nav: path: /hooks --- # useInterval A hook that handles the `setInterval` timer function. ## Examples ### Default usage ### Advanced usage ## API ```typescript useInterval( fn: () => void, delay?: number | undefined, options?: Options ): fn: () => void; ``` ### Params | Property | Description | Type | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | fn | The function to be executed every `delay` milliseconds. | `() => void` | | delay | The time in milliseconds, the timer should delay in between executions of the specified function. The timer will be cancelled if delay is set to `undefined`. | `number` \| `undefined` | | options | Config of the interval behavior. | `Options` | ### Options | Property | Description | Type | Default | | --------- | ---------------------------------------------------------------------- | --------- | ------- | | immediate | Whether the function should be executed immediately on first execution | `boolean` | `false` | ### Result | Property | Description | Type | | ------------- | -------------- | ------------ | | clearInterval | clear interval | `() => void` | ================================================ FILE: packages/hooks/src/useInterval/index.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isNumber } from '../utils'; const useInterval = (fn: () => void, delay?: number, options: { immediate?: boolean } = {}) => { const timerCallback = useMemoizedFn(fn); const timerRef = useRef | null>(null); const clear = useCallback(() => { if (timerRef.current) { clearInterval(timerRef.current); } }, []); useEffect(() => { if (!isNumber(delay) || delay < 0) { return; } if (options.immediate) { timerCallback(); } timerRef.current = setInterval(timerCallback, delay); return clear; }, [delay, options.immediate]); return clear; }; export default useInterval; ================================================ FILE: packages/hooks/src/useInterval/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useInterval 一个可以处理 setInterval 的 Hook。 ## 代码演示 ### 基础用法 ### 进阶使用 ## API ```typescript useInterval( fn: () => void, delay?: number | undefined, options?: Options ): fn: () => void; ``` ### Params | 参数 | 说明 | 类型 | | ------- | ----------------------------------------------- | ----------------------- | | fn | 要定时调用的函数 | `() => void` | | delay | 间隔时间,当设置值为 `undefined` 时会停止计时器 | `number` \| `undefined` | | options | 配置计时器的行为 | `Options` | ### Options | 参数 | 说明 | 类型 | 默认值 | | --------- | ------------------------ | --------- | ------- | | immediate | 是否在首次渲染时立即执行 | `boolean` | `false` | ### Result | 参数 | 说明 | 类型 | | ------------- | ---------- | ------------ | | clearInterval | 清除定时器 | `() => void` | ================================================ FILE: packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useIsomorphicLayoutEffect from '../index'; describe('useIsomorphicLayoutEffect', () => { const callback = vi.fn(); const { result } = renderHook(() => useIsomorphicLayoutEffect(callback)); test('cheak return value', () => { expect(result.current).toBeUndefined(); }); }); ================================================ FILE: packages/hooks/src/useIsomorphicLayoutEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useIsomorphicLayoutEffect In SSR mode, the following warning will appear when useLayoutEffect is used > ⚠️ Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://fb.me/react-uselayouteffect-ssr for common fixes. To avoid this warning, useIsomorphicLayoutEffect can be used instead of useLayoutEffect. The source code of useIsomorphicLayoutEffect is: ```javascript const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop; ``` Return useLayoutEffect for browser environment and a no-op in non-browser environments to avoid SSR warnings. For more information, please refer to [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a) ================================================ FILE: packages/hooks/src/useIsomorphicLayoutEffect/index.ts ================================================ import { useLayoutEffect } from 'react'; import isBrowser from '../utils/isBrowser'; import noop from '../utils/noop'; const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop; export default useIsomorphicLayoutEffect; ================================================ FILE: packages/hooks/src/useIsomorphicLayoutEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useIsomorphicLayoutEffect 在 SSR 模式下,使用 useLayoutEffect 时,会出现以下警告 > ⚠️ Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client. See https://fb.me/react-uselayouteffect-ssr for common fixes. 为了避免该警告,可以使用 useIsomorphicLayoutEffect 代替 useLayoutEffect。 useIsomorphicLayoutEffect 源码如下: ```js const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop; ``` 在浏览器环境返回 useLayoutEffect,在非浏览器环境返回空函数以避免 SSR 警告。 更多信息可以参考 [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a) ================================================ FILE: packages/hooks/src/useKeyPress/__tests__/index.spec.tsx ================================================ import { fireEvent, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import useKeyPress from '../index'; const callback = vi.fn(); afterEach(() => { callback.mockClear(); }); describe('useKeyPress ', () => { test('test single key', async () => { const { unmount } = renderHook(() => useKeyPress(['c'], callback)); fireEvent.keyDown(document, { key: 'c', keyCode: 67 }); expect(callback.mock.calls.length).toBe(1); unmount(); }); test('test standard key aliases', async () => { const { unmount } = renderHook(() => useKeyPress(['arrowleft', 'escape'], callback)); fireEvent.keyDown(document, { key: 'ArrowLeft', keyCode: 37 }); fireEvent.keyDown(document, { key: 'Escape', keyCode: 27 }); expect(callback.mock.calls.length).toBe(2); unmount(); }); test('test standard vs legacy key aliases', async () => { const aliasCallback = vi.fn(); const { unmount } = renderHook(() => useKeyPress( [ 'control', 'ctrl', 'escape', 'esc', 'arrowleft', 'leftarrow', 'spacebar', 'space', 'contextmenu', 'selectkey', 'pause', 'pausebreak', ], aliasCallback, ), ); fireEvent.keyDown(document, { key: 'Control', keyCode: 17, ctrlKey: true }); fireEvent.keyDown(document, { key: 'Escape', keyCode: 27 }); fireEvent.keyDown(document, { key: 'ArrowLeft', keyCode: 37 }); fireEvent.keyDown(document, { key: ' ', keyCode: 32 }); fireEvent.keyDown(document, { key: 'ContextMenu', keyCode: 93 }); fireEvent.keyDown(document, { key: 'Pause', keyCode: 19 }); // each event should match once (first alias hit) expect(aliasCallback.mock.calls.length).toBe(6); unmount(); }); test('test modifier key', async () => { const { unmount } = renderHook(() => useKeyPress(['ctrl'], callback)); fireEvent.keyDown(document, { key: 'ctrl', keyCode: 17, ctrlKey: true }); expect(callback.mock.calls.length).toBe(1); unmount(); }); test('test combination keys', async () => { const hook1 = renderHook(() => useKeyPress(['shift.c'], callback)); const hook2 = renderHook(() => useKeyPress(['shift'], callback)); const hook3 = renderHook(() => useKeyPress(['c'], callback)); fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 }); expect(callback.mock.calls.length).toBe(3); hook1.unmount(); hook2.unmount(); hook3.unmount(); }); test('test combination keys by exact match', async () => { const callbackShift = vi.fn(); const callbackC = vi.fn(); const callbackMulti = vi.fn(); const hook1 = renderHook(() => useKeyPress(['shift.c'], callback, { exactMatch: true })); const hook2 = renderHook(() => useKeyPress(['shift'], callbackShift, { exactMatch: true })); const hook3 = renderHook(() => useKeyPress(['c'], callbackC, { exactMatch: true })); const hook4 = renderHook(() => useKeyPress(['ctrl.shift.c'], callbackMulti)); fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 }); /** * 只有 shift.c 才会触发,shift 和 c 都不应该触发 */ expect(callback.mock.calls.length).toBe(1); expect(callbackShift.mock.calls.length).toBe(0); expect(callbackC.mock.calls.length).toBe(0); callback.mockClear(); fireEvent.keyDown(document, { key: 'c', ctrlKey: true, shiftKey: true, keyCode: 67 }); expect(callbackMulti.mock.calls.length).toBe(1); expect(callback.mock.calls.length).toBe(0); expect(callbackC.mock.calls.length).toBe(0); hook1.unmount(); hook2.unmount(); hook3.unmount(); hook4.unmount(); }); test('test multiple keys', async () => { const { unmount } = renderHook(() => useKeyPress(['0', 65], callback)); fireEvent.keyDown(document, { key: '0', keyCode: 48 }); fireEvent.keyDown(document, { key: 'a', keyCode: 65 }); expect(callback.mock.calls.length).toBe(2); unmount(); }); test('meta key should be work in keyup event', async () => { renderHook(() => useKeyPress(['meta'], callback, { events: ['keyup'], }), ); fireEvent.keyUp(document, { key: 'meta', keyCode: 91, metaKey: false }); expect(callback).toBeCalled(); }); test('test `keyFilter` function parameter', async () => { const callback1 = vi.fn(); const callback2 = vi.fn(); // all keys can trigger callback const hook1 = renderHook(() => useKeyPress(() => true, callback1)); fireEvent.keyDown(document, { key: '0', keyCode: 48 }); fireEvent.keyDown(document, { key: 'a', keyCode: 65 }); expect(callback1.mock.calls.length).toBe(2); // only some keys can trigger callback const hook2 = renderHook(() => useKeyPress((e) => ['0', 'meta'].includes(e.key), callback2)); fireEvent.keyDown(document, { key: '0', keyCode: 48 }); fireEvent.keyDown(document, { key: '1', keyCode: 49 }); fireEvent.keyDown(document, { key: 'ctrl', keyCode: 17, ctrlKey: true }); fireEvent.keyDown(document, { key: 'meta', keyCode: 91, metaKey: true }); expect(callback2.mock.calls.length).toBe(2); hook1.unmount(); hook2.unmount(); }); test('test key in `eventHandler` parameter', async () => { let pressedKey; const KEYS = ['c', 'shift.c', 'shift.ctrl.c']; const callbackKey = (e: any, key: any) => { pressedKey = key; }; // test `exactMatch: true` props const hook1 = renderHook(() => useKeyPress(KEYS, callbackKey, { exactMatch: true })); fireEvent.keyDown(document, { key: 'c', keyCode: 67 }); expect(pressedKey).toBe('c'); fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true }); expect(pressedKey).toBe('shift.c'); fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true, ctrlKey: true }); expect(pressedKey).toBe('shift.ctrl.c'); // test `exactMatch: false`(default) props const hook2 = renderHook(() => useKeyPress(KEYS, callbackKey)); fireEvent.keyDown(document, { key: 'c', keyCode: 67 }); expect(pressedKey).toBe('c'); fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true }); expect(pressedKey).toBe('c'); fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true, ctrlKey: true }); expect(pressedKey).toBe('c'); hook2.unmount(); hook1.unmount(); }); }); ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Supported keyCode and alias in keyboard events, pressing ArrowUp or ArrowDown to show effect. * * title.zh-CN: 基础用法 * desc.zh-CN: 支持键盘事件中的 keyCode 和别名,请按 ArrowUp 或 ArrowDown 键进行演示。 */ import { useState } from 'react'; import { useKeyPress } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); useKeyPress('uparrow', () => { setCounter((s) => s + 1); }); // keyCode value for ArrowDown useKeyPress(40, () => { setCounter((s) => s - 1); }); return (

Try pressing the following:

1. Press ArrowUp by key to increase
2. Press ArrowDown by keyCode to decrease
counter: {counter}
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo2.tsx ================================================ /** * title: Use key aliases * desc: Support using key aliases. Please refer to the [document](#remarks) below. * * title.zh-CN: 使用别名 * desc.zh-CN: 支持使用别名,更多内容请[查看备注](#remarks)。 */ import { useState } from 'react'; import { useKeyPress } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); useKeyPress('leftarrow', () => { setCounter((s) => s - 1); }); useKeyPress('rightarrow', () => { setCounter((s) => s + 1); }); return (

Try pressing the following:

1. Press ArrowLeft to decrease
2. Press ArrowRight to increase
counter: {counter}
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo3.tsx ================================================ import { useKeyPress } from 'ahooks'; import { useState } from 'react'; export default () => { const [num, setNum] = useState(); const [key, setKey] = useState(); const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; useKeyPress(filterKey, (event) => { setNum(event.key); }); // a s d f, Backspace, 8 useKeyPress([65, 83, 68, 70, 8, '8'], (event) => { setKey(event.key); }); return (

Try pressing the following:

1. Number key [0-9]: {num}
2. Press key [a, s, d, f, Backspace, 8]: {key}
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo4.tsx ================================================ /** * title: Advanced * desc: Supports receiving a Boolean callback function to handle preprocessing operations. * * title.zh-CN: 进阶使用 * desc.zh-CN: 支持接收一个返回 boolean 的回调函数,自己处理逻辑。 */ import { useState } from 'react'; import { useKeyPress } from 'ahooks'; export default () => { const [key, setKey] = useState(); const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; useKeyPress( (event) => !filterKey.includes(event.key), (event) => { if (event.type === 'keyup') { setKey(event.key); } }, { events: ['keydown', 'keyup'], }, ); return (
Pressing key except number key:{key}
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo5.tsx ================================================ /** * title: Custom DOM * desc: By default, listen for events on the window. You can also pass in a DOM to set listen area. such as the common listening for input box events. * * title.zh-CN: 自定义 DOM * desc.zh-CN: | * 默认监听挂载在 window 上的事件,你也可以传入 DOM 指定监听区域。 * * 如常见的监听输入框事件,支持多种 DOM 指定方式。 */ import { useState, useRef } from 'react'; import { useKeyPress } from 'ahooks'; export default () => { const inputRef = useRef(null); const [text, setText] = useState(''); const [textRef, setTextRef] = useState(''); const [textSync, setTextSync] = useState(''); useKeyPress( 'enter', (event: any) => { const { value } = event.target; setText(value); }, { events: ['keyup'], target: () => document.getElementById('input'), }, ); useKeyPress( 'enter', (event: any) => { const { value } = event.target; setTextRef(value); }, { target: inputRef, }, ); // Make sure the DOM exists useKeyPress( () => true, (event: any) => { const { value } = event.target; setTextSync(value); }, { events: ['keyup'], target: document.getElementById('input2'), }, ); return (

Input and pressing enter: {text}

Input and pressing enter: {textRef}

Input after enter change: {textSync}

); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo6.tsx ================================================ import { CheckOutlined } from '@ant-design/icons'; import { useKeyPress } from 'ahooks'; import { useState } from 'react'; export default () => { const [state, setState] = useState(); useKeyPress(['shift.c'], () => { setState(1); }); useKeyPress(['meta'], () => { setState(2); }); useKeyPress('ctrl.alt.c', () => { setState(3); }); useKeyPress('ctrl.enter', () => { setState(4); }); // Attention: event.key === '0' useKeyPress('ctrl.alt.0', () => { setState(5); }); return (

Try pressing the following:

1. Modifier key [shift.c]: {state === 1 && }
2. Modifier key [meta]: {state === 2 && }
3. Modifier key [ctrl.alt.c]: {state === 3 && }
4. Modifier key [ctrl.enter]: {state === 4 && }
5. Modifier key [ctrl.alt.0]: {state === 5 && }
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo7.tsx ================================================ /** * title: Exact match * desc: Enable exact matching by setting `exactMatch`. For example, press [shift + c], will not trigger [c]. * * title.zh-CN: 精确匹配 * desc.zh-CN: 通过配置 `exactMatch`, 开启精确匹配。比如按 [shift + c] ,不会触发 [c]。 */ import { CheckOutlined } from '@ant-design/icons'; import { useKeyPress } from 'ahooks'; import { useState } from 'react'; export default () => { const [state, setState] = useState(); useKeyPress(['shift.c'], () => { setState(1); }); useKeyPress( ['c'], () => { setState(2); }, { exactMatch: true, }, ); return (

Try pressing the following:

1. Modifier key [shift.c]: {state === 1 && }
2. Modifier key [c]: {state === 2 && }
); }; ================================================ FILE: packages/hooks/src/useKeyPress/demo/demo8.tsx ================================================ /** * title: Get the trigger key * desc: Multiple shortcuts are registered by a hook, each corresponding to a different logic. * * title.zh-CN: 获取触发的按键 * desc.zh-CN: 单个 hook 注册多个快捷键,每个快捷键对应不同逻辑。 */ import { useState } from 'react'; import { useKeyPress } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const keyCallbackMap = { w: () => { setCount((prev) => prev + 1); }, s: () => { setCount((prev) => prev - 1); }, 'shift.c': () => { setCount(0); }, }; useKeyPress(['w', 's', 'shift.c'], (e, key) => { keyCallbackMap[key as keyof typeof keyCallbackMap](); }); return (

Try pressing the following:

1. Press [w] to increase
2. Press [s] to decrease
3. Press [shift.c] to reset

counter: {count}

); }; ================================================ FILE: packages/hooks/src/useKeyPress/index.en-US.md ================================================ --- nav: path: /hooks --- # useKeyPress Listen for the keyboard press, support key combinations, and support alias. ## Examples ### Basic usage ### Combination keys ### Exact match ### Multiple keys ### Get the trigger key ### Custom method ### Custom DOM ## API ```typescript type KeyType = number | string; type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean); useKeyPress( keyFilter: KeyFilter, eventHandler: (event: KeyboardEvent, key: KeyType) => void, options?: Options ); ``` ### Params | Property | Description | Type | Default | | ------------ | ---------------------------------------------------------------- | --------------------------------------------------------------- | ------- | | keyFilter | Support keyCode、alias、combination keys、array、custom function | `KeyType` \| `KeyType[]` \| `(event: KeyboardEvent) => boolean` | - | | eventHandler | Callback function | `(event: KeyboardEvent, key: KeyType) => void` | - | | options | Advanced options | `Options` | - | ### Options | Property | Description | Type | Default | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------- | | events | Trigger Events | `('keydown' \| 'keyup')[]` | `['keydown']` | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | exactMatch | Exact match. If set `true`, the event will only be trigger when the keys match exactly. For example, pressing [shift + c] will not trigger [c] | `boolean` | `false` | | useCapture | to block events bubbling | `boolean` | `false` | ## Remarks 1. Supports part of standard browser key values. Reference: [MDN keyboard key values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values). Full alias list refers to [code](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21) 2. Modifier keys ```text ctrl alt shift meta ``` ================================================ FILE: packages/hooks/src/useKeyPress/index.ts ================================================ import useLatest from '../useLatest'; import { isFunction, isNumber, isString } from '../utils'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useDeepCompareEffectWithTarget from '../utils/useDeepCompareWithTarget'; import isAppleDevice from '../utils/isAppleDevice'; export type KeyType = number | string; export type KeyPredicate = (event: KeyboardEvent) => KeyType | boolean | undefined; export type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean); export type KeyEvent = 'keydown' | 'keyup'; export type Target = BasicTarget; export type Options = { events?: KeyEvent[]; target?: Target; exactMatch?: boolean; useCapture?: boolean; }; // 键盘事件 keyCode 别名 const aliasKeyCodeMap = { '0': 48, '1': 49, '2': 50, '3': 51, '4': 52, '5': 53, '6': 54, '7': 55, '8': 56, '9': 57, backspace: 8, tab: 9, enter: 13, shift: 16, // Legacy alias kept for compatibility; standard name is "control". ctrl: 17, control: 17, alt: 18, // Legacy alias kept for compatibility; standard name is "pause". pausebreak: 19, pause: 19, capslock: 20, // Legacy alias kept for compatibility; standard name is "escape". esc: 27, escape: 27, // Legacy alias kept for compatibility; standard name is "spacebar" (non-standard but widely used). space: 32, spacebar: 32, pageup: 33, pagedown: 34, end: 35, home: 36, // Legacy aliases kept for compatibility; standard names are "arrowleft/arrowup/arrowright/arrowdown". leftarrow: 37, arrowleft: 37, uparrow: 38, arrowup: 38, rightarrow: 39, arrowright: 39, downarrow: 40, arrowdown: 40, insert: 45, delete: 46, a: 65, b: 66, c: 67, d: 68, e: 69, f: 70, g: 71, h: 72, i: 73, j: 74, k: 75, l: 76, m: 77, n: 78, o: 79, p: 80, q: 81, r: 82, s: 83, t: 84, u: 85, v: 86, w: 87, x: 88, y: 89, z: 90, leftwindowkey: 91, rightwindowkey: 92, meta: isAppleDevice ? [91, 93] : [91, 92], // Legacy alias kept for compatibility; standard name is "contextmenu". selectkey: 93, contextmenu: 93, numpad0: 96, numpad1: 97, numpad2: 98, numpad3: 99, numpad4: 100, numpad5: 101, numpad6: 102, numpad7: 103, numpad8: 104, numpad9: 105, multiply: 106, add: 107, subtract: 109, decimalpoint: 110, divide: 111, f1: 112, f2: 113, f3: 114, f4: 115, f5: 116, f6: 117, f7: 118, f8: 119, f9: 120, f10: 121, f11: 122, f12: 123, numlock: 144, scrolllock: 145, semicolon: 186, equalsign: 187, comma: 188, dash: 189, period: 190, forwardslash: 191, graveaccent: 192, openbracket: 219, backslash: 220, closebracket: 221, singlequote: 222, }; // 修饰键 const modifierKey = { ctrl: (event: KeyboardEvent) => event.ctrlKey, shift: (event: KeyboardEvent) => event.shiftKey, alt: (event: KeyboardEvent) => event.altKey, meta: (event: KeyboardEvent) => { if (event.type === 'keyup') { return aliasKeyCodeMap.meta.includes(event.keyCode); } return event.metaKey; }, }; // 判断合法的按键类型 function isValidKeyType(value: unknown): value is string | number { return isString(value) || isNumber(value); } // 根据 event 计算激活键数量 function countKeyByEvent(event: KeyboardEvent) { const countOfModifier = Object.keys(modifierKey).reduce((total, key) => { if ((modifierKey as any)[key](event)) { return total + 1; } return total; }, 0); // 16 17 18 91 92 是修饰键的 keyCode,如果 keyCode 是修饰键,那么激活数量就是修饰键的数量,如果不是,那么就需要 +1 return [16, 17, 18, 91, 92].includes(event.keyCode) ? countOfModifier : countOfModifier + 1; } /** * 判断按键是否激活 * @param [event: KeyboardEvent]键盘事件 * @param [keyFilter: any] 当前键 * @returns string | number | boolean */ function genFilterKey(event: KeyboardEvent, keyFilter: KeyType, exactMatch: boolean) { // 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空 if (!event.key) { return false; } // 数字类型直接匹配事件的 keyCode if (isNumber(keyFilter)) { return event.keyCode === keyFilter ? keyFilter : false; } // 字符串依次判断是否有组合键 const genArr = keyFilter.split('.'); let genLen = 0; for (const key of genArr) { // 组合键 const genModifier = (modifierKey as any)[key]; // keyCode 别名 const aliasKeyCode: number | number[] = (aliasKeyCodeMap as any)[key.toLowerCase()]; if ((genModifier && genModifier(event)) || (aliasKeyCode && aliasKeyCode === event.keyCode)) { genLen++; } } /** * 需要判断触发的键位和监听的键位完全一致,判断方法就是触发的键位里有且等于监听的键位 * genLen === genArr.length 能判断出来触发的键位里有监听的键位 * countKeyByEvent(event) === genArr.length 判断出来触发的键位数量里有且等于监听的键位数量 * 主要用来防止按组合键其子集也会触发的情况,例如监听 ctrl+a 会触发监听 ctrl 和 a 两个键的事件。 */ if (exactMatch) { return genLen === genArr.length && countKeyByEvent(event) === genArr.length ? keyFilter : false; } return genLen === genArr.length ? keyFilter : false; } /** * 键盘输入预处理方法 * @param [keyFilter: any] 当前键 * @returns () => Boolean */ function genKeyFormatter(keyFilter: KeyFilter, exactMatch: boolean): KeyPredicate { if (isFunction(keyFilter)) { return keyFilter; } if (isValidKeyType(keyFilter)) { return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch); } if (Array.isArray(keyFilter)) { return (event: KeyboardEvent) => keyFilter.find((item) => genFilterKey(event, item, exactMatch)); } return () => Boolean(keyFilter); } const defaultEvents: KeyEvent[] = ['keydown']; function useKeyPress( keyFilter: KeyFilter, eventHandler: (event: KeyboardEvent, key: KeyType) => void, option?: Options, ) { const { events = defaultEvents, target, exactMatch = false, useCapture = false } = option || {}; const eventHandlerRef = useLatest(eventHandler); const keyFilterRef = useLatest(keyFilter); useDeepCompareEffectWithTarget( () => { const el = getTargetElement(target, window); if (!el) { return; } const callbackHandler = (event: Event) => { const keyEvent = event as KeyboardEvent; const genGuard = genKeyFormatter(keyFilterRef.current, exactMatch); const keyGuard = genGuard(keyEvent); const firedKey = isValidKeyType(keyGuard) ? keyGuard : keyEvent.key; if (keyGuard) { return eventHandlerRef.current?.(keyEvent, firedKey); } }; for (const eventName of events) { el?.addEventListener?.(eventName, callbackHandler, useCapture); } return () => { for (const eventName of events) { el?.removeEventListener?.(eventName, callbackHandler, useCapture); } }; }, [events], target, ); } export default useKeyPress; ================================================ FILE: packages/hooks/src/useKeyPress/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useKeyPress 监听键盘按键,支持组合键,支持按键别名。 ## 代码演示 ### 基础用法 ### 监听组合按键 ### 精确匹配 ### 监听多个按键 ### 获取触发的按键 ### 自定义监听方式 ### 自定义 DOM ## API ```typescript type KeyType = number | string; type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean); useKeyPress( keyFilter: KeyFilter, eventHandler: (event: KeyboardEvent, key: KeyType) => void, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | -------------------------------------------- | --------------------------------------------------------------- | ------ | | keyFilter | 支持 keyCode、别名、组合键、数组、自定义函数 | `KeyType` \| `KeyType[]` \| `(event: KeyboardEvent) => boolean` | - | | eventHandler | 回调函数 | `(event: KeyboardEvent, key: KeyType) => void` | - | | options | 可选配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | ---------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------- | | events | 触发事件 | `('keydown' \| 'keyup')[]` | `['keydown']` | | target | DOM 节点或者 ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | exactMatch | 精确匹配。如果开启,则只有在按键完全匹配的情况下触发事件。比如按键 [shift + c] 不会触发 [c] | `boolean` | `false` | | useCapture | 是否阻止事件冒泡 | `boolean` | `false` | ## Remarks 1. 支持部分浏览器标准按键值。参考文档:[MDN 键值表](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)。完整列表见 [代码](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21) 2. 修饰键 ```text ctrl alt shift meta ``` ================================================ FILE: packages/hooks/src/useLatest/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useLatest from '../index'; const setUp = (val: any) => renderHook((state) => useLatest(state), { initialProps: val }); describe('useLatest', () => { test('useLatest with basic variable should work', async () => { const { result, rerender } = setUp(0); rerender(1); expect(result.current.current).toBe(1); rerender(2); expect(result.current.current).toBe(2); rerender(3); expect(result.current.current).toBe(3); }); test('useLatest with reference variable should work', async () => { const val1 = {}; const { result, rerender } = setUp(val1); expect(result.current.current).toBe(val1); const val2: any[] = []; rerender(val2); expect(result.current.current).toBe(val2); }); }); ================================================ FILE: packages/hooks/src/useLatest/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: useLatest always returns the latest value * * title.zh-CN: 基础用法 * desc.zh-CN: useLatest 返回的永远是最新值 */ import { useState, useEffect } from 'react'; import { useLatest } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [count2, setCount2] = useState(0); const latestCountRef = useLatest(count); useEffect(() => { const interval = setInterval(() => { setCount(latestCountRef.current + 1); }, 1000); return () => clearInterval(interval); }, []); useEffect(() => { const interval = setInterval(() => { setCount2(count2 + 1); }, 1000); return () => clearInterval(interval); }, []); return ( <>

count(useLatest): {count}

count(default): {count2}

); }; ================================================ FILE: packages/hooks/src/useLatest/index.en-US.md ================================================ --- nav: path: /hooks --- # useLatest A Hook that returns the latest value, effectively avoiding the closure problem. ## Examples ### Basic usage ## API ```typescript const latestValueRef = useLatest(value: T): MutableRefObject; ``` ================================================ FILE: packages/hooks/src/useLatest/index.ts ================================================ import { useRef } from 'react'; function useLatest(value: T) { const ref = useRef(value); ref.current = value; return ref; } export default useLatest; ================================================ FILE: packages/hooks/src/useLatest/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useLatest 返回当前最新值的 Hook,可以避免闭包问题。 ## 代码演示 ### 基础用法 ## API ```typescript const latestValueRef = useLatest(value: T): MutableRefObject; ``` ================================================ FILE: packages/hooks/src/useLocalStorageState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import type { Options } from '../../createUseStorageState'; import useLocalStorageState from '../index'; describe('useLocalStorageState', () => { const setUp = (key: string, value: T, options?: Options) => renderHook(() => { const [state, setState] = useLocalStorageState(key, { defaultValue: value, ...options, }); return { state, setState, } as const; }); test('getKey should work', () => { const LOCAL_STORAGE_KEY = 'test-key'; const hook = setUp(LOCAL_STORAGE_KEY, 'A'); expect(hook.result.current.state).toBe('A'); act(() => { hook.result.current.setState('B'); }); expect(hook.result.current.state).toBe('B'); const anotherHook = setUp(LOCAL_STORAGE_KEY, 'A'); expect(anotherHook.result.current.state).toBe('B'); act(() => { anotherHook.result.current.setState('C'); }); expect(anotherHook.result.current.state).toBe('C'); expect(hook.result.current.state).toBe('B'); }); test('should support object', () => { const LOCAL_STORAGE_KEY = 'test-object-key'; const hook = setUp<{ name: string }>(LOCAL_STORAGE_KEY, { name: 'A', }); expect(hook.result.current.state).toEqual({ name: 'A' }); act(() => { hook.result.current.setState({ name: 'B' }); }); expect(hook.result.current.state).toEqual({ name: 'B' }); const anotherHook = setUp(LOCAL_STORAGE_KEY, { name: 'C', }); expect(anotherHook.result.current.state).toEqual({ name: 'B' }); act(() => { anotherHook.result.current.setState({ name: 'C', }); }); expect(anotherHook.result.current.state).toEqual({ name: 'C' }); expect(hook.result.current.state).toEqual({ name: 'B' }); }); test('should support number', () => { const LOCAL_STORAGE_KEY = 'test-number-key'; const hook = setUp(LOCAL_STORAGE_KEY, 1); expect(hook.result.current.state).toBe(1); act(() => { hook.result.current.setState(2); }); expect(hook.result.current.state).toBe(2); const anotherHook = setUp(LOCAL_STORAGE_KEY, 3); expect(anotherHook.result.current.state).toBe(2); act(() => { anotherHook.result.current.setState(3); }); expect(anotherHook.result.current.state).toBe(3); expect(hook.result.current.state).toBe(2); }); test('should support boolean', () => { const LOCAL_STORAGE_KEY = 'test-boolean-key'; const hook = setUp(LOCAL_STORAGE_KEY, true); expect(hook.result.current.state).toBe(true); act(() => { hook.result.current.setState(false); }); expect(hook.result.current.state).toBe(false); const anotherHook = setUp(LOCAL_STORAGE_KEY, true); expect(anotherHook.result.current.state).toBe(false); act(() => { anotherHook.result.current.setState(true); }); expect(anotherHook.result.current.state).toBe(true); expect(hook.result.current.state).toBe(false); }); test('should support null', () => { const LOCAL_STORAGE_KEY = 'test-boolean-key-with-null'; const hook = setUp(LOCAL_STORAGE_KEY, false); expect(hook.result.current.state).toBe(false); act(() => { hook.result.current.setState(null); }); expect(hook.result.current.state).toBeNull(); const anotherHook = setUp(LOCAL_STORAGE_KEY, false); expect(anotherHook.result.current.state).toBeNull(); }); test('should support function updater', () => { const LOCAL_STORAGE_KEY = 'test-func-updater'; const hook = setUp(LOCAL_STORAGE_KEY, 'hello world'); expect(hook.result.current.state).toBe('hello world'); act(() => { hook.result.current.setState((state) => `${state}, zhangsan`); }); expect(hook.result.current.state).toBe('hello world, zhangsan'); }); test('should sync state when changes', async () => { const LOCAL_STORAGE_KEY = 'test-sync-state'; const hook = setUp(LOCAL_STORAGE_KEY, 'foo', { listenStorageChange: true }); const anotherHook = setUp(LOCAL_STORAGE_KEY, 'bar', { listenStorageChange: true, }); expect(hook.result.current.state).toBe('foo'); expect(anotherHook.result.current.state).toBe('bar'); act(() => hook.result.current.setState('baz')); expect(hook.result.current.state).toBe('baz'); expect(anotherHook.result.current.state).toBe('baz'); act(() => anotherHook.result.current.setState('qux')); expect(hook.result.current.state).toBe('qux'); expect(anotherHook.result.current.state).toBe('qux'); }); test('should not rerender when setting the same reference', () => { const LOCAL_STORAGE_KEY = 'test-same-reference'; const value = { name: 'A', }; let renderCount = 0; const hook = renderHook(() => { renderCount += 1; const [state, setState] = useLocalStorageState(LOCAL_STORAGE_KEY, { defaultValue: value, listenStorageChange: true, }); return { state, setState, } as const; }); expect(renderCount).toBe(1); expect(hook.result.current.state).toBe(value); act(() => { hook.result.current.setState((prev) => prev!); }); expect(renderCount).toBe(1); expect(hook.result.current.state).toBe(value); expect(localStorage.getItem(LOCAL_STORAGE_KEY)).toBeNull(); }); }); ================================================ FILE: packages/hooks/src/useLocalStorageState/demo/demo1.tsx ================================================ /** * title: Store state into localStorage * desc: Refresh this page and you will get the state from localStorage. * * title.zh-CN: 将 state 存储在 localStorage 中 * desc.zh-CN: 刷新页面后,可以看到输入框中的内容被从 localStorage 中恢复了。 */ import { useLocalStorageState } from 'ahooks'; export default function () { const [message, setMessage] = useLocalStorageState( 'use-local-storage-state-demo1', { defaultValue: 'Hello~', }, ); return ( <> setMessage(e.target.value)} /> ); } ================================================ FILE: packages/hooks/src/useLocalStorageState/demo/demo2.tsx ================================================ /** * title: Store complex types such as array or object * desc: useLocalStorageState will do the serialization and deserialization work automatically. * * title.zh-CN: 存储数组或对象等复杂类型 * desc.zh-CN: useLocalStorageState 会自动处理序列化和反序列化的操作。 */ import { useLocalStorageState } from 'ahooks'; const defaultArray = ['a', 'e', 'i', 'o', 'u']; export default function () { const [value, setValue] = useLocalStorageState('use-local-storage-state-demo2', { defaultValue: defaultArray, }); return ( <>

{value?.join('-')}

); } ================================================ FILE: packages/hooks/src/useLocalStorageState/demo/demo3.tsx ================================================ /** * title: Custom serialization and deserialization functions * desc: You may not need the default `JSON.stringify/JSON.parse` to serialize string. * * title.zh-CN: 自定义序列化和反序列化函数 * desc.zh-CN: 对于普通的字符串,可能你不需要默认的 `JSON.stringify/JSON.parse` 来序列化。 */ import { useLocalStorageState } from 'ahooks'; export default function () { const [message, setMessage] = useLocalStorageState( 'use-local-storage-state-demo3', { defaultValue: 'Hello~', serializer: (v) => v ?? '', deserializer: (v) => v, }, ); return ( <> setMessage(e.target.value)} /> ); } ================================================ FILE: packages/hooks/src/useLocalStorageState/demo/demo4.tsx ================================================ /** * title: Sync state with localStorage * desc: When the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser (try to open two tabs of this page, clicking a button on one page will automatically update the "count" on the other page). * * title.zh-CN: 将 state 与 localStorage 保持同步 * desc.zh-CN: 存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间(尝试打开两个此页面,点击其中一个页面的按钮,另一个页面的 count 会自动更新) */ import { useLocalStorageState } from 'ahooks'; export default function () { return ( <> ); } function Counter() { const [count, setCount] = useLocalStorageState('use-local-storage-state-demo4', { defaultValue: 0, listenStorageChange: true, }); return (
); } ================================================ FILE: packages/hooks/src/useLocalStorageState/index.en-US.md ================================================ --- nav: path: /hooks --- # useLocalStorageState A Hook that store state into localStorage. ## Examples ### Store state into localStorage ### Store complex types of data ### Custom serialization and deserialization functions ### Sync state with localStorage ## API If you want to delete this record from localStorage, you can use `setState()` or `setState(undefined)`. ```typescript type SetState = S | ((prevState?: S) => S); interface Options { defaultValue?: T | (() => T); listenStorageChange?: boolean; serializer?: (value: T) => string; deserializer?: (value: string) => T; onError?: (error: unknown) => void; } const [state, setState] = useLocalStorageState( key: string, options: Options ): [T?, (value?: SetState) => void]; ``` ### Result | Property | Description | Type | | -------- | --------------------------- | ------------------------------- | | state | Local `localStorage` value | `T` | | setState | Update `localStorage` value | `(value?: SetState) => void` | ### Options | Property | Description | Type | Default | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- | | defaultValue | Default value | `any \| (() => any)` | - | | listenStorageChange | Whether to listen storage changes. If `true`, when the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser | `boolean` | `false` | | serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` | | deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` | | onError | On error callback | `(error: unknown) => void` | `(e) => { console.error(e) }` | ## Remark useLocalStorageState will call `serializer` before write data to localStorage, and call `deserializer` once after read data. ================================================ FILE: packages/hooks/src/useLocalStorageState/index.ts ================================================ import { createUseStorageState } from '../createUseStorageState'; import isBrowser from '../utils/isBrowser'; const useLocalStorageState = createUseStorageState(() => (isBrowser ? localStorage : undefined)); export default useLocalStorageState; ================================================ FILE: packages/hooks/src/useLocalStorageState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useLocalStorageState 将状态存储在 localStorage 中的 Hook 。 ## 代码演示 ### 将 state 存储在 localStorage 中 ### 存储复杂类型数据 ### 自定义序列化和反序列化函数 ### 将 state 与 localStorage 保持同步 ## API 如果想从 localStorage 中删除这条数据,可以使用 `setState()` 或 `setState(undefined)` 。 ```typescript type SetState = S | ((prevState?: S) => S); interface Options { defaultValue?: T | (() => T); listenStorageChange?: boolean; serializer?: (value: T) => string; deserializer?: (value: string) => T; onError?: (error: unknown) => void; } const [state, setState] = useLocalStorageState( key: string, options: Options ): [T?, (value?: SetState) => void]; ``` ### Result | 参数 | 说明 | 类型 | | -------- | ---------------------- | ------------------------------- | | state | 本地 `localStorage` 值 | `T` | | setState | 设置 `localStorage` 值 | `(value?: SetState) => void` | ### Options | 参数 | 说明 | 类型 | 默认值 | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- | | defaultValue | 默认值 | `any \| (() => any)` | - | | listenStorageChange | 是否监听存储变化。如果是 `true`,当存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间 | `boolean` | `false` | | serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` | | deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` | | onError | 错误回调函数 | `(error: unknown) => void` | `(e) => { console.error(e) }` | ## 备注 useLocalStorageState 在往 localStorage 写入数据前,会先调用一次 `serializer`,在读取数据之后,会先调用一次 `deserializer`。 ================================================ FILE: packages/hooks/src/useLockFn/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useCallback, useRef, useState } from 'react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useLockFn from '../index'; describe('useLockFn', () => { const setUp = (): any => renderHook(() => { const [tag, updateTag] = useState(false); const countRef = useRef(0); const persistFn = useCallback( async (step: number) => { countRef.current += step; await sleep(50); }, [tag], ); const locked = useLockFn(persistFn); return { locked, countRef, updateTag: () => updateTag(true), }; }); test('should work', async () => { const hook = setUp(); const { locked, countRef } = hook.result.current; locked(1); expect(countRef.current).toBe(1); locked(2); expect(countRef.current).toBe(1); await sleep(30); locked(3); expect(countRef.current).toBe(1); await sleep(30); locked(4); expect(countRef.current).toBe(5); locked(5); expect(countRef.current).toBe(5); }); test('should same', () => { const hook = setUp(); const preLocked = hook.result.current.locked; hook.rerender(); expect(hook.result.current.locked).toEqual(preLocked); act(hook.result.current.updateTag); expect(hook.result.current.locked).not.toEqual(preLocked); }); }); ================================================ FILE: packages/hooks/src/useLockFn/demo/demo1.tsx ================================================ /** * title: Prevent duplicated submits * desc: Before the `submit` function finishes, the other click actions will be ignored. * * title.zh-CN: 防止重复提交 * desc.zh-CN: 在 `submit` 函数执行完成前,其余的点击动作都会被忽略。 */ import { useLockFn } from 'ahooks'; import { message } from 'antd'; import { useState } from 'react'; function mockApiRequest() { return new Promise((resolve) => { setTimeout(() => { resolve(); }, 2000); }); } export default () => { const [count, setCount] = useState(0); const submit = useLockFn(async () => { message.info('Start to submit'); await mockApiRequest(); setCount((val) => val + 1); message.success('Submit finished'); }); return ( <>

Submit count: {count}

); }; ================================================ FILE: packages/hooks/src/useLockFn/index.en-US.md ================================================ --- nav: path: /hooks --- # useLockFn Add lock to an async function to prevent parallel executions. ## Examples ### Basic usage ## API ```typescript function useLockFn

( fn: (...args: P) => Promise ): fn: (...args: P) => Promise; ``` ### Result | Property | Description | Type | | -------- | ---------------------------- | ---------------------------------- | | fn | The async function with lock | `(...args: any[]) => Promise` | ### Params | Property | Description | Type | Default | | -------- | ----------------- | ---------------------------------- | ------- | | fn | An async function | `(...args: any[]) => Promise` | - | ================================================ FILE: packages/hooks/src/useLockFn/index.ts ================================================ import { useRef, useCallback } from 'react'; function useLockFn

(fn: (...args: P) => Promise) { const lockRef = useRef(false); return useCallback( async (...args: P) => { if (lockRef.current) { return; } lockRef.current = true; try { const ret = await fn(...args); return ret; } catch (e) { throw e; } finally { lockRef.current = false; } }, [fn], ); } export default useLockFn; ================================================ FILE: packages/hooks/src/useLockFn/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useLockFn 用于给一个异步函数增加竞态锁,防止并发执行。 ## 代码演示 ### 基础用法 ## API ```typescript function useLockFn

( fn: (...args: P) => Promise ): fn: (...args: P) => Promise; ``` ### Result | 参数 | 说明 | 类型 | | ---- | ------------------ | ---------------------------------- | | fn | 增加了竞态锁的函数 | `(...args: any[]) => Promise` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ---- | -------------------- | ---------------------------------- | ------ | | fn | 需要增加竞态锁的函数 | `(...args: any[]) => Promise` | - | ================================================ FILE: packages/hooks/src/useLongPress/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { Options } from '../index'; import useLongPress from '../index'; const mockCallback = vi.fn(); const mockClickCallback = vi.fn(); const mockLongPressEndCallback = vi.fn(); let events: Record = {}; const mockTarget = { addEventListener: vi.fn((event, callback) => { events[event] = callback; }), removeEventListener: vi.fn((event) => { Reflect.deleteProperty(events, event); }), }; const setup = (onLongPress: any, target: any, options?: Options) => renderHook(() => useLongPress(onLongPress, target, options)); describe('useLongPress', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { events = {}; vi.useRealTimers(); vi.clearAllMocks(); }); test('longPress callback correct', () => { setup(mockCallback, mockTarget, { onClick: mockClickCallback, onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); events['mousedown'](); vi.advanceTimersByTime(350); events['mouseleave'](); expect(mockCallback).toBeCalledTimes(1); expect(mockLongPressEndCallback).toBeCalledTimes(1); expect(mockClickCallback).toBeCalledTimes(0); }); test('click callback correct', () => { setup(mockCallback, mockTarget, { onClick: mockClickCallback, onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); events['mousedown'](); events['mouseup'](); events['mousedown'](); events['mouseup'](); expect(mockCallback).toBeCalledTimes(0); expect(mockLongPressEndCallback).toBeCalledTimes(0); expect(mockClickCallback).toBeCalledTimes(2); }); test('longPress and click callback correct', () => { setup(mockCallback, mockTarget, { onClick: mockClickCallback, onLongPressEnd: mockLongPressEndCallback, }); expect(mockTarget.addEventListener).toBeCalled(); events['mousedown'](); vi.advanceTimersByTime(350); events['mouseup'](); events['mousedown'](); events['mouseup'](); expect(mockCallback).toBeCalledTimes(1); expect(mockLongPressEndCallback).toBeCalledTimes(1); expect(mockClickCallback).toBeCalledTimes(1); }); test('onLongPress should not be called when over the threshold', () => { const { unmount } = setup(mockCallback, mockTarget, { moveThreshold: { x: 30, y: 20, }, }); expect(events['mousemove']).toBeDefined(); events['mousedown'](new MouseEvent('mousedown')); events['mousemove']( new MouseEvent('mousemove', { clientX: 40, clientY: 10, }), ); vi.advanceTimersByTime(320); expect(mockCallback).not.toBeCalled(); unmount(); expect(events['mousemove']).toBeUndefined(); }); test(`should not work when target don't support addEventListener method`, () => { Object.defineProperty(mockTarget, 'addEventListener', { get() { return false; }, }); setup(() => {}, mockTarget); expect(Object.keys(events)).toHaveLength(0); }); }); ================================================ FILE: packages/hooks/src/useLongPress/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Please keep pressing button to show effects. * * title.zh-CN: 基础用法 * desc.zh-CN: 请长按按钮查看效果。 */ import { useState, useRef } from 'react'; import { useLongPress } from 'ahooks'; export default () => { const [counter, setCounter] = useState(0); const ref = useRef(null); useLongPress(() => setCounter((s) => s + 1), ref); return (

counter: {counter}

); }; ================================================ FILE: packages/hooks/src/useLongPress/demo/demo2.tsx ================================================ import { useRef, useState } from 'react'; import { useLongPress } from 'ahooks'; export default () => { const [pressCounter, setPressCounter] = useState(0); const [clickCounter, setClickCounter] = useState(0); const ref = useRef(null); useLongPress(() => setPressCounter((s) => s + 1), ref, { onClick: () => setClickCounter((s) => s + 1), }); return (

pressCounter: {pressCounter}

clickCounter: {clickCounter}

); }; ================================================ FILE: packages/hooks/src/useLongPress/demo/demo3.tsx ================================================ /** * title: Basic usage * desc: After the movement threshold is exceeded, the long press event will not be triggered * * title.zh-CN: 超出移动阈值 * desc.zh-CN: 超出移动阈值之后,长按事件将不会触发 */ import { useRef, useState } from 'react'; import { useLongPress } from 'ahooks'; export default () => { const [pressCounter, setPressCounter] = useState(0); const ref = useRef(null); useLongPress(() => setPressCounter((s) => s + 1), ref, { moveThreshold: { x: 30 }, }); return (

counter: {pressCounter}

); }; ================================================ FILE: packages/hooks/src/useLongPress/index.en-US.md ================================================ --- nav: path: /hooks --- # useLongPress Listen for the long press event of the target element. ## Examples ### Basic usage ### Listen for click and long press events at the same time ### Move threshold ## API ```typescript useLongPress( onLongPress: (event: MouseEvent | TouchEvent) => void, target: Target, options: { delay?: number; moveThreshold?: { x?: number; y?: number }; onClick?: (event: MouseEvent | TouchEvent) => void; onLongPressEnd?: (event: MouseEvent | TouchEvent) => void; } ); ``` ### Params | Property | Description | Type | Default | | ----------- | ---------------------------- | ----------------------------------------------------------- | ------- | | onLongPress | Trigger function | `(event: MouseEvent \| TouchEvent) => void` | - | | target | DOM node or Ref | `Element` \| `() => Element` \| `MutableRefObject` | - | | options | Optional configuration items | `Options` | `{}` | ### Options | Property | Description | Type | Default | | -------------- | ----------------------------------------------------------------------------------- | ------------------------------------------- | ------- | | delay | Long press time | `number` | `300` | | moveThreshold | Move threshold after press. If exceeded, the long press function won't be triggered | `{ x?: number; y?: number }` | - | | onClick | Click event | `(event: MouseEvent \| TouchEvent) => void` | - | | onLongPressEnd | Long press end event | `(event: MouseEvent \| TouchEvent) => void` | - | ### Remark Please refer to: https://stackoverflow.com/a/11237968 to disable the ability to long press to select text on the phone ================================================ FILE: packages/hooks/src/useLongPress/index.ts ================================================ import { useRef } from 'react'; import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; type EventType = MouseEvent | TouchEvent; export interface Options { delay?: number; moveThreshold?: { x?: number; y?: number }; onClick?: (event: EventType) => void; onLongPressEnd?: (event: EventType) => void; } function useLongPress( onLongPress: (event: EventType) => void, target: BasicTarget, { delay = 300, moveThreshold, onClick, onLongPressEnd }: Options = {}, ) { const onLongPressRef = useLatest(onLongPress); const onClickRef = useLatest(onClick); const onLongPressEndRef = useLatest(onLongPressEnd); const timerRef = useRef>(undefined); const isTriggeredRef = useRef(false); const pervPositionRef = useRef({ x: 0, y: 0 }); const mousePressed = useRef(false); const touchPressed = useRef(false); const hasMoveThreshold = !!( (moveThreshold?.x && moveThreshold.x > 0) || (moveThreshold?.y && moveThreshold.y > 0) ); useEffectWithTarget( () => { const targetElement = getTargetElement(target); if (!targetElement?.addEventListener) { return; } const overThreshold = (event: EventType) => { const { clientX, clientY } = getClientPosition(event); const offsetX = Math.abs(clientX - pervPositionRef.current.x); const offsetY = Math.abs(clientY - pervPositionRef.current.y); return !!( (moveThreshold?.x && offsetX > moveThreshold.x) || (moveThreshold?.y && offsetY > moveThreshold.y) ); }; function getClientPosition(event: EventType) { if ('TouchEvent' in window && event instanceof TouchEvent) { return { clientX: event.touches[0].clientX, clientY: event.touches[0].clientY, }; } if (event instanceof MouseEvent) { return { clientX: event.clientX, clientY: event.clientY, }; } return { clientX: 0, clientY: 0 }; } const createTimer = (event: EventType) => { timerRef.current = setTimeout(() => { onLongPressRef.current(event); isTriggeredRef.current = true; }, delay); }; const onTouchStart = (event: TouchEvent) => { if (touchPressed.current) { return; } touchPressed.current = true; if (hasMoveThreshold) { const { clientX, clientY } = getClientPosition(event); pervPositionRef.current.x = clientX; pervPositionRef.current.y = clientY; } createTimer(event); }; const onMouseDown = (event: MouseEvent) => { if ((event as any)?.sourceCapabilities?.firesTouchEvents) { return; } mousePressed.current = true; if (hasMoveThreshold) { pervPositionRef.current.x = event.clientX; pervPositionRef.current.y = event.clientY; } createTimer(event); }; const onMove = (event: EventType) => { if (timerRef.current && overThreshold(event)) { clearTimeout(timerRef.current); timerRef.current = undefined; } }; const onTouchEnd = (event: TouchEvent) => { if (!touchPressed.current) { return; } touchPressed.current = false; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; } if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); } else if (onClickRef.current) { onClickRef.current(event); } isTriggeredRef.current = false; }; const onMouseUp = (event: MouseEvent) => { if ((event as any)?.sourceCapabilities?.firesTouchEvents) { return; } if (!mousePressed.current) { return; } mousePressed.current = false; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; } if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); } else if (onClickRef.current) { onClickRef.current(event); } isTriggeredRef.current = false; }; const onMouseLeave = (event: MouseEvent) => { if (!mousePressed.current) { return; } mousePressed.current = false; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; } if (isTriggeredRef.current) { onLongPressEndRef.current?.(event); isTriggeredRef.current = false; } }; targetElement.addEventListener('mousedown', onMouseDown as EventListener); targetElement.addEventListener('mouseup', onMouseUp as EventListener); targetElement.addEventListener('mouseleave', onMouseLeave as EventListener); targetElement.addEventListener('touchstart', onTouchStart as EventListener); targetElement.addEventListener('touchend', onTouchEnd as EventListener); if (hasMoveThreshold) { targetElement.addEventListener('mousemove', onMove as EventListener); targetElement.addEventListener('touchmove', onMove as EventListener); } return () => { if (timerRef.current) { clearTimeout(timerRef.current); isTriggeredRef.current = false; } targetElement.removeEventListener('mousedown', onMouseDown as EventListener); targetElement.removeEventListener('mouseup', onMouseUp as EventListener); targetElement.removeEventListener('mouseleave', onMouseLeave as EventListener); targetElement.removeEventListener('touchstart', onTouchStart as EventListener); targetElement.removeEventListener('touchend', onTouchEnd as EventListener); if (hasMoveThreshold) { targetElement.removeEventListener('mousemove', onMove as EventListener); targetElement.removeEventListener('touchmove', onMove as EventListener); } }; }, [], target, ); } export default useLongPress; ================================================ FILE: packages/hooks/src/useLongPress/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useLongPress 监听目标元素的长按事件。 ## 代码演示 ### 基本使用 ### 同时监听点击和长按事件 ### 移动阈值 ## API ```typescript useLongPress( onLongPress: (event: MouseEvent | TouchEvent) => void, target: Target, options: { delay?: number; moveThreshold?: { x?: number; y?: number }; onClick?: (event: MouseEvent | TouchEvent) => void; onLongPressEnd?: (event: MouseEvent | TouchEvent) => void; } ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ----------- | ---------------- | ----------------------------------------------------------- | ------ | | onLongPress | 触发函数 | `(event: MouseEvent \| TouchEvent) => void` | - | | target | DOM 节点或者 Ref | `Element` \| `() => Element` \| `MutableRefObject` | - | | options | 可选配置项 | `Options` | `{}` | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------------- | ------------------------------------ | ------------------------------------------- | ------ | | delay | 长按时间 | `number` | `300` | | moveThreshold | 按下后移动阈值,超出则不触发长按事件 | `{ x?: number; y?: number }` | - | | onClick | 点击事件 | `(event: MouseEvent \| TouchEvent) => void` | - | | onLongPressEnd | 长按结束事件 | `(event: MouseEvent \| TouchEvent) => void` | - | ### 备注 禁用在手机上长按选择文本的能力请参考:https://stackoverflow.com/a/11237968 ================================================ FILE: packages/hooks/src/useMap/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useMap from '../index'; const setup = (initialMap?: Iterable<[any, any]>) => renderHook(() => useMap(initialMap)); describe('useMap', () => { test('should init map and utils', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [map, utils] = result.current; expect(Array.from(map)).toEqual([ ['foo', 'bar'], ['a', 1], ]); expect(utils).toStrictEqual({ get: expect.any(Function), set: expect.any(Function), setAll: expect.any(Function), remove: expect.any(Function), reset: expect.any(Function), }); }); test('should init empty map if not initial object provided', () => { const { result } = setup(); expect([...result.current[0]]).toEqual([]); const { result: result2 } = setup(undefined); expect([...result2.current[0]]).toEqual([]); }); test('should get corresponding value for initial provided key', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [, utils] = result.current; let value; act(() => { value = utils.get('a'); }); expect(value).toBe(1); }); test('should get corresponding value for existing provided key', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); act(() => { result.current[1].set('a', 99); }); let value; act(() => { value = result.current[1].get('a'); }); expect(value).toBe(99); }); test('should get undefined for non-existing provided key', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [, utils] = result.current; let value; act(() => { value = utils.get('nonExisting'); }); expect(value).toBeUndefined(); }); test('should set new key-value pair', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [, utils] = result.current; act(() => { utils.set('newKey', 99); }); expect([...result.current[0]]).toEqual([ ['foo', 'bar'], ['a', 1], ['newKey', 99], ]); }); test('should override current value if setting existing key', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [, utils] = result.current; act(() => { utils.set('foo', 'qux'); }); expect([...result.current[0]]).toEqual([ ['foo', 'qux'], ['a', 1], ]); }); test('should set new map', () => { const { result } = setup([ ['foo', 'bar'], ['a', 1], ]); const [, utils] = result.current; act(() => { utils.setAll([ ['foo', 'foo'], ['a', 2], ]); }); expect([...result.current[0]]).toEqual([ ['foo', 'foo'], ['a', 2], ]); act(() => { // @ts-ignore utils.setAll(); }); expect([...result.current[0]]).toEqual([]); }); test('remove should be work', () => { const { result } = setup([['msg', 'hello']]); const { remove } = result.current[1]; expect(result.current[0].size).toBe(1); act(() => { remove('msg'); }); expect(result.current[0].size).toBe(0); const { result: result2 } = setup([ ['foo', 'bar'], ['a', 1], ['b', 2], ['c', 3], ]); const [, utils] = result2.current; act(() => { utils.remove('a'); }); expect([...result2.current[0]]).toEqual([ ['foo', 'bar'], ['b', 2], ['c', 3], ]); }); test('reset should be work', () => { const { result } = setup([['msg', 'hello']]); const { set, reset } = result.current[1]; act(() => { set('text', 'new map'); }); expect([...result.current[0]]).toEqual([ ['msg', 'hello'], ['text', 'new map'], ]); act(() => { reset(); }); expect([...result.current[0]]).toEqual([['msg', 'hello']]); }); }); ================================================ FILE: packages/hooks/src/useMap/demo/demo1.tsx ================================================ import { useMap } from 'ahooks'; export default () => { const [map, { set, setAll, remove, reset, get }] = useMap([ ['msg', 'hello world'], [123, 'number type'], ]); return (
{JSON.stringify(Array.from(map), null, 2)}
); }; ================================================ FILE: packages/hooks/src/useMap/index.en-US.md ================================================ --- nav: path: /hooks --- # useMap A hook that can manage the state of Map. ## Examples ### Default usage ## API ```typescript const [ map, { set, setAll, remove, reset, get } ] = useMap(initialValue); ``` ### Result | Property | Description | Type | | -------- | ---------------- | ------------------------------------ | | map | Map object | `Map` | | set | Add item | `(key: K, value: V) => void` | | get | Get item | `(key: K) => V \| undefined` | | setAll | Set a new Map | `(newMap: Iterable<[K, V]>) => void` | | remove | Remove key | `(key: K) => void` | | reset | Reset to default | `() => void` | ### Params | Property | Description | Type | Default | | ------------ | --------------------------- | ------------------ | ------- | | initialValue | Optional, set default value | `Iterable<[K, V]>` | - | ================================================ FILE: packages/hooks/src/useMap/index.ts ================================================ import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; function useMap(initialValue?: Iterable) { const getInitValue = () => new Map(initialValue); const [map, setMap] = useState>(getInitValue); const set = (key: K, entry: T) => { setMap((prev) => { const temp = new Map(prev); temp.set(key, entry); return temp; }); }; const setAll = (newMap: Iterable) => { setMap(new Map(newMap)); }; const remove = (key: K) => { setMap((prev) => { const temp = new Map(prev); temp.delete(key); return temp; }); }; const reset = () => setMap(getInitValue()); const get = (key: K) => map.get(key); return [ map, { set: useMemoizedFn(set), setAll: useMemoizedFn(setAll), remove: useMemoizedFn(remove), reset: useMemoizedFn(reset), get: useMemoizedFn(get), }, ] as const; } export default useMap; ================================================ FILE: packages/hooks/src/useMap/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useMap 管理 Map 类型状态的 Hook。 ## 代码演示 ## API ```typescript const [ map, { set, setAll, remove, reset, get } ] = useMap(initialValue); ``` ### Result | 参数 | 说明 | 类型 | | ------ | --------------------- | ------------------------------------ | | map | Map 对象 | `Map` | | set | 添加元素 | `(key: K, value: V) => void` | | get | 获取元素 | `(key: K) => V \| undefined` | | setAll | 生成一个新的 Map 对象 | `(newMap: Iterable<[K, V]>) => void` | | remove | 移除元素 | `(key: K) => void` | | reset | 重置为默认值 | `() => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | --------------------------- | ------------------ | ------ | | initialValue | 可选项,传入默认的 Map 参数 | `Iterable<[K, V]>` | - | ================================================ FILE: packages/hooks/src/useMemoizedFn/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import useMemoizedFn from '../'; const useCount = () => { const [count, setCount] = useState(0); const addCount = () => { setCount((c) => c + 1); }; const memoizedFn = useMemoizedFn(() => count); return { addCount, memoizedFn }; }; let hook: RenderHookResult; describe('useMemoizedFn', () => { test('useMemoizedFn should work', () => { act(() => { hook = renderHook(() => useCount()); }); const currentFn = hook.result.current.memoizedFn; expect(hook.result.current.memoizedFn()).toBe(0); act(() => { hook.result.current.addCount(); }); expect(currentFn).toEqual(hook.result.current.memoizedFn); expect(hook.result.current.memoizedFn()).toBe(1); }); }); ================================================ FILE: packages/hooks/src/useMemoizedFn/demo/demo1.tsx ================================================ /** * title: Default usage * desc: useMemoizedFn is the same as useCallback. * * title.zh-CN: 基础用法 * desc.zh-CN: useMemoizedFn 与 useCallback 可以实现同样的效果。 */ import { useState, useCallback } from 'react'; import { message } from 'antd'; import { useMemoizedFn } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const callbackFn = useCallback(() => { message.info(`Current count is ${count}`); }, [count]); const memoizedFn = useMemoizedFn(() => { message.info(`Current count is ${count}`); }); return ( <>

count: {count}

); }; ================================================ FILE: packages/hooks/src/useMemoizedFn/demo/demo2.tsx ================================================ /** * title: useMemoizedFn function reference will not change, which can be used for performance optimization. * desc: In the example, `memoizedFn` reference will not change, `callbackFn` will change when count changes. * * title.zh-CN: useMemoizedFn 函数地址不会变化,可以用于性能优化 * desc.zh-CN: 示例中 `memoizedFn` 是不会变化的,`callbackFn` 在 count 变化时变化。 */ import { useMemoizedFn } from 'ahooks'; import { message } from 'antd'; import { useCallback, useRef, useState, memo } from 'react'; export default () => { const [count, setCount] = useState(0); const callbackFn = useCallback(() => { message.info(`Current count is ${count}`); }, [count]); const memoizedFn = useMemoizedFn(() => { message.info(`Current count is ${count}`); }); return ( <>

count: {count}

You can click the button to see the number of sub-component renderings

Component with useCallback function:

{/* use callback function, ExpensiveTree component will re-render on state change */}

Component with useMemoizedFn function:

{/* use memoized function, ExpensiveTree component will only render once */}
); }; // some expensive component with React.memo const ExpensiveTree = memo<{ [key: string]: any }>(({ showCount }) => { const renderCountRef = useRef(0); renderCountRef.current += 1; return (

Render Count: {renderCountRef.current}

); }); ================================================ FILE: packages/hooks/src/useMemoizedFn/index.en-US.md ================================================ --- nav: path: /hooks --- # useMemoizedFn Hooks for persistent functions. In general, useMemoizedFn can be used instead of useCallback. See [FAQ](#faq) for special cases. In some scenarios, we need to use useCallback to cache a function, but when the second parameter deps changes, the function will be regenerated, causing the function reference to change. ```js const [state, setState] = useState(''); // When the state changes, the func reference will change const func = useCallback(() => { console.log(state); }, [state]); ``` Using useMemoizedFn, you can omit the second parameter deps, and ensure that the function reference never change. ```js const [state, setState] = useState(''); // func reference never change const func = useMemoizedFn(() => { console.log(state); }); ``` ## Examples ### Default usage ### Performance Improvement ## API ```typescript const memoizedFn = useMemoizedFn(fn: T): T; ``` ### Result | Property | Description | Type | | ---------- | ------------------------------------------------- | ------------------------- | | memoizedFn | Function that the reference address never changes | `(...args: any[]) => any` | ### Params | Property | Description | Type | Default | | -------- | --------------------------------- | ------------------------- | ------- | | fn | Function that require persistence | `(...args: any[]) => any` | - | ## FAQ ### The function returned by `useMemoizedFn` will not inherit properties from fn itself? The function returned by `useMemoizedFn` is entirely different from the reference of the passed `fn`, and it does not inherit any properties from `fn` itself. If you want to preserve the properties of the function itself after memoization, `useMemoizedFn` currently does not fulfill that requirement. In this case, consider downgrading to using `useCallback` or `useMemo` instead. Related issues: [2273](https://github.com/alibaba/hooks/issues/2273) ================================================ FILE: packages/hooks/src/useMemoizedFn/index.ts ================================================ import { useMemo, useRef } from 'react'; import { isFunction } from '../utils'; import isDev from '../utils/isDev'; type noop = (this: any, ...args: any[]) => any; type PickFunction = ( this: ThisParameterType, ...args: Parameters ) => ReturnType; const useMemoizedFn = (fn: T) => { if (isDev) { if (!isFunction(fn)) { console.error(`useMemoizedFn expected parameter is a function, got ${typeof fn}`); } } const fnRef = useRef(fn); // why not write `fnRef.current = fn`? // https://github.com/alibaba/hooks/issues/728 fnRef.current = useMemo(() => fn, [fn]); const memoizedFn = useRef>(undefined); if (!memoizedFn.current) { memoizedFn.current = function (this, ...args) { return fnRef.current.apply(this, args); }; } return memoizedFn.current; }; export default useMemoizedFn; ================================================ FILE: packages/hooks/src/useMemoizedFn/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useMemoizedFn 持久化 function 的 Hook,一般情况下,可以使用 useMemoizedFn 完全代替 useCallback,特殊情况见 [FAQ](#faq)。 在某些场景中,我们需要使用 useCallback 来记住一个函数,但是在第二个参数 deps 变化时,会重新生成函数,导致函数地址变化。 ```js const [state, setState] = useState(''); // 在 state 变化时,func 地址会变化 const func = useCallback(() => { console.log(state); }, [state]); ``` 使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。 ```js const [state, setState] = useState(''); // func 地址永远不会变化 const func = useMemoizedFn(() => { console.log(state); }); ``` ## 代码演示 ### 基础用法 ### 性能提升 ## API ```typescript const memoizedFn = useMemoizedFn(fn: T): T; ``` ### Result | 参数 | 说明 | 类型 | | ---------- | -------------------------- | ------------------------- | | memoizedFn | 引用地址永远不会变化的函数 | `(...args: any[]) => any` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ---- | ---------------- | ------------------------- | ------ | | fn | 需要持久化的函数 | `(...args: any[]) => any` | - | ## FAQ ### `useMemoizedFn` 返回的函数没有继承 fn 自身的属性? `useMemoizedFn` 返回的函数与传入的 fn 的引用完全不同,且没有继承 fn 自身的属性。如果想要持久化后函数自身的属性不丢失,目前 `useMemoizedFn` 满足不了,请降级使用 `useCallback`、`useMemo`。 Related issues: [2273](https://github.com/alibaba/hooks/issues/2273) ================================================ FILE: packages/hooks/src/useMount/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useMount from '../index'; describe('useMount', () => { test('test mount', async () => { const destructor = vi.fn(); const fn = vi.fn(); fn.mockReturnValue(destructor); const hook = renderHook(() => useMount(fn)); expect(fn).toHaveBeenCalledTimes(1); expect(destructor).toHaveBeenCalledTimes(0); hook.rerender(); expect(fn).toHaveBeenCalledTimes(1); expect(destructor).toHaveBeenCalledTimes(0); hook.unmount(); expect(fn).toHaveBeenCalledTimes(1); expect(destructor).toHaveBeenCalledTimes(1); renderHook(() => useMount(fn)).unmount(); expect(fn).toHaveBeenCalledTimes(2); expect(destructor).toHaveBeenCalledTimes(2); }); test('test mount with async function', async () => { const mockAsyncFn = vi.fn().mockResolvedValue(undefined); const hook = renderHook(() => useMount(mockAsyncFn)); expect(mockAsyncFn).toHaveBeenCalledTimes(1); hook.rerender(); expect(mockAsyncFn).toHaveBeenCalledTimes(1); hook.unmount(); expect(mockAsyncFn).toHaveBeenCalledTimes(1); }); test('test mount with async function that returns cleanup', async () => { const cleanup = vi.fn(); const mockAsyncFn = vi.fn().mockResolvedValue(cleanup); const hook = renderHook(() => useMount(mockAsyncFn)); expect(mockAsyncFn).toHaveBeenCalledTimes(1); hook.unmount(); // Cleanup should not be called for async functions expect(cleanup).toHaveBeenCalledTimes(0); }); // test('should output error when fn is not a function', () => { // const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); // renderHook(() => useMount(1 as any)); // expect(errSpy).toBeCalledWith( // 'useMount: parameter `fn` expected to be a function, but got "number".', // ); // errSpy.mockRestore(); // }); }); ================================================ FILE: packages/hooks/src/useMount/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: The function is called right after the component is mounted. * * title.zh-CN: 基础用法 * desc.zh-CN: 在组件首次渲染时,执行方法。 */ import { useMount, useBoolean } from 'ahooks'; import { message } from 'antd'; const MyComponent = () => { useMount(() => { message.info('mount'); return () => { message.info('unmount'); }; }); return
Hello World
; }; export default () => { const [state, { toggle }] = useBoolean(false); return ( <> {state && } ); }; ================================================ FILE: packages/hooks/src/useMount/index.en-US.md ================================================ --- nav: path: /hooks --- # useMount A hook that executes a function after the component is mounted. ## Examples ### Default Usage ## API ```typescript useMount(fn: EffectCallback); ``` ### Params | Property | Description | Type | Default | | -------- | --------------------------- | ------------ | ------- | | fn | The function to be executed | `EffectCallback` | - | ================================================ FILE: packages/hooks/src/useMount/index.ts ================================================ import { useEffect } from 'react'; import { type EffectCallback } from 'react'; import { isFunction } from '../utils'; import isDev from '../utils/isDev'; type MountCallback = EffectCallback | (() => Promise void)>); const useMount = (fn: MountCallback) => { if (isDev) { if (!isFunction(fn)) { console.error( `useMount: parameter \`fn\` expected to be a function, but got "${typeof fn}".`, ); } } useEffect(() => { const result = fn?.(); // If fn returns a Promise, don't return it as cleanup function if (result && typeof result === 'object' && typeof (result as any).then === 'function') { return; } return result as ReturnType; }, []); }; export default useMount; ================================================ FILE: packages/hooks/src/useMount/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useMount 只在组件初始化时执行的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript useMount(fn: EffectCallback); ``` ### 参数 | 参数 | 说明 | 类型 | 默认值 | | ---- | ------------------ | ------------ | ------ | | fn | 初始化时执行的函数 | `EffectCallback` | - | ================================================ FILE: packages/hooks/src/useMouse/__tests__/index.spec.ts ================================================ import { renderHook, waitFor } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useMouse from '../index'; describe('useMouse', () => { function moveMouse(x: number, y: number) { document.dispatchEvent( new MouseEvent('mousemove', { clientX: x, clientY: y, screenX: x, screenY: y, }), ); } test('on mouseMove', async () => { const hook = renderHook(() => useMouse()); expect(hook.result.current.pageX).toBeNaN(); expect(hook.result.current.pageY).toBeNaN(); moveMouse(10, 10); // In jsdom environment, pageX and pageY might be set to clientX and clientY await waitFor(() => expect(hook.result.current.clientX).toBe(10)); expect(hook.result.current.clientY).toBe(10); expect(hook.result.current.screenX).toBe(10); expect(hook.result.current.screenY).toBe(10); }); test('should be work with target', async () => { const events: Record = {}; const getBoundingClientRectMock = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect'); vi.spyOn(document, 'addEventListener').mockImplementation( vi.fn((event: any, callback: any) => { events[event] = callback; }), ); const targetEl = document.createElement('div'); getBoundingClientRectMock.mockReturnValue({ left: 100, top: 100, width: 200, height: 200, } as DOMRect); const { result } = renderHook(() => useMouse(targetEl)); events['mousemove']({ pageX: 100, pageY: 100 }); await waitFor(() => expect(result.current.elementX).toBe(0)); expect(result.current.elementX).toBe(0); expect(result.current.elementY).toBe(0); expect(result.current.elementPosX).toBe(100); expect(result.current.elementPosY).toBe(100); }); }); ================================================ FILE: packages/hooks/src/useMouse/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Tracking cursor position. * * title.zh-CN: 基础用法 * desc.zh-CN: 获取鼠标位置。 */ import { useMouse } from 'ahooks'; export default () => { const mouse = useMouse(); return (

Client - x: {mouse.clientX}, y: {mouse.clientY}

Page - x: {mouse.pageX}, y: {mouse.pageY}

Screen - x: {mouse.screenX}, y: {mouse.screenY}

); }; ================================================ FILE: packages/hooks/src/useMouse/demo/demo2.tsx ================================================ /** * title: Mouse position relative to the element * desc: By passing in the target element, you can get the position of the mouse relative to the element. * * title.zh-CN: 获取鼠标相对于元素的位置 * desc.zh-CN: 通过传入目标元素,可以获取鼠标相对于元素的位置。 */ import { useRef } from 'react'; import { useMouse } from 'ahooks'; export default () => { const ref = useRef(null); const mouse = useMouse(ref.current); return ( <>
element

Mouse In Element - x: {mouse.elementX}, y: {mouse.elementY}

Element Position - x: {mouse.elementPosX}, y: {mouse.elementPosY}

Element Dimensions - width: {mouse.elementW}, height: {mouse.elementH}

); }; ================================================ FILE: packages/hooks/src/useMouse/index.en-US.md ================================================ --- nav: path: /hooks --- # useMouse Track cursor position ## Examples ### Default Usage ### Mouse position relative to the element ## API ```typescript const state: { screenX: number, screenY: number, clientX: number, clientY: number, pageX: number, pageY: number, elementX: number, elementY: number, elementH: number, elementW: number, elementPosX: number, elementPosY: number, } = useMouse(target?: Target); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------ | ----------------------------------------------------------- | ------- | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | ### result | Property | Description | Type | | ----------- | ------------------------------------------------------------------------------------------------------------------ | -------- | | screenX | Position left relative to the top left of the physical screen/monitor | `number` | | screenY | Position top relative to the top left of the physical screen/monitor | `number` | | clientX | Position left relative to the upper left edge of the content area | `number` | | clientY | Position top relative to the upper left edge of the content area | `number` | | pageX | Position left relative to the top left of the fully rendered content area in the browser | `number` | | pageY | Position top relative to the top left of the fully rendered content area in the browser | `number` | | elementX | Position left relative to the upper left edge of the target element | `number` | | elementY | Position top relative to the upper left edge of the target element | `number` | | elementH | Target element height | `number` | | elementW | Target element width | `number` | | elementPosX | The position of the target element left relative to the top left of the fully rendered content area in the browser | `number` | | elementPosY | The position of the target element top relative to the top left of the fully rendered content area in the browser | `number` | ================================================ FILE: packages/hooks/src/useMouse/index.ts ================================================ import useRafState from '../useRafState'; import useEventListener from '../useEventListener'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; export interface CursorState { screenX: number; screenY: number; clientX: number; clientY: number; pageX: number; pageY: number; elementX: number; elementY: number; elementH: number; elementW: number; elementPosX: number; elementPosY: number; } const initState: CursorState = { screenX: NaN, screenY: NaN, clientX: NaN, clientY: NaN, pageX: NaN, pageY: NaN, elementX: NaN, elementY: NaN, elementH: NaN, elementW: NaN, elementPosX: NaN, elementPosY: NaN, }; export default (target?: BasicTarget) => { const [state, setState] = useRafState(initState); useEventListener( 'mousemove', (event: MouseEvent) => { const { screenX, screenY, clientX, clientY, pageX, pageY } = event; const newState = { screenX, screenY, clientX, clientY, pageX, pageY, elementX: NaN, elementY: NaN, elementH: NaN, elementW: NaN, elementPosX: NaN, elementPosY: NaN, }; const targetElement = getTargetElement(target); if (targetElement) { const { left, top, width, height } = targetElement.getBoundingClientRect(); newState.elementPosX = left + window.pageXOffset; newState.elementPosY = top + window.pageYOffset; newState.elementX = pageX - newState.elementPosX; newState.elementY = pageY - newState.elementPosY; newState.elementW = width; newState.elementH = height; } setState(newState); }, { target: () => document, }, ); return state; }; ================================================ FILE: packages/hooks/src/useMouse/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useMouse 监听鼠标位置 ## 代码演示 ### 基础用法 ### 获取鼠标相对于元素的位置 ## API ```typescript const state: { screenX: number, screenY: number, clientX: number, clientY: number, pageX: number, pageY: number, elementX: number, elementY: number, elementH: number, elementW: number, elementPosX: number, elementPosY: number, } = useMouse(target?: Target); ``` ### Params | 参数 | 说明 | 类型 | | ------ | ---------------- | ----------------------------------------------------------- | | target | DOM 节点或者 Ref | `Element` \| `() => Element` \| `MutableRefObject` | ### Result | 参数 | 说明 | 类型 | | ----------- | ------------------------------ | -------- | | screenX | 距离显示器左侧的距离 | `number` | | screenY | 距离显示器顶部的距离 | `number` | | clientX | 距离当前视窗左侧的距离 | `number` | | clientY | 距离当前视窗顶部的距离 | `number` | | pageX | 距离完整页面左侧的距离 | `number` | | pageY | 距离完整页面顶部的距离 | `number` | | elementX | 距离指定元素左侧的距离 | `number` | | elementY | 距离指定元素顶部的距离 | `number` | | elementH | 指定元素的高 | `number` | | elementW | 指定元素的宽 | `number` | | elementPosX | 指定元素距离完整页面左侧的距离 | `number` | | elementPosY | 指定元素距离完整页面顶部的距离 | `number` | ================================================ FILE: packages/hooks/src/useMutationObserver/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import useMutationObserver from '../index'; const options: MutationObserverInit = { attributes: true, childList: true }; describe('useMutationObserver', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); }); test('should callback work when target style be changed', async () => { const callback = vi.fn(); const { rerender } = renderHook(() => useMutationObserver(callback, () => container, options)); container.style.backgroundColor = '#000'; await rerender(); expect(callback).toBeCalled(); }); test('should callback work when target node tree be changed', async () => { const callback = vi.fn(); const { rerender } = renderHook(() => useMutationObserver(callback, () => container, options)); const paraEl = document.createElement('p'); container.appendChild(paraEl); await rerender(); expect(callback).toBeCalled(); }); test('should not work when target is null', async () => { const callback = vi.fn(); const { rerender } = renderHook(() => useMutationObserver(callback, null, options)); container.style.backgroundColor = '#000'; await rerender(); expect(callback).not.toBeCalled(); }); }); ================================================ FILE: packages/hooks/src/useMutationObserver/demo/demo1.tsx ================================================ /** * title: Basic usage * * title.zh-CN: 基础用法 */ import { useMutationObserver } from 'ahooks'; import { useRef, useState } from 'react'; const App: React.FC = () => { const [width, setWidth] = useState(200); const [count, setCount] = useState(0); const ref = useRef(null); useMutationObserver( (mutationsList) => { mutationsList.forEach(() => setCount((c) => c + 1)); }, ref, { attributes: true }, ); return (
current width:{width}

Mutation count {count}

); }; export default App; ================================================ FILE: packages/hooks/src/useMutationObserver/index.en-US.md ================================================ --- nav: path: /hooks --- # useMutationObserver A hook that provides the ability to watch for changes being made to the DOM tree, refer to [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) ## Examples ### Default Usage ## API ```typescript useMutationObserver( callback: MutationCallback, target: Target, options?: MutationObserverInit, ); ``` ## Params | Property | Description | Type | Default | | -------- | --------------------- | ------------------------------------------------------------------- | ------- | | target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | callback | The callback function | `(mutations: MutationRecord[], observer: MutationObserver) => void` | - | | options | Setting | `MutationObserverInit` | - | ### Options For options, please refer to [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters) ================================================ FILE: packages/hooks/src/useMutationObserver/index.ts ================================================ import { getTargetElement } from '../utils/domTarget'; import type { BasicTarget } from '../utils/domTarget'; import useDeepCompareEffectWithTarget from '../utils/useDeepCompareWithTarget'; import useLatest from '../useLatest'; const useMutationObserver = ( callback: MutationCallback, target: BasicTarget, options: MutationObserverInit = {}, ): void => { const callbackRef = useLatest(callback); useDeepCompareEffectWithTarget( () => { const element = getTargetElement(target); if (!element) { return; } const observer = new MutationObserver(callbackRef.current); observer.observe(element, options); return () => { observer?.disconnect(); }; }, [options], target, ); }; export default useMutationObserver; ================================================ FILE: packages/hooks/src/useMutationObserver/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useMutationObserver 一个监听指定的 DOM 树发生变化的 Hook,参考 [MutationObserver](https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver) ## 代码演示 ### 基础用法 ## API ```typescript useMutationObserver( callback: MutationCallback, target: Target, options?: MutationObserverInit, ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | -------- | ---------------- | ------------------------------------------------------------------- | ------ | | callback | 触发的回调函数 | `(mutations: MutationRecord[], observer: MutationObserver) => void` | - | | target | DOM 节点或者 Ref | `Element` \| `() => Element` \| `MutableRefObject` | - | | options | 设置项 | `MutationObserverInit` | `{}` | ### Options 配置项请参考 [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters) ================================================ FILE: packages/hooks/src/useNetwork/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useNetwork from '../index'; describe('useNetwork', () => { test('toggle network state', () => { const { result } = renderHook(() => useNetwork()); expect(result.current.online).toBeTruthy(); act(() => { window.dispatchEvent(new Event('offline')); }); expect(result.current.online).toBeFalsy(); act(() => { window.dispatchEvent(new Event('online')); }); expect(result.current.online).toBeTruthy(); }); }); ================================================ FILE: packages/hooks/src/useNetwork/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Return network status * * title.zh-CN: 基础用法 * desc.zh-CN: 返回网络状态信息 */ import { useNetwork } from 'ahooks'; export default () => { const networkState = useNetwork(); return (
Network information:
{JSON.stringify(networkState, null, 2)}
); }; ================================================ FILE: packages/hooks/src/useNetwork/index.en-US.md ================================================ --- nav: path: /hooks --- # useNetwork A hook that tracks the state of network connection. ## Examples ### Default usage ## API ```typescript interface NetworkState { online?: boolean; since?: Date; rtt?: number; type?: string; downlink?: number; saveData?: boolean; downlinkMax?: number; effectiveType?: string; } const result: NetworkState = useNetwork(); ``` ### Result | Property | Description | Type | | ------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | | online | Whether connected to network | `boolean` | | since | `online` latest update time | `Date` | | rtt | The effective round-trip time estimate in milliseconds | `number` | | type | The connection type that the user agent is using | `bluetooth` \| `cellular` \| `ethernet` \| `none` \| `wifi` \| `wimax` \| `other` \| `unknown` | | downlink | The effective bandwidth estimate in megabits per second, | `number` | | downlinkMax | An upper bound on the downlink speed of the first network hop | `number` | | saveData | Whether the user agent has set the option to reduce data usage | `boolean` | | effectiveType | The effective connection type | `slow-2g` \| `2g` \| `3g` \| `4g` | More information refer to [MDN NetworkInformation](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation) ================================================ FILE: packages/hooks/src/useNetwork/index.ts ================================================ import { useEffect, useState } from 'react'; import { isObject } from '../utils'; export interface NetworkState { since?: Date; online?: boolean; rtt?: number; type?: string; downlink?: number; saveData?: boolean; downlinkMax?: number; effectiveType?: string; } enum NetworkEventType { ONLINE = 'online', OFFLINE = 'offline', CHANGE = 'change', } function getConnection() { const nav = navigator as any; if (!isObject(nav)) { return null; } return nav.connection || nav.mozConnection || nav.webkitConnection; } function getConnectionProperty(): NetworkState { const c = getConnection(); if (!c) { return {}; } return { rtt: c.rtt, type: c.type, saveData: c.saveData, downlink: c.downlink, downlinkMax: c.downlinkMax, effectiveType: c.effectiveType, }; } function useNetwork(): NetworkState { const [state, setState] = useState(() => { return { since: undefined, online: navigator?.onLine, ...getConnectionProperty(), }; }); useEffect(() => { const onOnline = () => { setState((prevState) => ({ ...prevState, online: true, since: new Date(), })); }; const onOffline = () => { setState((prevState) => ({ ...prevState, online: false, since: new Date(), })); }; const onConnectionChange = () => { setState((prevState) => ({ ...prevState, ...getConnectionProperty(), })); }; window.addEventListener(NetworkEventType.ONLINE, onOnline); window.addEventListener(NetworkEventType.OFFLINE, onOffline); const connection = getConnection(); connection?.addEventListener(NetworkEventType.CHANGE, onConnectionChange); return () => { window.removeEventListener(NetworkEventType.ONLINE, onOnline); window.removeEventListener(NetworkEventType.OFFLINE, onOffline); connection?.removeEventListener(NetworkEventType.CHANGE, onConnectionChange); }; }, []); return state; } export default useNetwork; ================================================ FILE: packages/hooks/src/useNetwork/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useNetwork 管理网络连接状态的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript interface NetworkState { online?: boolean; since?: Date; rtt?: number; type?: string; downlink?: number; saveData?: boolean; downlinkMax?: number; effectiveType?: string; } const result: NetworkState = useNetwork(); ``` ### Result | 属性 | 描述 | 类型 | | ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------- | | online | 网络是否为在线 | `boolean` | | since | `online` 最后改变时间 | `Date` | | rtt | 当前连接下评估的往返时延 | `number` | | type | 设备使用与所述网络进行通信的连接的类型 | `bluetooth` \| `cellular` \| `ethernet` \| `none` \| `wifi` \| `wimax` \| `other` \| `unknown` | | downlink | 有效带宽估算(单位:兆比特/秒) | `number` | | downlinkMax | 最大下行速度(单位:兆比特/秒) | `number` | | saveData | 用户代理是否设置了减少数据使用的选项 | `boolean` | | effectiveType | 网络连接的类型 | `slow-2g` \| `2g` \| `3g` \| `4g` | 更多信息参考:[MDN NetworkInformation](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation) ================================================ FILE: packages/hooks/src/usePagination/__tests__/index.spec.ts ================================================ import type { RenderHookResult } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import usePagination from '../'; // 初始化 // 基本 action // refreshDeps // cache describe('usePagination', () => { let queryArgs: any; const asyncFn = (query: any) => { queryArgs = query; return Promise.resolve({ current: query.current, total: 55, pageSize: query.pageSize, list: [], }); }; const setUp = ( service: Parameters[0], options: Parameters[1], ) => renderHook((o) => usePagination(service, o || options)); let hook: RenderHookResult; test('should fetch after first render', async () => { queryArgs = undefined; act(() => { hook = setUp(asyncFn, {}); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.loading).toBe(false)); expect(hook.result.current.pagination.current).toBe(1); expect(hook.result.current.pagination.pageSize).toBe(10); expect(hook.result.current.pagination.total).toBe(55); expect(hook.result.current.pagination.totalPage).toBe(6); }); test('should action work', async () => { queryArgs = undefined; act(() => { hook = setUp(asyncFn, {}); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.loading).toBe(false)); act(() => { hook.result.current.pagination.changeCurrent(2); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.pagination.current).toBe(2)); act(() => { hook.result.current.pagination.changeCurrent(10); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(6); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.pagination.current).toBe(6)); act(() => { hook.result.current.pagination.changePageSize(20); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(3); expect(queryArgs.pageSize).toBe(20); await waitFor(() => expect(hook.result.current.pagination.current).toBe(3)); expect(hook.result.current.pagination.pageSize).toBe(20); expect(hook.result.current.pagination.totalPage).toBe(3); act(() => { hook.result.current.pagination.onChange(2, 10); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.pagination.current).toBe(2)); expect(hook.result.current.pagination.pageSize).toBe(10); expect(hook.result.current.pagination.totalPage).toBe(6); }); test('should refreshDeps work', async () => { queryArgs = undefined; let dep = 1; act(() => { hook = setUp(asyncFn, { refreshDeps: [dep], }); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(10); await waitFor(() => expect(hook.result.current.loading).toBe(false)); act(() => { hook.result.current.pagination.onChange(3, 20); }); expect(hook.result.current.loading).toBe(true); await waitFor(() => expect(hook.result.current.pagination.current).toBe(3)); expect(hook.result.current.pagination.pageSize).toBe(20); dep = 2; hook.rerender({ refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(1); expect(queryArgs.pageSize).toBe(20); await waitFor(() => expect(hook.result.current.pagination.current).toBe(1)); expect(hook.result.current.pagination.pageSize).toBe(20); }); test('should default params work', async () => { queryArgs = undefined; act(() => { hook = setUp(asyncFn, { defaultPageSize: 5, defaultCurrent: 2, }); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(2); expect(queryArgs.pageSize).toBe(5); await waitFor(() => expect(hook.result.current.loading).toBe(false)); expect(hook.result.current.pagination.current).toBe(2); expect(hook.result.current.pagination.pageSize).toBe(5); expect(hook.result.current.pagination.total).toBe(55); expect(hook.result.current.pagination.totalPage).toBe(11); act(() => { hook.result.current.pagination.changeCurrent(3); }); expect(hook.result.current.loading).toBe(true); expect(queryArgs.current).toBe(3); expect(queryArgs.pageSize).toBe(5); await waitFor(() => expect(hook.result.current.pagination.current).toBe(3)); expect(hook.result.current.pagination.pageSize).toBe(5); }); }); ================================================ FILE: packages/hooks/src/usePagination/demo/demo1.tsx ================================================ import { usePagination } from 'ahooks'; import { Pagination } from 'antd'; import Mock from 'mockjs'; interface UserListItem { id: string; name: string; gender: 'male' | 'female'; email: string; disabled: boolean; } const userList = (current: number, pageSize: number) => Mock.mock({ total: 55, [`list|${pageSize}`]: [ { id: '@guid', name: '@name', 'gender|1': ['male', 'female'], email: '@email', disabled: false, }, ], }); async function getUserList(params: { current: number; pageSize: number; }): Promise<{ total: number; list: UserListItem[] }> { return new Promise((resolve) => { setTimeout(() => { resolve(userList(params.current, params.pageSize)); }, 1000); }); } export default () => { const { data, loading, pagination } = usePagination(getUserList); return (
{loading ? (

loading

) : (
    {data?.list?.map((item) => (
  • {item.name} - {item.email}
  • ))}
)}
); }; ================================================ FILE: packages/hooks/src/usePagination/demo/demo2.tsx ================================================ import { Pagination } from 'antd'; import Mock from 'mockjs'; import { useEffect, useState } from 'react'; import { usePagination } from 'ahooks'; interface UserListItem { id: string; name: string; gender: 'male' | 'female'; email: string; disabled: boolean; } const userList = (current: number, pageSize: number) => Mock.mock({ total: 55, [`list|${pageSize}`]: [ { id: '@guid', name: '@name', 'gender|1': ['male', 'female'], email: '@email', disabled: false, }, ], }); async function getUserList(params: { current: number; pageSize: number; gender: string; }): Promise<{ total: number; list: UserListItem[] }> { return new Promise((resolve) => { setTimeout(() => { resolve(userList(params.current, params.pageSize)); }, 1000); }); } export default () => { const [gender, setGender] = useState('male'); const { data, loading, pagination, run, params } = usePagination( ({ current, pageSize }, g: string) => { return getUserList({ current, pageSize, gender: g, }); }, { manual: true, }, ); useEffect(() => { run( { current: 1, pageSize: params[0]?.pageSize || 10, }, gender, ); }, [gender]); return (
{loading ? (

loading

) : (
    {data?.list?.map((item) => (
  • {item.name} - {item.email}
  • ))}
)}
); }; ================================================ FILE: packages/hooks/src/usePagination/demo/demo3.tsx ================================================ import { usePagination } from 'ahooks'; import { Pagination } from 'antd'; import Mock from 'mockjs'; import { useState } from 'react'; interface UserListItem { id: string; name: string; gender: 'male' | 'female'; email: string; disabled: boolean; } const userList = (current: number, pageSize: number) => Mock.mock({ total: 55, [`list|${pageSize}`]: [ { id: '@guid', name: '@name', 'gender|1': ['male', 'female'], email: '@email', disabled: false, }, ], }); async function getUserList(params: { current: number; pageSize: number; gender: string; }): Promise<{ total: number; list: UserListItem[] }> { return new Promise((resolve) => { setTimeout(() => { resolve(userList(params.current, params.pageSize)); }, 1000); }); } export default () => { const [gender, setGender] = useState('male'); const { data, loading, pagination } = usePagination( ({ current, pageSize }) => { return getUserList({ current, pageSize, gender, }); }, { refreshDeps: [gender], }, ); return (
{loading ? (

loading

) : (
    {data?.list?.map((item) => (
  • {item.name} - {item.email}
  • ))}
)}
); }; ================================================ FILE: packages/hooks/src/usePagination/demo/demo4.tsx ================================================ import { useBoolean, useUpdateEffect } from 'ahooks'; import { Pagination } from 'antd'; import Mock from 'mockjs'; import { useState } from 'react'; import { usePagination } from 'ahooks'; interface UserListItem { id: string; name: string; gender: 'male' | 'female'; email: string; disabled: boolean; } const userList = (current: number, pageSize: number) => Mock.mock({ total: 55, [`list|${pageSize}`]: [ { id: '@guid', name: '@name', 'gender|1': ['male', 'female'], email: '@email', disabled: false, }, ], }); async function getUserList(params: { current: number; pageSize: number; gender: string; }): Promise<{ total: number; list: UserListItem[] }> { console.log('cache demo', params.current, params.pageSize, params.gender); return new Promise((resolve) => { setTimeout(() => { resolve(userList(params.current, params.pageSize)); }, 1000); }); } const PaginationComponent: React.FC = () => { const { data, loading, pagination, run, params } = usePagination( ({ current, pageSize }, g: string) => { return getUserList({ current, pageSize, gender: g, }); }, { cacheKey: 'userList', }, ); const [gender, setGender] = useState(params[1] || 'male'); useUpdateEffect(() => { run( { current: 1, pageSize: params[0]?.pageSize || 10, }, gender, ); }, [gender]); return (
{loading && !data ? (

loading

) : (
    {data?.list?.map((item) => (
  • {item.name} - {item.email}
  • ))}
)}
); }; export default () => { const [state, { toggle }] = useBoolean(); return (

You can click the button multiple times, the conditions of pagination will be cached.

{state && }
); }; ================================================ FILE: packages/hooks/src/usePagination/index.en-US.md ================================================ --- nav: path: /hooks --- # usePagination `usePagination` is implemented based on `useRequest` and encapsulates common paging logic. The differences from `useRequest` are as follows: 1. The first parameter of `service` is `{ current: number, pageSize: number }` 2. The data structure returned by `service` is `{ total: number, list: Item[] }` 3. It will additionally return the `pagination` field, which contains all the pagination information and functions to operate the pagination. 4. When `refreshDeps` changes, it will reset `current` to the first page and re-initiate the request. Generally, you can put the conditions that `pagination` depends on here ## Examples ### Basic usage The default usage is the same as `useRequest`, but an additional `pagination` parameter will be returned, which contains all pagination information and functions to operate pagination. ### More parameters The following code demonstrates that the gender parameter is added. When the gender is modified, the paging is reset to the first page and the data is requested again. ### refreshDeps `refreshDeps` is a syntactic sugar. When it changes, it will reset the page to the first page and request data again. Generally, you can put the dependent conditions here. The following example implements the previous function more conveniently through `refreshDeps`. ### Cache Through the `params` caching capability of `useRequest`, we can cache paging data and other conditions. ## API All parameters and returned results of `useRequest` are applicable to `usePagination`, so we won't repeat them here. ```typescript type Data = { total: number; list: T[] }; type Params = [{ current: number; pageSize: number, [key: string]: any }, ...any[]]; const { ..., pagination: { current: number; pageSize: number; total: number; totalPage: number; onChange: (current: number, pageSize: number) => void; changeCurrent: (current: number) => void; changePageSize: (pageSize: number) => void; } } = usePagination( service: (...args: TParams) => Promise, { ..., defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | Property | Description | Type | | ---------- | ------------------------------------------- | ---- | | pagination | Paging data and methods of paging operation | `-` | ### Params | Property | Description | Type | Default | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | ------- | | defaultPageSize | Default page size | `number` | 10 | | defaultCurrent | Number of pages on initial request | `number` | 1 | | refreshDeps | Changes in `refreshDeps` will reset current to the first page and re-initiate the request. Generally, you can put the dependent conditions here. | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/usePagination/index.ts ================================================ import { useMemo } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import useRequest from '../useRequest'; import type { Data, PaginationOptions, Params, Service, PaginationResult } from './types'; const usePagination = ( service: Service, options: PaginationOptions = {}, ) => { const { defaultPageSize = 10, defaultCurrent = 1, ...rest } = options; const result = useRequest(service, { defaultParams: [{ current: defaultCurrent, pageSize: defaultPageSize }] as unknown as TParams, refreshDepsAction: () => { // eslint-disable-next-line @typescript-eslint/no-use-before-define changeCurrent(1); }, ...rest, }); const { current = 1, pageSize = defaultPageSize } = result.params[0] || {}; const total = result.data?.total || 0; const totalPage = useMemo(() => Math.ceil(total / pageSize), [pageSize, total]); const onChange = (c: number, p: number) => { let toCurrent = c <= 0 ? 1 : c; const toPageSize = p <= 0 ? 1 : p; const tempTotalPage = Math.ceil(total / toPageSize); if (toCurrent > tempTotalPage) { toCurrent = Math.max(1, tempTotalPage); } const [oldPaginationParams = {}, ...restParams] = result.params || []; result.run( ...([ { ...oldPaginationParams, current: toCurrent, pageSize: toPageSize, }, ...restParams, ] as TParams), ); }; const changeCurrent = (c: number) => { onChange(c, pageSize); }; const changePageSize = (p: number) => { onChange(current, p); }; return { ...result, pagination: { current, pageSize, total, totalPage, onChange: useMemoizedFn(onChange), changeCurrent: useMemoizedFn(changeCurrent), changePageSize: useMemoizedFn(changePageSize), }, } as PaginationResult; }; export default usePagination; ================================================ FILE: packages/hooks/src/usePagination/index.zh-CN.md ================================================ --- nav: path: /hooks --- # usePagination `usePagination` 基于 `useRequest` 实现,封装了常见的分页逻辑。与 `useRequest` 不同的点有以下几点: 1. `service` 的第一个参数为 `{ current: number, pageSize: number }` 2. `service` 返回的数据结构为 `{ total: number, list: Item[] }` 3. 会额外返回 `pagination` 字段,包含所有分页信息,及操作分页的函数。 4. `refreshDeps` 变化,会重置 `current` 到第一页,并重新发起请求,一般你可以把 `pagination` 依赖的条件放这里 ## 代码演示 ### 基础用法 默认用法与 `useRequest` 一致,但会多返回一个 `pagination` 参数,包含所有分页信息,及操作分页的函数。 ### 更多参数 下面的代码演示了,增加了性别参数,在修改性别时,重置分页到第一页,并重新请求数据。 ### refreshDeps `refreshDeps` 是一个语法糖,当它变化时,会重置分页到第一页,并重新请求数据,一般你可以把依赖的条件放这里。以下示例通过 `refreshDeps` 更方便的实现了上一个功能。 ### 缓存 通过 `useRequest` 的 `params` 缓存能力,我们可以缓存分页数据和其它条件。 ## API `useRequest` 所有参数和返回结果均适用于 `usePagination`,此处不再赘述。 ```typescript type Data = { total: number; list: T[] }; type Params = [{ current: number; pageSize: number, [key: string]: any }, ...any[]]; const { ..., pagination: { current: number; pageSize: number; total: number; totalPage: number; onChange: (current: number, pageSize: number) => void; changeCurrent: (current: number) => void; changePageSize: (pageSize: number) => void; } } = usePagination( service: (...args: TParams) => Promise, { ..., defaultPageSize?: number; refreshDeps?: any[]; } ); ``` ### Result | 参数 | 说明 | 类型 | | ---------- | ------------------------ | ---- | | pagination | 分页数据及操作分页的方法 | `-` | ### Params | 参数 | 说明 | 类型 | 默认值 | | --------------- | ------------------------------------------------------------------------------------------- | ---------------------- | ------ | | defaultPageSize | 默认分页数量 | `number` | 10 | | defaultCurrent | 初次请求时的页数 | `number` | 1 | | refreshDeps | `refreshDeps` 变化,会重置 current 到第一页,并重新发起请求,一般你可以把依赖的条件放这里。 | `React.DependencyList` | `[]` | ================================================ FILE: packages/hooks/src/usePagination/types.ts ================================================ import type { Options, Result } from '../useRequest/src/types'; export type Data = { total: number; list: any[] }; export type Params = [{ current: number; pageSize: number; [key: string]: any }, ...any[]]; export type Service = ( ...args: TParams ) => Promise; export interface PaginationResult extends Result { pagination: { current: number; pageSize: number; total: number; totalPage: number; onChange: (current: number, pageSize: number) => void; changeCurrent: (current: number) => void; changePageSize: (pageSize: number) => void; }; } export interface PaginationOptions extends Options { defaultPageSize?: number; defaultCurrent?: number; } ================================================ FILE: packages/hooks/src/usePrevious/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import type { ShouldUpdateFunc } from '../'; import usePrevious from '../'; describe('usePrevious', () => { function getHook(initialValue?: T, compareFunction?: ShouldUpdateFunc) { return renderHook(({ val, cmp }) => usePrevious(val as T, cmp), { initialProps: { val: initialValue || 0, cmp: compareFunction, } as { val?: T; cmp?: ShouldUpdateFunc }, }); } test('should return undefined on init', () => { expect(getHook().result.current).toBeUndefined(); }); test('should update previous value only after render with different value', () => { const hook = getHook(0, () => true); expect(hook.result.current).toBeUndefined(); hook.rerender({ val: 1 }); expect(hook.result.current).toBe(0); hook.rerender({ val: 2 }); expect(hook.result.current).toBe(1); hook.rerender({ val: 3 }); expect(hook.result.current).toBe(2); hook.rerender({ val: 4 }); expect(hook.result.current).toBe(3); hook.rerender({ val: 5 }); expect(hook.result.current).toBe(4); }); test('should not update previous value if current value is the same', () => { const hook = getHook(0); expect(hook.result.current).toBeUndefined(); hook.rerender({ val: 1 }); expect(hook.result.current).toBe(0); hook.rerender({ val: 1 }); expect(hook.result.current).toBe(0); }); test('should work fine with `undefined` values', () => { const hook = renderHook(({ value }) => usePrevious(value), { initialProps: { value: undefined as undefined | number }, }); expect(hook.result.current).toBeUndefined(); hook.rerender({ value: 1 }); expect(hook.result.current).toBeUndefined(); hook.rerender({ value: undefined }); expect(hook.result.current).toBe(1); hook.rerender({ value: 2 }); expect(hook.result.current).toBeUndefined(); }); test('should receive a predicate as a second parameter that will compare prev and current', () => { const obj1 = { label: 'John', value: 'john' }; const obj2 = { label: 'Jonny', value: 'john' }; const obj3 = { label: 'Kate', value: 'kate' }; type Obj = { label: string; value: string }; const predicate = (a: Obj | undefined, b: Obj) => (a ? a.value !== b.value : true); const hook = getHook(obj1 as Obj, predicate as any); expect(hook.result.current).toBeUndefined(); hook.rerender({ val: obj2, cmp: predicate as any }); expect(hook.result.current).toBeUndefined(); hook.rerender({ val: obj3, cmp: predicate as any }); expect(hook.result.current).toBe(obj1); }); }); ================================================ FILE: packages/hooks/src/usePrevious/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Record the previous value. * * title.zh-CN: 基础用法 * desc.zh-CN: 记录上次的 count 值 */ import { usePrevious } from 'ahooks'; import { useState } from 'react'; export default () => { const [count, setCount] = useState(0); const previous = usePrevious(count); return ( <>
counter current value: {count}
counter previous value: {previous}
); }; ================================================ FILE: packages/hooks/src/usePrevious/demo/demo2.tsx ================================================ /** * title: Custom shouldUpdate function * desc: Previous value update only when the shouldUpdate function return true. * * title.zh-CN: 自定义 shouldUpdate 函数 * desc.zh-CN: 只有 shouldUpdate function 返回 true 时,才会记录值的变化。 */ import { useState } from 'react'; import { usePrevious } from 'ahooks'; interface Person { name: string; job: string; } const nameCompareFunction = (prev: Person | undefined, next: Person) => { if (!prev) { return true; } if (prev.name !== next.name) { return true; } return false; }; const jobCompareFunction = (prev: Person | undefined, next: Person) => { if (!prev) { return true; } if (prev.job !== next.job) { return true; } return false; }; export default () => { const [state, setState] = useState({ name: 'Jack', job: 'student' }); const [nameInput, setNameInput] = useState(''); const [jobInput, setJobInput] = useState(''); const previousName = usePrevious(state, nameCompareFunction as any); const previousJob = usePrevious(state, jobCompareFunction as any); return ( <>
current name: {state.name}
current job: {state.job}
previous name: {(previousName || {}).name}
previous job: {(previousJob || {}).job}
setNameInput(e.target.value)} placeholder="new name" />
setJobInput(e.target.value)} placeholder="new job" />
); }; ================================================ FILE: packages/hooks/src/usePrevious/index.en-US.md ================================================ --- nav: path: /hooks --- # usePrevious A Hook to return the previous state. ## Examples ### Default usage ### Custom shouldUpdate function ## API ```typescript const previousState: T = usePrevious( state: T, shouldUpdate?: (prev: T | undefined, next: T) => boolean ); ``` ### Result | Property | Description | Type | | ------------- | ------------------ | ---- | | previousState | The previous value | `T` | ### Params | Property | Description | Type | Default | | ------------ | ------------------------------------------------------------- | -------------------------------------------- | ---------------------------- | | state | The state that needs to be tracked | `T` | - | | shouldUpdate | Optional. Customize whether the state value should be updated | `(prev: T \| undefined, next: T) => boolean` | `(a, b) => !Object.is(a, b)` | ================================================ FILE: packages/hooks/src/usePrevious/index.ts ================================================ import { useRef } from 'react'; export type ShouldUpdateFunc = (prev?: T, next?: T) => boolean; const defaultShouldUpdate = (a?: T, b?: T) => !Object.is(a, b); function usePrevious( state: T, shouldUpdate: ShouldUpdateFunc = defaultShouldUpdate, ): T | undefined { const prevRef = useRef(undefined); const curRef = useRef(undefined); if (shouldUpdate(curRef.current, state)) { prevRef.current = curRef.current; curRef.current = state; } return prevRef.current; } export default usePrevious; ================================================ FILE: packages/hooks/src/usePrevious/index.zh-CN.md ================================================ --- nav: path: /hooks --- # usePrevious 保存上一次状态的 Hook。 ## 代码演示 ### 基础用法 ### 自定义 shouldUpdate 函数 ## API ```typescript const previousState: T = usePrevious( state: T, shouldUpdate?: (prev: T | undefined, next: T) => boolean ); ``` ### Result | 参数 | 说明 | 类型 | | ------------- | --------------- | ---- | | previousState | 上次 state 的值 | `T` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | -------------------------- | -------------------------------------------- | ---------------------------- | | state | 需要记录变化的值 | `T` | - | | shouldUpdate | 可选,自定义判断值是否变化 | `(prev: T \| undefined, next: T) => boolean` | `(a, b) => !Object.is(a, b)` | ================================================ FILE: packages/hooks/src/useRafInterval/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import useRafInterval from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay: number | undefined; options?: { immediate: boolean }; } const setUp = ({ fn, delay, options }: ParamsObj) => renderHook(() => useRafInterval(fn, delay, options)); const FRAME_TIME = 16; describe('useRafInterval', () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.restoreAllMocks(); }); test('interval should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 2.5); expect(callback).toHaveBeenCalledTimes(2); }); test('delay is undefined should stop', () => { const delay: number | undefined = undefined; const callback = vi.fn(); setUp({ fn: callback, delay }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).not.toBeCalled(); }); test('immediate in options should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: FRAME_TIME, options: { immediate: true } }); expect(callback).toBeCalled(); expect(callback).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).toHaveBeenCalledTimes(2); }); test('interval should be clear', () => { const callback = vi.fn(); const hook = setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); hook.result.current(); vi.advanceTimersByTime(FRAME_TIME * 2.5); // not to be called expect(callback).toHaveBeenCalledTimes(0); }); test('execute clear in the callback and interval should be clear', () => { const callback = vi.fn().mockImplementation(() => hook.result.current()); const hook = setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useRafInterval/__tests__/node.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { beforeAll, afterAll, describe, test, expect, vi } from 'vitest'; import useRafInterval from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay: number | undefined; options?: { immediate: boolean }; } const setUp = ({ fn, delay, options }: ParamsObj) => renderHook(() => useRafInterval(fn, delay, options)); const FRAME_TIME = 16; describe('useRafInterval', () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.restoreAllMocks(); }); test('should downgrade to setInterval when requstAnimationFrame is undefined', () => { Object.defineProperty(window, 'cancelAnimationFrame', { value: undefined }); Object.defineProperty(window, 'requestAnimationFrame', { value: undefined }); const callback = vi.fn(); setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useRafInterval/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Execute once per 1000ms. * * title.zh-CN: 基础用法 * desc.zh-CN: 每1000ms,执行一次 */ import { useState } from 'react'; import { useRafInterval } from 'ahooks'; export default () => { const [count, setCount] = useState(0); useRafInterval(() => { setCount(count + 1); }, 1000); return
count: {count}
; }; ================================================ FILE: packages/hooks/src/useRafInterval/demo/demo2.tsx ================================================ /** * title: Advanced usage * desc: Modify the delay to realize the timer interval change and pause. * * title.zh-CN: 进阶使用 * desc.zh-CN: 动态修改 delay 以实现定时器间隔变化与暂停。 */ import { useState } from 'react'; import { useRafInterval } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [interval, setInterval] = useState(1000); const clear = useRafInterval(() => { setCount(count + 1); }, interval); return (

count: {count}

interval: {interval}

); }; ================================================ FILE: packages/hooks/src/useRafInterval/index.en-US.md ================================================ --- nav: path: /hooks --- # useRafInterval A hook implements with `requestAnimationFrame` for better performance. The API is consistent with `useInterval`, the advantage is that the execution of the timer can be stopped when the page is not rendering, such as page hiding or minimization. Please note that the following two cases are likely to be inapplicable, and `useInterval` is preferred: - the time interval is less than `16ms` - want to execute the timer when page is not rendering; > `requestAnimationFrame` will automatically downgrade to `setInterval` in node environment ## Examples ### Default usage ### Advanced usage ## API ```typescript useRafInterval( fn: () => void, delay?: number | undefined, options?: Options ): fn: () => void; ``` ### Params | Property | Description | Type | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | | fn | The function to be executed every `delay` milliseconds. | `() => void` | | delay | The time in milliseconds, the timer should delay in between executions of the specified function. The timer will be cancelled if delay is set to `undefined`. | `number` \| `undefined` | | options | Config of the interval behavior. | `Options` | ### Options | Property | Description | Type | Default | | --------- | ---------------------------------------------------------------------- | --------- | ------- | | immediate | Whether the function should be executed immediately on first execution | `boolean` | `false` | ### Result | Property | Description | Type | | ------------- | -------------- | ------------ | | clearInterval | clear interval | `() => void` | ================================================ FILE: packages/hooks/src/useRafInterval/index.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import useLatest from '../useLatest'; import { isNumber } from '../utils'; interface Handle { id: ReturnType | ReturnType; } const setRafInterval = (callback: () => void, delay: number = 0): Handle => { if (typeof requestAnimationFrame === 'undefined') { return { id: setInterval(callback, delay), }; } let start = Date.now(); const handle: Handle = { id: 0, }; const loop = () => { const current = Date.now(); handle.id = requestAnimationFrame(loop); if (current - start >= delay) { callback(); start = Date.now(); } }; handle.id = requestAnimationFrame(loop); return handle; }; const cancelAnimationFrameIsNotDefined = (t: any): t is ReturnType => { return typeof cancelAnimationFrame === 'undefined'; }; const clearRafInterval = (handle: Handle) => { if (cancelAnimationFrameIsNotDefined(handle.id)) { return clearInterval(handle.id); } cancelAnimationFrame(handle.id); }; function useRafInterval( fn: () => void, delay: number | undefined, options?: { immediate?: boolean; }, ) { const immediate = options?.immediate; const fnRef = useLatest(fn); const timerRef = useRef(undefined); const clear = useCallback(() => { if (timerRef.current) { clearRafInterval(timerRef.current); } }, []); useEffect(() => { if (!isNumber(delay) || delay < 0) { return; } if (immediate) { fnRef.current(); } timerRef.current = setRafInterval(() => { fnRef.current(); }, delay); return clear; }, [delay]); return clear; } export default useRafInterval; ================================================ FILE: packages/hooks/src/useRafInterval/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useRafInterval 用 `requestAnimationFrame` 模拟实现 `setInterval`,API 和 `useInterval` 保持一致,好处是可以在页面不渲染的时候停止执行定时器,比如页面隐藏或最小化等。 请注意,如下两种情况下很可能是不适用的,优先考虑 `useInterval` : - 时间间隔小于 `16ms` - 希望页面不渲染的情况下依然执行定时器 > Node 环境下 `requestAnimationFrame` 会自动降级到 `setInterval` ## 代码演示 ### 基础用法 ### 进阶使用 ## API ```typescript useRafInterval( fn: () => void, delay?: number | undefined, options?: Options ): fn: () => void; ``` ### Params | 参数 | 说明 | 类型 | | ------- | ------------------------------------------- | ----------------------- | | fn | 要定时调用的函数 | `() => void` | | delay | 间隔时间,当取值 `undefined` 时会停止计时器 | `number` \| `undefined` | | options | 配置计时器的行为 | `Options` | ### Options | 参数 | 说明 | 类型 | 默认值 | | --------- | ------------------------ | --------- | ------- | | immediate | 是否在首次渲染时立即执行 | `boolean` | `false` | ### Result | 参数 | 说明 | 类型 | | ------------- | ---------- | ------------ | | clearInterval | 清除定时器 | `() => void` | ================================================ FILE: packages/hooks/src/useRafState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useRafState from '../index'; describe('useRafState', () => { test('should work', () => { const mockRaf = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((cb: FrameRequestCallback) => { cb(0); return 0; }); const { result } = renderHook(() => useRafState(0)); const setRafState = result.current[1]; expect(result.current[0]).toBe(0); act(() => { setRafState(1); }); expect(result.current[0]).toBe(1); mockRaf.mockRestore(); }); }); ================================================ FILE: packages/hooks/src/useRafState/demo/demo1.tsx ================================================ /** * title: Default usage * * title.zh-CN: 基础用法 */ import { useRafState } from 'ahooks'; import { useEffect } from 'react'; export default () => { const [state, setState] = useRafState({ width: 0, height: 0, }); useEffect(() => { const onResize = () => { setState({ width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, }); }; onResize(); window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); }; }, []); return (

Try to resize the window

current: {JSON.stringify(state)}
); }; ================================================ FILE: packages/hooks/src/useRafState/index.en-US.md ================================================ --- nav: path: /hooks --- # useRafState Update the state in [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) callback, generally used for performance optimization. ## Examples ### Default usage ### API Same as `React.useState`. ================================================ FILE: packages/hooks/src/useRafState/index.ts ================================================ import { useCallback, useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import useUnmount from '../useUnmount'; function useRafState(initialState: S | (() => S)): [S, Dispatch>]; function useRafState(): [S | undefined, Dispatch>]; function useRafState(initialState?: S | (() => S)) { const ref = useRef(0); const [state, setState] = useState(initialState); const setRafState = useCallback((value: S | ((prevState: S) => S)) => { cancelAnimationFrame(ref.current); ref.current = requestAnimationFrame(() => { setState(value as any); }); }, []); useUnmount(() => { cancelAnimationFrame(ref.current); }); return [state, setRafState] as const; } export default useRafState; ================================================ FILE: packages/hooks/src/useRafState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useRafState 只在 [requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) callback 时更新 state,一般用于性能优化。 ## 代码演示 ### 基础用法 ### API 与 `React.useState` 一致 ================================================ FILE: packages/hooks/src/useRafTimeout/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import useRafTimeout from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay: number | undefined; } const setUp = ({ fn, delay }: ParamsObj) => renderHook(() => useRafTimeout(fn, delay)); const FRAME_TIME = 16.7; describe('useRafTimeout', () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.restoreAllMocks(); }); test('timeout should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 2.5); expect(callback).toHaveBeenCalledTimes(1); }); test('timeout should stop when delay is undefined', () => { const delay: number | undefined = undefined; const callback = vi.fn(); setUp({ fn: callback, delay }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).not.toBeCalled(); }); test('timeout should be clear', () => { const callback = vi.fn(); const hook = setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); hook.result.current(); vi.advanceTimersByTime(FRAME_TIME * 2.5); expect(callback).toHaveBeenCalledTimes(0); }); }); ================================================ FILE: packages/hooks/src/useRafTimeout/__tests__/node.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import useRafTimeout from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay: number | undefined; } const setUp = ({ fn, delay }: ParamsObj) => renderHook(() => useRafTimeout(fn, delay)); const FRAME_TIME = 16.7; describe('useRafTimeout', () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { vi.restoreAllMocks(); }); test('should downgrade to setTimeout when requstAnimationFrame is undefined', () => { const _requestAnimationFrame = global.requestAnimationFrame; const _cancelAnimationFrame = global.cancelAnimationFrame; // @ts-ignore delete global.requestAnimationFrame; // @ts-ignore delete global.cancelAnimationFrame; const callback = vi.fn(); setUp({ fn: callback, delay: FRAME_TIME }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(FRAME_TIME * 1.5); expect(callback).toHaveBeenCalledTimes(1); global.requestAnimationFrame = _requestAnimationFrame; global.cancelAnimationFrame = _cancelAnimationFrame; }); }); ================================================ FILE: packages/hooks/src/useRafTimeout/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Execute after 2000ms. * * title.zh-CN: 基础用法 * desc.zh-CN: 在 2000ms 后执行。 */ import { useState } from 'react'; import { useRafTimeout } from 'ahooks'; export default () => { const [count, setCount] = useState(0); useRafTimeout(() => { setCount(count + 1); }, 2000); return
count: {count}
; }; ================================================ FILE: packages/hooks/src/useRafTimeout/demo/demo2.tsx ================================================ /** * title: Advanced usage * desc: Modify the delay to realize the timer timeout change and pause. * * title.zh-CN: 进阶使用 * desc.zh-CN: 动态修改 delay 以实现定时器间隔变化与暂停。 */ import { useState } from 'react'; import { useRafTimeout } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [delay, setDelay] = useState(1000); const clear = useRafTimeout(() => { setCount(count + 1); }, delay); return (

count: {count}

Delay: {delay}

); }; ================================================ FILE: packages/hooks/src/useRafTimeout/index.en-US.md ================================================ --- nav: path: /hooks --- # useRafTimeout A hook implements with `requestAnimationFrame` for better performance. The API is consistent with `useTimeout`. the advantage is that will not trigger function when the page is not rendering, such as page hiding or minimization. > `requestAnimationFrame` will automatically downgrade to `setTimeout` in node environment ## Examples ### Default usage ### Advanced usage ## API ```typescript useRafTimeout( fn: () => void, delay?: number | undefined, ): fn: () => void; ``` ### Params | Property | Description | Type | | -------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- | | fn | The function to be executed after `delay` milliseconds. | `() => void` | | delay | The number of milliseconds to wait before executing the function. The timer will be cancelled if delay is `undefined`. | `number` \| `undefined` | ### Result | Property | Description | Type | | ------------ | ------------- | ------------ | | clearTimeout | clear timeout | `() => void` | ================================================ FILE: packages/hooks/src/useRafTimeout/index.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import useLatest from '../useLatest'; import { isNumber } from '../utils'; interface Handle { id: ReturnType | ReturnType; } const setRafTimeout = (callback: () => void, delay: number = 0): Handle => { if (typeof requestAnimationFrame === 'undefined') { return { id: setTimeout(callback, delay), }; } const handle: Handle = { id: 0, }; const startTime = Date.now(); const loop = () => { const current = Date.now(); if (current - startTime >= delay) { callback(); } else { handle.id = requestAnimationFrame(loop); } }; handle.id = requestAnimationFrame(loop); return handle; }; const cancelAnimationFrameIsNotDefined = (t: any): t is ReturnType => { return typeof cancelAnimationFrame === 'undefined'; }; const clearRafTimeout = (handle: Handle) => { if (cancelAnimationFrameIsNotDefined(handle.id)) { return clearTimeout(handle.id); } cancelAnimationFrame(handle.id); }; function useRafTimeout(fn: () => void, delay: number | undefined) { const fnRef = useLatest(fn); const timerRef = useRef(undefined); const clear = useCallback(() => { if (timerRef.current) { clearRafTimeout(timerRef.current); } }, []); useEffect(() => { if (!isNumber(delay) || delay < 0) { return; } timerRef.current = setRafTimeout(() => { fnRef.current(); }, delay); return clear; }, [delay]); return clear; } export default useRafTimeout; ================================================ FILE: packages/hooks/src/useRafTimeout/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useRafTimeout 用 `requestAnimationFrame` 模拟实现 `setTimeout`,API 和 `useTimeout` 保持一致,好处是可以在页面不渲染的时候不触发函数执行,比如页面隐藏或最小化等。 > Node 环境下 `requestAnimationFrame` 会自动降级到 `setTimeout` ## 代码演示 ### 基础用法 ### 进阶使用 ## API ```typescript useRafTimeout( fn: () => void, delay?: number | undefined, ): fn: () => void; ``` ### Params | 参数 | 说明 | 类型 | | ----- | -------------------------------------------------------------------------- | ----------------------- | | fn | 待执行函数 | `() => void` | | delay | 定时时间(单位为毫秒),支持动态变化,,当取值为 `undefined` 时会停止计时器 | `number` \| `undefined` | ### Result | 参数 | 说明 | 类型 | | ------------ | ---------- | ------------ | | clearTimeout | 清除定时器 | `() => void` | ================================================ FILE: packages/hooks/src/useReactive/__tests__/index.spec.tsx ================================================ import { act, fireEvent, render, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useReactive from '../'; const Demo = () => { const state: { count: number; val: any; foo?: string; arr: number[]; } = useReactive({ count: 0, val: { val1: { val2: '', }, }, arr: [1], foo: 'foo', }); return (

counter state.count:{state.count}

delete property:{state.foo}



state.arr: {JSON.stringify(state.arr)}



nested structure

{state.val.val1.val2}

{ state.val.val1.val2 = e.target.value; }} />
); }; describe('test useReactive feature', () => { test('test count', () => { const wrap = render(); const count = wrap.getByRole('addCount'); const addCountBtn = wrap.getByRole('addCountBtn'); const subCountBtn = wrap.getByRole('subCountBtn'); act(() => { fireEvent.click(addCountBtn); }); expect(count.textContent).toBe('1'); act(() => { fireEvent.click(addCountBtn); fireEvent.click(addCountBtn); }); expect(count.textContent).toBe('3'); act(() => { fireEvent.click(subCountBtn); }); expect(count.textContent).toBe('2'); act(() => { fireEvent.click(subCountBtn); fireEvent.click(subCountBtn); fireEvent.click(subCountBtn); fireEvent.click(subCountBtn); fireEvent.click(subCountBtn); }); expect(count.textContent).toBe('-3'); }); test('test array', () => { const wrap = render(); const testArray = wrap.getAllByRole('test-array')[0]; const pushbtn = wrap.getAllByRole('pushbtn')[0]; const popbtn = wrap.getAllByRole('popbtn')[0]; const shiftbtn = wrap.getAllByRole('shiftbtn')[0]; const unshiftbtn = wrap.getAllByRole('unshiftbtn')[0]; act(() => { fireEvent.click(pushbtn); }); expect(JSON.parse(testArray.textContent as any).length).toBe(2); act(() => { fireEvent.click(popbtn); }); expect(JSON.parse(testArray.textContent as any).length).toBe(1); act(() => { fireEvent.click(unshiftbtn); }); expect(JSON.parse(testArray.textContent as any).length).toBe(2); act(() => { fireEvent.click(shiftbtn); }); expect(JSON.parse(testArray.textContent as any).length).toBe(1); }); test('test special objects', () => { const { result } = renderHook(() => { // Almost all of the built-in objects are tested. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects return useReactive({ a: new Function('return 1;'), b: new Boolean(true), c: Symbol.for('a'), d: new Error('a'), e: new Number(1), f: BigInt(1), g: Math, h: new Date(), i: new String('a'), j1: new RegExp(/a/), j2: /a/, k: new Array(1), l: new Map(), m: new Set(), n: new ArrayBuffer(1), o: new DataView(new ArrayBuffer(1)), p: Atomics, q: JSON, r: new Promise((resolve) => resolve(1)), s: Reflect, t: new Proxy({}, {}), u: Intl, v: WebAssembly, }); }); expect(() => result.current.a.name).not.toThrowError(); expect(() => result.current.b.valueOf()).not.toThrowError(); expect(() => result.current.c.valueOf()).not.toThrowError(); expect(() => result.current.d.message).not.toThrowError(); expect(() => result.current.e.valueOf()).not.toThrowError(); expect(() => result.current.f.valueOf()).not.toThrowError(); expect(() => result.current.g.PI).not.toThrowError(); expect(() => result.current.h.getFullYear()).not.toThrowError(); expect(() => result.current.i.valueOf()).not.toThrowError(); expect(() => result.current.j1.test('a')).not.toThrowError(); expect(() => result.current.j2.test('a')).not.toThrowError(); expect(() => result.current.k.length).not.toThrowError(); expect(() => result.current.l.size).not.toThrowError(); expect(() => result.current.m.size).not.toThrowError(); expect(() => result.current.n.byteLength).not.toThrowError(); expect(() => result.current.o.byteLength).not.toThrowError(); expect(() => result.current.p.isLockFree(1)).not.toThrowError(); expect(() => result.current.q.stringify(1)).not.toThrowError(); expect(() => result.current.r.then()).not.toThrowError(); expect(() => result.current.s.ownKeys({})).not.toThrowError(); expect(() => result.current.t.toString()).not.toThrowError(); expect(() => result.current.u.DateTimeFormat()).not.toThrowError(); expect(() => result.current.v.Module).not.toThrowError(); }); test('test JSX element', () => { const hook = renderHook(() => useReactive({ html:
foo
})); const proxy = hook.result.current; const wrap = render(proxy.html); const html = wrap.getByRole('id'); expect(html.textContent).toBe('foo'); act(() => { proxy.html =
bar
; wrap.rerender(proxy.html); }); expect(html.textContent).toBe('bar'); hook.unmount(); }); test('test read-only and non-configurable data property', () => { const obj = {} as { user: { name: string } }; Reflect.defineProperty(obj, 'user', { value: { name: 'foo' }, writable: false, configurable: false, }); const hook = renderHook(() => useReactive(obj)); const proxy = hook.result.current; expect(() => proxy.user.name).not.toThrowError(); hook.unmount(); }); test('test input1', () => { const wrap = render(); const input = wrap.getAllByRole('input1')[0]; const inputVal = wrap.getAllByRole('inputVal1')[0]; act(() => { fireEvent.change(input, { target: { value: 'a' } }); }); expect(inputVal.textContent).toBe('a'); act(() => { fireEvent.change(input, { target: { value: 'bbb' } }); }); expect(inputVal.textContent).toBe('bbb'); }); test('delete object property', () => { const wrap = render(); const deleteProperty = wrap.getAllByRole('deleteProperty')[0]; const deletePropertyBtn = wrap.getAllByRole('deletePropertyBtn')[0]; expect(deleteProperty.textContent).toBe('foo'); act(() => { fireEvent.click(deletePropertyBtn); }); expect(deleteProperty.textContent).toBe(''); }); test('access from self to prototype chain', () => { const parent: Record = { name: 'parent', get value() { return this.name; }, }; const child: Record = { name: 'child', }; const { result } = renderHook(() => useReactive(parent)); const proxy = result.current; Object.setPrototypeOf(child, proxy); expect(child.value).toBe('child'); expect(proxy.value).toBe('parent'); expect(parent.value).toBe('parent'); act(() => delete child.name); expect(child.value).toBe('parent'); expect(proxy.value).toBe('parent'); expect(parent.value).toBe('parent'); act(() => delete proxy.name); expect(child.value).toBeUndefined(); expect(proxy.value).toBeUndefined(); expect(parent.value).toBeUndefined(); }); }); ================================================ FILE: packages/hooks/src/useReactive/demo/demo1.tsx ================================================ import { useReactive } from 'ahooks'; export default () => { const state = useReactive({ count: 0, inputVal: '', obj: { value: '', }, }); return (

state.count:{state.count}

state.inputVal: {state.inputVal}

(state.inputVal = e.target.value)} />

state.obj.value: {state.obj.value}

(state.obj.value = e.target.value)} />
); }; ================================================ FILE: packages/hooks/src/useReactive/demo/demo2.tsx ================================================ import { useReactive } from 'ahooks'; export default () => { const state = useReactive<{ arr: number[] }>({ arr: [], }); return (

state.arr: {JSON.stringify(state.arr)}

); }; ================================================ FILE: packages/hooks/src/useReactive/demo/demo3.tsx ================================================ import { useReactive } from 'ahooks'; export default () => { const state = useReactive({ bug: '', bugs: ['feat', 'fix', 'chore'], addBug(bug: any) { this.bugs.push(bug); }, get bugsCount() { return this.bugs.length; }, }); return (

state.bugsCount: {state.bugsCount}

{ state.addBug(state.bug); state.bug = ''; e.preventDefault(); }} > (state.bug = e.target.value)} />
    {state.bugs.map((bug) => (
  • {bug}
  • ))}
); }; ================================================ FILE: packages/hooks/src/useReactive/demo/demo4.tsx ================================================ /** * desc: useReactive returns a proxy object which always has the same reference. If `useEffect`, `useMemo`, `useCallback` and props passed to child component rely on the proxy, none of the above will be invoked by any changes to the proxy. * * desc.zh-CN: useReactive 产生可操作的代理对象一直都是同一个引用,`useEffect` , `useMemo` ,`useCallback` ,`子组件属性传递` 等如果依赖的是这个代理对象是**不会**引起重新执行。 */ import { useEffect, useState } from 'react'; import { useReactive } from 'ahooks'; export default () => { const state = useReactive({ count: 0 }); const [stateCount, setStateCount] = useState(0); const state2 = useReactive({ count: 0 }); const [stateCount2, setStateCount2] = useState(0); // Depends on the object, because it is always the same reference, it will not be executed useEffect(() => { setStateCount(stateCount + 1); }, [state]); // Depends on the underlying data type, so as long as it changes, it will be re-executed useEffect(() => { setStateCount2(stateCount2 + 1); }, [state2.count]); return (

stateCount:{stateCount}

stateCount2:{stateCount2}

); }; ================================================ FILE: packages/hooks/src/useReactive/demo/index.tsx ================================================ import { useReactive } from 'ahooks'; export default () => { const state = useReactive({ count: 0, val: { val1: { val2: '', }, }, arr: [1], }); return (

counter state.count:{state.count}



state.arr: {JSON.stringify(state.arr)}



nested structure

{state.val.val1.val2}

{ state.val.val1.val2 = e.target.value; }} />
); }; ================================================ FILE: packages/hooks/src/useReactive/index.en-US.md ================================================ --- nav: path: /hooks --- ## useReactive It offers data reactivity when manipulating states and views, in which case `useState` is unnecessary for state definition. Modifying properties will automatically lead to view rerendering. ## Examples ### Default Usage ### Array ### Computed Properties ### Notice ## API ```js const state = useReactive(initialValue: Record); ``` ## Params | Params | Description | Type | Default | | ------------ | ------------- | --------------------- | ------- | | initialState | Current state | `Record` | - | ## FAQ ### When `useReactive` is used with `Map`, `Set`, it will throw an error or not work? `useReactive` is not compatible with `Map`, `Set`。 Related issues: [#2239](https://github.com/alibaba/hooks/discussions/2239) ================================================ FILE: packages/hooks/src/useReactive/index.ts ================================================ import { useRef } from 'react'; import isPlainObject from 'lodash/isPlainObject'; import useCreation from '../useCreation'; import useUpdate from '../useUpdate'; // k:v 原对象:代理过的对象 const proxyMap = new WeakMap(); // k:v 代理过的对象:原对象 const rawMap = new WeakMap(); function observer>(initialVal: T, cb: () => void): T { const existingProxy = proxyMap.get(initialVal); // 添加缓存 防止重新构建proxy if (existingProxy) { return existingProxy; } // 防止代理已经代理过的对象 // https://github.com/alibaba/hooks/issues/839 if (rawMap.has(initialVal)) { return initialVal; } const proxy = new Proxy(initialVal, { get(target, key, receiver) { const res = Reflect.get(target, key, receiver); // https://github.com/alibaba/hooks/issues/1317 const descriptor = Reflect.getOwnPropertyDescriptor(target, key); if (!descriptor?.configurable && !descriptor?.writable) { return res; } // Only proxy plain object or array, // otherwise it will cause: https://github.com/alibaba/hooks/issues/2080 return isPlainObject(res) || Array.isArray(res) ? observer(res, cb) : res; }, set(target, key, val) { const ret = Reflect.set(target, key, val); cb(); return ret; }, deleteProperty(target, key) { const ret = Reflect.deleteProperty(target, key); cb(); return ret; }, }); proxyMap.set(initialVal, proxy); rawMap.set(proxy, initialVal); return proxy; } function useReactive>(initialState: S): S { const update = useUpdate(); const stateRef = useRef(initialState); const state = useCreation(() => { return observer(stateRef.current, () => { update(); }); }, []); return state; } export default useReactive; ================================================ FILE: packages/hooks/src/useReactive/index.zh-CN.md ================================================ --- nav: path: /hooks --- ## useReactive 提供一种数据响应式的操作体验,定义数据状态不需要写`useState`,直接修改属性即可刷新视图。 ## 代码演示 ### 基本用法 ### 数组操作 ### 计算属性 ### 注意 ## API ```js const state = useReactive(initialState: Record); ``` ## 参数 | 参数 | 说明 | 类型 | 默认值 | | ------------ | -------------- | --------------------- | ------ | | initialState | 当前的数据对象 | `Record` | - | ## FAQ ### `useReactive` 和 `Map`、`Set` 一起使用时报错或无效? `useReactive` 目前不兼容 `Map`、`Set`。 相关 issues:[#2239](https://github.com/alibaba/hooks/discussions/2239) ================================================ FILE: packages/hooks/src/useRequest/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); describe('useRequest', () => { beforeAll(() => { vi.useFakeTimers(); }); afterAll(() => { errorSpy.mockRestore(); }); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; test('useRequest should auto run', async () => { let value = ''; let success: string | undefined; const successCallback = (data: string) => { success = data; }; const errorCallback = vi.fn(); const beforeCallback = () => { value = 'before'; }; const finallyCallback = () => { value = 'finally'; }; // auto run success act(() => { hook = setUp(request, { onSuccess: successCallback, onError: errorCallback, onBefore: beforeCallback, onFinally: finallyCallback, }); }); expect(hook.result.current.loading).toBe(true); expect(value).toBe('before'); expect(success).toBeUndefined(); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(success).toBe('success'); expect(hook.result.current.data).toBe('success'); expect(value).toBe('finally'); expect(errorCallback).toHaveBeenCalledTimes(0); //manual run fail act(() => { hook.result.current.run(0); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.error).toEqual(new Error('fail')); expect(hook.result.current.loading).toBe(false); expect(errorCallback).toHaveBeenCalledTimes(1); //manual run success act(() => { hook.result.current.run(1); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.data).toBe('success'); expect(hook.result.current.loading).toBe(false); expect(errorCallback).toHaveBeenCalledTimes(1); hook.unmount(); //auto run fail act(() => { hook = setUp(() => request(0), { onSuccess: successCallback, onError: errorCallback, }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.error).toEqual(new Error('fail')); expect(hook.result.current.loading).toBe(false); expect(errorCallback).toHaveBeenCalledTimes(2); hook.unmount(); }); test('useRequest should be manually triggered', async () => { act(() => { hook = setUp(request, { manual: true, }); }); expect(hook.result.current.loading).toBe(false); act(() => { hook.result.current.run(1); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.data).toBe('success'); act(() => { hook.result.current.run(0); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.error).toEqual(new Error('fail')); hook.unmount(); }); test('useRequest runAsync should work', async () => { let success = '', error = ''; act(() => { hook = setUp(request, { manual: true, }); }); act(() => { hook.result.current .runAsync(0) .then((res: any) => { success = res; }) .catch((err: any) => { error = err; }); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(success).toBe(''); expect(error).toEqual(new Error('fail')); success = ''; error = ''; act(() => { hook.result.current .runAsync(1) .then((res: any) => { success = res; }) .catch((err: any) => { error = err; }); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(success).toBe('success'); expect(error).toBe(''); hook.unmount(); }); test('useRequest mutate should work', async () => { act(() => { hook = setUp(request, {}); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.data).toBe('success'); act(() => { hook.result.current.mutate('hello'); }); expect(hook.result.current.data).toBe('hello'); hook.unmount(); }); test('runAsync should resolve immediately when ready=false', async () => { // manual = true act(() => { hook = setUp(request, { manual: true, ready: false, }); }); expect(hook.result.current.loading).toBe(false); let resolved = false; let value: any = 'init'; await act(async () => { hook.result.current.runAsync(1).then((res: any) => { resolved = true; value = res; }); await Promise.resolve(); }); expect(resolved).toBe(true); expect(value).toBeUndefined(); expect(hook.result.current.loading).toBe(false); hook.unmount(); // manual = false act(() => { hook = setUp(request, { ready: false, }); }); expect(hook.result.current.loading).toBe(false); resolved = false; value = 'init'; await act(async () => { hook.result.current.runAsync(1).then((res: any) => { resolved = true; value = res; }); await Promise.resolve(); }); expect(resolved).toBe(true); expect(value).toBeUndefined(); expect(hook.result.current.loading).toBe(false); hook.unmount(); }); test('useRequest defaultParams should work', async () => { act(() => { hook = setUp(request, { defaultParams: [1, 2, 3], }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.params).toEqual([1, 2, 3]); expect(hook.result.current.data).toBe('success'); expect(hook.result.current.loading).toBe(false); hook.unmount(); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useAutoRunPlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useAutoRunPlugin', () => { vi.useFakeTimers(); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; test('useAutoRunPlugin ready should work', async () => { let dep = 1; act(() => { hook = setUp(request, { refreshDeps: [dep], }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); dep = 2; hook.rerender({ refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin manual=false ready=true work fine', async () => { act(() => { hook = setUp(request, { ready: true, }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: false, }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: true, }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin manual=false ready=false work fine', async () => { act(() => { hook = setUp(request, { ready: false, }); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: true, }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: false, }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin manual=false ready&defaultParams work fine', async () => { act(() => { hook = setUp(request, { ready: false, defaultParams: [1], }); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: true, defaultParams: [2], }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.params).toEqual([2]); hook.rerender({ ready: false, defaultParams: [2], }); hook.rerender({ ready: true, defaultParams: [3], }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.params).toEqual([3]); }); test('useAutoRunPlugin manual=true ready work fine', async () => { act(() => { hook = setUp(request, { ready: false, manual: true, }); }); expect(hook.result.current.loading).toBe(false); act(() => { hook.result.current.run(); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: true, manual: true, }); expect(hook.result.current.loading).toBe(false); act(() => { hook.result.current.run(); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin manual=false refreshDeps should work', async () => { let dep = 1; act(() => { hook = setUp(request, { refreshDeps: [dep], }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); dep = 2; hook.rerender({ refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin manual=true refreshDeps should work', async () => { let dep = 1; act(() => { hook = setUp(request, { manual: true, refreshDeps: [dep], }); }); expect(hook.result.current.loading).toBe(false); dep = 2; hook.rerender({ manual: true, refreshDeps: [dep], }); expect(hook.result.current.loading).toBe(false); }); test('useAutoRunPlugin refreshDepsAction should work', async () => { let dep = 1; let count = 0; const refreshDepsAction = () => { count += 1; }; act(() => { hook = setUp(request, { refreshDeps: [dep], refreshDepsAction, }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); dep = 2; hook.rerender({ refreshDeps: [dep], refreshDepsAction, }); expect(hook.result.current.loading).toBe(false); expect(count).toBe(1); hook.rerender({ refreshDeps: [dep], refreshDepsAction, }); expect(hook.result.current.loading).toBe(false); expect(count).toBe(1); dep = 3; hook.rerender({ refreshDeps: [dep], refreshDepsAction, }); expect(hook.result.current.loading).toBe(false); expect(count).toBe(2); }); test('useAutoRunPlugin ready & refreshDeps change same time work fine', async () => { const fn = vi.fn(); const asyncFn = () => { return new Promise((resolve) => { fn(); return resolve('success'); }); }; act(() => { hook = setUp(asyncFn, { ready: false, defaultParams: [1], refreshDeps: [1], }); }); expect(hook.result.current.loading).toBe(false); hook.rerender({ ready: true, defaultParams: [2], refreshDeps: [2], }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.params).toEqual([2]); expect(fn).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useCachePlugin.spec.tsx ================================================ import { act, render, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest, { clearCache } from '../index'; describe('useCachePlugin', () => { vi.useFakeTimers(); const setup = ( service: Parameters[0], options: Parameters[1], ) => renderHook(() => useRequest(service, options)); const testCacheKey = async (options: any) => { const hook = setup(request, options); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.data).toBe('success'); hook.unmount(); }; test('useRequest cacheKey should work', async () => { await testCacheKey({ cacheKey: 'testCacheKey', }); vi.advanceTimersByTime(100); const hook2 = setup(request, { cacheKey: 'testCacheKey', }); expect(hook2.result.current.loading).toBe(true); expect(hook2.result.current.data).toBe('success'); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook2.result.current.loading).toBe(false); }); test('useRequest staleTime should work', async () => { await testCacheKey({ cacheKey: 'testStaleTime', staleTime: 3000, }); vi.advanceTimersByTime(1000); const hook2 = setup(request, { cacheKey: 'testStaleTime', staleTime: 3000, }); expect(hook2.result.current.loading).toBe(false); expect(hook2.result.current.data).toBe('success'); hook2.unmount(); vi.advanceTimersByTime(3001); const hook3 = setup(request, { cacheKey: 'testStaleTime', staleTime: 3000, }); expect(hook3.result.current.loading).toBe(true); expect(hook3.result.current.data).toBe('success'); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook3.result.current.loading).toBe(false); }); test('useRequest cacheTime should work', async () => { await testCacheKey({ cacheKey: 'testCacheTime', cacheTime: 5000, }); vi.advanceTimersByTime(1000); const hook2 = setup(request, { cacheKey: 'testCacheTime', cacheTime: 5000, }); expect(hook2.result.current.loading).toBe(true); expect(hook2.result.current.data).toBe('success'); hook2.unmount(); vi.advanceTimersByTime(5001); const hook3 = setup(request, { cacheKey: 'testCacheTime', cacheTime: 5000, }); expect(hook3.result.current.loading).toBe(true); expect(hook3.result.current.data).toBeUndefined(); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook3.result.current.loading).toBe(false); expect(hook3.result.current.data).toBe('success'); }); test('clearCache should work', async () => { await testCacheKey('testClearCache'); clearCache('testClearCache'); const hook2 = setup(request, { cacheKey: 'testClearCache', }); expect(hook2.result.current.loading).toBe(true); expect(hook2.result.current.data).toBeUndefined(); }); test('setCache/getCache should work', async () => { const cacheKey = `setCacheKey`; await testCacheKey({ cacheKey, setCache: (data: JSON) => localStorage.setItem(cacheKey, JSON.stringify(data)), getCache: () => JSON.parse(localStorage.getItem(cacheKey) || '{}'), }); vi.advanceTimersByTime(1000); const hook2 = setup(request, { cacheKey, setCache: (data) => localStorage.setItem(cacheKey, JSON.stringify(data)), getCache: () => JSON.parse(localStorage.getItem(cacheKey) || '{}'), }); expect(hook2.result.current.loading).toBe(true); expect(hook2.result.current.data).toBe('success'); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook2.result.current.loading).toBe(false); }); test('cache should work when change data immediately', async () => { const { result } = setup(request, { cacheKey: 'mutateCacheKey', }); act(() => { result.current.mutate(1); }); expect(result.current.data).toBe(1); await act(async () => { vi.advanceTimersByTime(1000); }); expect(result.current.loading).toBe(false); expect(result.current.data).toBe('success'); }); //github.com/alibaba/hooks/issues/1859 test('error should reset with activeKey', async () => { const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); let res = {} as any; const TestComponent = () => { const [key, setKey] = useState(1); const { data, error } = useRequest(() => request(key), { refreshDeps: [key], cacheKey: String(key), staleTime: 300000, }); res = { data, error, setKey, }; return null; }; render(); await act(async () => { vi.advanceTimersByTime(1000); }); expect(res.error).toBeUndefined(); act(() => res.setKey(0)); await act(async () => { vi.advanceTimersByTime(1000); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(errSpy).toBeCalled(); expect(res.error).not.toBeUndefined(); act(() => res.setKey(1)); await act(async () => { vi.advanceTimersByTime(1000); }); expect(res.error).toBeUndefined(); errSpy.mockRestore(); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useDebouncePlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useDebouncePlugin', () => { const setUp = ( service: Parameters[0], options: Parameters[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; test('useDebouncePlugin should work', () => { vi.useFakeTimers(); const callback = vi.fn(); act(() => { hook = setUp( () => { callback(); return request({}); }, { manual: true, debounceWait: 100, }, ); }); act(() => { hook.result.current.run(1); vi.advanceTimersByTime(50); hook.result.current.run(2); vi.advanceTimersByTime(50); hook.result.current.run(3); vi.advanceTimersByTime(50); hook.result.current.run(4); }); act(() => { vi.runAllTimers(); }); expect(callback).toHaveBeenCalledTimes(1); act(() => { hook.result.current.run(1); vi.advanceTimersByTime(50); hook.result.current.run(2); vi.advanceTimersByTime(50); hook.result.current.run(3); vi.advanceTimersByTime(50); hook.result.current.run(4); }); act(() => { vi.runAllTimers(); }); expect(callback).toHaveBeenCalledTimes(2); act(() => { hook.result.current.run(1); vi.advanceTimersByTime(50); hook.result.current.run(2); vi.advanceTimersByTime(50); hook.result.current.cancel(); }); act(() => { vi.runAllTimers(); }); expect(callback).toHaveBeenCalledTimes(2); hook.unmount(); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useLoadingDelayPlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useLoadingDelayPlugin', () => { vi.useFakeTimers(); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult, any>; afterEach(() => { hook.unmount(); }); test('useLoadingDelayPlugin should work', async () => { act(() => { hook = setUp(request, { loadingDelay: 2000, }); }); expect(hook.result.current.loading).toBe(false); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); act(() => { hook = setUp(request, { loadingDelay: 500, }); }); expect(hook.result.current.loading).toBe(false); act(() => { vi.advanceTimersByTime(501); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); }); test('useLoadingDelayPlugin should no update loading when ready is false', async () => { act(() => { hook = setUp(request, { loadingDelay: 2000, ready: false, }); }); expect(hook.result.current.loading).toBe(false); act(() => { vi.advanceTimersByTime(3000); }); expect(hook.result.current.loading).toBe(false); }); test('useLoadingDelayPlugin should update loading when ready is undefined', async () => { act(() => { hook = setUp(request, { loadingDelay: 2000, }); }); expect(hook.result.current.loading).toBe(false); act(() => { vi.advanceTimersByTime(3000); }); expect(hook.result.current.loading).toBe(true); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/usePollingPlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('usePollingPlugin', () => { vi.useFakeTimers(); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; test('usePollingPlugin pollingInterval=100 pollingWhenHidden=true should work', async () => { const callback = vi.fn(); act(() => { hook = setUp( () => { callback(); return request(1); }, { pollingInterval: 100, pollingWhenHidden: true, }, ); }); expect(hook.result.current.loading).toBe(true); // 第一次请求完成 await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook.result.current.loading).toBe(false); expect(hook.result.current.data).toBe('success'); expect(callback).toHaveBeenCalledTimes(1); // 第一次 polling (100ms 间隔 + 1000ms 执行) await act(async () => { vi.advanceTimersByTime(1100); }); expect(callback).toHaveBeenCalledTimes(2); // 第二次 polling await act(async () => { vi.advanceTimersByTime(1100); }); expect(callback).toHaveBeenCalledTimes(3); act(() => { hook.result.current.cancel(); }); // 取消后不应该再 polling act(() => { vi.advanceTimersByTime(1100); }); expect(callback).toHaveBeenCalledTimes(3); // 手动重新运行 act(() => { hook.result.current.run(); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(callback).toHaveBeenCalledTimes(4); // 恢复 polling await act(async () => { vi.advanceTimersByTime(1100); }); expect(callback).toHaveBeenCalledTimes(5); }); let hook2: RenderHookResult; test('usePollingPlugin pollingErrorRetryCount=3 should work', async () => { // if request error and set pollingErrorRetryCount // and the number of consecutive failures exceeds pollingErrorRetryCount, polling stops let errorCallback: any; act(() => { errorCallback = vi.fn(); hook2 = setUp(() => request(0), { pollingErrorRetryCount: 3, pollingInterval: 100, pollingWhenHidden: true, onError: errorCallback, }); }); expect(hook2.result.current.loading).toBe(true); expect(errorCallback).toHaveBeenCalledTimes(0); // 第一次请求失败 await act(async () => { vi.advanceTimersByTime(1000); }); expect(hook2.result.current.loading).toBe(false); expect(errorCallback).toHaveBeenCalledTimes(1); // 第一次重试失败 (100ms 间隔 + 1000ms 执行) await act(async () => { vi.advanceTimersByTime(1100); }); expect(errorCallback).toHaveBeenCalledTimes(2); // 第二次重试失败 await act(async () => { vi.advanceTimersByTime(1100); }); expect(errorCallback).toHaveBeenCalledTimes(3); // 第三次重试失败 await act(async () => { vi.advanceTimersByTime(1100); }); expect(errorCallback).toHaveBeenCalledTimes(4); // 达到重试限制,停止 polling act(() => { vi.advanceTimersByTime(1100); }); expect(errorCallback).toHaveBeenCalledTimes(4); // 手动重新运行 act(() => { hook2.result.current.run(); }); await act(async () => { vi.advanceTimersByTime(1000); }); expect(errorCallback).toHaveBeenCalledTimes(5); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useRefreshOnWindowFocusPlugin.spec.ts ================================================ import { act, fireEvent, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useRefreshOnWindowFocusPlugin', () => { vi.useFakeTimers(); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; let hook1: RenderHookResult; let hook2: RenderHookResult; test('useRefreshOnWindowFocusPlugin should work', async () => { act(() => { hook = setUp(request, { refreshOnWindowFocus: true, focusTimespan: 5000, }); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1001); }); expect(hook.result.current.loading).toBe(false); act(() => { fireEvent.focus(window); }); expect(hook.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(2000); }); expect(hook.result.current.loading).toBe(false); act(() => { vi.advanceTimersByTime(3000); fireEvent.focus(window); }); expect(hook.result.current.loading).toBe(true); }); test('fix: multiple unsubscriptions should not delete the last subscription listener ', async () => { act(() => { hook1 = setUp(request, { refreshOnWindowFocus: true, }); hook2 = setUp(request, { refreshOnWindowFocus: true, }); }); expect(hook1.result.current.loading).toBe(true); expect(hook2.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(1001); }); expect(hook1.result.current.loading).toBe(false); expect(hook2.result.current.loading).toBe(false); act(() => { fireEvent.focus(window); }); expect(hook1.result.current.loading).toBe(true); expect(hook2.result.current.loading).toBe(true); await act(async () => { vi.advanceTimersByTime(2000); }); expect(hook1.result.current.loading).toBe(false); expect(hook2.result.current.loading).toBe(false); hook1.unmount(); act(() => { vi.advanceTimersByTime(3000); fireEvent.focus(window); }); expect(hook1.result.current.loading).toBe(false); // hook2 should not unsubscribe expect(hook2.result.current.loading).toBe(true); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useRetryPlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useRetryPlugin', () => { vi.useFakeTimers(); const setUp = ( service: (...args: TParams) => Promise, options?: Parameters>[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; let hook2: RenderHookResult; test('useRetryPlugin should work', async () => { let errorCallback: any; act(() => { errorCallback = vi.fn(); hook = setUp(() => request(0), { retryCount: 3, onError: errorCallback, }); }); act(() => { vi.advanceTimersByTime(500); }); expect(errorCallback).toHaveBeenCalledTimes(0); // 第一次执行失败 (1000ms) await act(async () => { vi.advanceTimersByTime(1000); }); expect(errorCallback).toHaveBeenCalledTimes(1); // 第一次重试失败 (等待 2000ms 后重试 + 1000ms 执行) await act(async () => { vi.advanceTimersByTime(3000); }); expect(errorCallback).toHaveBeenCalledTimes(2); // 第二次重试失败 (等待 4000ms 后重试 + 1000ms 执行) await act(async () => { vi.advanceTimersByTime(5000); }); expect(errorCallback).toHaveBeenCalledTimes(3); // 第三次重试失败 (等待 8000ms 后重试 + 1000ms 执行) await act(async () => { vi.advanceTimersByTime(9000); }); expect(errorCallback).toHaveBeenCalledTimes(4); // 达到重试次数限制,不再重试 act(() => { vi.advanceTimersByTime(10000); }); expect(errorCallback).toHaveBeenCalledTimes(4); hook.unmount(); // cancel should work act(() => { errorCallback = vi.fn(); hook2 = setUp(() => request(0), { retryCount: 3, onError: errorCallback, }); }); expect(errorCallback).toHaveBeenCalledTimes(0); // 第一次执行失败 await act(async () => { vi.advanceTimersByTime(1000); }); expect(errorCallback).toHaveBeenCalledTimes(1); // 第一次重试失败 await act(async () => { vi.advanceTimersByTime(3000); }); expect(errorCallback).toHaveBeenCalledTimes(2); // 取消重试 act(() => { hook2.result.current.cancel(); }); act(() => { vi.advanceTimersByTime(10000); }); expect(errorCallback).toHaveBeenCalledTimes(2); hook2.unmount(); }); }); ================================================ FILE: packages/hooks/src/useRequest/__tests__/useThrottlePlugin.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { request } from '../../utils/testingHelpers'; import useRequest from '../index'; describe('useThrottlePlugin', () => { vi.useFakeTimers(); const setUp = ( service: Parameters[0], options: Parameters[1], ) => renderHook((o) => useRequest(service, o || options)); let hook: RenderHookResult; test('useThrottlePlugin should work', () => { const callback = vi.fn(); act(() => { hook = setUp( () => { callback(); return request({}); }, { manual: true, throttleWait: 100, }, ); }); act(() => { hook.result.current.run(1); vi.advanceTimersByTime(50); hook.result.current.run(2); vi.advanceTimersByTime(50); hook.result.current.run(3); vi.advanceTimersByTime(50); hook.result.current.run(4); vi.advanceTimersByTime(40); }); expect(callback).toHaveBeenCalledTimes(2); }); }); ================================================ FILE: packages/hooks/src/useRequest/doc/basic/basic.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Basic usage In this section, we will introduce the core and basic functionalities of `useRequest`, that is, the functionalities of the `useRequest` kernel. ## Default request By default, the first parameter of `useRequest` is an asynchronous function, which is automatically executed when the component is initialized. At the same time, it automatically manages the status of `loading`, `data`, `error` of the asynchronous function. ```js const { data, error, loading } = useRequest(service); ```
## Manually trigger If `options.manual = true` is set, `useRequest` will not be executed by default, and the execution needs to be triggered by `run` or `runAsync`. ```tsx | pure const { loading, run, runAsync } = useRequest(service, { manual: true }); ``` The difference between `run` and `runAsync` is: - `run` is a normal synchronous function, we will automatically catch the exception, you can use `options.onError` to handle the behavior of the exception. - `runAsync` is a asynchronous function that returns a `Promise`. If you use `runAsync` to call it, it means you need to catch the exception yourself. ```ts runAsync().then((data) => { console.log(data); }).catch((error) => { console.log(error); }) ``` Next, we will demonstrate the difference between `run` and `runAsync` through the simple scenario of editing the username. ## The life cycle `useRequest` provides the following life cycle for you to do some processing in different stages of asynchronous functions. - `onBefore`: Triggered before the request - `onSuccess`: Triggered when the request is resolved - `onError`: Triggered when the request is rejected - `onFinally`: Triggered when the request is completed ## Refresh (repeat the last request) `useRequest` provides the `refresh` and `refreshAsync` methods so that we can use the last parameters to re-run the request. If in the scenario of reading user information 1. We read the user information with ID 1 `run(1)` 2. We updated user information by some ways 3. We want to re-initiate the last request, then we can use `refresh` instead of `run(1)`, which is very useful in scenarios with complex parameters Of course, the difference between `refresh` and `refreshAsync` is the same as `run` and `runAsync`. ## Change data immediately `useRequest` provides `mutate`, which can immediate modify the `data`. The usage of `mutate` is consistent with `React.setState`, supports: `mutate(newData)` and `mutate((oldData) => newData)`. In the following example, we demonstrate a scenario of `mutate`. We have modified the user name, but we do not want to wait for the request to be successful before giving feedback to the user. Instead, modify the data directly, then call the modify request in background, and provide additional feedback after the request returns. ## Cancel response `useRequest` provides a `cancel` function, which will **ignore** the data and error returned by the current promise **Note: Calling `cancel` doesn't cancel the execution of promise** At the same time, `useRequest` will automatically ignore the response at the following timing: - When the component is unmounting, the ongoing promise - Race cancellation, when the previous promise has not returned, if the next promise is initiated, the previous promise will be ignored ## Parameter management The `params` returned by `useRequest` will record the parameters of `service`. For example, if you trigger `run(1, 2, 3)`, then `params` is equal to `[1, 2, 3]`. If we set `options.manual = false`, the parameters of calling `service` for the first time can be set by `options.defaultParams`. ## API ```ts const { loading: boolean, data?: TData, error?: Error, params: TParams || [], run: (...params: TParams) => void, runAsync: (...params: TParams) => Promise, refresh: () => void, refreshAsync: () => Promise, mutate: (data?: TData | ((oldData?: TData) => (TData | undefined))) => void, cancel: () => void, } = useRequest( service: (...args: TParams) => Promise, { manual?: boolean, defaultParams?: TParams, onBefore?: (params: TParams) => void, onSuccess?: (data: TData, params: TParams) => void, onError?: (e: Error, params: TParams) => void, onFinally?: (params: TParams, data?: TData, e?: Error) => void, } ); ``` ### Result | Property | Description | Type | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | data | Data returned by service | `TData` \| `undefined` | | error | Exception thrown by service | `Error` \| `undefined` | | loading | Is the service being executed | `boolean` | | params | An array of parameters for the service being executed. For example, you triggered `run(1, 2, 3)`, then params is equal to `[1, 2, 3]` | `TParams` \| `[]` | | run |
  • Manually trigger the execution of the service, and the parameters will be passed to the service
  • Automatic handling of exceptions, feedback through `onError`
| `(...params : TParams) => void` | | runAsync | The usage is the same as `run`, but it returns a Promise, so you need to handle the exception yourself. | `(...params: TParams) => Promise` | | refresh | Use the last params, call `run` again | `() => void` | | refreshAsync | Use the last params, call `runAsync` again | `() => Promise` | | mutate | Mutate `data` directly | `(data?: TData / ((oldData?: TData) => (TData / undefined))) => void` | | cancel | Ignore the current promise response | `() => void` | ### Options | Property | Description | Type | Default | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------- | | manual |
  • The default is `false`. That is, the service is automatically executed during initialization.
  • If set to `true`, you need to manually call `run` or `runAsync` to trigger execution.
| `boolean` | `false` | | defaultParams | The parameters passed to the service at the first default execution | `TParams` | - | | onBefore | Triggered before service execution | `(params: TParams) => void` | - | | onSuccess | Triggered when service resolve | `(data: TData, params: TParams) => void` | - | | onError | Triggered when service reject | `(e: Error, params: TParams) => void` | - | | onFinally | Triggered when service execution is complete | `(params: TParams, data?: TData, e?: Error) => void` | - | Above we have introduced the most basic functionalities of useRequest, and then we will introduce some more advanced functionalities. ================================================ FILE: packages/hooks/src/useRequest/doc/basic/basic.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 基础用法 这一小节我们会介绍 `useRequest` 最核心,最基础的能力,也就是 `useRequest` 内核的能力。 ## 默认请求 默认情况下,`useRequest` 第一个参数是一个异步函数,在组件初始化时,会自动执行该异步函数。同时自动管理该异步函数的 `loading` , `data` , `error` 等状态。 ```js const { data, error, loading } = useRequest(service); ```
## 手动触发 如果设置了 `options.manual = true`,则 `useRequest` 不会默认执行,需要通过 `run` 或者 `runAsync` 来触发执行。 ```tsx | pure const { loading, run, runAsync } = useRequest(service, { manual: true }); ``` `run` 与 `runAsync` 的区别在于: - `run` 是一个普通的同步函数,我们会自动捕获异常,你可以通过 `options.onError` 来处理异常时的行为。 - `runAsync` 是一个返回 `Promise` 的异步函数,如果使用 `runAsync` 来调用,则意味着你需要自己捕获异常。 ```ts runAsync().then((data) => { console.log(data); }).catch((error) => { console.log(error); }) ``` 接下来我们通过修改用户名这个简单的场景,来演示 useRequest 手动触发模式,以及 `run` 与 `runAsync` 的区别。 ## 生命周期 `useRequest` 提供了以下几个生命周期配置项,供你在异步函数的不同阶段做一些处理。 - `onBefore`:请求之前触发 - `onSuccess`:请求成功触发 - `onError`:请求失败触发 - `onFinally`:请求完成触发 ## 刷新(重复上一次请求) `useRequest` 提供了 `refresh` 和 `refreshAsync` 方法,使我们可以使用上一次的参数,重新发起请求。 假如在读取用户信息的场景中 1. 我们读取了 ID 为 1 的用户信息 `run(1)` 2. 我们通过某种手段更新了用户信息 3. 我们想重新发起上一次的请求,那我们就可以使用 `refresh` 来代替 `run(1)`,这在复杂参数的场景中是非常有用的 当然 `refresh` 和 `refreshAsync` 的区别和 `run` 和 `runAsync` 是一致的。 ## 立即变更数据 `useRequest` 提供了 `mutate`, 支持立即修改 `useRequest` 返回的 `data` 参数。 `mutate` 的用法与 `React.setState` 一致,支持 `mutate(newData)` 和 `mutate((oldData) => newData)` 两种写法。 下面的示例,我们演示了一种 `mutate` 的应用场景。 我们修改了用户名,但是我们不希望等编辑接口调用成功之后,才给用户反馈。而是直接修改页面数据,同时在背后去调用修改接口,等修改接口返回之后,另外提供反馈。 ## 取消响应 `useRequest` 提供了 `cancel` 函数,用于**忽略**当前 promise 返回的数据和错误 **注意:调用 `cancel` 函数并不会取消 promise 的执行** 同时 `useRequest` 会在以下时机自动忽略响应: - 组件卸载时,正在进行的 promise - 竞态取消,当上一次 promise 还没返回时,又发起了下一次 promise,则会忽略上一次 promise 的响应 ## 参数管理 `useRequest` 返回的 `params` 会记录当次调用 `service` 的参数数组。比如你触发了 `run(1, 2, 3)`,则 `params` 等于 `[1, 2, 3]` 。 如果我们设置了 `options.manual = false`,则首次调用 `service` 的参数可以通过 `options.defaultParams` 来设置。 ## API ```ts const { loading: boolean, data?: TData, error?: Error, params: TParams || [], run: (...params: TParams) => void, runAsync: (...params: TParams) => Promise, refresh: () => void, refreshAsync: () => Promise, mutate: (data?: TData | ((oldData?: TData) => (TData | undefined))) => void, cancel: () => void, } = useRequest( service: (...args: TParams) => Promise, { manual?: boolean, defaultParams?: TParams, onBefore?: (params: TParams) => void, onSuccess?: (data: TData, params: TParams) => void, onError?: (e: Error, params: TParams) => void, onFinally?: (params: TParams, data?: TData, e?: Error) => void, } ); ``` ### Result | 参数 | 说明 | 类型 | | ------------ | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | data | service 返回的数据 | `TData` \| `undefined` | | error | service 抛出的异常 | `Error` \| `undefined` | | loading | service 是否正在执行 | `boolean` | | params | 当次执行的 service 的参数数组。比如你触发了 `run(1, 2, 3)`,则 params 等于 `[1, 2, 3]` | `TParams` \| `[]` | | run |
  • 手动触发 service 执行,参数会传递给 service
  • 异常自动处理,通过 `onError` 反馈
| `(...params: TParams) => void` | | runAsync | 与 `run` 用法一致,但返回的是 Promise,需要自行处理异常。 | `(...params: TParams) => Promise` | | refresh | 使用上一次的 params,重新调用 `run` | `() => void` | | refreshAsync | 使用上一次的 params,重新调用 `runAsync` | `() => Promise` | | mutate | 直接修改 `data` | `(data?: TData / ((oldData?: TData) => (TData / undefined))) => void` | | cancel | 忽略当前 Promise 的响应 | `() => void` | ### Options | 参数 | 说明 | 类型 | 默认值 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ------- | | manual |
  • 默认 `false`。 即在初始化时自动执行 service。
  • 如果设置为 `true`,则需要手动调用 `run` 或 `runAsync` 触发执行。
| `boolean` | `false` | | defaultParams | 首次默认执行时,传递给 service 的参数 | `TParams` | - | | onBefore | service 执行前触发 | `(params: TParams) => void` | - | | onSuccess | service resolve 时触发 | `(data: TData, params: TParams) => void` | - | | onError | service reject 时触发 | `(e: Error, params: TParams) => void` | - | | onFinally | service 执行完成时触发 | `(params: TParams, data?: TData, e?: Error) => void` | - | 以上我们介绍了 useRequest 最基础的功能,接下来我们介绍一些更高级的能力。 ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/cancel.tsx ================================================ import { message } from 'antd'; import { useState } from 'react'; import { useRequest } from 'ahooks'; function editUsername(username: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(); } else { reject(new Error('Failed to modify username')); } }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, run, cancel } = useRequest(editUsername, { manual: true, onSuccess: (result, params) => { setState(''); message.success(`The username was changed to "${params[0]}" !`); }, onError: (error) => { message.error(error.message); }, }); return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/default.tsx ================================================ /** * title: Read username * * title.zh-CN: 读取用户名称 */ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; function getUsername(): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(Mock.mock('@name')); } else { reject(new Error('Failed to get username')); } }, 1000); }); } export default () => { const { data, error, loading } = useRequest(getUsername); if (error) { return
{error.message}
; } if (loading) { return
loading...
; } return
Username: {data}
; }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/lifeCycle.tsx ================================================ import { message } from 'antd'; import { useState } from 'react'; import { useRequest } from 'ahooks'; function editUsername(username: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(); } else { reject(new Error('Failed to modify username')); } }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, run } = useRequest(editUsername, { manual: true, onBefore: (params) => { message.info(`Start Request: ${params[0]}`); }, onSuccess: (result, params) => { setState(''); message.success(`The username was changed to "${params[0]}" !`); }, onError: (error) => { message.error(error.message); }, onFinally: (params, result, error) => { message.info(`Request finish`); }, }); return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/manual-run.tsx ================================================ /** * title: Edit username * desc: In this example, we use `run(username)` to edit the username, and use `onSuccess` and `onError` to handle success and failure. * * title.zh-CN: 修改用户名 * desc.zh-CN: 在这个例子中,我们通过 `run(username)` 来修改用户名,通过 `onSuccess` 和 `onError` 来处理成功和失败。 */ import { message } from 'antd'; import { useState } from 'react'; import { useRequest } from 'ahooks'; function editUsername(username: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(); } else { reject(new Error('Failed to modify username')); } }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, run } = useRequest(editUsername, { manual: true, onSuccess: (result, params) => { setState(''); message.success(`The username was changed to "${params[0]}" !`); }, onError: (error) => { message.error(error.message); }, }); return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/manual-runAsync.tsx ================================================ /** * title: Edit username * desc: In this example, we use `runAsync(username)` to edit the user name. At this time, we must catch the exception through catch. * * title.zh-CN: 修改用户名 * desc.zh-CN: 在这个例子中,我们通过 `runAsync(username)` 来修改用户名,此时必须通过 catch 来自行处理异常。 */ import { message } from 'antd'; import { useState } from 'react'; import { useRequest } from 'ahooks'; function editUsername(username: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(); } else { reject(new Error('Failed to modify username')); } }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, runAsync } = useRequest(editUsername, { manual: true, }); const onClick = async () => { try { await runAsync(state); setState(''); message.success(`The username was changed to "${state}" !`); } catch (error) { message.error((error as Error).message); } }; return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/mutate.tsx ================================================ /** * title: Edit username * * title.zh-CN: 修改用户名 */ import { message } from 'antd'; import { useState, useRef } from 'react'; import { useRequest } from 'ahooks'; import Mock from 'mockjs'; function getUsername(): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } function editUsername(username: string): Promise { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve(); } else { reject(new Error('Failed to modify username')); } }, 1000); }); } export default () => { // store last username const lastRef = useRef(undefined); const [state, setState] = useState(''); // get username const { data: username, mutate } = useRequest(getUsername); // edit username const { run: edit } = useRequest(editUsername, { manual: true, onSuccess: (result, params) => { setState(''); message.success(`The username was changed to "${params[0]}" !`); }, onError: (error) => { message.error(error.message); mutate(lastRef.current); }, }); const onChange = () => { lastRef.current = username || undefined; mutate(state); edit(state); }; return (

Username: {username}

setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/params.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; import { useState } from 'react'; function getUsername(id: string): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const [state, setState] = useState(''); // get username const { data: username, run, params, } = useRequest(getUsername, { defaultParams: ['1'], }); const onChange = () => { run(state); }; return (
setState(e.target.value)} value={state} placeholder="Please enter userId" style={{ width: 240, marginRight: 16 }} />

UserId: {params[0]}

Username: {username}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/basic/demo/refresh.tsx ================================================ /** * title: Refresh username * * title.zh-CN: 刷新用户名称 */ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; import { useEffect } from 'react'; function getUsername(id: number): Promise { console.log('use-request-refresh-id', id); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const { data, loading, run, refresh } = useRequest((id: number) => getUsername(id), { manual: true, }); useEffect(() => { run(1); }, []); if (loading) { return
loading...
; } return (

Username: {data}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/cache.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Cache & SWR If `options.cacheKey` is set, `useRequest` will cache the successful data . The next time the component is initialized, if there is cached data, we will return the cached data first, and then send a new request in background, which is the ability of SWR. You can set the data retention time through `options.staleTime`. During this time, we consider the data to be fresh and will not re-initiate the request. You can also set the data cache time through `options.cacheTime`, after this time, we will clear the cached data. Next, through a few examples to experience these features. ### SWR In the following example, we set the `cacheKey`. When the component is loaded for the second time, the cached content will be returned first, and then the request will be re-run in background. You can experience the effect by clicking the button. ### Keep your data fresh By setting `staleTime`, we can specify the data retention time, during which time the request will not be re-run. The following example sets a fresh time of 5s, you can experience the effect by clicking the button ### Data sharing > Note: If no new request is issued, the "Data sharing" will not be triggered. `cacheTime` and `staleTime` parameters will invalidate "Data sharing". [#2313](https://github.com/alibaba/hooks/issues/2313) The content of the same `cacheKey` is shared globally, which will bring the following features: - Sharing request `Promise`: Only one of the same `cacheKey` will initiate a request at the same time, and the subsequent ones will share the same request `Promise`. - Data synchronization: When a request is made by one `cacheKey`, the contents of other identical `cacheKey` will be synchronized accordingly. In the following example, the two components will only initiate one request during initialization. And the content of the two articles is always synchronized. ### Parameters cache The cached data includes `data` and `params`. Through the `params` caching mechanism, we can remember the conditions of the last request and initialize it next time. In the following example, we can initialize the `keyword` from the cached `params` ### Clear cache ahooks provides a `clearCache` method, which can clear the cache data of the specified `cacheKey`. ### Custom cache By setting `setCache` and `getCache`, you can customize the cache, for example, you can store data in `localStorage`, `IndexDB`, etc. Please note: 1. `setCache` and `getCache` need to be used together. 2. In the custom cache mode, `cacheTime` and `clearCache` will be unused, please implement it yourself according to the actual situation. ## API ```ts interface CachedData { data: TData; params: TParams; time: number; } ``` ### Options | Property | Description | Type | Default | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------- | | cacheKey | A unique ID of the request. Data of the same `cacheKey` will synchronized globally (`cacheTime` and `staleTime` parameters will invalidate this mechanism, see demo: [Data sharing](#data-sharing)) | `string` | - | | cacheTime |
  • Set the cache time. By default, the cached data will be cleared after 5 minutes.
  • If set to `-1`, the cached data will never expire
| `number` | `300000` | | staleTime |
  • Time to consider the cached data is fresh. Within this time interval, the request will not be re-initiated
  • If set to `-1`, it means that the data is always fresh
| `number` | `0` | | setCache |
  • Custom set cache
  • `setCache` and `getCache` need to be used together
  • In the custom cache mode, `cacheTime` and `clearCache` are useless, please implement it yourself according to the actual situation.
| `(data: CachedData) => void;` | - | | getCache | Custom get cache | `(params: TParams) => CachedData` | - | ### clearCache ```tsx | pure import { clearCache } from 'ahooks'; clearCache(cacheKey?: string | string[]); ``` 1. Support clearing a single cache, or a group of caches 2. If `cacheKey` is empty, all cached data will be cleared ## Remark - Only successful request data will be cached - Cached data includes `data` and `params` ================================================ FILE: packages/hooks/src/useRequest/doc/cache/cache.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 缓存 & SWR 如果设置了 `options.cacheKey`,`useRequest` 会将当前请求成功的数据缓存起来。下次组件初始化时,如果有缓存数据,我们会优先返回缓存数据,然后在背后发送新请求,也就是 SWR 的能力。 你可以通过 `options.staleTime` 设置数据保持新鲜时间,在该时间内,我们认为数据是新鲜的,不会重新发起请求。 你也可以通过 `options.cacheTime` 设置数据缓存时间,超过该时间,我们会清空该条缓存数据。 接下来通过几个例子来体验缓存这些功能。 ### SWR 下面的示例,我们设置了 `cacheKey`,在组件第二次加载时,会优先返回缓存的内容,然后在背后重新发起请求。你可以通过点击按钮来体验效果。 ### 数据保持新鲜 通过设置 `staleTime`,我们可以指定数据新鲜时间,在这个时间内,不会重新发起请求。下面的示例设置了 5s 的新鲜时间,你可以通过点击按钮来体验效果 ### 数据共享 > 注意:如果没有发起新请求,不会触发数据共享。`cacheTime`、`staleTime` 参数会使数据共享失效。[#2313](https://github.com/alibaba/hooks/issues/2313) 同一个 `cacheKey` 的内容,在全局是共享的,这会带来以下几个特性: - 请求 `Promise` 共享:相同的 `cacheKey` 同时只会有一个在发起请求,后发起的会共用同一个请求 `Promise` - 数据同步:当某个 `cacheKey` 发起请求时,其它相同 `cacheKey` 的内容均会随之同步 下面的示例中,初始化时,两个组件只会发起一个请求。并且两篇文章的内容永远是同步的。 ### 参数缓存 缓存的数据包括 `data` 和 `params`,通过 `params` 缓存机制,我们可以记忆上一次请求的条件,并在下次初始化。 下面的示例中,我们可以从缓存的 `params` 中初始化 `keyword` ### 删除缓存 ahooks 提供了一个 `clearCache` 方法,可以清除指定 `cacheKey` 的缓存数据。 ### 自定义缓存 通过配置 `setCache` 和 `getCache`,可以自定义数据缓存,比如可以将数据存储到 `localStorage`、`IndexDB` 等。 请注意: 1. `setCache` 和 `getCache` 需要配套使用。 2. 在自定义缓存模式下,`cacheTime` 和 `clearCache` 不会生效,请根据实际情况自行实现。 ## API ```ts interface CachedData { data: TData; params: TParams; time: number; } ``` ### Options | 参数 | 说明 | 类型 | 默认值 | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------- | | cacheKey | 请求的唯一标识。相同 `cacheKey` 的数据全局同步(`cacheTime`、`staleTime` 参数会使该机制失效,见示例:[数据共享](#数据共享))。 | `string` | - | | cacheTime |
  • 设置缓存数据回收时间。默认缓存数据 5 分钟后回收
  • 如果设置为 `-1`, 则表示缓存数据永不过期
| `number` | `300000` | | staleTime |
  • 缓存数据保持新鲜时间。在该时间间隔内,认为数据是新鲜的,不会重新发请求
  • 如果设置为 `-1`,则表示数据永远新鲜
| `number` | `0` | | setCache |
  • 自定义设置缓存
  • `setCache` 和 `getCache` 需要配套使用
  • 在自定义缓存模式下,`cacheTime` 和 `clearCache` 不会生效,请根据实际情况自行实现。
| `(data: CachedData) => void;` | - | | getCache | 自定义读取缓存 | `(params: TParams) => CachedData` | - | ### clearCache ```tsx | pure import { clearCache } from 'ahooks'; clearCache(cacheKey?: string | string[]); ``` 1. 支持清空单个缓存,或一组缓存 2. 如果 `cacheKey` 为空,则清空所有缓存数据 ## 备注 - 只有成功的请求数据才会缓存 - 缓存的数据包括 `data` 和 `params` ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/cacheKey.tsx ================================================ import { useBoolean } from 'ahooks'; import Mock from 'mockjs'; import React from 'react'; import { useRequest } from 'ahooks'; const getArticle = async () => { return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 1000); }); }; const Article: React.FC = () => { const { data, loading } = useRequest(getArticle, { cacheKey: 'cacheKey-demo', }); if (!data && loading) { return

Loading

; } return ( <>

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

{data?.data}

); }; export default () => { const [state, { toggle }] = useBoolean(); return (
{state &&
}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/clearCache.tsx ================================================ import Mock from 'mockjs'; import { useRequest, clearCache, useBoolean } from 'ahooks'; import { message } from 'antd'; const getArticle = async () => { return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 3000); }); }; const Article = ({ cacheKey }: { cacheKey: string }) => { const { data, loading } = useRequest(getArticle, { cacheKey, }); if (!data && loading) { return

Loading

; } return ( <>

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

{data?.data}

); }; const clear = (cacheKey?: string | string[]) => { clearCache(cacheKey); const tips = Array.isArray(cacheKey) ? cacheKey.join('、') : cacheKey; message.success(`Clear ${tips ?? 'All'} finished`); }; export default () => { const [state, { toggle }] = useBoolean(); return (

Article 1

{state &&
}

Article 2

{state &&
}

Article 3

{state &&
}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/params.tsx ================================================ import { useBoolean } from 'ahooks'; import useRequest from '../../../'; import Mock from 'mockjs'; import { useState } from 'react'; const getArticle = async (keyword: string) => { console.log('cacheKey', keyword); return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 1000); }); }; const Article = () => { const { data, params, loading, run } = useRequest(getArticle, { cacheKey: 'cacheKey-demo', }); const [keyword, setKeyword] = useState(params[0] || ''); if (!data && loading) { return

Loading

; } return ( <>
setKeyword(e.target.value)} />

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

Keyword: {keyword}

{data?.data}

); }; export default () => { const [state, { toggle }] = useBoolean(); return (
{state &&
}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/setCache.tsx ================================================ import { useBoolean } from 'ahooks'; import Mock from 'mockjs'; import React from 'react'; import { useRequest } from 'ahooks'; const getArticle = async () => { return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 1000); }); }; const cacheKey = 'setCache-demo'; const Article: React.FC = () => { const { data, loading } = useRequest(getArticle, { cacheKey, setCache: (value) => localStorage.setItem(cacheKey, JSON.stringify(value)), getCache: () => JSON.parse(localStorage.getItem(cacheKey) || '{}'), }); if (!data && loading) { return

Loading

; } return ( <>

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

{data?.data}

); }; export default () => { const [state, { toggle }] = useBoolean(); return (
{state &&
}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/share.tsx ================================================ import Mock from 'mockjs'; import { useRequest } from 'ahooks'; const getArticle = async () => { console.log('cacheKey-share'); return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 3000); }); }; const Article = () => { const { data, loading, refresh } = useRequest(getArticle, { cacheKey: 'cacheKey-share', }); if (!data && loading) { return

Loading

; } return ( <>

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

{data?.data}

); }; export default () => { return (

Article 1

Article 2

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/cache/demo/staleTime.tsx ================================================ import { useBoolean } from 'ahooks'; import Mock from 'mockjs'; import { useRequest } from 'ahooks'; const getArticle = async () => { console.log('cacheKey-staleTime'); return new Promise<{ data: string; time: number }>((resolve) => { setTimeout(() => { resolve({ data: Mock.mock('@paragraph'), time: Date.now(), }); }, 1000); }); }; const Article = () => { const { data, loading } = useRequest(getArticle, { cacheKey: 'staleTime-demo', staleTime: 5000, }); if (!data && loading) { return

Loading

; } return ( <>

Background loading: {loading ? 'true' : 'false'}

Latest request time: {data?.time}

{data?.data}

); }; export default () => { const [state, { toggle }] = useBoolean(); return (
{state &&
}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/debounce/debounce.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Debounce Enter the debounce mode by setting `options.debounceWait`. At this time, if `run` or `runAsync` is triggered frequently, the request will be executed with the debounce strategy. ```tsx | pure const { data, run } = useRequest(getUsername, { debounceWait: 300, manual: true }); ``` As in the example code above, if `run` is triggered frequently, it will only wait for 300ms to execute after the last trigger. You can quickly enter text in the input box below to experience the effect ## API ### Options The usage and effect of all debounce property are the same as [lodash.debounce](https://lodash.com/docs/4.17.15#debounce) | Property | Description | Type | Default Value | | ---------------- | ---------------------------------------------------------------------------- | --------- | ------------- | | debounceWait | Debounce delay time, in milliseconds. After setting, enter the debounce mode | `number` | - | | debounceLeading | Execute the request before the delay starts | `boolean` | `false` | | debounceTrailing | Execute the request after the delay ends | `boolean` | `true` | | debounceMaxWait | The maximum time request is allowed to be delayed before it’s executed | `number` | - | ## Remark - `options.debounceWait`, `options.debounceLeading`, `options.debounceTrailing`, `options.debounceMaxWait` support dynamic changes. - `runAsync` will return a `Promise` when it is actually executed. When it is not executed, there will be no return. - `cancel` can abort a function waiting to be executed. ================================================ FILE: packages/hooks/src/useRequest/doc/debounce/debounce.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 防抖 通过设置 `options.debounceWait`,进入防抖模式,此时如果频繁触发 `run` 或者 `runAsync`,则会以防抖策略进行请求。 ```tsx | pure const { data, run } = useRequest(getUsername, { debounceWait: 300, manual: true }); ``` 如上示例代码,频繁触发 `run`,只会在最后一次触发结束后等待 300ms 执行。 你可以在下面 input 框中快速输入文本,体验效果 ## API ### Options debounce 所有参数用法和效果同 [lodash.debounce](https://www.lodashjs.com/docs/lodash.debounce/) | 参数 | 说明 | 类型 | 默认值 | | ---------------- | ---------------------------------------------- | --------- | ------- | | debounceWait | 防抖等待时间, 单位为毫秒,设置后,进入防抖模式 | `number` | - | | debounceLeading | 在延迟开始前执行调用 | `boolean` | `false` | | debounceTrailing | 在延迟结束后执行调用 | `boolean` | `true` | | debounceMaxWait | 允许被延迟的最大值 | `number` | - | ## 备注 - `options.debounceWait`、`options.debounceLeading`、`options.debounceTrailing`、`options.debounceMaxWait` 支持动态变化。 - `runAsync` 在真正执行时,会返回 `Promise`。在未被执行时,不会有任何返回。 - `cancel` 可以中止正在等待执行的函数。 ================================================ FILE: packages/hooks/src/useRequest/doc/debounce/demo/debounce.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; async function getEmail(search?: string): Promise { console.log('debounce getEmail', search); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock({ 'data|5': ['@email'] }).data); }, 300); }); } export default () => { const { data, loading, run } = useRequest(getEmail, { debounceWait: 1000, manual: true, }); return (
run(e.target.value)} /> {loading ? (

loading

) : (
    {data?.map((i) => (
  • {i}
  • ))}
)}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/index/demo/default.tsx ================================================ /** * title: Read username * * title.zh-CN: 读取用户名称 */ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; function getUsername(): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const { data, error, loading } = useRequest(getUsername); if (error) { return
failed to load
; } if (loading) { return
loading...
; } return
Username: {data}
; }; ================================================ FILE: packages/hooks/src/useRequest/doc/index/demo/manual.tsx ================================================ /** * title: Edit username * desc: In this example, we modify the username. * * title.zh-CN: 修改用户名 * desc.zh-CN: 在这个例子中,我们尝试修改用户名。 */ import { message } from 'antd'; import { useState } from 'react'; import { useRequest } from 'ahooks'; // eslint-disable-next-line @typescript-eslint/no-unused-vars function changeUsername(username: string): Promise<{ success: boolean }> { return new Promise((resolve) => { setTimeout(() => { resolve({ success: true }); }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, run } = useRequest(changeUsername, { manual: true, onSuccess: (result, params) => { if (result.success) { setState(''); message.success(`The username was changed to "${params[0]}" !`); } }, }); return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/index/index.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Quick Start `useRequest` is a powerful Hooks for asynchronous data management. `useRequest` is sufficient enough for network request scenarios in React projects. `useRequest` organizes code through a plug-in pattern, the core code is extremely simple, and can be easily extended for more advanced features. Current features include: - Automatic/manual request - Polling - Debounce - Throttle - Refresh on window focus - Error retry - Loading delay - SWR(stale-while-revalidate) - Caching Next, let's get to know `useRequest` from the two simplest examples. ## Default usage The first parameter of `useRequest` is an asynchronous function, which will be automatically triggered when the component is first loaded. At the same time, it automatically manages the status of `loading`, `data`, `error` of the asynchronous function. ```js const { data, error, loading } = useRequest(getUsername); ```
## Manual trigger If `options.manual = true` is set, useRequest will not be executed by default, and the execution needs to be triggered by `run`. ```js const { loading, run } = useRequest(changeUsername, { manual: true }); ```
In the above two examples, we demonstrated the most basic usages of `useRequest`. Next, we will introduce the features of `useRequest` one by one. ================================================ FILE: packages/hooks/src/useRequest/doc/index/index.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 快速上手 `useRequest` 是一个强大的异步数据管理的 Hooks,React 项目中的网络请求场景使用 `useRequest` 就够了。 `useRequest` 通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括: - 自动请求/手动请求 - 轮询 - 防抖 - 节流 - 屏幕聚焦重新请求 - 错误重试 - loading delay - SWR(stale-while-revalidate) - 缓存 接下来让我们先从两个最简单的例子认识 `useRequest`。 ## 默认用法 `useRequest` 的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 `loading` , `data` , `error` 等状态。 ```js const { data, error, loading } = useRequest(getUsername); ```
## 手动触发 如果设置了 `options.manual = true`,则 useRequest 不会默认执行,需要通过 `run` 来触发执行。 ```js const { loading, run } = useRequest(changeUsername, { manual: true }); ```
上面两个例子,我们演示了 `useRequest` 最基础的用法,接下来的我们开始逐个详细介绍 `useRequest` 的特性。 ================================================ FILE: packages/hooks/src/useRequest/doc/loadingDelay/demo/loadingDelay.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; function getUsername(): Promise { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 200); }); } export default () => { const action = useRequest(getUsername); const withLoadingDelayAction = useRequest(getUsername, { loadingDelay: 300, }); const trigger = () => { action.run(); withLoadingDelayAction.run(); }; return (
Username: {action.loading ? 'Loading...' : action.data}
Username: {withLoadingDelayAction.loading ? 'Loading...' : withLoadingDelayAction.data}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/loadingDelay/loadingDelay.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Loading Delay By setting `options.loadingDelay`, you can delay the time when `loading` turns to `true`, effectively prevent UI flashing. ```tsx | pure const { loading, data } = useRequest(getUsername, { loadingDelay: 300 }); return
{ loading ? 'Loading...' : data }
``` For example, in the above scenario, if `getUsername` returns within 300ms, `loading` will not become `true`, avoiding the page displays `Loading...`. You can quickly click the button in the example below to experience the effect ## API | Property | Description | Type | Default | | ------------ | ------------------------------------------------- | -------- | ------- | | loadingDelay | Set the delay time for `loading` to become `true` | `number` | `0` | ## Remark `options.loadingDelay` supports dynamic changes. ================================================ FILE: packages/hooks/src/useRequest/doc/loadingDelay/loadingDelay.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Loading Delay 通过设置 `options.loadingDelay` ,可以延迟 `loading` 变成 `true` 的时间,有效防止闪烁。 ```tsx | pure const { loading, data } = useRequest(getUsername, { loadingDelay: 300 }); return
{ loading ? 'Loading...' : data }
``` 例如上面的场景,假如 `getUsername` 在 300ms 内返回,则 `loading` 不会变成 `true`,避免了页面展示 `Loading...` 的情况。 你可以快速点击下面示例中的按钮以体验效果 ## API | 参数 | 说明 | 类型 | 默认值 | | ------------ | ------------------------------------- | -------- | ------ | | loadingDelay | 设置 `loading` 变成 `true` 的延迟时间 | `number` | `0` | ## 备注 `options.loadingDelay` 支持动态变化。 ================================================ FILE: packages/hooks/src/useRequest/doc/polling/demo/polling.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; function getUsername() { console.log('polling getUsername'); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const { data, loading, run, cancel } = useRequest(getUsername, { pollingInterval: 1000, pollingWhenHidden: false, }); return ( <>

Username: {loading ? 'Loading' : String(data)}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/polling/demo/pollingError.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; import { message } from 'antd'; function getUsername() { console.log('polling getUsername Error'); return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error(Mock.mock('@name'))); }, 1000); }); } export default () => { const { data, loading, run, cancel } = useRequest(getUsername, { pollingInterval: 1000, pollingWhenHidden: false, pollingErrorRetryCount: 3, manual: true, onError: (error) => { message.error(error.message); }, }); return ( <>

Username: {loading ? 'Loading' : String(data)}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/polling/polling.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Polling By setting `options.pollingInterval`, enter the polling mode, `useRequest` will periodically trigger service execution. ```tsx | pure const { data, run, cancel } = useRequest(getUsername, { pollingInterval: 3000, }); ``` For example, in the above scenario, `getUsername` will be requested every 3000ms. You can stop polling by `cancel` and start polling by `run/runAsync`. You can experience the effect through the following example ## Polling error retry Polling by `options. PollingErrorRetryCount` configuration error retry count. ```tsx | pure const { data, run, cancel } = useRequest(getUsername, { pollingInterval: 3000, pollingErrorRetryCount: 3, }); ``` You can experience the effect through the following example. ## API ### Return | Property | Description | Type | | -------- | ------------- | ---------------------------------------- | | run | Start polling | `(...params: TParams) => void` | | runAsync | Start polling | `(...params: TParams) => Promise` | | cancel | Stop polling | `() => void` | ### Options | Property | Description | Type | Default | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------- | | pollingInterval | Polling interval, in milliseconds. If the value is greater than 0, the polling mode is activated. | `number` | `0` | | pollingWhenHidden | Whether to continue polling when the page is hidden. If set to false, polling will be temporarily paused when the page is hidden, and resume when the page is visible again. | `boolean` | `true` | | pollingErrorRetryCount | Number of polling error retries. If set to -1, an infinite number of times | `number` | `-1` | ## Remark - `options.pollingInterval`, `options.pollingWhenHidden` support dynamic changes. - If you set `options.manual = true`, the initialization will not start polling, you need start it by `run/runAsync`. - If the `pollingInterval` changes from 0 to a value greater than 0, polling will not start automatically, and you need start it by `run/runAsync`. - The polling logic is to wait for `pollingInterval` time after each request is completed, and then initiate the next request. ================================================ FILE: packages/hooks/src/useRequest/doc/polling/polling.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 轮询 通过设置 `options.pollingInterval`,进入轮询模式,`useRequest` 会定时触发 service 执行。 ```tsx | pure const { data, run, cancel } = useRequest(getUsername, { pollingInterval: 3000, }); ``` 例如上面的场景,会每隔 3000ms 请求一次 `getUsername`。同时你可以通过 `cancel` 来停止轮询,通过 `run/runAsync` 来启动轮询。 你可以通过下面的示例来体验效果 ## 轮询错误重试 通过 `options.pollingErrorRetryCount` 轮询错误重试次数。 ```tsx | pure const { data, run, cancel } = useRequest(getUsername, { pollingInterval: 3000, pollingErrorRetryCount: 3, }); ``` 你可以通过下面的示例来体验效果。 ## API ### Return | 参数 | 说明 | 类型 | | -------- | -------- | ---------------------------------------- | | run | 启动轮询 | `(...params: TParams) => void` | | runAsync | 启动轮询 | `(...params: TParams) => Promise` | | cancel | 停止轮询 | `() => void` | ### Options | 参数 | 说明 | 类型 | 默认值 | | ---------------------- | ------------------------------------------------------------------------------------------------------ | --------- | ------ | | pollingInterval | 轮询间隔,单位为毫秒。如果值大于 0,则处于轮询模式。 | `number` | `0` | | pollingWhenHidden | 在页面隐藏时,是否继续轮询。如果设置为 false,在页面隐藏时会暂时停止轮询,页面重新显示时继续上次轮询。 | `boolean` | `true` | | pollingErrorRetryCount | 轮询错误重试次数。如果设置为 -1,则无限次 | `number` | `-1` | ## 备注 - `options.pollingInterval`、`options.pollingWhenHidden` 支持动态变化。 - 如果设置 `options.manual = true`,则初始化不会启动轮询,需要通过 `run/runAsync` 触发开始。 - 如果设置 `pollingInterval` 由 `0` 变成 `大于 0` 的值,不会启动轮询,需要通过 `run/runAsync` 触发开始。 - 轮询原理是在每次请求完成后,等待 `pollingInterval` 时间,发起下一次请求。 ================================================ FILE: packages/hooks/src/useRequest/doc/ready/demo/manualReady.tsx ================================================ import { useRequest, useToggle } from 'ahooks'; import Mock from 'mockjs'; function getUsername() { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const [ready, { toggle }] = useToggle(false); const { data, loading, run } = useRequest(getUsername, { ready, manual: true, }); return ( <>

Ready: {JSON.stringify(ready)}

Username: {loading ? 'Loading' : String(data)}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/ready/demo/ready.tsx ================================================ import { useRequest, useToggle } from 'ahooks'; import Mock from 'mockjs'; function getUsername() { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const [ready, { toggle }] = useToggle(false); const { data, loading } = useRequest(getUsername, { ready, }); return ( <>

Ready: {JSON.stringify(ready)}

Username: {loading ? 'Loading' : String(data)}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/ready/ready.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Ready By setting `options.ready`, you can control whether a request is sent. When its value is `false`, the request will never be sent. The specific behavior is as follows: 1. In the automatic mode of `manual=false`, every time `ready` changes from `false` to `true`, a request will be automatically executed with the parameter `options.defaultParams`. 2. When `manual=true` manual request mode, as long as `ready=false`, the request triggered by `run/runAsync` will not be executed. ## Automatic mode The following example demonstrates the behavior of `ready` in automatic mode. Every time `ready` changes from `false` to `true`, the request will be executed. ## Manual mode The following example demonstrates the behavior of `ready` in manual mode. Only when `ready` is equal to `true`, `run` will be executed. ## API ### Options | Property | Description | Type | Default | | -------- | ---------------------------- | --------- | ------- | | ready | Is the current request ready | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useRequest/doc/ready/ready.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Ready 通过设置 `options.ready`,可以控制请求是否发出。当其值为 `false` 时,请求永远都不会发出。 其具体行为如下: 1. 当 `manual=false` 自动请求模式时,每次 `ready` 从 `false` 变为 `true` 时,都会自动发起请求,会带上参数 `options.defaultParams`。 2. 当 `manual=true` 手动请求模式时,只要 `ready=false`,则通过 `run/runAsync` 触发的请求都不会执行。 ## 自动模式 以下示例演示了自动模式下 `ready` 的行为。每次 `ready` 从 `false` 变为 `true` 时,都会重新发起请求。 ## 手动模式 以下示例演示了手动模式下 `ready` 的行为。只有当 `ready` 等于 `true` 时,`run` 才会执行。 ## API ### Options | 参数 | 说明 | 类型 | 默认值 | | ----- | -------------------- | --------- | ------ | | ready | 当前请求是否准备好了 | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useRequest/doc/refreshDeps/demo/refreshDeps.tsx ================================================ /** * title: Repeat last request * desc: When the dependency array changes, use the previous parameters to make the request again. * * title.zh-CN: 重复上一次请求 * desc.zh-CN: 依赖数组变化时,使用上一次的参数重新发起请求。 */ import { useState } from 'react'; import Mock from 'mockjs'; import { Space, Button } from 'antd'; import { useRequest } from 'ahooks'; function getUsername(id: number): Promise { console.log('getUsername id:', id); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const [userId, setUserId] = useState(); const { data, loading, run } = useRequest((id: number) => getUsername(id), { refreshDeps: [userId], }); return (

Username: {loading ? 'loading...' : data}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/refreshDeps/demo/refreshDepsAction.tsx ================================================ /** * title: Custom refresh * desc: This example shows that when the dependency array changes, it checks the parameters' validity first and then makes a new request. * * title.zh-CN: 自定义刷新行为 * desc.zh-CN: 该示例展示了当依赖数组变化时,首先校验参数合法性,然后发起新的请求。 */ import { useState } from 'react'; import Mock from 'mockjs'; import isNumber from 'lodash/isNumber'; import { Button, Space } from 'antd'; import { useRequest } from 'ahooks'; function getUsername(id: number): Promise { console.log('getUsername id:', id); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const [userId, setUserId] = useState(); const { data, loading, run } = useRequest((id: number) => getUsername(id), { refreshDeps: [userId], refreshDepsAction: () => { if (!isNumber(userId)) { console.log( `parameter "userId" expected to be a number, but got ${typeof userId}.`, userId, ); return; } run(userId); }, }); return (

Username: {loading ? 'loading...' : data}

); }; ================================================ FILE: packages/hooks/src/useRequest/doc/refreshDeps/refresyDeps.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # RefreshDeps By setting `options.refreshDeps`, `useRequest` will run [refresh](https://ahooks.js.org/hooks/use-request/basic/#result) automatically when dependencies change, achieving the effect of [Refresh (repeat the last request)](https://ahooks.js.org/hooks/use-request/basic/#refresh-repeat-the-last-request). ```tsx | pure const [userId, setUserId] = useState('1'); const { data, run } = useRequest(() => getUserSchool(userId), { refreshDeps: [userId], }); ``` In the example code above, `useRequest` will execution when it is initialized and `userId` changes. It is exactly the same with the following implementation ```tsx | pure const [userId, setUserId] = useState('1'); const { data, refresh } = useRequest(() => getUserSchool(userId)); useEffect(() => { refresh(); }, [userId]); ``` ### Repeat last request ### Custom refresh ## API ### Options | Property | Description | Type | Default | | ----------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------- | ------- | | refreshDeps | When the content of the array changes, trigger refresh. | `React.DependencyList` | `[]` | | refreshDepsAction | Customize the request behavior during dependency refresh; this parameter is invoked when dependencies change. | `() => void` | - | ## Remark - If you set `options.manual = true`, both `refreshDeps` and `refreshDepsAction` are no longer effective, you need to trigger the request by `run/runAsync`. ================================================ FILE: packages/hooks/src/useRequest/doc/refreshDeps/refresyDeps.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 依赖刷新 通过设置 `options.refreshDeps`,在依赖变化时, `useRequest` 会自动调用 [refresh](https://ahooks.js.org/zh-CN/hooks/use-request/basic/#result) 方法,实现[刷新(重复上一次请求)](https://ahooks.js.org/zh-CN/hooks/use-request/basic/#刷新重复上一次请求)的效果。 ```tsx | pure const [userId, setUserId] = useState('1'); const { data, run } = useRequest(() => getUserSchool(userId), { refreshDeps: [userId], }); ``` 上面的示例代码,`useRequest` 会在初始化和 `userId` 变化时,触发函数执行。 与下面代码实现功能完全一致 ```tsx | pure const [userId, setUserId] = useState('1'); const { data, refresh } = useRequest(() => getUserSchool(userId)); useEffect(() => { refresh(); }, [userId]); ``` ### 重复上一次请求 ### 自定义刷新行为 ## API ### Options | 参数 | 说明 | 类型 | 默认值 | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------ | | refreshDeps | 依赖数组。当数组内容变化后[刷新(重复上一次请求)](https://ahooks.js.org/zh-CN/hooks/use-request/basic/#刷新重复上一次请求)。同 `useEffect` 的第二个参数。 | `any[]` | `[]` | | refreshDepsAction | 自定义依赖数组变化时的请求行为。 | `() => void` | - | ## 备注 - 如果设置 `options.manual = true`,则 `refreshDeps`, `refreshDepsAction` 都不再生效,需要通过 `run/runAsync` 手动触发请求。 ================================================ FILE: packages/hooks/src/useRequest/doc/refreshOnWindowFocus/demo/refreshOnWindowFocus.tsx ================================================ import Mock from 'mockjs'; import { useRequest } from 'ahooks'; function getUsername() { return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock('@name')); }, 1000); }); } export default () => { const { data, loading } = useRequest(getUsername, { refreshOnWindowFocus: true, }); return
Username: {loading ? 'Loading' : String(data)}
; }; ================================================ FILE: packages/hooks/src/useRequest/doc/refreshOnWindowFocus/refreshOnWindowFocus.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # RefreshOnWindowFocus By setting `options.refreshOnWindowFocus`, the request will be refreshed when the browser is `refocus` and `revisible`. ```tsx | pure const { data } = useRequest(getUsername, { refreshOnWindowFocus: true, }); ``` You can click outside the browser, and then click the current page to experience the effect (or hide the current page and redisplay). If the interval from the previous request is greater than 5000ms, it will be requested again. ## API ### Options | Property | Description | Type | Default | | -------------------- | ------------------------------------------------------------------------ | --------- | ------- | | refreshOnWindowFocus | Whether to re-initiate the request when the screen refocus or revisible. | `boolean` | `false` | | focusTimespan | Re-request interval, in milliseconds | `number` | `5000` | ## Remark - `options.refreshOnWindowFocus`, `options.focusTimespan` support dynamic changes. - Listen for browser events `visibilitychange` and `focus`. ================================================ FILE: packages/hooks/src/useRequest/doc/refreshOnWindowFocus/refreshOnWindowFocus.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 屏幕聚焦重新请求 通过设置 `options.refreshOnWindowFocus`,在浏览器窗口 `refocus` 和 `revisible` 时,会重新发起请求。 ```tsx | pure const { data } = useRequest(getUsername, { refreshOnWindowFocus: true, }); ``` 你可以点击浏览器外部,再点击当前页面来体验效果(或者隐藏当前页面,重新展示),如果和上一次请求间隔大于 5000ms,则会重新请求一次。 ## API ### Options | 参数 | 说明 | 类型 | 默认值 | | -------------------- | -------------------------------------------- | --------- | ------- | | refreshOnWindowFocus | 在屏幕重新获取焦点或重新显示时,重新发起请求 | `boolean` | `false` | | focusTimespan | 重新请求间隔,单位为毫秒 | `number` | `5000` | ## 备注 - `options.refreshOnWindowFocus`、`options.focusTimespan` 支持动态变化。 - 监听的浏览器事件为 `visibilitychange` 和 `focus`。 ================================================ FILE: packages/hooks/src/useRequest/doc/retry/demo/retry.tsx ================================================ import { useRequest } from 'ahooks'; import { useState } from 'react'; import { message } from 'antd'; function editUsername(username: string) { return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('Failed to modify username')); }, 1000); }); } export default () => { const [state, setState] = useState(''); const { loading, run } = useRequest(editUsername, { retryCount: 3, manual: true, onError: (error) => { message.error(error.message); }, }); return (
setState(e.target.value)} value={state} placeholder="Please enter username" style={{ width: 240, marginRight: 16 }} />
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/retry/retry.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Error Retry By setting `options.retryCount`, set the number of error retries, useRequest will retry after it fails. ```tsx | pure const { data, run } = useRequest(getUsername, { retryCount: 3, }); ``` As in the example code above, after the request is failed, it will retry 3 times. You can type text in the input box below and click the Edit button to experience the effect ## API ### Options | Property | Description | Type | Default | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | | retryCount | The number of retries. If set to `-1`, it will try again indefinitely. | `number` | - | | retryInterval |
  • Retry interval in milliseconds.
  • If not set, the simple exponential backoff algorithm will be used by default, taking `1000 * 2 ** retryCount`, that is, waiting for 2s for the first retry, and 4s for the second retry. By analogy, if it is greater than 30s, take 30s
| `number` | - | ## Remark - `options.retryCount`, `options.retryInterval` support dynamic changes. - `cancel` can cancel the ongoing retry behavior. ================================================ FILE: packages/hooks/src/useRequest/doc/retry/retry.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 错误重试 通过设置 `options.retryCount`,指定错误重试次数,则 useRequest 在失败后会进行重试。 ```tsx | pure const { data, run } = useRequest(getUsername, { retryCount: 3, }); ``` 如上示例代码,在请求异常后,会做 3 次重试。 你可以在下面 input 框中输入文本,并点击 Edit 按钮,体验效果 ## API ### Options | 参数 | 说明 | 类型 | 默认值 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------ | | retryCount | 错误重试次数。如果设置为 `-1`,则无限次重试。 | `number` | - | | retryInterval |
  • 重试时间间隔,单位为毫秒。
  • 如果不设置,默认采用简易的指数退避算法,取 `1000 * 2 ** retryCount`,也就是第一次重试等待 2s,第二次重试等待 4s,以此类推,如果大于 30s,则取 30s
| `number` | - | ## 备注 - `options.retryCount`、`options.retryInterval` 支持动态变化。 - `cancel` 可以取消正在进行的重试行为。 ================================================ FILE: packages/hooks/src/useRequest/doc/throttle/demo/throttle.tsx ================================================ import { useRequest } from 'ahooks'; import Mock from 'mockjs'; async function getEmail(search?: string): Promise { console.log('throttle getEmail', search); return new Promise((resolve) => { setTimeout(() => { resolve(Mock.mock({ 'data|5': ['@email'] }).data); }, 300); }); } export default () => { const { data, loading, run } = useRequest(getEmail, { throttleWait: 1000, manual: true, }); return (
run(e.target.value)} /> {loading ? (

loading

) : (
    {data?.map((i) => (
  • {i}
  • ))}
)}
); }; ================================================ FILE: packages/hooks/src/useRequest/doc/throttle/throttle.en-US.md ================================================ --- nav: path: /hooks group: path: /use-request --- # Throttle Enter the throttle mode by setting `options.throttleWait`. At this time, if `run` or `runAsync` is triggered frequently, the request will be executed with the throttle strategy. ```tsx | pure const { data, run } = useRequest(getUsername, { throttleWait: 300, manual: true }); ``` As in the example code above, if `run` is triggered frequently, it will only be executed once every 300ms. You can quickly enter text in the input box below to experience the effect ## API ### Options The usage and effects of all throttle property are the same as [lodash.throttle](https://lodash.com/docs/4.17.15#throttle) | Property | Description | Type | Default Value | | ---------------- | --------------------------------------------------------------------------- | --------- | ------------- | | throttleWait | Throttle wait time, in milliseconds. After setting, enter the throttle mode | `number` | - | | throttleLeading | Execute the request before throttling starts | `boolean` | `true` | | throttleTrailing | Execute the request after throttling ends | `boolean` | `true` | ## Remark - `options.throttleWait`, `options.throttleLeading`, `options.throttleTrailing` support dynamic changes. - `runAsync` will return a `Promise` when it is actually executed. When it is not executed, there will be no return. - `cancel` can abort a function waiting to be executed. ================================================ FILE: packages/hooks/src/useRequest/doc/throttle/throttle.zh-CN.md ================================================ --- nav: path: /hooks group: path: /use-request --- # 节流 通过设置 `options.throttleWait`,进入节流模式,此时如果频繁触发 `run` 或者 `runAsync`,则会以节流策略进行请求。 ```tsx | pure const { data, run } = useRequest(getUsername, { throttleWait: 300, manual: true }); ``` 如上示例代码,频繁触发 `run`,只会每隔 300ms 执行一次。 你可以在下面 input 框中快速输入文本,体验效果 ## API ### Options throttle 所有参数用法和效果同 [lodash.throttle](https://www.lodashjs.com/docs/lodash.throttle/) | 参数 | 说明 | 类型 | 默认值 | | ---------------- | ---------------------------------------------- | --------- | ------ | | throttleWait | 节流等待时间, 单位为毫秒,设置后,进入节流模式 | `number` | - | | throttleLeading | 在节流开始前执行调用 | `boolean` | `true` | | throttleTrailing | 在节流结束后执行调用 | `boolean` | `true` | ## 备注 - `options.throttleWait`、`options.throttleLeading`、`options.throttleTrailing` 支持动态变化。 - `runAsync` 在真正执行时,会返回 `Promise`。在未被执行时,不会有任何返回。 - `cancel` 可以中止正在等待执行的函数。 ================================================ FILE: packages/hooks/src/useRequest/index.ts ================================================ import useRequest from './src/useRequest'; import { clearCache } from './src/utils/cache'; export { clearCache }; export default useRequest; ================================================ FILE: packages/hooks/src/useRequest/src/Fetch.ts ================================================ /* eslint-disable @typescript-eslint/no-parameter-properties */ import type { RefObject } from 'react'; import { isFunction } from '../../utils'; import type { FetchState, Options, PluginReturn, Service, Subscribe } from './types'; export default class Fetch { pluginImpls: PluginReturn[] = []; count: number = 0; state: FetchState = { loading: false, params: undefined, data: undefined, error: undefined, }; constructor( public serviceRef: RefObject>, public options: Options, public subscribe: Subscribe, public initState: Partial> = {}, ) { this.state = { ...this.state, loading: !options.manual, ...initState, }; } setState(s: Partial> = {}) { this.state = { ...this.state, ...s, }; this.subscribe(); } runPluginHandler(event: keyof PluginReturn, ...rest: any[]) { // @ts-ignore const r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean); return Object.assign({}, ...r); } async runAsync(...params: TParams): Promise { this.count += 1; const currentCount = this.count; const { stopNow = false, returnNow = false, ...state } = this.runPluginHandler('onBefore', params); // stop request if (stopNow) { return Promise.resolve(state.data); } this.setState({ loading: true, params, ...state, }); // return now if (returnNow) { return Promise.resolve(state.data); } this.options.onBefore?.(params); try { // replace service let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params); if (!servicePromise) { servicePromise = this.serviceRef.current(...params); } const res = await servicePromise; if (currentCount !== this.count) { // prevent run.then when request is canceled return new Promise(() => {}); } // const formattedResult = this.options.formatResultRef.current ? this.options.formatResultRef.current(res) : res; this.setState({ data: res, error: undefined, loading: false, }); this.options.onSuccess?.(res, params); this.runPluginHandler('onSuccess', res, params); this.options.onFinally?.(params, res, undefined); if (currentCount === this.count) { this.runPluginHandler('onFinally', params, res, undefined); } return res; } catch (error) { if (currentCount !== this.count) { // prevent run.then when request is canceled return new Promise(() => {}); } this.setState({ error: error as Error | undefined, loading: false, }); this.options.onError?.(error as Error, params); this.runPluginHandler('onError', error, params); this.options.onFinally?.(params, undefined, error as Error | undefined); if (currentCount === this.count) { this.runPluginHandler('onFinally', params, undefined, error); } throw error; } } run(...params: TParams) { this.runAsync(...params).catch((error) => { if (!this.options.onError) { console.error(error); } }); } cancel() { this.count += 1; this.setState({ loading: false, }); this.runPluginHandler('onCancel'); } refresh() { // @ts-ignore this.run(...(this.state.params || [])); } refreshAsync() { // @ts-ignore return this.runAsync(...(this.state.params || [])); } mutate(data?: TData | ((oldData?: TData) => TData | undefined)) { const targetData = isFunction(data) ? data(this.state.data) : data; this.runPluginHandler('onMutate', targetData); this.setState({ data: targetData, }); } } ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useAutoRunPlugin.ts ================================================ import { useRef } from 'react'; import useUpdateEffect from '../../../useUpdateEffect'; import type { Plugin } from '../types'; // support refreshDeps & ready const useAutoRunPlugin: Plugin = ( fetchInstance, { manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction }, ) => { const hasAutoRun = useRef(false); hasAutoRun.current = false; useUpdateEffect(() => { if (!manual && ready) { hasAutoRun.current = true; fetchInstance.run(...defaultParams); } }, [ready]); useUpdateEffect(() => { if (hasAutoRun.current) { return; } if (!manual) { hasAutoRun.current = true; if (refreshDepsAction) { refreshDepsAction(); } else { fetchInstance.refresh(); } } }, [...refreshDeps]); return { onBefore: () => { if (!ready) { return { stopNow: true, }; } }, }; }; useAutoRunPlugin.onInit = ({ ready = true, manual }) => { return { loading: !manual && ready, }; }; export default useAutoRunPlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useCachePlugin.ts ================================================ import { useRef } from 'react'; import useCreation from '../../../useCreation'; import useUnmount from '../../../useUnmount'; import type { Plugin } from '../types'; import { setCache, getCache } from '../utils/cache'; import type { CachedData } from '../utils/cache'; import { setCachePromise, getCachePromise } from '../utils/cachePromise'; import { trigger, subscribe } from '../utils/cacheSubscribe'; const useCachePlugin: Plugin = ( fetchInstance, { cacheKey, cacheTime = 5 * 60 * 1000, staleTime = 0, setCache: customSetCache, getCache: customGetCache, }, ) => { const unSubscribeRef = useRef<() => void>(undefined); const currentPromiseRef = useRef>(undefined); const _setCache = (key: string, cachedData: CachedData) => { if (customSetCache) { customSetCache(cachedData); } else { setCache(key, cacheTime, cachedData); } trigger(key, cachedData.data); }; const _getCache = (key: string, params: any[] = []) => { if (customGetCache) { return customGetCache(params); } return getCache(key); }; useCreation(() => { if (!cacheKey) { return; } // get data from cache when init const cacheData = _getCache(cacheKey); if (cacheData && Object.hasOwnProperty.call(cacheData, 'data')) { fetchInstance.state.data = cacheData.data; fetchInstance.state.params = cacheData.params; if (staleTime === -1 || Date.now() - cacheData.time <= staleTime) { fetchInstance.state.loading = false; } } // subscribe same cachekey update, trigger update unSubscribeRef.current = subscribe(cacheKey, (data) => { fetchInstance.setState({ data }); }); }, []); useUnmount(() => { unSubscribeRef.current?.(); }); if (!cacheKey) { return {}; } return { onBefore: (params) => { const cacheData = _getCache(cacheKey, params); if (!cacheData || !Object.hasOwnProperty.call(cacheData, 'data')) { return {}; } // If the data is fresh, stop request if (staleTime === -1 || Date.now() - cacheData.time <= staleTime) { return { loading: false, data: cacheData?.data, error: undefined, returnNow: true, }; } else { // If the data is stale, return data, and request continue return { data: cacheData?.data, error: undefined, }; } }, onRequest: (service, args) => { let servicePromise = getCachePromise(cacheKey); // If has servicePromise, and is not trigger by self, then use it if (servicePromise && servicePromise !== currentPromiseRef.current) { return { servicePromise }; } servicePromise = service(...args); currentPromiseRef.current = servicePromise; setCachePromise(cacheKey, servicePromise); return { servicePromise }; }, onSuccess: (data, params) => { if (cacheKey) { // cancel subscribe, avoid trgger self unSubscribeRef.current?.(); _setCache(cacheKey, { data, params, time: Date.now(), }); // resubscribe unSubscribeRef.current = subscribe(cacheKey, (d) => { fetchInstance.setState({ data: d }); }); } }, onMutate: (data) => { if (cacheKey) { // cancel subscribe, avoid trigger self unSubscribeRef.current?.(); _setCache(cacheKey, { data, params: fetchInstance.state.params, time: Date.now(), }); // resubscribe unSubscribeRef.current = subscribe(cacheKey, (d) => { fetchInstance.setState({ data: d }); }); } }, }; }; export default useCachePlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useDebouncePlugin.ts ================================================ import type { DebouncedFunc, DebounceSettings } from 'lodash'; import debounce from 'lodash/debounce'; import { useEffect, useMemo, useRef } from 'react'; import type { Plugin } from '../types'; const useDebouncePlugin: Plugin = ( fetchInstance, { debounceWait, debounceLeading, debounceTrailing, debounceMaxWait }, ) => { const debouncedRef = useRef>(undefined); const options = useMemo(() => { const ret: DebounceSettings = {}; if (debounceLeading !== undefined) { ret.leading = debounceLeading; } if (debounceTrailing !== undefined) { ret.trailing = debounceTrailing; } if (debounceMaxWait !== undefined) { ret.maxWait = debounceMaxWait; } return ret; }, [debounceLeading, debounceTrailing, debounceMaxWait]); useEffect(() => { if (debounceWait) { const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance); debouncedRef.current = debounce( (callback: (...args: any[]) => void) => { callback(); }, debounceWait, options, ); // debounce runAsync should be promise // https://github.com/lodash/lodash/issues/4400#issuecomment-834800398 fetchInstance.runAsync = (...args) => { return new Promise((resolve, reject) => { debouncedRef.current?.(() => { _originRunAsync(...args) .then(resolve) .catch(reject); }); }); }; return () => { debouncedRef.current?.cancel(); fetchInstance.runAsync = _originRunAsync; }; } }, [debounceWait, options]); if (!debounceWait) { return {}; } return { onCancel: () => { debouncedRef.current?.cancel(); }, }; }; export default useDebouncePlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useLoadingDelayPlugin.ts ================================================ import { useRef } from 'react'; import type { Plugin, Timeout } from '../types'; const useLoadingDelayPlugin: Plugin = (fetchInstance, { loadingDelay, ready }) => { const timerRef = useRef(undefined); if (!loadingDelay) { return {}; } const cancelTimeout = () => { if (timerRef.current) { clearTimeout(timerRef.current); } }; return { onBefore: () => { cancelTimeout(); // Two cases: // 1. ready === undefined // 2. ready === true if (ready !== false) { timerRef.current = setTimeout(() => { fetchInstance.setState({ loading: true, }); }, loadingDelay); } return { loading: false, }; }, onFinally: () => { cancelTimeout(); }, onCancel: () => { cancelTimeout(); }, }; }; export default useLoadingDelayPlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/usePollingPlugin.ts ================================================ import { useRef } from 'react'; import useUpdateEffect from '../../../useUpdateEffect'; import type { Plugin, Timeout } from '../types'; import isDocumentVisible from '../utils/isDocumentVisible'; import subscribeReVisible from '../utils/subscribeReVisible'; const usePollingPlugin: Plugin = ( fetchInstance, { pollingInterval, pollingWhenHidden = true, pollingErrorRetryCount = -1 }, ) => { const timerRef = useRef(undefined); const unsubscribeRef = useRef<() => void>(undefined); const countRef = useRef(0); const stopPolling = () => { if (timerRef.current) { clearTimeout(timerRef.current); } unsubscribeRef.current?.(); }; useUpdateEffect(() => { if (!pollingInterval) { stopPolling(); } }, [pollingInterval]); if (!pollingInterval) { return {}; } return { onBefore: () => { stopPolling(); }, onError: () => { countRef.current += 1; }, onSuccess: () => { countRef.current = 0; }, onFinally: () => { if ( pollingErrorRetryCount === -1 || // When an error occurs, the request is not repeated after pollingErrorRetryCount retries (pollingErrorRetryCount !== -1 && countRef.current <= pollingErrorRetryCount) ) { timerRef.current = setTimeout(() => { // if pollingWhenHidden = false && document is hidden, then stop polling and subscribe revisible if (!pollingWhenHidden && !isDocumentVisible()) { unsubscribeRef.current = subscribeReVisible(() => { fetchInstance.refresh(); }); } else { fetchInstance.refresh(); } }, pollingInterval); } else { countRef.current = 0; } }, onCancel: () => { stopPolling(); }, }; }; export default usePollingPlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useRefreshOnWindowFocusPlugin.ts ================================================ import { useEffect, useRef } from 'react'; import useUnmount from '../../../useUnmount'; import type { Plugin } from '../types'; import limit from '../utils/limit'; import subscribeFocus from '../utils/subscribeFocus'; const useRefreshOnWindowFocusPlugin: Plugin = ( fetchInstance, { refreshOnWindowFocus, focusTimespan = 5000 }, ) => { const unsubscribeRef = useRef<() => void>(undefined); const stopSubscribe = () => { unsubscribeRef.current?.(); }; useEffect(() => { if (refreshOnWindowFocus) { const limitRefresh = limit(fetchInstance.refresh.bind(fetchInstance), focusTimespan); unsubscribeRef.current = subscribeFocus(() => { limitRefresh(); }); } return () => { stopSubscribe(); }; }, [refreshOnWindowFocus, focusTimespan]); useUnmount(() => { stopSubscribe(); }); return {}; }; export default useRefreshOnWindowFocusPlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useRetryPlugin.ts ================================================ import { useRef } from 'react'; import type { Plugin } from '../types'; const useRetryPlugin: Plugin = (fetchInstance, { retryInterval, retryCount }) => { const timerRef = useRef>(undefined); const countRef = useRef(0); const triggerByRetry = useRef(false); if (!retryCount) { return {}; } return { onBefore: () => { if (!triggerByRetry.current) { countRef.current = 0; } triggerByRetry.current = false; if (timerRef.current) { clearTimeout(timerRef.current); } }, onSuccess: () => { countRef.current = 0; }, onError: () => { countRef.current += 1; if (retryCount === -1 || countRef.current <= retryCount) { // Exponential backoff const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000); timerRef.current = setTimeout(() => { triggerByRetry.current = true; fetchInstance.refresh(); }, timeout); } else { countRef.current = 0; } }, onCancel: () => { countRef.current = 0; if (timerRef.current) { clearTimeout(timerRef.current); } }, }; }; export default useRetryPlugin; ================================================ FILE: packages/hooks/src/useRequest/src/plugins/useThrottlePlugin.ts ================================================ import type { DebouncedFunc, ThrottleSettings } from 'lodash'; import throttle from 'lodash/throttle'; import { useEffect, useRef } from 'react'; import type { Plugin } from '../types'; const useThrottlePlugin: Plugin = ( fetchInstance, { throttleWait, throttleLeading, throttleTrailing }, ) => { const throttledRef = useRef>(undefined); const options: ThrottleSettings = {}; if (throttleLeading !== undefined) { options.leading = throttleLeading; } if (throttleTrailing !== undefined) { options.trailing = throttleTrailing; } useEffect(() => { if (throttleWait) { const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance); throttledRef.current = throttle( (callback) => { callback(); }, throttleWait, options, ); // throttle runAsync should be promise // https://github.com/lodash/lodash/issues/4400#issuecomment-834800398 fetchInstance.runAsync = (...args) => { return new Promise((resolve, reject) => { throttledRef.current?.(() => { _originRunAsync(...args) .then(resolve) .catch(reject); }); }); }; return () => { fetchInstance.runAsync = _originRunAsync; throttledRef.current?.cancel(); }; } }, [throttleWait, throttleLeading, throttleTrailing]); if (!throttleWait) { return {}; } return { onCancel: () => { throttledRef.current?.cancel(); }, }; }; export default useThrottlePlugin; ================================================ FILE: packages/hooks/src/useRequest/src/types.ts ================================================ import type { DependencyList } from 'react'; import type Fetch from './Fetch'; import type { CachedData } from './utils/cache'; export type Service = (...args: TParams) => Promise; export type Subscribe = () => void; // for Fetch export interface FetchState { loading: boolean; params?: TParams; data?: TData; error?: Error; } export interface PluginReturn { onBefore?: (params: TParams) => | ({ stopNow?: boolean; returnNow?: boolean; } & Partial>) | void; onRequest?: ( service: Service, params: TParams, ) => { servicePromise?: Promise; }; onSuccess?: (data: TData, params: TParams) => void; onError?: (e: Error, params: TParams) => void; onFinally?: (params: TParams, data?: TData, e?: Error) => void; onCancel?: () => void; onMutate?: (data: TData) => void; } // for useRequestImplement export interface Options { manual?: boolean; onBefore?: (params: TParams) => void; onSuccess?: (data: TData, params: TParams) => void; onError?: (e: Error, params: TParams) => void; // formatResult?: (res: any) => TData; onFinally?: (params: TParams, data?: TData, e?: Error) => void; defaultParams?: TParams; // refreshDeps refreshDeps?: DependencyList; refreshDepsAction?: () => void; // loading delay loadingDelay?: number; // polling pollingInterval?: number; pollingWhenHidden?: boolean; pollingErrorRetryCount?: number; // refresh on window focus refreshOnWindowFocus?: boolean; focusTimespan?: number; // debounce debounceWait?: number; debounceLeading?: boolean; debounceTrailing?: boolean; debounceMaxWait?: number; // throttle throttleWait?: number; throttleLeading?: boolean; throttleTrailing?: boolean; // cache cacheKey?: string; cacheTime?: number; staleTime?: number; setCache?: (data: CachedData) => void; getCache?: (params: TParams) => CachedData | undefined; // retry retryCount?: number; retryInterval?: number; // ready ready?: boolean; // [key: string]: any; } export type Plugin = { ( fetchInstance: Fetch, options: Options, ): PluginReturn; onInit?: (options: Options) => Partial>; }; // for index // export type OptionsWithoutFormat = Omit, 'formatResult'>; // export interface OptionsWithFormat extends Omit, 'formatResult'> { // formatResult: (res: TData) => TFormated; // }; export interface Result { loading: boolean; data?: TData; error?: Error; params: TParams | []; cancel: Fetch['cancel']; refresh: Fetch['refresh']; refreshAsync: Fetch['refreshAsync']; run: Fetch['run']; runAsync: Fetch['runAsync']; mutate: Fetch['mutate']; } export type Timeout = ReturnType; ================================================ FILE: packages/hooks/src/useRequest/src/useRequest.ts ================================================ import useAutoRunPlugin from './plugins/useAutoRunPlugin'; import useCachePlugin from './plugins/useCachePlugin'; import useDebouncePlugin from './plugins/useDebouncePlugin'; import useLoadingDelayPlugin from './plugins/useLoadingDelayPlugin'; import usePollingPlugin from './plugins/usePollingPlugin'; import useRefreshOnWindowFocusPlugin from './plugins/useRefreshOnWindowFocusPlugin'; import useRetryPlugin from './plugins/useRetryPlugin'; import useThrottlePlugin from './plugins/useThrottlePlugin'; import type { Options, Plugin, Service } from './types'; import useRequestImplement from './useRequestImplement'; // function useRequest( // service: Service, // options: OptionsWithFormat, // plugins?: Plugin[], // ): Result // function useRequest( // service: Service, // options?: OptionsWithoutFormat, // plugins?: Plugin[], // ): Result function useRequest( service: Service, options?: Options, plugins?: Plugin[], ) { return useRequestImplement(service, options, [ ...(plugins || []), useDebouncePlugin, useLoadingDelayPlugin, usePollingPlugin, useRefreshOnWindowFocusPlugin, useThrottlePlugin, useAutoRunPlugin, useCachePlugin, useRetryPlugin, ] as Plugin[]); } export default useRequest; ================================================ FILE: packages/hooks/src/useRequest/src/useRequestImplement.ts ================================================ import useCreation from '../../useCreation'; import useLatest from '../../useLatest'; import useMemoizedFn from '../../useMemoizedFn'; import useMount from '../../useMount'; import useUnmount from '../../useUnmount'; import useUpdate from '../../useUpdate'; import isDev from '../../utils/isDev'; import Fetch from './Fetch'; import type { Options, Plugin, Result, Service } from './types'; function useRequestImplement( service: Service, options: Options = {}, plugins: Plugin[] = [], ) { const { manual = false, ready = true, ...rest } = options; if (isDev) { if (options.defaultParams && !Array.isArray(options.defaultParams)) { console.warn(`expected defaultParams is array, got ${typeof options.defaultParams}`); } } const fetchOptions = { manual, ready, ...rest, }; const serviceRef = useLatest(service); const update = useUpdate(); const fetchInstance = useCreation(() => { const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean); return new Fetch( serviceRef, fetchOptions, update, Object.assign({}, ...initState), ); }, []); fetchInstance.options = fetchOptions; // run all plugins hooks fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions)); useMount(() => { if (!manual && ready) { // useCachePlugin can set fetchInstance.state.params from cache when init const params = fetchInstance.state.params || options.defaultParams || []; // @ts-ignore fetchInstance.run(...params); } }); useUnmount(() => { fetchInstance.cancel(); }); return { loading: fetchInstance.state.loading, data: fetchInstance.state.data, error: fetchInstance.state.error, params: fetchInstance.state.params || [], cancel: useMemoizedFn(fetchInstance.cancel.bind(fetchInstance)), refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)), refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)), run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)), runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)), mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)), } as Result; } export default useRequestImplement; ================================================ FILE: packages/hooks/src/useRequest/src/utils/cache.ts ================================================ type Timer = ReturnType; type CachedKey = string | number; export interface CachedData { data: TData; params: TParams; time: number; } interface RecordData extends CachedData { timer: Timer | undefined; } const cache = new Map(); const setCache = (key: CachedKey, cacheTime: number, cachedData: CachedData) => { const currentCache = cache.get(key); if (currentCache?.timer) { clearTimeout(currentCache.timer); } let timer: Timer | undefined = undefined; if (cacheTime > -1) { // if cache out, clear it timer = setTimeout(() => { cache.delete(key); }, cacheTime); } cache.set(key, { ...cachedData, timer, }); }; const getCache = (key: CachedKey) => { return cache.get(key); }; const clearCache = (key?: string | string[]) => { if (key) { const cacheKeys = Array.isArray(key) ? key : [key]; cacheKeys.forEach((cacheKey) => cache.delete(cacheKey)); } else { cache.clear(); } }; export { getCache, setCache, clearCache }; ================================================ FILE: packages/hooks/src/useRequest/src/utils/cachePromise.ts ================================================ type CachedKey = string | number; const cachePromise = new Map>(); const getCachePromise = (cacheKey: CachedKey) => { return cachePromise.get(cacheKey); }; const setCachePromise = (cacheKey: CachedKey, promise: Promise) => { // Should cache the same promise, cannot be promise.finally // Because the promise.finally will change the reference of the promise cachePromise.set(cacheKey, promise); // no use promise.finally for compatibility promise .then((res) => { cachePromise.delete(cacheKey); return res; }) .catch(() => { cachePromise.delete(cacheKey); }); }; export { getCachePromise, setCachePromise }; ================================================ FILE: packages/hooks/src/useRequest/src/utils/cacheSubscribe.ts ================================================ type Listener = (data: any) => void; const listeners: Record = {}; const trigger = (key: string, data: any) => { if (listeners[key]) { listeners[key].forEach((item) => item(data)); } }; const subscribe = (key: string, listener: Listener) => { if (!listeners[key]) { listeners[key] = []; } listeners[key].push(listener); return function unsubscribe() { const index = listeners[key].indexOf(listener); listeners[key].splice(index, 1); }; }; export { trigger, subscribe }; ================================================ FILE: packages/hooks/src/useRequest/src/utils/isDocumentVisible.ts ================================================ import isBrowser from '../../../utils/isBrowser'; export default function isDocumentVisible(): boolean { if (isBrowser) { return document.visibilityState !== 'hidden'; } return true; } ================================================ FILE: packages/hooks/src/useRequest/src/utils/isOnline.ts ================================================ import isBrowser from '../../../utils/isBrowser'; const isOnline = () => { if (isBrowser && typeof navigator.onLine !== 'undefined') { return navigator.onLine; } return true; }; export default isOnline; ================================================ FILE: packages/hooks/src/useRequest/src/utils/limit.ts ================================================ export default function limit(fn: any, timespan: number) { let pending = false; return (...args: any[]) => { if (pending) return; pending = true; fn(...args); setTimeout(() => { pending = false; }, timespan); }; } ================================================ FILE: packages/hooks/src/useRequest/src/utils/subscribeFocus.ts ================================================ // from swr import isBrowser from '../../../utils/isBrowser'; import isDocumentVisible from './isDocumentVisible'; import isOnline from './isOnline'; type Listener = () => void; const listeners = new Set(); function subscribe(listener: Listener) { listeners.add(listener); return function unsubscribe() { listeners.has(listener) && listeners.delete(listener); }; } if (isBrowser) { const revalidate = () => { if (!isDocumentVisible() || !isOnline()) return; listeners.forEach((listener) => listener()); }; window.addEventListener('visibilitychange', revalidate, false); window.addEventListener('focus', revalidate, false); } export default subscribe; ================================================ FILE: packages/hooks/src/useRequest/src/utils/subscribeReVisible.ts ================================================ import isBrowser from '../../../utils/isBrowser'; import isDocumentVisible from './isDocumentVisible'; type Listener = () => void; const listeners = new Set(); function subscribe(listener: Listener) { listeners.add(listener); return function unsubscribe() { listeners.has(listener) && listeners.delete(listener); }; } if (isBrowser) { const revalidate = () => { if (!isDocumentVisible()) return; listeners.forEach((listener) => listener()); }; window.addEventListener('visibilitychange', revalidate, false); } export default subscribe; ================================================ FILE: packages/hooks/src/useResetState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useResetState from '../index'; describe('useResetState', () => { const setUp = (initialValue: S | (() => S)) => renderHook(() => { const [state, setState, resetState] = useResetState(initialValue); return { state, setState, resetState, } as const; }); test('should support initialValue', () => { const hook = setUp({ hello: 'world', }); expect(hook.result.current.state).toEqual({ hello: 'world' }); }); test('should support functional initialValue', () => { const hook = setUp(() => ({ hello: 'world', })); expect(hook.result.current.state).toEqual({ hello: 'world' }); }); test('should reset state', () => { const hook = setUp({ hello: '', count: 0, }); act(() => { hook.result.current.setState({ hello: 'world', count: 1, }); }); act(() => { hook.result.current.resetState(); }); expect(hook.result.current.state).toEqual({ hello: '', count: 0 }); }); test('should support function update', () => { const hook = setUp({ count: 0, }); act(() => { hook.result.current.setState((prev) => ({ count: prev.count + 1 })); }); expect(hook.result.current.state).toEqual({ count: 1 }); }); test('should keep random initial state', () => { const random = Math.random(); const hook = setUp({ count: random, }); act(() => { hook.result.current.setState({ count: Math.random() }); }); act(() => { hook.result.current.resetState(); }); expect(hook.result.current.state).toEqual({ count: random }); }); test('should support random functional initialValue', () => { const random = Math.random(); const hook = setUp(() => ({ count: random, })); act(() => { hook.result.current.setState({ count: Math.random() }); }); act(() => { hook.result.current.resetState(); }); expect(hook.result.current.state).toEqual({ count: random }); }); }); ================================================ FILE: packages/hooks/src/useResetState/demo/demo1.tsx ================================================ import { useMemo } from 'react'; import { Button, Space } from 'antd'; import { useResetState } from 'ahooks'; export default () => { const initialValue = { hello: '', value: Math.random(), }; const initialValueMemo = useMemo(() => { return initialValue; }, []); const [state, setState, resetState] = useResetState(initialValue); return (
initial state:
{JSON.stringify(initialValueMemo, null, 2)}
current state:
{JSON.stringify(state, null, 2)}
); }; ================================================ FILE: packages/hooks/src/useResetState/index.en-US.md ================================================ --- nav: path: /hooks --- # useResetState useResetState works similar to `React.useState`, it provides a `reset` method ## Examples ### Default Usage ## API ```typescript const [state, setState, resetState] = useResetState( initialState: S | (() => S), ): [S, Dispatch>, () => void] ``` ================================================ FILE: packages/hooks/src/useResetState/index.ts ================================================ import { useRef, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { isFunction } from '../utils'; import useMemoizedFn from '../useMemoizedFn'; import useCreation from '../useCreation'; type ResetState = () => void; const useResetState = ( initialState: S | (() => S), ): [S, Dispatch>, ResetState] => { const initialStateRef = useRef(initialState); const initialStateMemo = useCreation( () => isFunction(initialStateRef.current) ? initialStateRef.current() : initialStateRef.current, [], ); const [state, setState] = useState(initialStateMemo); const resetState = useMemoizedFn(() => { setState(initialStateMemo); }); return [state, setState, resetState]; }; export default useResetState; ================================================ FILE: packages/hooks/src/useResetState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useResetState 提供重置 state 方法的 Hooks,用法与 `React.useState` 基本一致。 ## 代码演示 ### 基础用法 ## API ```typescript const [state, setState, resetState] = useResetState( initialState: S | (() => S), ): [S, Dispatch>, () => void] ``` ================================================ FILE: packages/hooks/src/useResponsive/__tests__/__snapshots__/index.spec.ts.snap ================================================ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`useResponsive > should response to window width changes 1`] = ` { "lg": true, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive > should response to window width changes 2`] = ` { "lg": false, "md": false, "sm": false, "xl": false, "xs": true, } `; exports[`useResponsive > should response to window width changes 3`] = ` { "lg": false, "md": false, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive > should response to window width changes 4`] = ` { "lg": false, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive > should response to window width changes 5`] = ` { "lg": true, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive > should response to window width changes 6`] = ` { "lg": true, "md": true, "sm": true, "xl": true, "xs": true, } `; exports[`useResponsive should response to window width changes 1`] = ` { "lg": true, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive should response to window width changes 2`] = ` { "lg": false, "md": false, "sm": false, "xl": false, "xs": true, } `; exports[`useResponsive should response to window width changes 3`] = ` { "lg": false, "md": false, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive should response to window width changes 4`] = ` { "lg": false, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive should response to window width changes 5`] = ` { "lg": true, "md": true, "sm": true, "xl": false, "xs": true, } `; exports[`useResponsive should response to window width changes 6`] = ` { "lg": true, "md": true, "sm": true, "xl": true, "xs": true, } `; ================================================ FILE: packages/hooks/src/useResponsive/__tests__/index.spec.ts ================================================ import { describe, expect, test } from 'vitest'; import { act, renderHook } from '../../utils/tests'; import useResponsive from '../'; describe('useResponsive', () => { function changeWidth(width: number) { act(() => { (global as any).innerWidth = width; (global as any).dispatchEvent(new Event('resize')); }); } changeWidth(1024); const hook = renderHook(() => useResponsive()); test('should response to window width changes', () => { expect(hook.result.current).toMatchSnapshot(); changeWidth(300); expect(hook.result.current).toMatchSnapshot(); changeWidth(700); expect(hook.result.current).toMatchSnapshot(); changeWidth(800); expect(hook.result.current).toMatchSnapshot(); changeWidth(1000); expect(hook.result.current).toMatchSnapshot(); changeWidth(1200); expect(hook.result.current).toMatchSnapshot(); }); }); ================================================ FILE: packages/hooks/src/useResponsive/demo/demo1.tsx ================================================ /** * title: Get responsive info in components * desc: By calling useResponsive in components, you can retrieve the responsive infomation of the browser page and subscribe to it at the same time. * * title.zh-CN: 在组件中获取响应式信息 * desc.zh-CN: 在组件中调用 useResponsive 可以获取并订阅浏览器窗口的响应式信息。 */ import { configResponsive, useResponsive } from 'ahooks'; configResponsive({ small: 0, middle: 800, large: 1200, }); export default function () { const responsive = useResponsive(); return ( <>

Please change the width of the browser window to see the effect:

{Object.keys(responsive).map((key) => (

{key} {responsive[key] ? '✔' : '✘'}

))} ); } ================================================ FILE: packages/hooks/src/useResponsive/index.en-US.md ================================================ --- nav: path: /hooks --- # useResponsive React Hook for getting responsive info. ## Examples ### Get responsive info in components ## API ```typescript interface ResponsiveConfig { [key: string]: number; } interface ResponsiveInfo { [key: string]: boolean; } function configResponsive(config: ResponsiveConfig): void; function useResponsive(): ResponsiveInfo; ``` ### Config The default config is the same as bootstrap: ```javascript { 'xs': 0, 'sm': 576, 'md': 768, 'lg': 992, 'xl': 1200, } ``` If you want to config your own responsive breakpoints, you can use `configResponsive`: (Caution: You only need to config it once. Don't call this config function repeatedly.) ```javascript configResponsive({ small: 0, middle: 800, large: 1200, }); ``` ================================================ FILE: packages/hooks/src/useResponsive/index.ts ================================================ import { useEffect, useState } from 'react'; import isBrowser from '../utils/isBrowser'; type Subscriber = () => void; const subscribers = new Set(); type ResponsiveConfig = Record; type ResponsiveInfo = Record; let info: ResponsiveInfo; let responsiveConfig: ResponsiveConfig = { xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, }; function handleResize() { const oldInfo = info; calculate(); if (oldInfo === info) { return; } for (const subscriber of subscribers) { subscriber(); } } let listening = false; function calculate() { const width = window.innerWidth; const newInfo = {} as ResponsiveInfo; let shouldUpdate = false; for (const key of Object.keys(responsiveConfig)) { newInfo[key] = width >= responsiveConfig[key]; if (newInfo[key] !== info[key]) { shouldUpdate = true; } } if (shouldUpdate) { info = newInfo; } } export function configResponsive(config: ResponsiveConfig) { responsiveConfig = config; if (info) calculate(); } function useResponsive() { if (isBrowser && !listening) { info = {}; calculate(); window.addEventListener('resize', handleResize); listening = true; } const [state, setState] = useState(info); useEffect(() => { if (!isBrowser) { return; } // In React 18's StrictMode, useEffect perform twice, resize listener is remove, so handleResize is never perform. // https://github.com/alibaba/hooks/issues/1910 if (!listening) { window.addEventListener('resize', handleResize); } const subscriber = () => { setState(info); }; subscribers.add(subscriber); return () => { subscribers.delete(subscriber); if (subscribers.size === 0) { window.removeEventListener('resize', handleResize); listening = false; } }; }, []); return state; } export default useResponsive; ================================================ FILE: packages/hooks/src/useResponsive/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useResponsive 获取响应式信息。 ## 代码演示 ### 在组件中获取响应式信息 ## API ```typescript interface ResponsiveConfig { [key: string]: number; } interface ResponsiveInfo { [key: string]: boolean; } function configResponsive(config: ResponsiveConfig): void; function useResponsive(): ResponsiveInfo; ``` ### 配置 默认的响应式配置和 bootstrap 是一致的: ```javascript { 'xs': 0, 'sm': 576, 'md': 768, 'lg': 992, 'xl': 1200, } ``` 如果你想配置自己的响应式断点,可以使用 `configResponsive` : (注意:只需配置一次,请勿在组件中重复调用该方法) ```javascript configResponsive({ small: 0, middle: 800, large: 1200, }); ``` ================================================ FILE: packages/hooks/src/useSafeState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useSafeState from '../index'; describe('useSetState', () => { const setUp = (initialValue: S | (() => S)) => renderHook(() => { const [state, setState] = useSafeState(initialValue); return { state, setState, } as const; }); test('should support initialValue', () => { const hook = setUp({ hello: 'world', }); expect(hook.result.current.state).toEqual({ hello: 'world' }); }); test('should support update', () => { const hook = setUp(0); act(() => { hook.result.current.setState(5); }); expect(hook.result.current.state).toBe(5); }); test('should not support update when unmount', () => { const hook = setUp(0); hook.unmount(); act(() => { hook.result.current.setState(5); }); expect(hook.result.current.state).toBe(0); }); }); ================================================ FILE: packages/hooks/src/useSafeState/demo/demo1.tsx ================================================ import { useSafeState } from 'ahooks'; import { useEffect, useState } from 'react'; const Child = () => { const [value, setValue] = useSafeState(); useEffect(() => { setTimeout(() => { setValue('data loaded from server'); }, 5000); }, []); const text = value || 'Loading...'; return
{text}
; }; export default () => { const [visible, setVisible] = useState(true); return (
{visible && }
); }; ================================================ FILE: packages/hooks/src/useSafeState/index.en-US.md ================================================ --- nav: path: /hooks --- # useSafeState It is exactly the same with `React.useState` , but after the component is unmounted, the `setState` in the asynchronous callback will no longer be executed to avoid memory leakage caused by updating the state after the component is unmounted. ## Examples ### Basic usage ## API ```typescript const [state, setState] = useSafeState(initialState); ``` ================================================ FILE: packages/hooks/src/useSafeState/index.ts ================================================ import { useCallback, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import useUnmountedRef from '../useUnmountedRef'; function useSafeState(initialState: S | (() => S)): [S, Dispatch>]; function useSafeState(): [S | undefined, Dispatch>]; function useSafeState(initialState?: S | (() => S)) { const unmountedRef = useUnmountedRef(); const [state, setState] = useState(initialState); const setCurrentState = useCallback((currentState: S) => { /** if component is unmounted, stop update */ if (unmountedRef.current) { return; } setState(currentState); }, []); return [state, setCurrentState] as const; } export default useSafeState; ================================================ FILE: packages/hooks/src/useSafeState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSafeState 用法与 `React.useState` 完全一样,但是在组件卸载后异步回调内的 `setState` 不再执行,避免因组件卸载后更新状态而导致的内存泄漏。 ## 代码演示 ### 基础用法 ## API ```typescript const [state, setState] = useSafeState(initialState); ``` ================================================ FILE: packages/hooks/src/useScroll/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useScroll from '../index'; describe('useScroll', () => { test('document body', () => { const hook = renderHook(() => useScroll(document)); expect(hook.result.current).toBeUndefined(); }); }); ================================================ FILE: packages/hooks/src/useScroll/demo/demo1.tsx ================================================ /** * title: Basic Usage * desc: Try to scroll the box below. * * title.zh-CN: 基础用法 * desc.zh-CN: 尝试滚动一下文字内容。 */ import { useRef } from 'react'; import { useScroll } from 'ahooks'; export default () => { const ref = useRef(null); const scroll = useScroll(ref); return ( <>

{JSON.stringify(scroll)}

Lorem ipsum dolor sit amet, consectetur adipisicing elit. A aspernatur atque, debitis ex excepturi explicabo iste iure labore molestiae neque optio perspiciatis
Aspernatur cupiditate, deleniti id incidunt mollitia omnis! A aspernatur assumenda consequuntur culpa cumque dignissimos enim eos, et fugit natus nemo nesciunt
Alias aut deserunt expedita, inventore maiores minima officia porro rem. Accusamus ducimus magni modi mollitia nihil nisi provident
Alias aut autem consequuntur doloremque esse facilis id molestiae neque officia placeat, quia quisquam repellendus reprehenderit.
Adipisci blanditiis facere nam perspiciatis sit soluta ullam! Architecto aut blanditiis, consectetur corporis cum deserunt distinctio dolore eius est exercitationem
Ab aliquid asperiores assumenda corporis cumque dolorum expedita
Culpa cumque eveniet natus totam! Adipisci, animi at commodi delectus distinctio dolore earum, eum expedita facilis
Quod sit, temporibus! Amet animi fugit officiis perspiciatis, quis unde. Cumque dignissimos distinctio, dolor eaque est fugit nisi non pariatur porro possimus, quas quasi
); }; ================================================ FILE: packages/hooks/src/useScroll/demo/demo2.tsx ================================================ /** * title: Listen Page Scroll * desc: Try to scroll this webpage. * * title.zh-CN: 监测整页的滚动 * desc.zh-CN: 尝试滚动一下页面。 */ import { useScroll } from 'ahooks'; export default () => { const scroll = useScroll(document); return (
{JSON.stringify(scroll)}
); }; ================================================ FILE: packages/hooks/src/useScroll/demo/demo3.tsx ================================================ /** * title: Custom update * desc: listen on scroll event between 100px ~ 200px in vertical direction * * title.zh-CN: 自定义更新 * desc.zh-CN: 在垂直方向 100px 到 200px 的滚动范围内监听 */ import { useRef } from 'react'; import { useScroll } from 'ahooks'; export default () => { const ref = useRef(null); const scroll = useScroll(ref, (val) => val.top > 100 && val.top < 200); return ( <>

{JSON.stringify(scroll)}

Lorem ipsum dolor sit amet, consectetur adipisicing elit. A aspernatur atque, debitis ex excepturi explicabo iste iure labore molestiae neque optio perspiciatis
Aspernatur cupiditate, deleniti id incidunt mollitia omnis! A aspernatur assumenda consequuntur culpa cumque dignissimos enim eos, et fugit natus nemo nesciunt
Alias aut deserunt expedita, inventore maiores minima officia porro rem. Accusamus ducimus magni modi mollitia nihil nisi provident
Alias aut autem consequuntur doloremque esse facilis id molestiae neque officia placeat, quia quisquam repellendus reprehenderit.
Adipisci blanditiis facere nam perspiciatis sit soluta ullam! Architecto aut blanditiis, consectetur corporis cum deserunt distinctio dolore eius est exercitationem
Ab aliquid asperiores assumenda corporis cumque dolorum expedita
Culpa cumque eveniet natus totam! Adipisci, animi at commodi delectus distinctio dolore earum, eum expedita facilis
Quod sit, temporibus! Amet animi fugit officiis perspiciatis, quis unde. Cumque dignissimos distinctio, dolor eaque est fugit nisi non pariatur porro possimus, quas quasi
); }; ================================================ FILE: packages/hooks/src/useScroll/index.en-US.md ================================================ --- nav: path: /hooks --- # useScroll Get the scroll position of an element. ## Examples ### Basic Usage ### Detect Whole Page Scroll ### Control listen on scroll status ## API ```typescript const position = useScroll(target, shouldUpdate); ``` ### Params | Property | Description | Type | Default | | ------------ | ------------------------- | --------------------------------------------------------------------------- | ------------ | | target | DOM element or ref object | `Element` \| `Document` \| `(() => Element)` \| `MutableRefObject` | `document` | | shouldUpdate | Whether update position | `({ top: number, left: number }) => boolean` | `() => true` | ### Result | Property | Description | Type | | -------- | ------------------------------------------- | -------------------------------------------- | | position | The current scroll position of the element. | `{ left: number, top: number } \| undefined` | ================================================ FILE: packages/hooks/src/useScroll/index.ts ================================================ import useRafState from '../useRafState'; import useLatest from '../useLatest'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; type Position = { left: number; top: number }; export type Target = BasicTarget; export type ScrollListenController = (val: Position) => boolean; function useScroll( target?: Target, shouldUpdate: ScrollListenController = () => true, ): Position | undefined { const [position, setPosition] = useRafState(); const shouldUpdateRef = useLatest(shouldUpdate); useEffectWithTarget( () => { const el = getTargetElement(target, document); if (!el) { return; } const updatePosition = () => { let newPosition: Position; if (el === document) { if (document.scrollingElement) { newPosition = { left: document.scrollingElement.scrollLeft, top: document.scrollingElement.scrollTop, }; } else { // When in quirks mode, the scrollingElement attribute returns the HTML body element if it exists and is potentially scrollable, otherwise it returns null. // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/scrollingElement // https://stackoverflow.com/questions/28633221/document-body-scrolltop-firefox-returns-0-only-js newPosition = { left: Math.max( window.pageXOffset, document.documentElement.scrollLeft, document.body.scrollLeft, ), top: Math.max( window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop, ), }; } } else { newPosition = { left: (el as Element).scrollLeft, top: (el as Element).scrollTop, }; } if (shouldUpdateRef.current(newPosition)) { setPosition(newPosition); } }; updatePosition(); el.addEventListener('scroll', updatePosition); return () => { el.removeEventListener('scroll', updatePosition); }; }, [], target, ); return position; } export default useScroll; ================================================ FILE: packages/hooks/src/useScroll/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useScroll 监听元素的滚动位置。 ## 代码演示 ### 基础用法 ### 监测整页的滚动 ### 控制滚动状态的监听 ## API ```typescript const position = useScroll(target, shouldUpdate); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | -------------------- | --------------------------------------------------------------------------- | ------------ | | target | DOM 节点或者 ref | `Element` \| `Document` \| `(() => Element)` \| `MutableRefObject` | `document` | | shouldUpdate | 控制是否更新滚动信息 | `({ top: number, left: number }) => boolean` | `() => true` | ### Result | 参数 | 说明 | 类型 | | -------- | ---------------------- | -------------------------------------------- | | position | 滚动容器当前的滚动位置 | `{ left: number, top: number } \| undefined` | ================================================ FILE: packages/hooks/src/useSelections/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test } from 'vitest'; import type { Options } from '../index'; import useSelections from '../index'; const _data = [1, 2, 3]; const _selected = [1]; const _selectedItem = 1; const _dataObj = [{ id: 1 }, { id: 2 }, { id: 3 }]; const _selectedObj = [{ id: 1 }]; const _selectedItemObj = { id: 1 }; const setup = (items: T[], options?: T[] | Options) => { return renderHook(() => useSelections(items, options)); }; type CaseCallback = (data: T[], selected: T[], selectedItem: T) => void; const runCaseCallback = ( dataCallback: CaseCallback, objDataCallback: CaseCallback, ) => { dataCallback(_data, _selected, _selectedItem); objDataCallback(_dataObj, _selectedObj, _selectedItemObj); }; describe('useSelections', () => { test('defaultSelected should work correct', () => { const caseCallback: CaseCallback = (data, selected, selectedItem) => { const { result } = setup(data, { defaultSelected: selected, itemKey: 'id', }); expect(result.current.selected).toEqual(selected); expect(result.current.isSelected(selectedItem)).toBe(true); }; runCaseCallback(caseCallback, caseCallback); }); test('select and unSelect should work correct', () => { const caseCallback: CaseCallback = (data, selected, selectedItem) => { const { result } = setup(data, { defaultSelected: selected, itemKey: 'id', }); const { unSelect, select } = result.current; act(() => { unSelect(selectedItem); }); expect(result.current.selected).toEqual([]); expect(result.current.isSelected(selectedItem)).toBe(false); expect(result.current.allSelected).toBe(false); act(() => { select(selectedItem); }); expect(result.current.selected).toEqual(selected); expect(result.current.isSelected(selectedItem)).toBe(true); expect(result.current.allSelected).toBe(false); }; runCaseCallback(caseCallback, caseCallback); }); test('toggle should work correct', () => { const caseCallback: CaseCallback = (data, selected, selectedItem) => { const { result } = setup(data, { itemKey: 'id', }); const { toggle } = result.current; act(() => { toggle(selectedItem); }); expect(result.current.selected).toEqual(selected); expect(result.current.isSelected(selectedItem)).toBe(true); expect(result.current.allSelected).toBe(false); act(() => { toggle(selectedItem); }); expect(result.current.selected).toEqual([]); expect(result.current.isSelected(selectedItem)).toBe(false); expect(result.current.allSelected).toBe(false); }; runCaseCallback(caseCallback, caseCallback); }); test('selectAll and unSelectAll should work correct', async () => { const caseCallback: CaseCallback = (data) => { const { result } = setup(data, { itemKey: 'id', }); const { selectAll, unSelectAll } = result.current; expect(result.current.noneSelected).toBe(true); act(() => { selectAll(); }); expect(result.current.selected).toEqual(data); expect(result.current.allSelected).toBe(true); expect(result.current.noneSelected).toBe(false); expect(result.current.partiallySelected).toBe(false); act(() => { unSelectAll(); }); expect(result.current.selected).toEqual([]); expect(result.current.allSelected).toBe(false); expect(result.current.noneSelected).toBe(true); expect(result.current.partiallySelected).toBe(false); }; runCaseCallback(caseCallback, caseCallback); }); test('toggleAll should work correct', async () => { const caseCallback: CaseCallback = (data) => { const { result } = setup(data, { itemKey: 'id', }); const { toggleAll } = result.current; expect(result.current.noneSelected).toBe(true); act(() => { toggleAll(); }); expect(result.current.selected).toEqual(data); expect(result.current.allSelected).toBe(true); expect(result.current.noneSelected).toBe(false); expect(result.current.partiallySelected).toBe(false); act(() => { toggleAll(); }); expect(result.current.selected).toEqual([]); expect(result.current.allSelected).toBe(false); expect(result.current.noneSelected).toBe(true); expect(result.current.partiallySelected).toBe(false); }; runCaseCallback(caseCallback, caseCallback); }); test('setSelected should work correct', async () => { const caseCallback: CaseCallback = (data, selected, selectedItem) => { const { result } = setup(data, { itemKey: 'id', }); const { setSelected } = result.current; expect(result.current.noneSelected).toBe(true); act(() => { setSelected(selected); }); expect(result.current.selected).toEqual(selected); expect(result.current.isSelected(selectedItem)).toBe(true); expect(result.current.noneSelected).toBe(false); expect(result.current.allSelected).toBe(false); expect(result.current.partiallySelected).toBe(true); act(() => { setSelected([]); }); expect(result.current.selected).toEqual([]); expect(result.current.isSelected(selectedItem)).toBe(false); expect(result.current.noneSelected).toBe(true); expect(result.current.allSelected).toBe(false); expect(result.current.partiallySelected).toBe(false); act(() => { setSelected(data); }); expect(result.current.selected).toEqual(data); expect(result.current.isSelected(selectedItem)).toBe(true); expect(result.current.noneSelected).toBe(false); expect(result.current.allSelected).toBe(true); expect(result.current.partiallySelected).toBe(false); // Keep compatible with older versions. act(() => { expect(() => setSelected(undefined!)).not.toThrowError(); expect(() => setSelected(null!)).not.toThrowError(); }); }; runCaseCallback(caseCallback, caseCallback); }); test('legacy parameter should work in <4.0', async () => { const { result } = setup(_data, _selected); expect(result.current.selected).toEqual(_selected); expect(result.current.isSelected(_selectedItem)).toBe(true); }); test('clearAll should work correct', async () => { const runCase = (data: any, newData: any, remainData: any) => { const { result } = renderHook(() => { const [list, setList] = useState(data); const hook = useSelections(list, { itemKey: 'id', }); return { setList, hook }; }); const { setSelected, unSelectAll, clearAll } = result.current.hook; act(() => { setSelected(data); }); expect(result.current.hook.selected).toEqual(data); expect(result.current.hook.allSelected).toBe(true); act(() => { result.current.setList(newData); }); expect(result.current.hook.allSelected).toBe(false); act(() => { unSelectAll(); }); expect(result.current.hook.selected).toEqual(remainData); act(() => { clearAll(); }); expect(result.current.hook.selected).toEqual([]); expect(result.current.hook.allSelected).toEqual(false); expect(result.current.hook.noneSelected).toBe(true); expect(result.current.hook.partiallySelected).toBe(false); }; runCase(_data, [3, 4, 5], [1, 2]); runCase(_dataObj, [{ id: 3 }, { id: 4 }, { id: 5 }], [{ id: 1 }, { id: 2 }]); }); }); ================================================ FILE: packages/hooks/src/useSelections/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Checkbox group. * * title.zh-CN: 基础用法 * desc.zh-CN: 常见的 Checkbox 联动 */ import { Checkbox, Col, Row } from 'antd'; import { useMemo, useState } from 'react'; import { useSelections } from 'ahooks'; export default () => { const [hideOdd, setHideOdd] = useState(false); const list = useMemo(() => { if (hideOdd) { return [2, 4, 6, 8]; } return [1, 2, 3, 4, 5, 6, 7, 8]; }, [hideOdd]); const { selected, allSelected, isSelected, toggle, toggleAll, partiallySelected } = useSelections( list, { defaultSelected: [1], }, ); return (
Selected: {selected.join(',')}
Check all setHideOdd((v) => !v)}> Hide Odd
{list.map((o) => (
toggle(o)}> {o} ))} ); }; ================================================ FILE: packages/hooks/src/useSelections/demo/demo2.tsx ================================================ /** * title: Object array * desc: When array items are object, you need to specify the field name for the unique key. * * title.zh-CN: 对象数组 * desc.zh-CN: 数组项是对象时,需要指定唯一 key 的字段名称。 */ import { Checkbox, Col, Row } from 'antd'; import { useMemo, useState } from 'react'; import { useSelections } from 'ahooks'; export default () => { const [hideOdd, setHideOdd] = useState(false); const list = useMemo(() => { if (hideOdd) { return [2, 4, 6, 8].map((id) => ({ id })); } return [1, 2, 3, 4, 5, 6, 7, 8].map((id) => ({ id })); }, [hideOdd]); const { selected, allSelected, isSelected, toggle, toggleAll, partiallySelected } = useSelections( list, { defaultSelected: [{ id: 1 }], itemKey: 'id', }, ); return (
Selected: {JSON.stringify(selected)}
Check all setHideOdd((v) => !v)}> Hide Odd
{list.map((item) => (
toggle(item)}> {item.id} ))} ); }; ================================================ FILE: packages/hooks/src/useSelections/demo/demo3.tsx ================================================ /** * title: Pagination * desc: Load data with pagination and enable cross-page selection. * * title.zh-CN: 分页多选 * desc.zh-CN: 分页加载数据,并跨页选择。 */ import { Checkbox, Divider, Pagination, Spin } from 'antd'; import { useEffect, useState } from 'react'; import { useSelections } from 'ahooks'; interface DataType { id: number; title: string; } interface PaginationType { current: number; pageSize: number; total?: number; } const dataSource = Array.from({ length: 50 }, (item, index) => ({ id: index, title: `title ${index}`, })); const getDataFromServer = (props: PaginationType) => { const { current, pageSize } = props; const data = dataSource.slice((current - 1) * pageSize, current * pageSize); return new Promise<{ data: DataType[]; total: PaginationType['total']; }>((resolve) => { setTimeout( () => resolve({ data, total: dataSource.length, }), 500, ); }); }; export default () => { const [dataList, setDataList] = useState([]); const [loading, setLoading] = useState(false); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, }); const getData = async (params: PaginationType) => { setLoading(true); const { data, total } = await getDataFromServer(params); setLoading(false); setDataList(data); setPagination({ ...params, total }); }; useEffect(() => { getData(pagination); }, []); const { selected, allSelected, isSelected, toggle, toggleAll, partiallySelected } = useSelections( dataList, { itemKey: 'id', }, ); return ( {dataList.map((item) => { const { id, title } = item; return (
toggle(item)} checked={isSelected(item)} > {title}
); })} { getData({ current: page, pageSize: size, }); }} />
Check all Selected: {selected.length}
{!!selected.length && ( <> {JSON.stringify(selected)} )}
); }; ================================================ FILE: packages/hooks/src/useSelections/index.en-US.md ================================================ --- nav: path: /hooks --- # useSelections This hook is used for Checkbox group, supports multiple selection, single selection, select-all, select-none and semi-selected etc. ## Examples ### Default usage ### Object array ### Pagination ## API ```typescript interface Options { defaultSelected?: T[]; itemKey?: string | ((item: T) => Key); } // works when >=3.8.0, recommended ✅ const result: Result = useSelections(items: T[], options?: Options); // works when <4.0.0, will be removed in ahooks 4.0 🙅🏻‍♀️ const result: Result = useSelections(items: T[], defaultSelected?: T[]); ``` ### Params | Property | Description | Type | Default | | --- | --- | --- | --- | | items | Data items | `T[]` | - | | options | Optional configuration | `Options` | - | ### Options | Property | Description | Type | Default | | --- | --- | --- | --- | | defaultSelected | Default selected data | `T[]` | `[]` | | itemKey | The unique key of data item. Typically, this parameter needs to be specified when the data source is an array of object | `string` \| `(item: T) => React.Key` | - | ### Result | Property | Description | Type | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | selected | Selected items | `T[]` | | allSelected | Is all items selected | `boolean` | | noneSelected | Is no item selected | `boolean` | | partiallySelected | Is partially items selected | `boolean` | | isSelected | Whether item is selected | `(value: T) => boolean` | | setSelected | Select multiple items. When executed multiple times, the later return value overwrites the previous one, so if you want to merge the results of multiple operations, you need to do this manually: `setSelected((oldArray) => oldArray.concat(newArray))` | `(value: T[]) => void \| (value: (prevState: T[]) => T[]) => void` | | select | Select single item | `(value: T) => void` | | unSelect | UnSelect single item | `(value: T) => void` | | toggle | Toggle single item select status | `(value: T) => void` | | selectAll | Select all items | `() => void` | | unSelectAll | UnSelect all items | `() => void` | | toggleAll | Toggle select all items | `() => void` | | clearAll | Clear all selected (In general, `clearAll` is equivalent to `unSelectAll`. If the items is dynamic, `clearAll` will clear "all selected data", while `unSelectAll` will only clear "the currently selected data in the items") | `() => void` | ================================================ FILE: packages/hooks/src/useSelections/index.ts ================================================ import isPlainObject from 'lodash/isPlainObject'; import useMemoizedFn from '../useMemoizedFn'; import { isFunction, isString } from '../utils'; import { useMemo, useState } from 'react'; export interface Options { defaultSelected?: T[]; itemKey?: string | ((item: T) => React.Key); } function useSelections(items: T[], options?: T[] | Options) { let defaultSelected: T[] = []; let itemKey: Options['itemKey']; if (Array.isArray(options)) { defaultSelected = options; } else if (isPlainObject(options)) { defaultSelected = options?.defaultSelected ?? defaultSelected; itemKey = options?.itemKey ?? itemKey; } const getKey = (item: T) => { if (isFunction(itemKey)) { return itemKey(item); } if (isString(itemKey) && isPlainObject(item)) { return (item as any)[itemKey]; } return item as React.Key; }; const [selected, setSelected] = useState(defaultSelected); const selectedMap = useMemo(() => { const keyToItemMap = new Map(); if (!Array.isArray(selected)) { return keyToItemMap; } selected.forEach((item) => { keyToItemMap.set(getKey(item), item); }); return keyToItemMap; }, [selected]); const isSelected = (item: T) => selectedMap.has(getKey(item)); const select = (item: T) => { selectedMap.set(getKey(item), item); setSelected(Array.from(selectedMap.values())); }; const unSelect = (item: T) => { selectedMap.delete(getKey(item)); setSelected(Array.from(selectedMap.values())); }; const toggle = (item: T) => { if (isSelected(item)) { unSelect(item); } else { select(item); } }; const selectAll = () => { items.forEach((item) => { selectedMap.set(getKey(item), item); }); setSelected(Array.from(selectedMap.values())); }; const unSelectAll = () => { items.forEach((item) => { selectedMap.delete(getKey(item)); }); setSelected(Array.from(selectedMap.values())); }; const noneSelected = useMemo( () => items.every((item) => !selectedMap.has(getKey(item))), [items, selectedMap], ); const allSelected = useMemo( () => items.every((item) => selectedMap.has(getKey(item))) && !noneSelected, [items, selectedMap, noneSelected], ); const partiallySelected = useMemo( () => !noneSelected && !allSelected, [noneSelected, allSelected], ); const toggleAll = () => (allSelected ? unSelectAll() : selectAll()); const clearAll = () => { selectedMap.clear(); setSelected([]); }; return { selected, noneSelected, allSelected, partiallySelected, setSelected, isSelected, select: useMemoizedFn(select), unSelect: useMemoizedFn(unSelect), toggle: useMemoizedFn(toggle), selectAll: useMemoizedFn(selectAll), unSelectAll: useMemoizedFn(unSelectAll), clearAll: useMemoizedFn(clearAll), toggleAll: useMemoizedFn(toggleAll), } as const; } export default useSelections; ================================================ FILE: packages/hooks/src/useSelections/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSelections 常见联动 Checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。 ## 代码演示 ### 基础用法 ### 对象数组 ### 分页多选 ## API ```typescript interface Options { defaultSelected?: T[]; itemKey?: string | ((item: T) => Key); } // >=3.8.0 可用,推荐的写法 ✅ const result: Result = useSelections(items: T[], options?: Options); // <4.0.0 可用,将会在 ahooks 4.0 中移除 🙅🏻‍♀️ const result: Result = useSelections(items: T[], defaultSelected?: T[]); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | | items | 元素列表 | `T[]` | - | | options | 可选配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | | defaultSelected | 默认选择的数据 | `T[]` | `[]` | | itemKey | 数据项的唯一 key。一般来说,数据源是对象数组时,才需要指定该参数 | `string` \| `(item: T) => React.Key` | - | ### Result | 参数 | 说明 | 类型 | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | selected | 已经选择的元素 | `T[]` | | allSelected | 是否全选 | `boolean` | | noneSelected | 是否一个都没有选择 | `boolean` | | partiallySelected | 是否半选 | `boolean` | | isSelected | 是否被选择 | `(value: T) => boolean` | | setSelected | 选择多个元素。多次执行时,后面的返回值会覆盖前面的,因此如果希望合并多次操作的结果,需要手动处理:`setSelected((oldArray) => oldArray.concat(newArray))` | `(value: T[]) => void \| (value: (prevState: T[]) => T[]) => void` | | select | 选择单个元素 | `(value: T) => void` | | unSelect | 取消选择单个元素 | `(value: T) => void` | | toggle | 反选单个元素 | `(value: T) => void` | | selectAll | 选择全部元素 | `() => void` | | unSelectAll | 取消选择全部元素 | `() => void` | | toggleAll | 反选全部元素 | `() => void` | | clearAll | 清除所有选中元素(一般情况下,`clearAll` 等价于 `unSelectAll`。如果元素列表是动态的,则 `clearAll` 会清除掉“所有选中过的元素”,而 `unSelectAll` 只会清除掉“当前元素列表里选中的元素”) | `() => void` | ================================================ FILE: packages/hooks/src/useSessionStorageState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useSessionStorageState from '../index'; describe('useSessionStorageState', () => { const setUp = (key: string, value: T) => renderHook(() => { const [state, setState] = useSessionStorageState(key, { defaultValue: value }); return { state, setState, } as const; }); test('should support object', () => { const LOCAL_STORAGE_KEY = 'test-object-key'; const hook = setUp<{ name: string }>(LOCAL_STORAGE_KEY, { name: 'A', }); expect(hook.result.current.state).toEqual({ name: 'A' }); act(() => { hook.result.current.setState({ name: 'B' }); }); expect(hook.result.current.state).toEqual({ name: 'B' }); const anotherHook = setUp(LOCAL_STORAGE_KEY, { name: 'C', }); expect(anotherHook.result.current.state).toEqual({ name: 'B' }); act(() => { anotherHook.result.current.setState({ name: 'C', }); }); expect(anotherHook.result.current.state).toEqual({ name: 'C' }); expect(hook.result.current.state).toEqual({ name: 'B' }); }); test('should support function updater', () => { const LOCAL_STORAGE_KEY = 'test-func-updater'; const hook = setUp(LOCAL_STORAGE_KEY, 'hello world'); expect(hook.result.current.state).toBe('hello world'); act(() => { hook.result.current.setState((state) => `${state}, zhangsan`); }); expect(hook.result.current.state).toBe('hello world, zhangsan'); }); }); ================================================ FILE: packages/hooks/src/useSessionStorageState/index.en-US.md ================================================ --- nav: path: /hooks --- # useSessionStorageState A Hook for store state into sessionStorage. Usage is exactly the same as [useLocalStorageState](./use-local-storage-state). ================================================ FILE: packages/hooks/src/useSessionStorageState/index.ts ================================================ import { createUseStorageState } from '../createUseStorageState'; import isBrowser from '../utils/isBrowser'; const useSessionStorageState = createUseStorageState(() => isBrowser ? sessionStorage : undefined, ); export default useSessionStorageState; ================================================ FILE: packages/hooks/src/useSessionStorageState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSessionStorageState 将状态存储在 sessionStorage 中的 Hook。 用法与 [useLocalStorageState](./use-local-storage-state) 一致。 ================================================ FILE: packages/hooks/src/useSet/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useSet from '../index'; const setUp = (initialSet?: Iterable) => renderHook(() => useSet(initialSet)); describe('useSet', () => { test('should init set and utils', () => { const { result } = setUp([1, 2]); const [set, utils] = result.current; expect(set).toEqual(new Set([1, 2])); expect(utils).toStrictEqual({ add: expect.any(Function), remove: expect.any(Function), reset: expect.any(Function), }); }); test('should init empty set if no initial set provided', () => { const { result } = setUp(); expect(result.current[0]).toEqual(new Set()); const { result: result1 } = setUp(undefined); expect(result1.current[0]).toEqual(new Set()); }); test('should have an initially provided key', () => { const { result } = setUp(['a']); const [set] = result.current; let value = false; act(() => { value = set.has('a'); }); expect(value).toBe(true); }); test('should have an added key', () => { const { result } = setUp(); act(() => { result.current[1].add('newKey'); }); let value = false; act(() => { value = result.current[0].has('newKey'); }); expect(value).toBe(true); }); test('should get false for non-existing key', () => { const { result } = setUp(['a']); const [set] = result.current; let value = true; act(() => { value = set.has('nonExisting'); }); expect(value).toBe(false); }); test('should add a new key', () => { const { result } = setUp(['oldKey']); const [, utils] = result.current; act(() => { utils.add('newKey'); }); expect(result.current[0]).toEqual(new Set(['oldKey', 'newKey'])); }); test('should work if setting existing key', () => { const { result } = setUp(['oldKey']); const [, utils] = result.current; act(() => { utils.add('oldKey'); }); expect(result.current[0]).toEqual(new Set(['oldKey'])); }); test('should remove existing key', () => { const { result } = setUp([1, 2]); const [, utils] = result.current; act(() => { utils.remove(2); }); expect(result.current[0]).toEqual(new Set([1])); }); test('should do nothing if removing non-existing key', () => { const { result } = setUp(['a', 'b']); const [, utils] = result.current; act(() => { utils.remove('nonExisting'); }); expect(result.current[0]).toEqual(new Set(['a', 'b'])); }); test('should reset to initial set provided', () => { const { result } = setUp([1]); const [, utils] = result.current; act(() => { utils.add(2); }); expect(result.current[0]).toEqual(new Set([1, 2])); act(() => { utils.reset(); }); expect(result.current[0]).toEqual(new Set([1])); }); test('should memoized its utils methods', () => { const { result } = setUp(['a', 'b']); const [, utils] = result.current; const { add, remove, reset } = utils; act(() => { add('foo'); }); expect(result.current[1].add).toBe(add); expect(result.current[1].remove).toBe(remove); expect(result.current[1].reset).toBe(reset); }); }); ================================================ FILE: packages/hooks/src/useSet/demo/demo1.tsx ================================================ import { useSet } from 'ahooks'; export default () => { const [set, { add, remove, reset }] = useSet(['Hello']); return (
{JSON.stringify(Array.from(set), null, 2)}
); }; ================================================ FILE: packages/hooks/src/useSet/index.en-US.md ================================================ --- nav: path: /hooks --- # useSet A hook that can manage the state of Set. ## Examples ### Default usage ## API ```typescript const [ set, { add, remove, reset } ] = useSet(initialValue); ``` ### Result | Property | Description | Type | | -------- | ---------------- | ------------------ | | set | Set object | `Set` | | add | Add item | `(key: K) => void` | | remove | Remove item | `(key: K) => void` | | reset | Reset to default | `() => void` | ### Params | Property | Description | Type | Default | | ------------ | --------------------------- | ------------- | ------- | | initialValue | Optional, set default value | `Iterable` | - | ================================================ FILE: packages/hooks/src/useSet/index.ts ================================================ import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; function useSet(initialValue?: Iterable) { const getInitValue = () => new Set(initialValue); const [set, setSet] = useState>(getInitValue); const updateSet = (updater: (set: Set) => Set) => { setSet((prevSet) => updater(new Set(prevSet))); }; const add = (key: K) => { if (set.has(key)) { return; } updateSet((newSet) => { newSet.add(key); return newSet; }); }; const remove = (key: K) => { if (!set.has(key)) { return; } updateSet((newSet) => { newSet.delete(key); return newSet; }); }; const reset = () => setSet(getInitValue()); return [ set, { add: useMemoizedFn(add), remove: useMemoizedFn(remove), reset: useMemoizedFn(reset), }, ] as const; } export default useSet; ================================================ FILE: packages/hooks/src/useSet/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSet 管理 Set 类型状态的 Hook。 ## 代码演示 ## API ```typescript const [ set, { add, remove, reset } ] = useSet(initialValue); ``` ### Result | 参数 | 说明 | 类型 | | ------ | ------------ | ------------------ | | set | Set 对象 | `Set` | | add | 添加元素 | `(key: K) => void` | | remove | 移除元素 | `(key: K) => void` | | reset | 重置为默认值 | `() => void` | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | --------------------------- | ------------- | ------ | | initialValue | 可选项,传入默认的 Set 参数 | `Iterable` | - | ================================================ FILE: packages/hooks/src/useSetState/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useSetState from '../index'; describe('useSetState', () => { const setUp = (initialValue: T) => renderHook(() => { const [state, setState] = useSetState(initialValue); return { state, setState, } as const; }); test('should support initialValue', () => { const hook = setUp({ hello: 'world', }); expect(hook.result.current.state).toEqual({ hello: 'world' }); }); test('should support object', () => { const hook = setUp({ hello: 'world', }); act(() => { hook.result.current.setState({ foo: 'bar' }); }); expect(hook.result.current.state).toEqual({ hello: 'world', foo: 'bar' }); }); test('should support function update', () => { const hook = setUp({ count: 0, }); act(() => { hook.result.current.setState((prev) => ({ count: prev.count + 1 })); }); expect(hook.result.current.state).toEqual({ count: 1 }); }); }); ================================================ FILE: packages/hooks/src/useSetState/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Automatically merge object. * * title.zh-CN: 基础用法 * desc.zh-CN: 自动合并对象。 */ import { useSetState } from 'ahooks'; interface State { hello: string; [key: string]: any; } export default () => { const [state, setState] = useSetState({ hello: '', }); return (
{JSON.stringify(state, null, 2)}

); }; ================================================ FILE: packages/hooks/src/useSetState/demo/demo2.tsx ================================================ /** * title: Updating with callback * desc: When using the callback to update, the previous state can be received, and the return value will be automatically merged. * * title.zh-CN: 使用回调更新 * desc.zh-CN: 通过回调进行更新,可以获取上一次的状态,并且也会自动合并返回的对象。 */ import { useSetState } from 'ahooks'; interface State { hello: string; count: number; } export default () => { const [state, setState] = useSetState({ hello: 'world', count: 0, }); return (
{JSON.stringify(state, null, 2)}

); }; ================================================ FILE: packages/hooks/src/useSetState/index.en-US.md ================================================ --- nav: path: /hooks --- # useSetState useSetState works similar to `this.setState` of class component, used to manage the state of object type. ## Examples ### Default usage ### Updating with callback ## API ```typescript const [state, setState] = useSetState(initialState); ``` ### Result | Property | Description | Type | Default | | -------- | -------------------- | ----------------------------------------------------------------------------------------- | ------- | | state | Current state | `T` | - | | setState | Update current state | `(state: Partial \| null) => void` \| `((prevState: T) => Partial \| null) => void` | - | ### Params | Property | Description | Type | Default | | ------------ | ------------- | -------------- | ------- | | initialState | Initial state | `T \| () => T` | - | ================================================ FILE: packages/hooks/src/useSetState/index.ts ================================================ import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isFunction } from '../utils'; export type SetState> = ( state: Pick | null | ((prevState: Readonly) => Pick | S | null), ) => void; const useSetState = >( initialState: S | (() => S), ): [S, SetState] => { const [state, setState] = useState(initialState); const setMergeState = useMemoizedFn((patch) => { setState((prevState) => { const newState = isFunction(patch) ? patch(prevState) : patch; return newState ? { ...prevState, ...newState } : prevState; }); }); return [state, setMergeState]; }; export default useSetState; ================================================ FILE: packages/hooks/src/useSetState/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSetState 管理 object 类型 state 的 Hooks,用法与 class 组件的 `this.setState` 基本一致。 ## 代码演示 ### 基础用法 ### 使用回调更新 ## API ```typescript const [state, setState] = useSetState(initialState); ``` ### Result | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------ | ----------------------------------------------------------------------------------------- | ------ | | state | 当前状态 | `T` | - | | setState | 设置当前状态 | `(state: Partial \| null) => void` \| `((prevState: T) => Partial \| null) => void` | - | ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | -------- | -------------- | ------ | | initialState | 初始状态 | `T \| () => T` | - | ================================================ FILE: packages/hooks/src/useSize/__tests__/index.spec.tsx ================================================ import { act, render, renderHook, screen } from '@testing-library/react'; import { useRef } from 'react'; import { describe, expect, test, vi } from 'vitest'; import useSize from '../index'; let callback: any; vi.mock('resize-observer-polyfill', () => { return { default: vi.fn().mockImplementation((cb) => { callback = cb; return { observe: () => {}, disconnect: () => {}, }; }), }; }); // test about Resize Observer see https://github.com/que-etc/resize-observer-polyfill/tree/master/tests describe('useSize', () => { test('should work when target is a mounted DOM', () => { const hook = renderHook(() => useSize(document.body)); expect(hook.result.current).toEqual({ height: 0, width: 0 }); }); test('should work when target is a `MutableRefObject`', async () => { const mockRaf = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((cb: FrameRequestCallback) => { cb(0); return 0; }); function Setup() { const ref = useRef(null); const size = useSize(ref); return (
width: {String(size?.width)}
height: {String(size?.height)}
); } render(); expect((await screen.findByText(/^width/)).textContent).toBe('width: undefined'); expect((await screen.findByText(/^height/)).textContent).toBe('height: undefined'); act(() => callback([{ target: { clientWidth: 10, clientHeight: 10 } }])); expect((await screen.findByText(/^width/)).textContent).toBe('width: 10'); expect((await screen.findByText(/^height/)).textContent).toBe('height: 10'); mockRaf.mockRestore(); }); test('should not work when target is null', () => { expect(() => { renderHook(() => useSize(null)); }).not.toThrowError(); }); test('should work', () => { const mockRaf = vi .spyOn(window, 'requestAnimationFrame') .mockImplementation((cb: FrameRequestCallback) => { cb(0); return 0; }); const targetEl = document.createElement('div'); const { result } = renderHook(() => useSize(targetEl)); act(() => { callback([ { target: { clientWidth: 100, clientHeight: 50, }, }, ]); }); expect(result.current).toMatchObject({ width: 100, height: 50, }); mockRaf.mockRestore(); }); }); ================================================ FILE: packages/hooks/src/useSize/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: useSize can receive ref as argument * * title.zh-CN: 基础用法 * desc.zh-CN: useSize 可以接收 ref 参数 */ import { useRef } from 'react'; import { useSize } from 'ahooks'; export default () => { const ref = useRef(null); const size = useSize(ref); return (

Try to resize the preview window

width: {size?.width}px, height: {size?.height}px

); }; ================================================ FILE: packages/hooks/src/useSize/demo/demo2.tsx ================================================ /** * title: pass in the DOM element * desc: useSize can receive a dom element as parameter. In SSR scenarios, you can pass in function `() => dom` * * title.zh-CN: 传入 DOM 元素 * desc.zh-CN: useSize 可以接收 dom,在 SSR 场景可以传入函数 `() => dom` */ import { useSize } from 'ahooks'; export default () => { const size = useSize(document.querySelector('body')); return (

Try to resize the preview window

width: {size?.width}px, height: {size?.height}px

); }; ================================================ FILE: packages/hooks/src/useSize/index.en-US.md ================================================ --- nav: path: /hooks --- # useSize A hook that observes size change of an element. ## Examples ### Default usage ### Pass in the DOM element ## API ```typescript const size = useSize(target); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------------- | ------------------------------------------------------------- | ------- | | target | DOM element or ref object | `Element` \| `(() => Element)` \| `MutableRefObject` | - | ### Result | Property | Description | Type | Default | | -------- | ------------------- | ------------------------------------------------ | ------------------------------------------------------------------------- | | size | Size of the element | `{ width: number, height: number } \| undefined` | `{ width: target.clientWidth, height: target.clientHeight } \| undefined` | ================================================ FILE: packages/hooks/src/useSize/index.ts ================================================ import ResizeObserver from 'resize-observer-polyfill'; import useRafState from '../useRafState'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useIsomorphicLayoutEffectWithTarget from '../utils/useIsomorphicLayoutEffectWithTarget'; type Size = { width: number; height: number }; function useSize(target: BasicTarget): Size | undefined { const [state, setState] = useRafState(() => { const el = getTargetElement(target); return el ? { width: el.clientWidth, height: el.clientHeight } : undefined; }); useIsomorphicLayoutEffectWithTarget( () => { const el = getTargetElement(target); if (!el) { return; } const resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { const { clientWidth, clientHeight } = entry.target; setState({ width: clientWidth, height: clientHeight }); }); }); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); }; }, [], target, ); return state; } export default useSize; ================================================ FILE: packages/hooks/src/useSize/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useSize 监听 DOM 节点尺寸变化的 Hook。 ## 代码演示 ### 基础用法 ### 传入 DOM 节点 ## API ```typescript const size = useSize(target); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------ | ---------------- | ------------------------------------------------------------- | ------ | | target | DOM 节点或者 ref | `Element` \| `(() => Element)` \| `MutableRefObject` | - | ### Result | 参数 | 说明 | 类型 | 默认值 | | ---- | -------------- | ------------------------------------------------ | ------------------------------------------------------------------------- | | size | DOM 节点的尺寸 | `{ width: number, height: number } \| undefined` | `{ width: target.clientWidth, height: target.clientHeight } \| undefined` | ================================================ FILE: packages/hooks/src/useTextSelection/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useTextSelection from '../index'; // test about Resize Observer see https://github.com/que-etc/resize-observer-polyfill/tree/master/tests describe('useTextSelection', () => { function downMouse(x: number, y: number, options?: MouseEventInit) { act(() => { document.dispatchEvent( new MouseEvent('mousedown', { clientX: x, clientY: y, screenX: x, screenY: y, ...options, }), ); }); } function upMouse(x: number, y: number) { act(() => { document.dispatchEvent( new MouseEvent('mouseup', { clientX: x, clientY: y, screenX: x, screenY: y, }), ); }); } function initGetSelection({ top = 0, left = 0, height = 0, width = 0, text = 'hello world!' }) { // TODO // @ts-ignore window.getSelection = () => { return { toString: () => { return text; }, rangeCount: text.length, removeAllRanges: () => {}, getRangeAt: (index: number) => { return { getBoundingClientRect: () => { return { top, left, bottom: top + height, right: left + width, height, width, }; }, }; }, }; }; } test('on textSelection', async () => { initGetSelection({ left: 10, top: 10, height: 100, width: 100, text: 'on textSelection' }); // TODO // @ts-ignore const hook = renderHook(() => useTextSelection(() => document)); expect(hook.result.current.text).toBe(''); expect(hook.result.current.left).toBeNaN(); expect(hook.result.current.right).toBeNaN(); expect(hook.result.current.top).toBeNaN(); expect(hook.result.current.bottom).toBeNaN(); expect(hook.result.current.height).toBeNaN(); expect(hook.result.current.width).toBeNaN(); downMouse(0, 0); upMouse(100, 100); expect(hook.result.current.left).toBe(10); expect(hook.result.current.text).toBe('on textSelection'); hook.unmount(); }); test('keep/cancel the selected text range', async () => { initGetSelection({ text: 'aaa' }); const hook = renderHook(() => useTextSelection(() => document)); expect(hook.result.current.text).toBe(''); downMouse(0, 0); upMouse(100, 100); expect(hook.result.current.text).toBe('aaa'); // trigger the secondary button of mouse (usually the right button) downMouse(0, 0, { button: 2 }); expect(hook.result.current.text).toBe('aaa'); // // trigger the main button of mouse (usually the left button) downMouse(0, 0, { button: 0 }); expect(hook.result.current.text).toBe(''); hook.unmount(); }); }); ================================================ FILE: packages/hooks/src/useTextSelection/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Tracking content of user text selection * * title.zh-CN: 基础用法 * desc.zh-CN: 实时获取页面上选择的文本 */ import { useTextSelection } from 'ahooks'; export default () => { const { text } = useTextSelection(); return (

You can select text all page.

Result:{text}

); }; ================================================ FILE: packages/hooks/src/useTextSelection/demo/demo2.tsx ================================================ /** * title: Translate user text selection * desc: Use Antd.Popover to translate user text selection * * title.zh-CN: 划词翻译 * desc.zh-CN: 配合 Popover 做划词翻译 */ import { useRequest, useTextSelection } from 'ahooks'; import { Popover, Spin } from 'antd'; import { useEffect, useState } from 'react'; const getResult = (keyword: string): Promise => { const trimedText = keyword.trim() !== ''; if (!trimedText) { return Promise.resolve(''); } return new Promise((resolve) => { setTimeout(() => resolve(`[translate result] ${keyword}`), 2000); }); }; export default () => { const { text = '', left = 0, top = 0, height = 0, width = 0, } = useTextSelection(() => document.querySelector('#translate-dom')); const [open, setOpen] = useState(false); const { data, run, loading } = useRequest(getResult, { manual: true, }); useEffect(() => { if (text.trim() === '') { setOpen(false); return; } setOpen(true); run(text); }, [text]); return (

Translation of this paragraph;Translation of this paragraph;Translation of this paragraph;

{loading ? 'Translating……' : data}} open={open} >
); }; ================================================ FILE: packages/hooks/src/useTextSelection/demo/demo3.tsx ================================================ /** * title: Listen specified area * desc: useTextSelection can receive dom or ref, for listen specified area. * * title.zh-CN: 监听特定区域文本选择 * desc.zh-CN: useTextSelection 可以接收 dom 或 ref,指定监听区域。 */ import { useRef } from 'react'; import { useTextSelection } from 'ahooks'; export default () => { const ref = useRef(null); const selection = useTextSelection(ref); return (

Please swipe your mouse to select any text on this paragraph.

Result:{JSON.stringify(selection)}

); }; ================================================ FILE: packages/hooks/src/useTextSelection/index.en-US.md ================================================ --- nav: path: /hooks --- # useTextSelection Tracking content, size, position of user text selection. ## Examples ### Default usage ### Listen for specified area ### Translate user text selection ## API ```typescript const state = useTextSelection(target?); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------ | ------------------------------------------------------------------------------------ | ---------- | | target | DOM element or ref | `Element` \| `Document` \| `(() => Element\Document)` \| `MutableRefObject` | `document` | ### Result | Property | Description | Type | | -------- | ---------------------------------------------- | ------- | | state | Content, size, position of user text selection | `State` | ### State | Property | Description | Type | | -------- | ----------------------------------- | -------- | | text | Selected text | `string` | | left | The left coordinate value of text | `number` | | right | The right coordinate value of text | `number` | | top | The top coordinate value of text | `number` | | bottom | The bottom coordinate value of text | `number` | | height | The height of text | `number` | | width | The width of text | `number` | ================================================ FILE: packages/hooks/src/useTextSelection/index.ts ================================================ import { useRef, useState } from 'react'; import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; import useEffectWithTarget from '../utils/useEffectWithTarget'; interface Rect { top: number; left: number; bottom: number; right: number; height: number; width: number; } export interface State extends Rect { text: string; } const initRect: Rect = { top: NaN, left: NaN, bottom: NaN, right: NaN, height: NaN, width: NaN, }; const initState: State = { text: '', ...initRect, }; function getRectFromSelection(selection: Selection | null): Rect { if (!selection) { return initRect; } if (selection.rangeCount < 1) { return initRect; } const range = selection.getRangeAt(0); const { height, width, top, left, right, bottom } = range.getBoundingClientRect(); return { height, width, top, left, right, bottom, }; } function useTextSelection(target?: BasicTarget): State { const [state, setState] = useState(initState); const stateRef = useRef(state); const isInRangeRef = useRef(false); stateRef.current = state; useEffectWithTarget( () => { const el = getTargetElement(target, document); if (!el) { return; } const mouseupHandler = () => { let selObj: Selection | null = null; let text = ''; let rect = initRect; if (!window.getSelection) { return; } selObj = window.getSelection(); text = selObj ? selObj.toString() : ''; if (text && isInRangeRef.current) { rect = getRectFromSelection(selObj); setState({ ...state, text, ...rect }); } }; // 任意点击都需要清空之前的 range const mousedownHandler = (e: MouseEvent) => { // 如果是鼠标右键需要跳过 这样选中的数据就不会被清空 if (e.button === 2) { return; } if (!window.getSelection) { return; } if (stateRef.current.text) { setState({ ...initState }); } isInRangeRef.current = false; const selObj = window.getSelection(); if (!selObj) { return; } selObj.removeAllRanges(); isInRangeRef.current = el.contains(e.target as Node); }; el.addEventListener('mouseup', mouseupHandler); document.addEventListener('mousedown', mousedownHandler); return () => { el.removeEventListener('mouseup', mouseupHandler); document.removeEventListener('mousedown', mousedownHandler); }; }, [], target, ); return state; } export default useTextSelection; ================================================ FILE: packages/hooks/src/useTextSelection/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useTextSelection 实时获取用户当前选取的文本内容及位置。 ## 代码演示 ### 基础用法 ### 监听特定区域文本选择 ### 划词翻译 ## API ```typescript const state = useTextSelection(target?); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------ | ------------------ | ------------------------------------------------------------------------------------ | ---------- | | target | DOM element or ref | `Element` \| `Document` \| `(() => Element\Document)` \| `MutableRefObject` | `document` | ### Result | 参数 | 说明 | 类型 | | ----- | ------------------------------ | ------- | | state | DOM 节点内选取文本的内容和位置 | `State` | ### State | 参数 | 说明 | 类型 | | ------ | ---------------- | -------- | | text | 用户选取的文本值 | `string` | | left | 文本的左坐标 | `number` | | right | 文本的右坐标 | `number` | | top | 文本的顶坐标 | `number` | | bottom | 文本的底坐标 | `number` | | height | 文本的高度 | `number` | | width | 文本的宽度 | `number` | ================================================ FILE: packages/hooks/src/useTheme/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { beforeAll, describe, expect, test, vi } from 'vitest'; import useTheme from '../index'; describe('useTheme', () => { beforeAll(() => { // Mock window.matchMedia Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, // Default value, can be overridden for specific tests media: query, onchange: null, addListener: vi.fn(), // Deprecated but often still present removeListener: vi.fn(), // Deprecated addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); }); test('themeMode init', () => { const { result } = renderHook(useTheme); expect(result.current.themeMode).toBe('system'); }); test('setThemeMode light', () => { const { result } = renderHook(useTheme); act(() => result.current.setThemeMode('light')); expect(result.current.theme).toBe('light'); expect(result.current.themeMode).toBe('light'); }); test('setThemeMode dark', () => { const { result } = renderHook(useTheme); act(() => result.current.setThemeMode('dark')); expect(result.current.theme).toBe('dark'); expect(result.current.themeMode).toBe('dark'); }); test('setThemeMode system', () => { const { result } = renderHook(useTheme); act(() => result.current.setThemeMode('system')); expect(result.current.themeMode).toBe('system'); }); }); ================================================ FILE: packages/hooks/src/useTheme/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: The 'theme' is the system display theme ("light" or "dark"), the 'themeMode' can set 'theme' to "light" or "dark" or follow the system setting. * * title.zh-CN: 基础用法 * desc.zh-CN: 'theme' 为系统当前显示主题("light" 或 "dark"),'themeMode' 为当前主题设置("light" 或 "dark" 或 "system")。 */ import { useTheme } from 'ahooks'; export default () => { const { theme, themeMode, setThemeMode } = useTheme({ localStorageKey: 'themeMode', }); return ( <>
theme: {theme}
themeMode: {themeMode}
); }; ================================================ FILE: packages/hooks/src/useTheme/index.en-US.md ================================================ --- nav: path: /hooks --- # useTheme This hook is used to get and set the theme, and store the `themeMode` into `localStorage`. ## Examples ### Default usage ## API ```typescript const { theme, themeMode, setThemeMode } = useTheme({ localStorageKey?: string; }); ``` ### Params | Property | Description | Type | Default | | --------------- | ----------------------------------------------------- | -------- | --------- | | localStorageKey | The key in localStorage to store selected theme mode | `string` | `undefined` | ### Result | Property | Description | Type | Default | | ------------ | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- | | theme | current display theme | `"light" \| "dark"` | if themeMode is "system" then equals to system setting,otherwise equals to themeMode | | themeMode | selected theme mode | `"light" \| "dark" \| "system"` | equals to localStorage "themeMode", otherwise equals to "system" | | setThemeMode | select theme mode | `(mode: "light" \| "dark" \| "system") => void` | | ================================================ FILE: packages/hooks/src/useTheme/index.ts ================================================ import { useEffect, useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import isBrowser from '../utils/isBrowser'; export enum ThemeMode { LIGHT = 'light', DARK = 'dark', SYSTEM = 'system', } export type ThemeModeType = `${ThemeMode}`; export type ThemeType = 'light' | 'dark'; const useCurrentTheme = () => { const matchMedia = isBrowser ? window.matchMedia('(prefers-color-scheme: dark)') : undefined; const [theme, setTheme] = useState(() => { if (isBrowser) { return matchMedia?.matches ? ThemeMode.DARK : ThemeMode.LIGHT; } else { return ThemeMode.LIGHT; } }); useEffect(() => { const onThemeChange: MediaQueryList['onchange'] = (event) => { if (event.matches) { setTheme(ThemeMode.DARK); } else { setTheme(ThemeMode.LIGHT); } }; matchMedia?.addEventListener('change', onThemeChange); return () => { matchMedia?.removeEventListener('change', onThemeChange); }; }, []); return theme; }; type Options = { localStorageKey?: string; }; export default function useTheme(options: Options = {}) { const { localStorageKey } = options; const [themeMode, setThemeMode] = useState(() => { const preferredThemeMode = localStorageKey?.length && (localStorage.getItem(localStorageKey) as ThemeModeType | null); return preferredThemeMode || ThemeMode.SYSTEM; }); const setThemeModeWithLocalStorage = (mode: ThemeModeType) => { setThemeMode(mode); if (localStorageKey?.length) { localStorage.setItem(localStorageKey, mode); } }; const currentTheme = useCurrentTheme(); const theme = themeMode === ThemeMode.SYSTEM ? currentTheme : themeMode; return { theme, themeMode, setThemeMode: useMemoizedFn(setThemeModeWithLocalStorage), }; } ================================================ FILE: packages/hooks/src/useTheme/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useTheme 获取并设置当前主题,并将 `themeMode` 存储在 `localStorage` 中。 ## 代码演示 ### 基础用法 ## API ```typescript const { theme, themeMode, setThemeMode } = useTheme({ localStorageKey?: string; }); ``` ### 参数 | 参数 | 说明 | 类型 | 默认值 | | --------------- | ------------------------------------ | -------- | --------- | | localStorageKey | localStorage 中用于存放主题模式的键 | `string` | `undefined` | ### 返回值 | 值 | 说明 | 类型 | 默认值 | | ------------ | -------------- | ----------------------------------------------- | ---------------------------------------------------------------------- | | theme | 当前显示的主题 | `"light" \| "dark"` | 若 themeMode 为 "system" 则为系统当前使用主题,否则与 themeMode 值相同 | | themeMode | 选择的主题模式 | `"light" \| "dark" \| "system"` | 等于 localStorage "themeMode" 字段的值,否则为 "system" | | setThemeMode | 选择主题模式 | `(mode: "light" \| "dark" \| "system") => void` | | ================================================ FILE: packages/hooks/src/useThrottle/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useThrottle from '../index'; let hook: RenderHookResult; describe('useThrottle', () => { test('default useThrottle should work', async () => { let mountedState = 1; act(() => { hook = renderHook(() => useThrottle(mountedState, { wait: 500 })); }); expect(hook.result.current).toBe(1); mountedState = 2; hook.rerender(); mountedState = 3; hook.rerender(); await act(async () => { await sleep(250); }); expect(hook.result.current).toBe(1); mountedState = 4; hook.rerender(); await act(async () => { await sleep(260); }); expect(hook.result.current).toBe(4); }); test('leading:false & trailing:false of options useThrottle should work', async () => { let mountedState = 0; act(() => { hook = renderHook(() => useThrottle(mountedState, { wait: 500, leading: false, trailing: false, }), ); }); //Never get the latest value mountedState = 1; expect(hook.result.current).toBe(0); mountedState = 2; hook.rerender(); mountedState = 3; hook.rerender(); await sleep(250); expect(hook.result.current).toBe(0); mountedState = 4; hook.rerender(); await sleep(260); expect(hook.result.current).toBe(0); }); test('leading:true & trailing:false of options useThrottle should work', async () => { let mountedState = 0; act(() => { hook = renderHook(() => useThrottle(mountedState, { wait: 500, leading: true, trailing: false }), ); }); expect(hook.result.current).toBe(0); mountedState = 1; hook.rerender(); await sleep(0); expect(hook.result.current).toBe(0); mountedState = 2; await sleep(200); hook.rerender(); await sleep(0); expect(hook.result.current).toBe(0); mountedState = 3; //Need to wait more than 500ms to get the latest value await act(async () => { await sleep(300); }); hook.rerender(); await sleep(0); expect(hook.result.current).toBe(3); }); test('leading:false & trailing:true of options useThrottle should work', async () => { let mountedState = 0; act(() => { hook = renderHook(() => useThrottle(mountedState, { wait: 500, leading: false, trailing: true }), ); }); expect(hook.result.current).toBe(0); mountedState = 1; hook.rerender(); await sleep(0); expect(hook.result.current).toBe(0); mountedState = 2; hook.rerender(); await sleep(250); expect(hook.result.current).toBe(0); mountedState = 3; hook.rerender(); await act(async () => { await sleep(260); }); await sleep(260); expect(hook.result.current).toBe(3); }); }); ================================================ FILE: packages/hooks/src/useThrottle/demo/demo1.tsx ================================================ /** * title: Default usage * desc: ThrottledValue will change every 500ms. * * title.zh-CN: 基础用法 * desc.zh-CN: ThrottledValue 每隔 500ms 变化一次。 */ import { useState } from 'react'; import { useThrottle } from 'ahooks'; export default () => { const [value, setValue] = useState(); const throttledValue = useThrottle(value, { wait: 500 }); return (
setValue(e.target.value)} placeholder="Typed value" style={{ width: 280 }} />

throttledValue: {throttledValue}

); }; ================================================ FILE: packages/hooks/src/useThrottle/index.en-US.md ================================================ --- nav: path: /hooks --- # useThrottle A hook that deal with the throttled value. ## Examples ### Default usage ## API ```typescript const throttledValue = useThrottle( value: any, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ---------------------------------- | --------- | ------- | | value | The value to throttle. | `any` | - | | options | Config for the throttle behaviors. | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ----------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to delay. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `true` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useThrottle/index.ts ================================================ import { useEffect, useState } from 'react'; import useThrottleFn from '../useThrottleFn'; import type { ThrottleOptions } from './throttleOptions'; function useThrottle(value: T, options?: ThrottleOptions) { const [throttled, setThrottled] = useState(value); const { run } = useThrottleFn(() => { setThrottled(value); }, options); useEffect(() => { run(); }, [value]); return throttled; } export default useThrottle; ================================================ FILE: packages/hooks/src/useThrottle/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useThrottle 用来处理节流值的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const throttledValue = useThrottle( value: any, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | -------------- | --------- | ------ | | value | 需要节流的值 | `any` | - | | options | 配置节流的行为 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------------------ | --------- | ------ | | wait | 等待时间,单位为毫秒 | `number` | `1000` | | leading | 是否在延迟开始前调用函数 | `boolean` | `true` | | trailing | 是否在延迟开始后调用函数 | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useThrottle/throttleOptions.ts ================================================ export interface ThrottleOptions { wait?: number; leading?: boolean; trailing?: boolean; } ================================================ FILE: packages/hooks/src/useThrottleEffect/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useThrottleEffect from '../index'; let hook: RenderHookResult; describe('useThrottleEffect', () => { test('useThrottleEffect should work', async () => { const mockEffect = vi.fn(() => {}); const mockCleanUp = vi.fn(() => {}); act(() => { hook = renderHook( ({ value, wait }) => useThrottleEffect( () => { mockEffect(); return () => { mockCleanUp(); }; }, [value], { wait }, ), { initialProps: { value: 1, wait: 200 } }, ); }); hook.rerender({ value: 2, wait: 200 }); await sleep(100); expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(0); await act(async () => { await sleep(150); }); expect(mockEffect.mock.calls.length).toBe(2); expect(mockCleanUp.mock.calls.length).toBe(1); hook.rerender({ value: 3, wait: 100 }); await sleep(50); expect(mockEffect.mock.calls.length).toBe(3); expect(mockCleanUp.mock.calls.length).toBe(2); await act(async () => { await sleep(100); }); expect(mockEffect.mock.calls.length).toBe(3); expect(mockCleanUp.mock.calls.length).toBe(2); }); test('should cancel timeout on unmount', async () => { const mockEffect = vi.fn(() => {}); const mockCleanUp = vi.fn(() => {}); const hook2 = renderHook( (props) => useThrottleEffect( () => { mockEffect(); return () => { mockCleanUp(); }; }, [props], { wait: 200 }, ), { initialProps: 0 }, ); await act(async () => { expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(0); hook2.rerender(1); await sleep(50); hook2.unmount(); expect(mockEffect.mock.calls.length).toBe(1); expect(mockCleanUp.mock.calls.length).toBe(1); }); }); }); ================================================ FILE: packages/hooks/src/useThrottleEffect/demo/demo1.tsx ================================================ import { useState } from 'react'; import { useThrottleEffect } from 'ahooks'; export default () => { const [value, setValue] = useState('hello'); const [records, setRecords] = useState([]); useThrottleEffect( () => { setRecords((val) => [...val, value]); }, [value], { wait: 1000, }, ); return (
setValue(e.target.value)} placeholder="Typed value" style={{ width: 280 }} />

    {records.map((record, index) => (
  • {record}
  • ))}

); }; ================================================ FILE: packages/hooks/src/useThrottleEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useThrottleEffect Throttle your `useEffect`. ## Examples ### Default usage ## API ```typescript useThrottleEffect( effect: EffectCallback, deps?: DependencyList, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | ------------------------------------------------------------ | ---------------- | ------- | | effect | The effect callback. | `EffectCallback` | - | | deps | The dependencies list. | `DependencyList` | - | | options | Config the throttle behavior. See the Options section below. | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ----------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to wait. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `true` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useThrottleEffect/index.ts ================================================ import { useEffect, useState } from 'react'; import type { DependencyList, EffectCallback } from 'react'; import type { ThrottleOptions } from '../useThrottle/throttleOptions'; import useThrottleFn from '../useThrottleFn'; import useUpdateEffect from '../useUpdateEffect'; function useThrottleEffect( effect: EffectCallback, deps?: DependencyList, options?: ThrottleOptions, ) { const [flag, setFlag] = useState({}); const { run } = useThrottleFn(() => { setFlag({}); }, options); useEffect(() => { return run(); }, deps); useUpdateEffect(effect, [flag]); } export default useThrottleEffect; ================================================ FILE: packages/hooks/src/useThrottleEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useThrottleEffect 为 `useEffect` 增加节流的能力。 ## 代码演示 ### 基础用法 ## API ```typescript useThrottleEffect( effect: EffectCallback, deps?: DependencyList, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | ---------------------------------- | ---------------- | ------ | | effect | 执行函数 | `EffectCallback` | - | | deps | 依赖数组 | `DependencyList` | - | | options | 配置节流的行为,详见下面的 Options | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ---------------------- | --------- | ------ | | wait | 等待时间,单位为毫秒 | `number` | `1000` | | leading | 是否在在延迟开始前调用 | `boolean` | `true` | | trailing | 是否在在延迟结束后调用 | `boolean` | `true` | ================================================ FILE: packages/hooks/src/useThrottleFn/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { sleep } from '../../utils/testingHelpers'; import useThrottleFn from '../index'; interface ParamsObj { fn: (...arg: any) => any; deps?: any[]; wait: number; } const setUp = ({ fn, wait }: ParamsObj) => renderHook(() => useThrottleFn(fn, { wait })); let hook: RenderHookResult; describe('useThrottleFn', () => { test('run, cancel and flush should work', async () => { let count = 0; const throttleFn = (gap: number) => { count += gap; }; act(() => { hook = setUp({ fn: throttleFn, wait: 500, }); }); await act(async () => { hook.result.current.run(1); expect(count).toBe(1); hook.result.current.run(1); hook.result.current.run(1); hook.result.current.run(1); expect(count).toBe(1); await sleep(450); // t: 450 hook.result.current.run(2); expect(count).toBe(1); await sleep(100); // t: 550 hook.result.current.run(2); expect(count).toBe(3); hook.result.current.run(3); hook.result.current.run(3); await sleep(500); // t: 1050 expect(count).toBe(6); hook.result.current.run(1); hook.result.current.run(4); hook.result.current.cancel(); await sleep(500); // t: 1550 expect(count).toBe(7); hook.result.current.run(1); hook.result.current.run(1); expect(count).toBe(8); hook.result.current.flush(); expect(count).toBe(9); await sleep(550); // t: 2100 expect(count).toBe(9); }); }); }); ================================================ FILE: packages/hooks/src/useThrottleFn/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Frequent calls run, but the function is only executed every 500ms. * * title.zh-CN: 基础用法 * desc.zh-CN: 频繁调用 run,但只会每隔 500ms 执行一次相关函数。 */ import { useState } from 'react'; import { useThrottleFn } from 'ahooks'; export default () => { const [value, setValue] = useState(0); const { run } = useThrottleFn( () => { setValue(value + 1); }, { wait: 500 }, ); return (

Clicked count: {value}

); }; ================================================ FILE: packages/hooks/src/useThrottleFn/index.en-US.md ================================================ --- nav: path: /hooks --- # useThrottleFn A hook that deal with the throttled function. ## Examples ### Default usage ## API ```typescript const { run, cancel, flush } = useThrottleFn( fn: (...args: any[]) => any, options?: Options ); ``` ### Params | Property | Description | Type | Default | | -------- | --------------------------------- | ------------------------- | ------- | | fn | The function to throttle. | `(...args: any[]) => any` | - | | options | Config for the throttle behaviors | `Options` | - | ### Options | Property | Description | Type | Default | | -------- | ----------------------------------------------------- | --------- | ------- | | wait | The number of milliseconds to delay. | `number` | `1000` | | leading | Specify invoking on the leading edge of the timeout. | `boolean` | `true` | | trailing | Specify invoking on the trailing edge of the timeout. | `boolean` | `true` | ### Result | Property | Description | Type | | -------- | ------------------------------------------------------ | ------------------------- | | run | Invoke and pass parameters to fn. | `(...args: any[]) => any` | | cancel | Cancel the invocation of currently throttled function. | `() => void` | | flush | Immediately invoke currently throttled function | `() => void` | ================================================ FILE: packages/hooks/src/useThrottleFn/index.ts ================================================ import throttle from 'lodash/throttle'; import { useMemo } from 'react'; import useLatest from '../useLatest'; import type { ThrottleOptions } from '../useThrottle/throttleOptions'; import useUnmount from '../useUnmount'; import { isFunction } from '../utils'; import isDev from '../utils/isDev'; type noop = (...args: any[]) => any; function useThrottleFn(fn: T, options?: ThrottleOptions) { if (isDev) { if (!isFunction(fn)) { console.error(`useThrottleFn expected parameter is a function, got ${typeof fn}`); } } const fnRef = useLatest(fn); const wait = options?.wait ?? 1000; const throttled = useMemo( () => throttle( (...args: Parameters): ReturnType => { return fnRef.current(...args); }, wait, options, ), [], ); useUnmount(() => { throttled.cancel(); }); return { run: throttled, cancel: throttled.cancel, flush: throttled.flush, }; } export default useThrottleFn; ================================================ FILE: packages/hooks/src/useThrottleFn/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useThrottleFn 用来处理函数节流的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const { run, cancel, flush } = useThrottleFn( fn: (...args: any[]) => any, options?: Options ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------- | -------------- | ------------------------- | ------ | | fn | 需要节流的函数 | `(...args: any[]) => any` | - | | options | 配置节流的行为 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | -------- | ------------------------ | --------- | ------ | | wait | 等待时间,单位为毫秒 | `number` | `1000` | | leading | 是否在延迟开始前调用函数 | `boolean` | `true` | | trailing | 是否在延迟开始后调用函数 | `boolean` | `true` | ### Result | 参数 | 说明 | 类型 | | ------ | ---------------------------------- | ------------------------- | | run | 触发执行 fn,函数参数将会传递给 fn | `(...args: any[]) => any` | | cancel | 取消当前节流 | `() => void` | | flush | 当前节流立即调用 | `() => void` | ================================================ FILE: packages/hooks/src/useTimeout/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useTimeout from '../index'; interface ParamsObj { fn: (...arg: any) => any; delay?: number; } const setUp = ({ fn, delay }: ParamsObj) => renderHook(() => useTimeout(fn, delay)); describe('useTimeout', () => { vi.useFakeTimers(); vi.spyOn(global, 'clearTimeout'); test('timeout should work', () => { const callback = vi.fn(); setUp({ fn: callback, delay: 20 }); expect(callback).not.toBeCalled(); vi.advanceTimersByTime(70); expect(callback).toHaveBeenCalledTimes(1); }); test('timeout should stop', () => { const callback = vi.fn(); setUp({ fn: callback, delay: undefined }); vi.advanceTimersByTime(50); expect(callback).toHaveBeenCalledTimes(0); setUp({ fn: callback, delay: -2 }); vi.advanceTimersByTime(50); expect(callback).toHaveBeenCalledTimes(0); }); test('timeout should be clear', () => { const callback = vi.fn(); const hook = setUp({ fn: callback, delay: 20 }); expect(callback).not.toBeCalled(); hook.result.current(); vi.advanceTimersByTime(30); expect(callback).toHaveBeenCalledTimes(0); expect(clearTimeout).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useTimeout/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Execute once after 3000ms * * title.zh-CN: 基础用法 * desc.zh-CN: 3000ms 后执行一次 */ import { useState } from 'react'; import { useTimeout } from 'ahooks'; export default () => { const [state, setState] = useState(1); useTimeout(() => { setState(state + 1); }, 3000); return
{state}
; }; ================================================ FILE: packages/hooks/src/useTimeout/demo/demo2.tsx ================================================ /** * title: Advanced usage * desc: Modify the delay to realize the timer timeout change and pause. * * title.zh-CN: 进阶使用 * desc.zh-CN: 动态修改 delay 以实现定时器间隔变化与暂停。 */ import { useState } from 'react'; import { useTimeout } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [delay, setDelay] = useState(1000); const clear = useTimeout(() => { setCount(count + 1); }, delay); return (

count: {count}

Delay: {delay}

); }; ================================================ FILE: packages/hooks/src/useTimeout/index.en-US.md ================================================ --- nav: path: /hooks --- # useTimeout A hook that handles the `setTimeout` timer function. ## Examples ### Default usage ## API ```typescript useTimeout( fn: () => void, delay?: number | undefined ): fn: () => void; ``` ### Params | Property | Description | Type | | -------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- | | fn | The function to be executed after `delay` milliseconds. | `() => void` | | delay | The number of milliseconds to wait before executing the function. The timer will be cancelled if delay is `undefined`. | `number` \| `undefined` | ### Result | Property | Description | Type | | ------------ | ------------- | ------------ | | clearTimeout | clear timeout | `() => void` | ================================================ FILE: packages/hooks/src/useTimeout/index.ts ================================================ import { useCallback, useEffect, useRef } from 'react'; import useMemoizedFn from '../useMemoizedFn'; import { isNumber } from '../utils'; const useTimeout = (fn: () => void, delay?: number) => { const timerCallback = useMemoizedFn(fn); const timerRef = useRef | null>(null); const clear = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); } }, []); useEffect(() => { if (!isNumber(delay) || delay < 0) { return; } timerRef.current = setTimeout(timerCallback, delay); return clear; }, [delay]); return clear; }; export default useTimeout; ================================================ FILE: packages/hooks/src/useTimeout/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useTimeout 一个可以处理 setTimeout 计时器函数的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript useTimeout( fn: () => void, delay?: number | undefined ): fn: () => void; ``` ### Params | 参数 | 说明 | 类型 | | ----- | -------------------------------------------------------------------------- | ----------------------- | | fn | 待执行函数 | `() => void` | | delay | 定时时间(单位为毫秒),支持动态变化,,当取值为 `undefined` 时会停止计时器 | `number` \| `undefined` | ### Result | 参数 | 说明 | 类型 | | ------------ | ---------- | ------------ | | clearTimeout | 清除定时器 | `() => void` | ================================================ FILE: packages/hooks/src/useTitle/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useTitle from '../index'; describe('useTitle', () => { test('should update document title', () => { const hook = renderHook((props) => useTitle(props), { initialProps: 'Current Page Title' }); expect(document.title).toBe('Current Page Title'); act(() => { hook.rerender('Other Page Title'); }); expect(document.title).toBe('Other Page Title'); }); test('should restore document title on unmount', () => { document.title = 'Old Title'; const hook = renderHook((props) => useTitle(props, { restoreOnUnmount: true }), { initialProps: 'Current Page Title', }); expect(document.title).toBe('Current Page Title'); hook.unmount(); expect(document.title).toBe('Old Title'); }); test('should not restore document title on unmount', () => { document.title = 'Old Title'; const hook = renderHook((props) => useTitle(props, { restoreOnUnmount: false }), { initialProps: 'Current Page Title', }); expect(document.title).toBe('Current Page Title'); hook.unmount(); expect(document.title).toBe('Current Page Title'); }); }); ================================================ FILE: packages/hooks/src/useTitle/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Set title of the page. * * title.zh-CN: 基础用法 * desc.zh-CN: 设置页面标题 */ import { useTitle } from 'ahooks'; export default () => { useTitle('Page Title'); return (

Set title of the page.

); }; ================================================ FILE: packages/hooks/src/useTitle/index.en-US.md ================================================ --- nav: path: /hooks --- # useTitle A hook that set title of the page. ## Examples ### Default usage ## API ```typescript useTitle(title: string, options?: Options); ``` ### Params | Property | Description | Type | Default | | -------- | ----------- | -------- | ------- | | title | Page title | `string` | - | ### Options | Property | Description | Type | Default | | ---------------- | -------------------------------------------------------------------------- | --------- | ------- | | restoreOnUnmount | Whether to restore the previous page title when the component is unmounted | `boolean` | `false` | ================================================ FILE: packages/hooks/src/useTitle/index.ts ================================================ import { useEffect, useRef } from 'react'; import useUnmount from '../useUnmount'; import isBrowser from '../utils/isBrowser'; export interface Options { restoreOnUnmount?: boolean; } const DEFAULT_OPTIONS: Options = { restoreOnUnmount: false, }; function useTitle(title: string, options: Options = DEFAULT_OPTIONS) { const titleRef = useRef(isBrowser ? document.title : ''); useEffect(() => { document.title = title; }, [title]); useUnmount(() => { if (options.restoreOnUnmount) { document.title = titleRef.current; } }); } export default useTitle; ================================================ FILE: packages/hooks/src/useTitle/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useTitle 用于设置页面标题。 ## 代码演示 ### 基础用法 ## API ```typescript useTitle(title: string, options?: Options); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ----- | -------- | -------- | ------ | | title | 页面标题 | `string` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | ---------------- | ---------------------------------- | --------- | ------- | | restoreOnUnmount | 组件卸载时,是否恢复上一个页面标题 | `boolean` | `false` | ================================================ FILE: packages/hooks/src/useToggle/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useToggle from '../index'; const callToggle = (hook: any) => { act(() => { hook.result.current[1].toggle(); }); }; describe('useToggle', () => { test('test on init', async () => { const hook = renderHook(() => useToggle()); expect(hook.result.current[0]).toBeFalsy(); }); test('test on methods', async () => { const hook = renderHook(() => useToggle('Hello')); expect(hook.result.current[0]).toBe('Hello'); callToggle(hook); expect(hook.result.current[0]).toBeFalsy(); act(() => { hook.result.current[1].setLeft(); }); expect(hook.result.current[0]).toBe('Hello'); act(() => { hook.result.current[1].setRight(); }); expect(hook.result.current[0]).toBeFalsy(); }); test('test on optional', () => { const hook = renderHook(() => useToggle('Hello', 'World')); callToggle(hook); expect(hook.result.current[0]).toBe('World'); act(() => { hook.result.current[1].set('World'); }); expect(hook.result.current[0]).toBe('World'); callToggle(hook); expect(hook.result.current[0]).toBe('Hello'); }); }); ================================================ FILE: packages/hooks/src/useToggle/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Default value is boolean,alike useBoolean. * * title.zh-CN: 基础用法 * desc.zh-CN: 默认为 boolean 切换,基础用法与 useBoolean 一致。 */ import { useToggle } from 'ahooks'; export default () => { const [state, { toggle, setLeft, setRight }] = useToggle(); return (

Effects:{`${state}`}

); }; ================================================ FILE: packages/hooks/src/useToggle/demo/demo2.tsx ================================================ /** * title: Toggle between any two values * desc: Accept two optional parameters and toggle between them. * * title.zh-CN: 在任意两个值之间切换 * desc.zh-CN: 接受两个可选参数,在它们之间进行切换。 */ import { useToggle } from 'ahooks'; export default () => { const [state, { toggle, set, setLeft, setRight }] = useToggle('Hello', 'World'); return (

Effects:{state}

); }; ================================================ FILE: packages/hooks/src/useToggle/index.en-US.md ================================================ --- nav: path: /hooks --- # useToggle A hook that toggle states. ## Examples ### Default usage ### Advanced usage ## API ```typescript const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean); const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue: T); const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue: T, reverseValue: U) ``` ### Params | Property | Description | Type | Default | | ------------ | --------------------------- | ---- | ------- | | defaultValue | The default value. Optional | `T` | `false` | | reverseValue | The reverse value. Optional | `U` | - | ### Result | Property | Description | Type | | -------- | -------------------------------------- | --------- | | state | Current state | - | | actions | A set of methods to update state value | `Actions` | ### Actions | Property | Description | Type | | -------- | ------------------------------------------------------------------------------------------------------------- | ------------------------- | | toggle | Toggle state | `() => void` | | set | Set state | `(state: T \| U) => void` | | setLeft | Set state to `defaultValue` | `() => void` | | setRight | Set state to `reverseValue` if `reverseValue` is available. Otherwise set it to the reverse of `defaultValue` | `() => void` | ================================================ FILE: packages/hooks/src/useToggle/index.ts ================================================ import { useMemo, useState } from 'react'; export interface Actions { setLeft: () => void; setRight: () => void; set: (value: T) => void; toggle: () => void; } function useToggle(): [boolean, Actions]; function useToggle(defaultValue: T): [T, Actions]; function useToggle(defaultValue: T, reverseValue: U): [T | U, Actions]; function useToggle(defaultValue: D = false as unknown as D, reverseValue?: R) { const [state, setState] = useState(defaultValue); const actions = useMemo(() => { const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R; const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue)); const set = (value: D | R) => setState(value); const setLeft = () => setState(defaultValue); const setRight = () => setState(reverseValueOrigin); return { toggle, set, setLeft, setRight, }; // useToggle ignore value change // }, [defaultValue, reverseValue]); }, []); return [state, actions]; } export default useToggle; ================================================ FILE: packages/hooks/src/useToggle/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useToggle 用于在两个状态值间切换的 Hook。 ## 代码演示 ### 基础用法 ### 高级用法 ## API ```typescript const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean); const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue: T); const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue: T, reverseValue: U); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | ------------------------ | ---- | ------- | | defaultValue | 可选项,传入默认的状态值 | `T` | `false` | | reverseValue | 可选项,传入取反的状态值 | `U` | - | ### Result | 参数 | 说明 | 类型 | | ------- | -------- | --------- | | state | 状态值 | - | | actions | 操作集合 | `Actions` | ### Actions | 参数 | 说明 | 类型 | | -------- | ------------------------------------------------------------------------------- | ------------------------- | | toggle | 切换 state | `() => void` | | set | 修改 state | `(state: T \| U) => void` | | setLeft | 设置为 defaultValue | `() => void` | | setRight | 如果传入了 reverseValue, 则设置为 reverseValue。 否则设置为 defaultValue 的反值 | `() => void` | ================================================ FILE: packages/hooks/src/useTrackedEffect/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import useTrackedEffect from '../index'; describe('useTrackedEffect', () => { //We use a array to store which dependency has changed let changedDepIndexes: number[] = []; let prevDependencies: any[] = []; let currentDependencies: any[] = []; let mockEffectCleanup: any; let mockEffectCallback: any; let mockEffectWithTracked: any; beforeEach(() => { changedDepIndexes = []; prevDependencies = []; currentDependencies = []; mockEffectCleanup = vi.fn(); mockEffectCallback = vi.fn().mockReturnValue(mockEffectCleanup); mockEffectWithTracked = vi.fn().mockImplementation((changes, prevDeps, curDeps) => { //This effect callback accept an addition parameter which contains indexes of dependencies which changed their equalities. changedDepIndexes = changes; prevDependencies = prevDeps; currentDependencies = curDeps; return mockEffectCleanup; }); }); test("should run provided effect and return single changed dependency's index ", () => { const deps = { var1: 0, var2: '0', var3: { value: 0 } }; const { rerender } = renderHook(() => useTrackedEffect(mockEffectWithTracked, [deps.var1, deps.var2, deps.var3]), ); expect(mockEffectWithTracked).toHaveBeenCalledTimes(1); rerender(); expect(changedDepIndexes).toHaveLength(3); changedDepIndexes = []; deps.var1++; rerender(); expect(changedDepIndexes).toHaveLength(1); expect(changedDepIndexes[0]).toBe(0); }); test('should run provided effect and return correct dependencies (previous and current)', () => { const deps = { var1: 0, var2: '0', var3: { value: 0 } }; const { rerender } = renderHook(() => useTrackedEffect(mockEffectWithTracked, [deps.var1, deps.var2, deps.var3]), ); expect(mockEffectWithTracked).toHaveBeenCalledTimes(1); rerender(); expect(changedDepIndexes).toHaveLength(3); changedDepIndexes = []; deps.var1++; deps.var2 = '1'; rerender(); expect(prevDependencies[0]).toBe(0); expect(currentDependencies[0]).toBe(1); expect(prevDependencies[1] === '0').toBe(true); expect(currentDependencies[1] === '1').toBe(true); changedDepIndexes = []; deps.var2 = '2'; rerender(); expect(prevDependencies[1]).toBe('1'); expect(currentDependencies[1]).toBe('2'); }); test(" should run provided effect and return multiple changed dependecy's indexes", () => { const deps = { var1: 0, var2: '0', var3: { value: 0 } }; const { rerender } = renderHook(() => useTrackedEffect(mockEffectWithTracked, [deps.var1, deps.var2, deps.var3]), ); expect(mockEffectWithTracked).toHaveBeenCalledTimes(1); rerender(); expect(changedDepIndexes).toHaveLength(3); changedDepIndexes = []; deps.var1++; deps.var2 = '1'; rerender(); expect(changedDepIndexes).toHaveLength(2); expect(changedDepIndexes[0]).toBe(0); expect(changedDepIndexes[1]).toBe(1); changedDepIndexes = []; deps.var2 = '2'; rerender(); expect(changedDepIndexes).toHaveLength(1); expect(changedDepIndexes[0]).toBe(1); }); test('should run provided effect and return empty if no dependency changed', () => { const deps = { var1: 0, var2: '0', var3: { value: 0 } }; const { rerender } = renderHook(() => useTrackedEffect(mockEffectWithTracked, [deps.var1, deps.var2, deps.var3]), ); expect(mockEffectWithTracked).toHaveBeenCalledTimes(1); rerender(); expect(changedDepIndexes).toHaveLength(3); changedDepIndexes = []; deps.var1 = 0; rerender(); expect(changedDepIndexes).toHaveLength(0); }); test('should run provided effect and make sure reference equality is correct', () => { const deps = { var1: 0, var2: '0', var3: { value: 0 } }; const { rerender } = renderHook(() => useTrackedEffect(mockEffectWithTracked, [deps.var1, deps.var2, deps.var3]), ); expect(mockEffectWithTracked).toHaveBeenCalledTimes(1); rerender(); expect(changedDepIndexes).toHaveLength(3); changedDepIndexes = []; deps.var3.value = 123; rerender(); expect(changedDepIndexes).toHaveLength(0); }); test('should run clean-up provided on unmount as a normal useEffect', () => { const { unmount } = renderHook(() => useTrackedEffect(mockEffectCallback)); expect(mockEffectCleanup).not.toHaveBeenCalled(); unmount(); expect(mockEffectCleanup).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useTrackedEffect/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Display the changed deps when effect function is executed. * * title.zh-CN: 基础用法 * desc.zh-CN: 查看每次 effect 执行时发生变化的依赖项 */ import { useState } from 'react'; import { useTrackedEffect } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [count2, setCount2] = useState(0); useTrackedEffect( (changes) => { console.log('Index of changed dependencies: ', changes); }, [count, count2], ); return (

Please open the browser console to view the output!

Count: {count}

Count2: {count2}

); }; ================================================ FILE: packages/hooks/src/useTrackedEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useTrackedEffect A hook of useEffect that allow us to track which dependencies caused the effect to trigger. ## Examples ### Basic usage ## API ```typescript useTrackedEffect( effect: (changes: [], previousDeps: [], currentDeps: []) => (void | (() => void | undefined)), deps?: deps, ) ``` The API is alike `React.useEffect`, but the first function will receive three parameters: `changes`, `previousDeps`, and `currentDeps`. - changes: Index of changed dependencies - previousDeps: Last deps - currentDeps: Current deps ================================================ FILE: packages/hooks/src/useTrackedEffect/index.ts ================================================ import type { DependencyList } from 'react'; import { useEffect, useRef } from 'react'; type Effect = ( changes?: number[], previousDeps?: T, currentDeps?: T, ) => void | (() => void); const diffTwoDeps = (deps1?: DependencyList, deps2?: DependencyList) => { // Let's do a reference equality check on 2 dependency list. // If deps1 is defined, we iterate over deps1 and do comparison on each element with equivalent element from deps2 // As this func is used only in this hook, we assume 2 deps always have same length. return deps1 ? deps1 .map((_, idx) => (!Object.is(deps1[idx], deps2?.[idx]) ? idx : -1)) .filter((ele) => ele >= 0) : deps2 ? deps2.map((_, idx) => idx) : []; }; const useTrackedEffect = (effect: Effect, deps?: [...T]) => { const previousDepsRef = useRef(undefined); useEffect(() => { const changes = diffTwoDeps(previousDepsRef.current, deps); const previousDeps = previousDepsRef.current; previousDepsRef.current = deps; return effect(changes, previousDeps, deps); }, deps); }; export default useTrackedEffect; ================================================ FILE: packages/hooks/src/useTrackedEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useTrackedEffect 追踪是哪个依赖变化触发了 `useEffect` 的执行。 ## 代码演示 ### 基础用法 ## API ```typescript useTrackedEffect( effect: (changes: [], previousDeps: [], currentDeps: []) => (void | (() => void | undefined)), deps?: deps, ) ``` API 与 `React.useEffect` 基本一致,不过第一个函数会接收 `changes`、`previousDeps`、`currentDeps` 三个参数。 - changes:变化的依赖 index 数组 - previousDeps:上一个依赖 - currentDeps:当前依赖 ================================================ FILE: packages/hooks/src/useUnmount/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test, vi } from 'vitest'; import useUnmount from '../index'; describe('useUnmount', () => { test('useUnmount should work', async () => { const fn = vi.fn(); const hook = renderHook(() => useUnmount(fn)); expect(fn).toBeCalledTimes(0); hook.rerender(); expect(fn).toBeCalledTimes(0); hook.unmount(); expect(fn).toBeCalledTimes(1); }); }); ================================================ FILE: packages/hooks/src/useUnmount/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: The function is called right before the component is unmounted. * * title.zh-CN: 基础用法 * desc.zh-CN: 在组件卸载时,执行函数。 */ import { useBoolean, useUnmount } from 'ahooks'; import { message } from 'antd'; const MyComponent = () => { useUnmount(() => { message.info('unmount'); }); return

Hello World!

; }; export default () => { const [state, { toggle }] = useBoolean(true); return ( <> {state && } ); }; ================================================ FILE: packages/hooks/src/useUnmount/index.en-US.md ================================================ --- nav: path: /hooks --- # useUnmount A hook that executes the function right before the component is unmounted. ## Examples ### Default Usage ## API ```typescript useUnmount(fn: () => void); ``` ### Params | Property | Description | Type | Default | | -------- | --------------------------- | ------------ | ------- | | fn | The function to be executed | `() => void` | - | ================================================ FILE: packages/hooks/src/useUnmount/index.ts ================================================ import { useEffect } from 'react'; import useLatest from '../useLatest'; import { isFunction } from '../utils'; import isDev from '../utils/isDev'; const useUnmount = (fn: () => void) => { if (isDev) { if (!isFunction(fn)) { console.error(`useUnmount expected parameter is a function, got ${typeof fn}`); } } const fnRef = useLatest(fn); useEffect( () => () => { fnRef.current(); }, [], ); }; export default useUnmount; ================================================ FILE: packages/hooks/src/useUnmount/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useUnmount 在组件卸载(unmount)时执行的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript useUnmount(fn: () => void); ``` ### 参数 | 参数 | 说明 | 类型 | 默认值 | | ---- | -------------------- | ------------ | ------ | | fn | 组件卸载时执行的函数 | `() => void` | - | ================================================ FILE: packages/hooks/src/useUnmountedRef/__tests__/index.spec.ts ================================================ import { describe, expect, test } from 'vitest'; import { renderHook } from '../../utils/tests'; import useUnmountedRef from '../index'; describe('useUnmountedRef', () => { test('should work', async () => { const hook = renderHook(() => useUnmountedRef()); expect(hook.result.current.current).toBe(false); hook.rerender(); expect(hook.result.current.current).toBe(false); hook.unmount(); expect(hook.result.current.current).toBe(true); }); }); ================================================ FILE: packages/hooks/src/useUnmountedRef/demo/demo1.tsx ================================================ /** * title: Default usage * desc: unmountedRef.current means whether the component is unmounted * * title.zh-CN: 基础用法 * desc.zh-CN: unmountedRef.current 代表组件是否已经卸载 */ import { useBoolean, useUnmountedRef } from 'ahooks'; import { message } from 'antd'; import { useEffect } from 'react'; const MyComponent = () => { const unmountedRef = useUnmountedRef(); useEffect(() => { setTimeout(() => { if (!unmountedRef.current) { message.info('component is alive'); } }, 3000); }, []); return

Hello World!

; }; export default () => { const [state, { toggle }] = useBoolean(true); return ( <> {state && } ); }; ================================================ FILE: packages/hooks/src/useUnmountedRef/index.en-US.md ================================================ --- nav: path: /hooks --- # useUnmountedRef A Hook can be used to get whether the component is unmounted. ## Examples ### Default Usage ## API ```typescript const unmountRef: { current: boolean } = useUnmountedRef(); ``` ### Result | Property | Description | Type | | ---------- | ---------------------------------- | ---------------------- | | unmountRef | Whether the component is unmounted | `{ current: boolean }` | ================================================ FILE: packages/hooks/src/useUnmountedRef/index.tsx ================================================ import { useEffect, useRef } from 'react'; const useUnmountedRef = () => { const unmountedRef = useRef(false); useEffect(() => { unmountedRef.current = false; return () => { unmountedRef.current = true; }; }, []); return unmountedRef; }; export default useUnmountedRef; ================================================ FILE: packages/hooks/src/useUnmountedRef/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useUnmountedRef 获取当前组件是否已经卸载的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript const unmountRef: { current: boolean } = useUnmountedRef(); ``` ### Result | 参数 | 说明 | 类型 | | ---------- | ---------------- | ---------------------- | | unmountRef | 组件是否已经卸载 | `{ current: boolean }` | ================================================ FILE: packages/hooks/src/useUpdate/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useMemoizedFn from '../../useMemoizedFn'; import useUpdate from '..'; describe('useUpdate', () => { test('should update', () => { let count = 0; const hooks = renderHook(() => { const update = useUpdate(); return { update, count, onChange: useMemoizedFn(() => { count++; update(); }), }; }); expect(hooks.result.current.count).toBe(0); act(hooks.result.current.onChange); expect(hooks.result.current.count).toBe(1); }); test('should return same update function', () => { const hooks = renderHook(() => useUpdate()); const preUpdate = hooks.result.current; hooks.rerender(); expect(hooks.result.current).toEqual(preUpdate); }); }); ================================================ FILE: packages/hooks/src/useUpdate/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: Forces component to re-render. * * title.zh-CN: 基础用法 * desc.zh-CN: 强制组件重新渲染。 */ import { useUpdate } from 'ahooks'; export default () => { const update = useUpdate(); return ( <>
Time: {Date.now()}
); }; ================================================ FILE: packages/hooks/src/useUpdate/index.en-US.md ================================================ --- nav: path: /hooks --- # useUpdate A hook that returns a function which can be used to force the component to re-render. ## Examples ### Default Usage ## API ```typescript const update = useUpdate(); ``` ================================================ FILE: packages/hooks/src/useUpdate/index.ts ================================================ import { useState } from 'react'; import useMemoizedFn from '../useMemoizedFn'; const useUpdate = () => { const [, setState] = useState({}); return useMemoizedFn(() => setState({})); }; export default useUpdate; ================================================ FILE: packages/hooks/src/useUpdate/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useUpdate useUpdate 会返回一个函数,调用该函数会强制组件重新渲染。 ## 代码演示 ### 基础用法 ## API ```typescript const update = useUpdate(); ``` ================================================ FILE: packages/hooks/src/useUpdateEffect/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useUpdateEffect from '../index'; describe('useUpdateEffect', () => { test('test on mounted', async () => { let mountedState = 1; const hook = renderHook(() => useUpdateEffect(() => { mountedState = 2; }), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(2); }); test('test on optional', () => { let mountedState = 1; const hook = renderHook(() => useUpdateEffect(() => { mountedState = 3; }, [mountedState]), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(1); mountedState = 2; hook.rerender(); expect(mountedState).toBe(3); }); }); ================================================ FILE: packages/hooks/src/useUpdateEffect/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: This hook is exactly the same as useEffect, except it skips running the effect for the first time. * * title.zh-CN: 基础用法 * desc.zh-CN: 使用上与 useEffect 完全相同,只是它忽略了首次执行,只在依赖项更新时执行。 */ import { useEffect, useState } from 'react'; import { useUpdateEffect } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [effectCount, setEffectCount] = useState(0); const [updateEffectCount, setUpdateEffectCount] = useState(0); useEffect(() => { setEffectCount((c) => c + 1); }, [count]); useUpdateEffect(() => { setUpdateEffectCount((c) => c + 1); return () => { // do something }; }, [count]); // you can include deps array if necessary return (

effectCount: {effectCount}

updateEffectCount: {updateEffectCount}

); }; ================================================ FILE: packages/hooks/src/useUpdateEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useUpdateEffect A hook alike `useEffect` but skips running the effect for the first time. ## Examples ### Basic usage ## API The API is exactly the same as `React.useEffect`. ```typescript useUpdateEffect( effect: React.EffectCallback, deps?: React.DependencyList, ) ``` ================================================ FILE: packages/hooks/src/useUpdateEffect/index.ts ================================================ import { useEffect } from 'react'; import { createUpdateEffect } from '../createUpdateEffect'; export default createUpdateEffect(useEffect); ================================================ FILE: packages/hooks/src/useUpdateEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useUpdateEffect `useUpdateEffect` 用法等同于 `useEffect`,但是会忽略首次执行,只在依赖更新时执行。 ## 代码演示 ### 基础用法 ## API API 与 `React.useEffect` 完全一致。 ```typescript useUpdateEffect( effect: React.EffectCallback, deps?: React.DependencyList, ) ``` ================================================ FILE: packages/hooks/src/useUpdateLayoutEffect/__tests__/index.spec.ts ================================================ import { renderHook } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import useUpdateLayoutEffect from '../index'; describe('useUpdateLayoutEffect', () => { test('test on mounted', async () => { let mountedState = 1; const hook = renderHook(() => useUpdateLayoutEffect(() => { mountedState = 2; }), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(2); }); test('test on optional', () => { let mountedState = 1; const hook = renderHook(() => useUpdateLayoutEffect(() => { mountedState = 3; }, [mountedState]), ); expect(mountedState).toBe(1); hook.rerender(); expect(mountedState).toBe(1); mountedState = 2; hook.rerender(); expect(mountedState).toBe(3); }); }); ================================================ FILE: packages/hooks/src/useUpdateLayoutEffect/demo/demo1.tsx ================================================ /** * title: Basic usage * desc: This hook is exactly the same as useLayoutEffect, except it skips running the effect for the first time. * * title.zh-CN: 基础用法 * desc.zh-CN: 使用上与 useLayoutEffect 完全相同,只是它忽略了首次执行,且只在依赖项更新时执行。 */ import { useLayoutEffect, useState } from 'react'; import { useUpdateLayoutEffect } from 'ahooks'; export default () => { const [count, setCount] = useState(0); const [layoutEffectCount, setLayoutEffectCount] = useState(0); const [updateLayoutEffectCount, setUpdateLayoutEffectCount] = useState(0); useLayoutEffect(() => { setLayoutEffectCount((c) => c + 1); }, [count]); useUpdateLayoutEffect(() => { setUpdateLayoutEffectCount((c) => c + 1); return () => { // do something }; }, [count]); // you can include deps array if necessary return (

layoutEffectCount: {layoutEffectCount}

updateLayoutEffectCount: {updateLayoutEffectCount}

); }; ================================================ FILE: packages/hooks/src/useUpdateLayoutEffect/index.en-US.md ================================================ --- nav: path: /hooks --- # useUpdateLayoutEffect A hook alike `useLayoutEffect` but skips running the effect for the first time. ## Examples ### Basic usage ## API The API is exactly the same as `React.useLayoutEffect`. ```typescript useUpdateEffect( effect: React.EffectCallback, deps?: React.DependencyList, ) ``` ================================================ FILE: packages/hooks/src/useUpdateLayoutEffect/index.ts ================================================ import { useLayoutEffect } from 'react'; import { createUpdateEffect } from '../createUpdateEffect'; export default createUpdateEffect(useLayoutEffect); ================================================ FILE: packages/hooks/src/useUpdateLayoutEffect/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useUpdateLayoutEffect `useUpdateLayoutEffect` 用法等同于 `useLayoutEffect`,但是会忽略首次执行,只在依赖更新时执行。 ## 代码演示 ### 基础用法 ## API API 与 `React.useLayoutEffect` 完全一致。 ```typescript useUpdateLayoutEffect( effect: React.EffectCallback, deps?: React.DependencyList, ) ``` ================================================ FILE: packages/hooks/src/useVirtualList/__tests__/index.spec.ts ================================================ import { act, type RenderHookResult, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { Options } from '../index'; import useVirtualList from '../index'; describe('useVirtualList', () => { describe('virtual list render', () => { let hook: RenderHookResult; let container: HTMLDivElement; let wrapper: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); // mock clientheight, clientWidth // see: https://github.com/testing-library/react-testing-library/issues/353 vi.spyOn(container, 'clientHeight', 'get').mockImplementation(() => 300); vi.spyOn(container, 'clientWidth', 'get').mockImplementation(() => 300); wrapper = document.createElement('div'); container.appendChild(wrapper); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); hook.unmount(); }); const setup = (list: any[] = [], options: Options) => { hook = renderHook(() => useVirtualList(list, options)); }; test('test return list size', () => { setup(Array.from(Array(99999).keys()), { containerTarget: () => container, wrapperTarget: () => wrapper, itemHeight: 30, }); act(() => { hook.result.current[1](80); }); // 10 items plus 5 overscan * 2 expect(hook.result.current[0].length).toBe(20); expect(container.scrollTop).toBe(80 * 30); }); test('test with fixed height', () => { setup(Array.from(Array(99999).keys()), { overscan: 0, itemHeight: 30, containerTarget: () => container, wrapperTarget: () => wrapper, }); act(() => { hook.result.current[1](20); }); expect(hook.result.current[0].length).toBe(10); expect(container.scrollTop).toBe(20 * 30); expect(hook.result.current[0][0].data).toBe(20); expect(hook.result.current[0][0].index).toBe(20); }); test('test with dynamic height', async () => { const list = Array.from(Array(99999).keys()); setup(list, { overscan: 0, containerTarget: () => container, wrapperTarget: () => wrapper, itemHeight: (i: number, data) => { expect(list[i] === data).toBe(true); return i % 2 === 0 ? 30 : 60; }, }); act(() => { hook.result.current[1](20); }); // average height for easy calculation const averageHeight = (30 + 60) / 2; expect(hook.result.current[0].length).toBe(Math.floor(300 / averageHeight)); expect(container.scrollTop).toBe(10 * 30 + 10 * 60); expect((hook.result.current[0][0] as { data: number }).data).toBe(20); expect((hook.result.current[0][0] as { index: number }).index).toBe(20); expect((hook.result.current[0][5] as { data: number }).data).toBe(25); expect((hook.result.current[0][5] as { index: number }).index).toBe(25); expect(wrapper.style.marginTop).toBe(20 * averageHeight + 'px'); expect(wrapper.style.height).toBe((99998 - 20) * averageHeight + 30 + 'px'); }); }); }); ================================================ FILE: packages/hooks/src/useVirtualList/demo/demo1.tsx ================================================ /** * title: Default usage * desc: render 100,000 items in a list. * * title.zh-CN: 基础用法 * desc.zh-CN: 渲染大量数据 */ import { useMemo, useRef } from 'react'; import { useVirtualList } from 'ahooks'; export default () => { const containerRef = useRef(null); const wrapperRef = useRef(null); const originalList = useMemo(() => Array.from(Array(99999).keys()), []); const [list] = useVirtualList(originalList, { containerTarget: containerRef, wrapperTarget: wrapperRef, itemHeight: 60, overscan: 10, }); return ( <>
{list.map((ele) => (
Row: {ele.data}
))}
); }; ================================================ FILE: packages/hooks/src/useVirtualList/demo/demo2.tsx ================================================ /** * title: Dynamic item height * desc: Specify item height dynamically. * * title.zh-CN: 动态元素高度 * desc.zh-CN: 动态指定每个元素的高度 */ import { useMemo, useRef, useState } from 'react'; import { useVirtualList } from 'ahooks'; export default () => { const containerRef = useRef(null); const wrapperRef = useRef(null); const originalList = useMemo(() => Array.from(Array(99999).keys()), []); const [value, onChange] = useState(0); const [list, scrollTo] = useVirtualList(originalList, { containerTarget: containerRef, wrapperTarget: wrapperRef, itemHeight: (i) => (i % 2 === 0 ? 42 + 8 : 84 + 8), overscan: 10, }); return (
onChange(Number(e.target.value))} />
{list.map((ele) => (
Row: {ele.data} size: {ele.index % 2 === 0 ? 'small' : 'large'}
))}
); }; ================================================ FILE: packages/hooks/src/useVirtualList/index.en-US.md ================================================ --- nav: path: /hooks --- # useVirtualList A hook that allows you to use virtual list to render huge chunks of list data. ## Examples ### Default usage ### Dynamic item height ## API ```typescript const [list, scrollTo] = useVirtualList( originalList: T[], options: { containerTarget: (() => Element) | Element | MutableRefObject, wrapperTarget: (() => Element) | Element | MutableRefObject, itemHeight: number | ((index: number, data: T) => number), overscan?: number, } ); ``` ### Params | Property | Description | Type | Default | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------- | | originalList | The original list that contains a lot of data entries. **Attention: must undergo useMemo processing or never change, otherwise there will be a dead loop** | `T[]` | `[]` | | options | config | `Options` | - | ### Options | Property | Description | Type | Default | | --------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------- | ------- | | containerTarget | Outter Container,support DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | wrapperTarget | Inner Container,DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - | | itemHeight | Item height, accept a pixel value or a function that returns the height | `number` \| `((index: number, data: T) => number)` | - | | overscan | The extra buffer items outside of the view area | `number` | `5` | ### Result | Property | Description | Type | | -------- | ------------------------------------------------------ | ------------------------------ | | list | The current portion of data need to be rendered to DOM | `{ data: T, index: number }[]` | | scrollTo | Scroll to specific index | `(index: number) => void` | ================================================ FILE: packages/hooks/src/useVirtualList/index.ts ================================================ import { useEffect, useMemo, useState, useRef } from 'react'; import type { CSSProperties } from 'react'; import useEventListener from '../useEventListener'; import useLatest from '../useLatest'; import useMemoizedFn from '../useMemoizedFn'; import useSize from '../useSize'; import { getTargetElement } from '../utils/domTarget'; import type { BasicTarget } from '../utils/domTarget'; import { isNumber } from '../utils'; import useUpdateEffect from '../useUpdateEffect'; type ItemHeight = (index: number, data: T) => number; export interface Options { containerTarget: BasicTarget; wrapperTarget: BasicTarget; itemHeight: number | ItemHeight; overscan?: number; } const useVirtualList = (list: T[], options: Options) => { const { containerTarget, wrapperTarget, itemHeight, overscan = 5 } = options; const itemHeightRef = useLatest(itemHeight); const size = useSize(containerTarget); const scrollTriggerByScrollToFunc = useRef(false); const [targetList, setTargetList] = useState<{ index: number; data: T }[]>([]); const [wrapperStyle, setWrapperStyle] = useState({}); const getVisibleCount = (containerHeight: number, fromIndex: number) => { if (isNumber(itemHeightRef.current)) { return Math.ceil(containerHeight / itemHeightRef.current); } let sum = 0; let endIndex = 0; for (let i = fromIndex; i < list.length; i++) { const height = itemHeightRef.current(i, list[i]); sum += height; endIndex = i; if (sum >= containerHeight) { break; } } return endIndex - fromIndex; }; const getOffset = (scrollTop: number) => { if (isNumber(itemHeightRef.current)) { return Math.floor(scrollTop / itemHeightRef.current); } let sum = 0; let offset = 0; for (let i = 0; i < list.length; i++) { const height = itemHeightRef.current(i, list[i]); sum += height; if (sum >= scrollTop) { offset = i; break; } } return offset + 1; }; // 获取上部高度 const getDistanceTop = (index: number) => { if (isNumber(itemHeightRef.current)) { const height = index * itemHeightRef.current; return height; } const height = list .slice(0, index) .reduce((sum, _, i) => sum + (itemHeightRef.current as ItemHeight)(i, list[i]), 0); return height; }; const totalHeight = useMemo(() => { if (isNumber(itemHeightRef.current)) { return list.length * itemHeightRef.current; } return list.reduce( (sum, _, index) => sum + (itemHeightRef.current as ItemHeight)(index, list[index]), 0, ); }, [list]); const calculateRange = () => { const container = getTargetElement(containerTarget); if (container) { const { scrollTop, clientHeight } = container; const offset = getOffset(scrollTop); const visibleCount = getVisibleCount(clientHeight, offset); const start = Math.max(0, offset - overscan); const end = Math.min(list.length, offset + visibleCount + overscan); const offsetTop = getDistanceTop(start); setWrapperStyle({ height: totalHeight - offsetTop + 'px', marginTop: offsetTop + 'px', }); setTargetList( list.slice(start, end).map((ele, index) => ({ data: ele, index: index + start, })), ); } }; useUpdateEffect(() => { const wrapper = getTargetElement(wrapperTarget) as HTMLElement; if (wrapper) { Object.keys(wrapperStyle).forEach( (key) => ((wrapper.style as any)[key] = (wrapperStyle as any)[key]), ); } }, [wrapperStyle]); useEffect(() => { if (!size?.width || !size?.height) { return; } calculateRange(); }, [size?.width, size?.height, list]); useEventListener( 'scroll', (e) => { if (scrollTriggerByScrollToFunc.current) { scrollTriggerByScrollToFunc.current = false; return; } e.preventDefault(); calculateRange(); }, { target: containerTarget, }, ); const scrollTo = (index: number) => { const container = getTargetElement(containerTarget); if (container) { scrollTriggerByScrollToFunc.current = true; container.scrollTop = getDistanceTop(index); calculateRange(); } }; return [targetList, useMemoizedFn(scrollTo)] as const; }; export default useVirtualList; ================================================ FILE: packages/hooks/src/useVirtualList/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useVirtualList 提供虚拟化列表能力的 Hook,用于解决展示海量数据渲染时首屏渲染缓慢和滚动卡顿问题。 ## 代码演示 ### 基础用法 ### 动态元素高度 ## API ```typescript const [list, scrollTo] = useVirtualList( originalList: T[], options: { containerTarget: (() => Element) | Element | MutableRefObject, wrapperTarget: (() => Element) | Element | MutableRefObject, itemHeight: number | ((index: number, data: T) => number), overscan?: number, } ); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------ | ------------------------------------------------------------------------------ | --------- | ------ | | originalList | 包含大量数据的列表。 **注意:必须经过 useMemo 处理或者永不变化,否则会死循环** | `T[]` | `[]` | | options | 配置项 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | --------------- | ------------------------------------------------------ | ----------------------------------------------------------- | ------ | | containerTarget | 外面容器,支持 DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | wrapperTarget | 内部容器,支持 DOM 节点或者 Ref 对象 | `() => Element` \| `Element` \| `MutableRefObject` | - | | itemHeight | 行高度,静态高度可以直接写入像素值,动态高度可传入函数 | `number` \| `((index: number, data: T) => number)` | - | | overscan | 视区上、下额外展示的 DOM 节点数量 | `number` | `5` | ### Result | 参数 | 说明 | 类型 | | -------- | ---------------------- | ------------------------------ | | list | 当前需要展示的列表内容 | `{ data: T, index: number }[]` | | scrollTo | 快速滚动到指定 index | `(index: number) => void` | ================================================ FILE: packages/hooks/src/useWebSocket/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import WS from 'vitest-websocket-mock'; import { sleep } from '../../utils/testingHelpers'; import useWebSocket, { ReadyState } from '../index'; const promise: Promise = new Promise((resolve) => resolve()); const wsUrl = 'ws://localhost:9999'; describe('useWebSocket', () => { afterEach(() => { WS.clean(); }); test('should work', async () => { const wsServer = new WS(wsUrl); const hooks = renderHook(() => useWebSocket(wsUrl)); // connect expect(hooks.result.current.readyState).toBe(ReadyState.Connecting); expect(hooks.result.current.latestMessage).toBeUndefined(); await act(async () => { await wsServer.connected; return promise; }); expect(hooks.result.current.readyState).toBe(ReadyState.Open); // send message const nowTime = `${Date.now()}`; hooks.result.current.sendMessage?.(nowTime); await expect(wsServer).toReceiveMessage(nowTime); // receive message act(() => { wsServer.send(nowTime); }); expect(hooks.result.current.latestMessage?.data).toBe(nowTime); // disconnect act(() => wsServer.close()); await act(async () => { await wsServer.closed; return promise; }); expect(hooks.result.current.readyState).toBe(ReadyState.Closed); }); test('disconnect should work', async () => { const wsServer = new WS(wsUrl); const hooks = renderHook(() => useWebSocket(wsUrl)); // connect expect(hooks.result.current.readyState).toBe(ReadyState.Connecting); await act(() => wsServer.connected); expect(hooks.result.current.readyState).toBe(ReadyState.Open); // disconnect act(() => hooks.result.current.disconnect()); await act(() => wsServer.closed); expect(hooks.result.current.readyState).toBe(ReadyState.Closed); }); test('useWebSocket should be manually triggered', async () => { const wsServer = new WS(wsUrl); new WebSocket(wsUrl); const hooks = renderHook(() => useWebSocket(wsUrl, { manual: true })); expect(hooks.result.current.readyState).toBe(ReadyState.Closed); await act(async () => { await wsServer.connected; }); // We set "manual: true", so the connection status should be still closed. expect(hooks.result.current.readyState).toBe(ReadyState.Closed); await act(async () => { hooks.result.current.connect!(); await sleep(100); // To make sure connection is established }); expect(hooks.result.current.readyState).toBe(ReadyState.Open); act(() => wsServer.close()); }); test('should not call connect when initial socketUrl is empty', async () => { const wsServer = new WS(wsUrl); const onOpen = vi.fn(); const onClose = vi.fn(); let url = ''; const hooks = renderHook(() => useWebSocket(url, { onOpen, onClose })); await act(async () => { await sleep(1000); }); expect(hooks.result.current.readyState).toBe(ReadyState.Closed); url = wsUrl; hooks.rerender(); await act(async () => { await wsServer.connected; }); expect(hooks.result.current.readyState).toBe(ReadyState.Open); expect(onOpen).toBeCalledTimes(1); act(() => wsServer.close()); }); test('change socketUrl should connect correctly', async () => { const wsUrl1 = 'ws://localhost:8888'; const wsServer1 = new WS(wsUrl); const wsServer2 = new WS(wsUrl1); const onOpen = vi.fn(); const onClose = vi.fn(); let url = wsUrl; const hooks = renderHook(() => useWebSocket(url, { onOpen, onClose, reconnectInterval: 300 })); expect(hooks.result.current.readyState).toBe(ReadyState.Connecting); await act(async () => { await wsServer1.connected; }); expect(hooks.result.current.readyState).toBe(ReadyState.Open); url = wsUrl1; hooks.rerender(); await act(async () => { await wsServer2.connected; }); expect(hooks.result.current.readyState).toBe(ReadyState.Open); await act(async () => { await sleep(3000); }); expect(onOpen).toBeCalledTimes(2); expect(onClose).toBeCalledTimes(1); act(() => wsServer1.close()); act(() => wsServer2.close()); }); }); ================================================ FILE: packages/hooks/src/useWebSocket/demo/demo1.tsx ================================================ import { useRef, useMemo } from 'react'; import { useWebSocket } from 'ahooks'; enum ReadyState { Connecting = 0, Open = 1, Closing = 2, Closed = 3, } export default () => { const messageHistory = useRef([]); const { readyState, sendMessage, latestMessage, disconnect, connect } = useWebSocket( 'wss://ws.postman-echo.com/raw', ); messageHistory.current = useMemo( () => messageHistory.current.concat(latestMessage), [latestMessage], ); return (
{/* send message */} {/* disconnect */} {/* connect */}
readyState: {readyState}

received message:

{messageHistory.current.map((message, index) => (

{message?.data}

))}
); }; ================================================ FILE: packages/hooks/src/useWebSocket/index.en-US.md ================================================ --- nav: path: /hooks --- # useWebSocket A hook for WebSocket. ## Examples ### Default usage ## API ```typescript enum ReadyState { Connecting = 0, Open = 1, Closing = 2, Closed = 3, } interface Options { reconnectLimit?: number; reconnectInterval?: number; onOpen?: (event: WebSocketEventMap['open'], instance: WebSocket) => void; onClose?: (event: WebSocketEventMap['close'], instance: WebSocket) => void; onMessage?: (message: WebSocketEventMap['message'], instance: WebSocket) => void; onError?: (event: WebSocketEventMap['error'], instance: WebSocket) => void; protocols?: string | string[]; } interface Result { latestMessage?: WebSocketEventMap['message']; sendMessage: WebSocket['send']; disconnect: () => void; connect: () => void; readyState: ReadyState; webSocketIns?: WebSocket; } useWebSocket(socketUrl: string, options?: Options): Result; ``` ### Params | Property | Description | Type | Default | | --------- | ------------------------------ | --------- | ------- | | socketUrl | Required, webSocket url | `string` | - | | options | connect the configuration item | `Options` | - | #### Options | Options Property | Description | Type | Default | | ----------------- | ---------------------------------- | ---------------------------------------------------------------------- | ------- | | onOpen | The webSocket connect callback | `(event: WebSocketEventMap['open'], instance: WebSocket) => void` | - | | onClose | WebSocket close callback | `(event: WebSocketEventMap['close'], instance: WebSocket) => void` | - | | onMessage | WebSocket receive message callback | `(message: WebSocketEventMap['message'], instance: WebSocket) => void` | - | | onError | WebSocket error callback | `(event: WebSocketEventMap['error'], instance: WebSocket) => void` | - | | reconnectLimit | Retry times | `number` | `3` | | reconnectInterval | Retry interval(ms) | `number` | `3000` | | manual | Manually starts connection | `boolean` | `false` | | protocols | Sub protocols | `string` \| `string[]` | - | ### Result | Options Property | Description | Type | | ---------------- | -------------------------------------------------------------------------------------- | ------------------------------ | | latestMessage | Latest message | `WebSocketEventMap['message']` | | sendMessage | Send message function | `WebSocket['send']` | | disconnect | Disconnect webSocket manually | `() => void` | | connect | Connect webSocket manually. If already connected, close the current one and reconnect. | `() => void` | | readyState | Current webSocket connection status | `ReadyState` | | webSocketIns | WebSocket instance | `WebSocket` | ================================================ FILE: packages/hooks/src/useWebSocket/index.ts ================================================ import { useEffect, useRef, useState } from 'react'; import useLatest from '../useLatest'; import useMemoizedFn from '../useMemoizedFn'; import useUnmount from '../useUnmount'; export enum ReadyState { Connecting = 0, Open = 1, Closing = 2, Closed = 3, } export interface Options { reconnectLimit?: number; reconnectInterval?: number; manual?: boolean; onOpen?: (event: WebSocketEventMap['open'], instance: WebSocket) => void; onClose?: (event: WebSocketEventMap['close'], instance: WebSocket) => void; onMessage?: (message: WebSocketEventMap['message'], instance: WebSocket) => void; onError?: (event: WebSocketEventMap['error'], instance: WebSocket) => void; protocols?: string | string[]; } export interface Result { latestMessage?: WebSocketEventMap['message']; sendMessage: WebSocket['send']; disconnect: () => void; connect: () => void; readyState: ReadyState; webSocketIns?: WebSocket; } function useWebSocket(socketUrl: string, options: Options = {}): Result { const { reconnectLimit = 3, reconnectInterval = 3 * 1000, manual = false, onOpen, onClose, onMessage, onError, protocols, } = options; const [latestMessage, setLatestMessage] = useState(); const [readyState, setReadyState] = useState(ReadyState.Closed); const onOpenRef = useLatest(onOpen); const onCloseRef = useLatest(onClose); const onMessageRef = useLatest(onMessage); const onErrorRef = useLatest(onError); const readyStateRef = useLatest(readyState); const reconnectTimesRef = useRef(0); const reconnectTimerRef = useRef>(undefined); const websocketRef = useRef(undefined); const reconnect = () => { if ( reconnectTimesRef.current < reconnectLimit && websocketRef.current?.readyState !== ReadyState.Open ) { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } reconnectTimerRef.current = setTimeout(() => { // eslint-disable-next-line @typescript-eslint/no-use-before-define connectWs(); reconnectTimesRef.current++; }, reconnectInterval); } }; const connectWs = () => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } if (websocketRef.current) { websocketRef.current.close(); } const ws = new WebSocket(socketUrl, protocols); setReadyState(ReadyState.Connecting); ws.onerror = (event) => { if (websocketRef.current !== ws) { return; } reconnect(); onErrorRef.current?.(event, ws); setReadyState(ws.readyState || ReadyState.Closed); }; ws.onopen = (event) => { if (websocketRef.current !== ws) { return; } onOpenRef.current?.(event, ws); reconnectTimesRef.current = 0; setReadyState(ws.readyState || ReadyState.Open); }; ws.onmessage = (message: WebSocketEventMap['message']) => { if (websocketRef.current !== ws) { return; } onMessageRef.current?.(message, ws); setLatestMessage(message); }; ws.onclose = (event) => { onCloseRef.current?.(event, ws); // closed by server if (websocketRef.current === ws) { reconnect(); } // closed by disconnect or closed by server if (!websocketRef.current || websocketRef.current === ws) { setReadyState(ws.readyState || ReadyState.Closed); } }; websocketRef.current = ws; }; const sendMessage: WebSocket['send'] = (message) => { if (readyStateRef.current === ReadyState.Open) { websocketRef.current?.send(message); } else { throw new Error('WebSocket disconnected'); } }; const connect = () => { reconnectTimesRef.current = 0; connectWs(); }; const disconnect = () => { if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); } reconnectTimesRef.current = reconnectLimit; websocketRef.current?.close(); websocketRef.current = undefined; }; useEffect(() => { if (!manual && socketUrl) { connect(); } }, [socketUrl, manual]); useUnmount(() => { disconnect(); }); return { latestMessage, sendMessage: useMemoizedFn(sendMessage), connect: useMemoizedFn(connect), disconnect: useMemoizedFn(disconnect), readyState, webSocketIns: websocketRef.current, }; } export default useWebSocket; ================================================ FILE: packages/hooks/src/useWebSocket/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useWebSocket 用于处理 WebSocket 的 Hook。 ## 代码演示 ### 基础用法 ## API ```typescript enum ReadyState { Connecting = 0, Open = 1, Closing = 2, Closed = 3, } interface Options { reconnectLimit?: number; reconnectInterval?: number; onOpen?: (event: WebSocketEventMap['open'], instance: WebSocket) => void; onClose?: (event: WebSocketEventMap['close'], instance: WebSocket) => void; onMessage?: (message: WebSocketEventMap['message'], instance: WebSocket) => void; onError?: (event: WebSocketEventMap['error'], instance: WebSocket) => void; protocols?: string | string[]; } interface Result { latestMessage?: WebSocketEventMap['message']; sendMessage: WebSocket['send']; disconnect: () => void; connect: () => void; readyState: ReadyState; webSocketIns?: WebSocket; } useWebSocket(socketUrl: string, options?: Options): Result; ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | --------- | -------------------- | --------- | ------ | | socketUrl | 必填,webSocket 地址 | `string` | - | | options | 可选,连接配置项 | `Options` | - | #### Options | 参数 | 说明 | 类型 | 默认值 | | ----------------- | ---------------------- | ---------------------------------------------------------------------- | ------- | | onOpen | webSocket 连接成功回调 | `(event: WebSocketEventMap['open'], instance: WebSocket) => void` | - | | onClose | webSocket 关闭回调 | `(event: WebSocketEventMap['close'], instance: WebSocket) => void` | - | | onMessage | webSocket 收到消息回调 | `(message: WebSocketEventMap['message'], instance: WebSocket) => void` | - | | onError | webSocket 错误回调 | `(event: WebSocketEventMap['error'], instance: WebSocket) => void` | - | | reconnectLimit | 重试次数 | `number` | `3` | | reconnectInterval | 重试时间间隔(ms) | `number` | `3000` | | manual | 手动启动连接 | `boolean` | `false` | | protocols | 子协议 | `string` \| `string[]` | - | ### Result | 参数 | 说明 | 类型 | | ------------- | ------------------------------------------------------ | ------------------------------ | | latestMessage | 最新消息 | `WebSocketEventMap['message']` | | sendMessage | 发送消息函数 | `WebSocket['send']` | | disconnect | 手动断开 webSocket 连接 | `() => void` | | connect | 手动连接 webSocket,如果当前已有连接,则关闭后重新连接 | `() => void` | | readyState | 当前 webSocket 连接状态 | `ReadyState` | | webSocketIns | webSocket 实例 | `WebSocket` | ================================================ FILE: packages/hooks/src/useWhyDidYouUpdate/__tests__/index.spec.ts ================================================ import { act, renderHook } from '@testing-library/react'; import { useState } from 'react'; import { describe, expect, test, vi } from 'vitest'; import useWhyDidYouUpdate from '../index'; describe('useWhyDidYouUpdate', () => { test('should work', () => { console.log = vi.fn(); const setup = () => renderHook(() => { const [count, setCount] = useState(100); useWhyDidYouUpdate('UseWhyDidYouUpdateComponent', { count }); return { setCount, }; }); const hook = setup(); act(() => { hook.result.current.setCount(1); }); expect(console.log).toHaveBeenCalledWith( '[why-did-you-update]', 'UseWhyDidYouUpdateComponent', { count: { from: 100, to: 1, }, }, ); }); }); ================================================ FILE: packages/hooks/src/useWhyDidYouUpdate/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Update state or props, you can see the output in the console * * title.zh-CN: 基础用法 * desc.zh-CN: 更新 state 或 props,可以在控制台看到输出 */ import { useWhyDidYouUpdate } from 'ahooks'; import { useState } from 'react'; const Demo: React.FC<{ count: number }> = (props) => { const [randomNum, setRandomNum] = useState(Math.random()); useWhyDidYouUpdate('useWhyDidYouUpdateComponent', { ...props, randomNum }); return (
number: {props.count}
randomNum: {randomNum}
); }; export default () => { const [count, setCount] = useState(0); return (

Please open the browser console to view the output!

); }; ================================================ FILE: packages/hooks/src/useWhyDidYouUpdate/index.en-US.md ================================================ --- nav: path: /hooks --- # useWhyDidYouUpdate Help developers troubleshoot what changes have caused component rerender. ## Examples ### Default usage ## API ```typescript type IProps = Record; useWhyDidYouUpdate(componentName: string, props: IProps): void; ``` ### Params | Property | Description | Type | Default | | ------------- | ------------------------------------------------------------------------------------------- | -------- | ------- | | componentName | Required, the name of the observation component | `string` | - | | props | Required, data to be observed (`state` or `props` and other data that may lead to rerender) | `object` | - | ### Result Please open the browser console, you can see the output of the changed observed `state` or `props`. ================================================ FILE: packages/hooks/src/useWhyDidYouUpdate/index.ts ================================================ import { useEffect, useRef } from 'react'; export type IProps = Record; function useWhyDidYouUpdate(componentName: string, props: IProps) { const prevProps = useRef({}); useEffect(() => { if (prevProps.current) { const allKeys = Object.keys({ ...prevProps.current, ...props }); const changedProps: IProps = {}; allKeys.forEach((key) => { if (!Object.is(prevProps.current[key], props[key])) { changedProps[key] = { from: prevProps.current[key], to: props[key], }; } }); if (Object.keys(changedProps).length) { console.log('[why-did-you-update]', componentName, changedProps); } } prevProps.current = props; }); } export default useWhyDidYouUpdate; ================================================ FILE: packages/hooks/src/useWhyDidYouUpdate/index.zh-CN.md ================================================ --- nav: path: /hooks --- # useWhyDidYouUpdate 帮助开发者排查是哪个属性改变导致了组件的 rerender。 ## 代码演示 ### 基础用法 ## API ```typescript type IProps = Record; useWhyDidYouUpdate(componentName: string, props: IProps): void; ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | ------------- | -------------------------------------------------------------------------------------- | -------- | ------ | | componentName | 必填,观测组件的名称 | `string` | - | | props | 必填,需要观测的数据(当前组件 `state` 或者传入的 `props` 等可能导致 rerender 的数据) | `object` | - | ### Result 打开控制台,可以看到改变的属性。 ================================================ FILE: packages/hooks/src/utils/__tests__/index.spec.ts ================================================ import { describe, expect, test } from 'vitest'; import { isBoolean, isFunction, isNumber, isObject, isString, isUndef } from '../index'; describe('shared utils methods', () => { test('isBoolean', () => { expect(isBoolean(true)).toBe(true); expect(isBoolean(false)).toBe(true); expect(isBoolean('')).toBe(false); expect(isBoolean([])).toBe(false); }); test('isFunction', () => { expect(isFunction(function foo() {})).toBe(true); expect(isFunction(() => {})).toBe(true); expect(isFunction({})).toBe(false); expect(isFunction(1)).toBe(false); }); test('isNumber', () => { expect(isNumber(1)).toBe(true); expect(isNumber(Infinity)).toBe(true); expect(isNumber(NaN)).toBe(true); expect(isNumber('str')).toBe(false); expect(isNumber({})).toBe(false); }); test('isObject', () => { expect(isObject({})).toBe(true); expect(isObject([])).toBe(true); expect(isObject(/(?:)/)).toBe(true); expect(isObject(new Date())).toBe(true); expect(isObject(null)).toBe(false); expect(isObject(function foo() {})).toBe(false); expect(isObject(123)).toBe(false); }); test('isString', () => { expect(isString('1')).toBe(true); expect(isString(String('1'))).toBe(true); expect(isString(1)).toBe(false); expect(isString({})).toBe(false); }); test('isUndef', () => { expect(isUndef(undefined)).toBe(true); expect(isUndef(0)).toBe(false); expect(isUndef(null)).toBe(false); expect(isUndef(NaN)).toBe(false); expect(isUndef('')).toBe(false); }); }); ================================================ FILE: packages/hooks/src/utils/createEffectWithTarget.ts ================================================ import type { DependencyList, EffectCallback, useEffect, useLayoutEffect } from 'react'; import { useRef } from 'react'; import useUnmount from '../useUnmount'; import depsAreSame from './depsAreSame'; import type { BasicTarget } from './domTarget'; import { getTargetElement } from './domTarget'; const createEffectWithTarget = (useEffectType: typeof useEffect | typeof useLayoutEffect) => { /** * * @param effect * @param deps * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom */ const useEffectWithTarget = ( effect: EffectCallback, deps: DependencyList, target: BasicTarget | BasicTarget[], ) => { const hasInitRef = useRef(false); const lastElementRef = useRef<(Element | null)[]>([]); const lastDepsRef = useRef([]); const unLoadRef = useRef(undefined); useEffectType(() => { const targets = Array.isArray(target) ? target : [target]; const els = targets.map((item) => getTargetElement(item)); // init run if (!hasInitRef.current) { hasInitRef.current = true; lastElementRef.current = els; lastDepsRef.current = deps; unLoadRef.current = effect(); return; } if ( els.length !== lastElementRef.current.length || !depsAreSame(lastElementRef.current, els) || !depsAreSame(lastDepsRef.current, deps) ) { unLoadRef.current?.(); lastElementRef.current = els; lastDepsRef.current = deps; unLoadRef.current = effect(); } }); useUnmount(() => { unLoadRef.current?.(); // for react-refresh hasInitRef.current = false; }); }; return useEffectWithTarget; }; export default createEffectWithTarget; ================================================ FILE: packages/hooks/src/utils/depsAreSame.ts ================================================ import type { DependencyList } from 'react'; function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean { if (oldDeps === deps) { return true; } for (let i = 0; i < oldDeps.length; i++) { if (!Object.is(oldDeps[i], deps[i])) { return false; } } return true; } export default depsAreSame; ================================================ FILE: packages/hooks/src/utils/depsEqual.ts ================================================ import type { DependencyList } from 'react'; import isEqual from 'react-fast-compare'; export const depsEqual = (aDeps: DependencyList = [], bDeps: DependencyList = []) => isEqual(aDeps, bDeps); ================================================ FILE: packages/hooks/src/utils/domTarget.ts ================================================ import { isFunction } from './index'; import isBrowser from './isBrowser'; import type { RefObject } from 'react'; type TargetValue = T | undefined | null; type TargetType = HTMLElement | Element | Window | Document; export type BasicTarget = | (() => TargetValue) | TargetValue | RefObject>; export function getTargetElement(target: BasicTarget, defaultElement?: T) { if (!isBrowser) { return undefined; } if (!target) { return defaultElement; } let targetElement: TargetValue; if (isFunction(target)) { targetElement = target(); } else if ('current' in target) { targetElement = target.current; } else { targetElement = target; } return targetElement; } ================================================ FILE: packages/hooks/src/utils/getDocumentOrShadow.ts ================================================ import type { BasicTarget } from '../utils/domTarget'; import { getTargetElement } from '../utils/domTarget'; declare type TargetValue = T | undefined | null; const checkIfAllInShadow = (targets: BasicTarget[]) => { return targets.every((item) => { const targetElement = getTargetElement(item); if (!targetElement) { return false; } if (targetElement.getRootNode() instanceof ShadowRoot) { return true; } return false; }); }; const getShadow = (node: TargetValue) => { if (!node) { return document; } return node.getRootNode(); }; const getDocumentOrShadow = (target: BasicTarget | BasicTarget[]): Document | Node => { if (!target || !document.getRootNode) { return document; } const targets = Array.isArray(target) ? target : [target]; if (checkIfAllInShadow(targets)) { return getShadow(getTargetElement(targets[0])); } return document; }; export default getDocumentOrShadow; ================================================ FILE: packages/hooks/src/utils/index.ts ================================================ export const isObject = (value: unknown): value is Record => value !== null && typeof value === 'object'; export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; export const isString = (value: unknown): value is string => typeof value === 'string'; export const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'; export const isNumber = (value: unknown): value is number => typeof value === 'number'; export const isUndef = (value: unknown): value is undefined => typeof value === 'undefined'; ================================================ FILE: packages/hooks/src/utils/isAppleDevice.ts ================================================ const isAppleDevice = /(mac|iphone|ipod|ipad)/i.test( typeof navigator !== 'undefined' ? navigator?.platform : '', ); export default isAppleDevice; ================================================ FILE: packages/hooks/src/utils/isBrowser.ts ================================================ const isBrowser = !!( typeof window !== 'undefined' && window.document && window.document.createElement ); export default isBrowser; ================================================ FILE: packages/hooks/src/utils/isDev.ts ================================================ const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'; export default isDev; ================================================ FILE: packages/hooks/src/utils/lodash-polyfill.ts ================================================ import debounce from 'lodash/debounce'; function isNodeOrWeb() { const freeGlobal = (typeof global === 'undefined' ? 'undefined' : typeof global) == 'object' && global && global.Object === Object && global; const freeSelf = typeof self == 'object' && self && self.Object === Object && self; return freeGlobal || freeSelf; } if (!isNodeOrWeb()) { global.Date = Date; } export { debounce }; ================================================ FILE: packages/hooks/src/utils/noop.ts ================================================ const noop = () => {}; export default noop; ================================================ FILE: packages/hooks/src/utils/rect.ts ================================================ const getScrollTop = (el: Document | Element) => { if (el === document || el === document.documentElement || el === document.body) { return Math.max( window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop, ); } return (el as Element).scrollTop; }; const getScrollHeight = (el: Document | Element) => { return ( (el as Element).scrollHeight || Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) ); }; const getClientHeight = (el: Document | Element) => { return ( (el as Element).clientHeight || Math.max(document.documentElement.clientHeight, document.body.clientHeight) ); }; export { getScrollTop, getScrollHeight, getClientHeight }; ================================================ FILE: packages/hooks/src/utils/testingHelpers.ts ================================================ export function sleep(time: number) { return new Promise((resolve) => { setTimeout(() => { resolve(); }, time); }); } export function request(req: any) { return new Promise((resolve, reject) => setTimeout(() => { if (req === 0) { reject(new Error('fail')); } else { resolve('success'); } }, 1000), ); } ================================================ FILE: packages/hooks/src/utils/tests.tsx ================================================ import { StrictMode } from 'react'; import { renderHook } from '@testing-library/react'; export * from '@testing-library/react'; const Wrapper = process.env.REACT_MODE === 'strict' ? StrictMode : undefined; const customRender: typeof renderHook = (ui, options) => renderHook(ui, { wrapper: Wrapper, ...options }); export { customRender as renderHook }; ================================================ FILE: packages/hooks/src/utils/useDeepCompareWithTarget.ts ================================================ import type { DependencyList, EffectCallback } from 'react'; import { useRef } from 'react'; import type { BasicTarget } from './domTarget'; import useEffectWithTarget from './useEffectWithTarget'; import { depsEqual } from './depsEqual'; const useDeepCompareEffectWithTarget = ( effect: EffectCallback, deps: DependencyList, target: BasicTarget | BasicTarget[], ) => { const ref = useRef(undefined); const signalRef = useRef(0); if (!depsEqual(deps, ref.current)) { signalRef.current += 1; } ref.current = deps; useEffectWithTarget(effect, [signalRef.current], target); }; export default useDeepCompareEffectWithTarget; ================================================ FILE: packages/hooks/src/utils/useEffectWithTarget.ts ================================================ import { useEffect } from 'react'; import createEffectWithTarget from './createEffectWithTarget'; const useEffectWithTarget = createEffectWithTarget(useEffect); export default useEffectWithTarget; ================================================ FILE: packages/hooks/src/utils/useIsomorphicLayoutEffectWithTarget.ts ================================================ import isBrowser from './isBrowser'; import useEffectWithTarget from './useEffectWithTarget'; import useLayoutEffectWithTarget from './useLayoutEffectWithTarget'; const useIsomorphicLayoutEffectWithTarget = isBrowser ? useLayoutEffectWithTarget : useEffectWithTarget; export default useIsomorphicLayoutEffectWithTarget; ================================================ FILE: packages/hooks/src/utils/useLayoutEffectWithTarget.ts ================================================ import { useLayoutEffect } from 'react'; import createEffectWithTarget from './createEffectWithTarget'; const useEffectWithTarget = createEffectWithTarget(useLayoutEffect); export default useEffectWithTarget; ================================================ FILE: packages/hooks/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json" } ================================================ FILE: packages/hooks/tsconfig.pro.json ================================================ { "extends": "../../tsconfig.pro.json", "compilerOptions": { "rootDir": "src", "sourceMap": false }, "include": ["src"] } ================================================ FILE: packages/hooks/vitest.config.ts ================================================ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; // https://cn.vitest.dev/guide/ export default defineConfig({ resolve: { alias: { src: resolve(__dirname, 'src'), }, }, test: { reporters: ['default', 'verbose'], environment: 'jsdom', include: ['src/**/__tests__/*.spec.ts?(x)'], coverage: { provider: 'istanbul', include: ['src/**/*.ts'], }, }, }); ================================================ FILE: packages/hooks/webpack.config.js ================================================ const { merge } = require('webpack-merge'); const common = require('../../webpack.common.js'); const path = require('node:path'); module.exports = merge(common, { entry: './es/index.js', output: { filename: 'ahooks.js', library: 'ahooks', path: path.resolve(__dirname, './dist'), }, }); ================================================ FILE: packages/use-url-state/README.md ================================================ # useUrlState A hook that stores the state into url query parameters. ## Installing Inside your React project directory, run the following: ```bash yarn add @ahooksjs/use-url-state -S ``` Or with npm: ```bash npm install @ahooksjs/use-url-state -S ``` Or with pnpm ```bash pnpm add @ahooksjs/use-url-state ``` Or with Bun ```bash bun add @ahooksjs/use-url-state ``` ## Example ```javascript import useUrlState from '@ahooksjs/use-url-state'; const [state, setState] = useUrlState({ demoCount: '1' }); ``` ## Documentation https://ahooks.js.org/hooks/state/use-url-state ================================================ FILE: packages/use-url-state/__tests__/browser.spec.tsx ================================================ import { act } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { setup } from './setup'; describe('useUrlState', () => { test('state should be url search params', () => { const res = setup([ { pathname: '/index', search: '?count=1', }, ]); expect(res.state).toMatchObject({ count: '1' }); }); test('url shoule be changed when use setState', () => { const res = setup(['/index']); expect(res.state).toMatchObject({}); act(() => { res.setState({ count: 1 }); }); expect(res.state).toMatchObject({ count: '1' }); }); test('multiple states should be work', () => { const res = setup(['/index']); act(() => { res.setState({ page: 1 }); }); act(() => { res.setState({ pageSize: 10 }); }); expect(res.state).toMatchObject({ page: '1', pageSize: '10' }); }); test('query-string options should work', async () => { const res = setup( [ { pathname: '/index', search: '?foo=1,2,3', }, ], {}, { parseOptions: { arrayFormat: 'comma', }, stringifyOptions: { arrayFormat: 'comma', }, }, ); expect(res.state).toMatchObject({ foo: ['1', '2', '3'] }); act(() => { res.setState({ foo: ['4', '5', '6'] }); }); expect(res.state).toMatchObject({ foo: ['4', '5', '6'] }); }); test('location.state should be remain', () => { const res = setup([ { pathname: '/index', state: 'state', }, ]); expect(res.location.state).toBe('state'); act(() => { res.setState({ count: 1 }); }); expect(res.state).toMatchObject({ count: '1' }); expect(res.location.state).toBe('state'); }); }); ================================================ FILE: packages/use-url-state/__tests__/router.spec.tsx ================================================ import { act } from '@testing-library/react'; import { describe, expect, test } from 'vitest'; import { setup } from './setup'; describe('React Router V6', () => { test('useUrlState should be work', () => { const res = setup(['/index']); act(() => { res.setState({ count: 1 }); }); expect(res.state).toMatchObject({ count: '1' }); }); }); ================================================ FILE: packages/use-url-state/__tests__/setup.tsx ================================================ import { render } from '@testing-library/react'; import type { MemoryRouterProps } from 'react-router'; import { MemoryRouter, useLocation } from 'react-router'; import type { Options } from 'src'; import useUrlState from 'src'; import { expect, test } from 'vitest'; export const setup = ( initialEntries: MemoryRouterProps['initialEntries'], baseState: any = {}, options?: Options, ) => { const res = {} as any; const Component = () => { const [state, setState] = useUrlState(baseState, options); const location = useLocation(); Object.assign(res, { state, setState, location }); return null; }; render( , ); return res; }; test('useUrlState should be defined', () => { expect(useUrlState).toBeDefined(); }); ================================================ FILE: packages/use-url-state/demo/demo1.tsx ================================================ /** * title: Default usage * desc: Store the state into url query. By set the value to `undefined`, the attribute can be removed from the url query. * * title.zh-CN: 基础用法 * desc.zh-CN: 将状态同步到 url query 中。通过设置值为 `undefined`, 可以从 url query 上彻底删除某个属性,从而使用默认值。 */ import useUrlState from '@ahooksjs/use-url-state'; export default () => { const [state, setState] = useUrlState({ count: '1' }); return ( <>
state: {state?.count}
); }; ================================================ FILE: packages/use-url-state/demo/demo2.tsx ================================================ /** * title: Multi-state management * desc: useUrlState can manage multiple states at the same time * * title.zh-CN: 多状态管理 * desc.zh-CN: useUrlState 可以同时管理多个状态 */ import useUrlState from '@ahooksjs/use-url-state'; export default () => { const [state, setState] = useUrlState({ page: '1', pageSize: '10' }); return ( <>
page: {state.page}

pageSize: {state.pageSize}
); }; ================================================ FILE: packages/use-url-state/demo/demo3.tsx ================================================ /** * title: Custom query-string options * desc: The rules can be customized by passing in parseOptions and stringifyOptions. * * title.zh-CN: 自定义 query-string 配置 * desc.zh-CN: 可以通过传入 parseOptions 和 stringifyOptions 自定义转换规则。 */ import useUrlState from '@ahooksjs/use-url-state'; export default () => { const [state, setState] = useUrlState( { ids: ['1', '2', '3'] }, { parseOptions: { arrayFormat: 'comma', }, stringifyOptions: { arrayFormat: 'comma', }, }, ); return (
ids: {JSON.stringify(state.ids)}
); }; ================================================ FILE: packages/use-url-state/demo/demo4.tsx ================================================ /** * title: Multi-state management (split) * desc: useUrlState can handle multiple useUrlState updates simultaneously * * title.zh-CN: 多状态管理 (拆分) * desc.zh-CN: useUrlState 可以同时处理多个 useUrlState 更新 */ import useUrlState from '@ahooksjs/use-url-state'; export default () => { const [page, setPage] = useUrlState({ page: '1' }); const [pageSize, setPageSize] = useUrlState({ pageSize: '10' }); return ( <>
page: {page.page}

pageSize: {pageSize.pageSize}
); }; ================================================ FILE: packages/use-url-state/gulpfile.js ================================================ const commonConfig = require('../../gulpfile'); exports.default = commonConfig.default; ================================================ FILE: packages/use-url-state/package.json ================================================ { "name": "@ahooksjs/use-url-state", "version": "3.6.0", "description": "A hook that stores the state into url query parameters.", "main": "./lib/index.js", "module": "./es/index.js", "types": "./lib/index.d.ts", "unpkg": "dist/ahooks-use-url-state.js", "files": [ "dist", "lib", "es", "package.json" ], "repository": { "type": "git", "url": "git+https://github.com/alibaba/hooks.git" }, "scripts": { "build": "gulp && webpack-cli", "test": "vitest run --color", "test:cov": "vitest run --color --coverage", "tsc": "tsc --noEmit" }, "publishConfig": { "registry": "https://registry.npmjs.org/" }, "license": "MIT", "bugs": { "url": "https://github.com/alibaba/hooks/issues" }, "homepage": "https://github.com/alibaba/hooks", "gitHead": "11f6ad571bd365c95ecb9409ca3050cbbfc9b34a", "dependencies": { "@babel/runtime": "^7.21.0", "ahooks": "^3.4.1", "query-string": "^8.1.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-router": "^5.0.0 || ^6.0.0 || ^7.0.0" } } ================================================ FILE: packages/use-url-state/src/index.ts ================================================ import { useMemoizedFn, useUpdate } from 'ahooks'; import qs from 'query-string'; import type { ParseOptions, StringifyOptions } from 'query-string'; import { useMemo, useRef } from 'react'; import type * as React from 'react'; import * as tmp from 'react-router'; // ignore waring `"export 'useNavigate' (imported as 'rc') was not found in 'react-router'` const rc = tmp as any; export interface Options { navigateMode?: 'push' | 'replace'; parseOptions?: ParseOptions; stringifyOptions?: StringifyOptions; } const baseParseConfig: ParseOptions = { parseNumbers: false, parseBooleans: false, }; const baseStringifyConfig: StringifyOptions = { skipNull: false, skipEmptyString: false, }; type UrlState = Record; const useUrlState = ( baseState?: S | (() => S), options?: Options, ) => { type State = Partial<{ [key in keyof S]: Required[key] extends any[] ? string[] : string; }>; const { navigateMode = 'push', parseOptions, stringifyOptions } = options || {}; const mergedParseOptions = { ...baseParseConfig, ...parseOptions }; const mergedStringifyOptions = { ...baseStringifyConfig, ...stringifyOptions, }; const location = rc.useLocation(); // react-router v5 const history = rc.useHistory?.(); // react-router v6 & v7 const navigate = rc.useNavigate?.(); const update = useUpdate(); const baseStateRef = useRef(typeof baseState === 'function' ? baseState() : baseState || {}); const queryFromUrl = useMemo(() => { return qs.parse(location.search, mergedParseOptions); }, [location.search]); const targetQuery = useMemo( () => ({ ...baseStateRef.current, ...queryFromUrl, }), [queryFromUrl], ); const setState = (s: React.SetStateAction) => { const newQuery = typeof s === 'function' ? s(targetQuery) : s; // 1. 如果 setState 后,search 没变化,就需要 update 来触发一次更新。比如 demo1 直接点击 clear,就需要 update 来触发更新。 // 2. update 和 history 的更新会合并,不会造成多次更新 update(); if (history) { history[navigateMode]( { hash: location.hash, search: qs.stringify({ ...queryFromUrl, ...newQuery }, mergedStringifyOptions) || '?', }, location.state, ); } if (navigate) { navigate( { hash: location.hash, search: qs.stringify({ ...queryFromUrl, ...newQuery }, mergedStringifyOptions) || '?', }, { replace: navigateMode === 'replace', state: location.state, }, ); } }; return [targetQuery, useMemoizedFn(setState)] as const; }; export default useUrlState; ================================================ FILE: packages/use-url-state/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "paths": { "@ahooksjs/use-url-state": ["./src/index.ts"] } } } ================================================ FILE: packages/use-url-state/tsconfig.pro.json ================================================ { "extends": "../../tsconfig.pro.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: packages/use-url-state/use-url-state.en-US.md ================================================ --- nav: path: /hooks group: path: / --- # useUrlState A hook that store the state into url query. ## Install ```bash npm i @ahooksjs/use-url-state -S ``` > This hook relies on useLocation & useHistory & useNavigate from `react-router`, to use this hook, you need to ensure > > 1\. Your project is using `react-router` version 5.0 or 6.0 to manage routing > > 2\. Installed @ahooksjs/use-url-state ## Usage ```js import useUrlState from '@ahooksjs/use-url-state'; ``` ## Examples ### CodeSandbox Demo React Router V5: https://codesandbox.io/s/suspicious-feather-cz4e0?file=/App.tsx React Router V6: https://codesandbox.io/s/autumn-shape-odrt9?file=/App.tsx ### Default usage ### Multi-state management ### Multi-state management (split) ### Custom query-string options ## API ```typescript const [state, setState] = useUrlState(baseState, options); ``` ### Params | Property | Description | Type | Default | | --------- | ---------------------------------------------------------------------------------------------- | -------------- | ------- | | baseState | URL search params will be merged into BaseState | `S \| () => S` | - | | options | Url config | `Options` | - | ### Options | Property | Description | Type | Default | | ---------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- | -------- | | navigateMode | Type of history navigate mode | `'push' \| 'replace'` | `'push'` | | parseOptions | [parse](https://github.com/sindresorhus/query-string#parsestring-options) options of `query-string` | `ParseOptions` | - | | stringifyOptions | [stringify](https://github.com/sindresorhus/query-string#stringifyobject-options) options of `query-string` | `StringifyOptions` | - | ### Result | Property | Description | Type | | -------- | -------------------------------------------- | ------------------------------------------------- | | state | Url query object | `object` | | setState | Same as useState, but state should be object | `(state: S) => void \| (() => ((state: S) => S))` | ================================================ FILE: packages/use-url-state/use-url-state.zh-CN.md ================================================ --- nav: path: /hooks group: path: / --- # useUrlState 通过 url query 来管理 state 的 Hook。 ## 安装 ```bash npm i @ahooksjs/use-url-state -S ``` > 该 Hooks 基于 `react-router` 的 useLocation & useHistory & useNavigate 进行 query 管理,所以使用该 Hooks 之前,你需要保证 > > 1\. 你项目正在使用 `react-router` 5.x 或 6.x 版本来管理路由 > > 2\. 独立安装了 @ahooksjs/use-url-state ## 使用 ```js import useUrlState from '@ahooksjs/use-url-state'; ``` ## 代码演示 ### 在线演示 React Router V5: https://codesandbox.io/s/suspicious-feather-cz4e0?file=/App.tsx React Router V6: https://codesandbox.io/s/autumn-shape-odrt9?file=/App.tsx ### 基础用法 ### 多状态管理 ### 多状态管理(拆分) ### 自定义 query-string 配置 ## API ```typescript const [state, setState] = useUrlState(baseState, options); ``` ### Params | 参数 | 说明 | 类型 | 默认值 | | --------- | -------------------------------------------------------------- | -------------- | ------ | | baseState | 基准状态,URL 查询参数会在此基础上进行合并 | `S \| () => S` | - | | options | url 配置 | `Options` | - | ### Options | 参数 | 说明 | 类型 | 默认值 | | ---------------- | ------------------------------------------------------------------------------------------------------- | --------------------- | -------- | | navigateMode | 状态变更时切换 history 的方式 | `'push' \| 'replace'` | `'push'` | | parseOptions | `query-string` [parse](https://github.com/sindresorhus/query-string#parsestring-options) 的配置 | `ParseOptions` | - | | stringifyOptions | `query-string` [stringify](https://github.com/sindresorhus/query-string#stringifyobject-options) 的配置 | `StringifyOptions` | - | ### Result | 参数 | 说明 | 类型 | | -------- | --------------------------------------- | ------------------------------------------------- | | state | url query 对象 | `object` | | setState | 用法同 useState,但 state 需要是 object | `(state: S) => void \| (() => ((state: S) => S))` | ================================================ FILE: packages/use-url-state/vitest.config.ts ================================================ import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; // https://cn.vitest.dev/guide/ export default defineConfig({ resolve: { alias: { src: resolve(__dirname, 'src'), }, }, test: { reporters: ['default', 'verbose'], environment: 'jsdom', include: ['__tests__/**/*.spec.ts?(x)'], coverage: { provider: 'istanbul', include: ['src/**/*.ts'], }, }, }); ================================================ FILE: packages/use-url-state/webpack.config.js ================================================ const { merge } = require('webpack-merge'); const common = require('../../webpack.common.js'); const path = require('node:path'); module.exports = merge(common, { entry: './es/index.js', output: { filename: 'ahooks-use-url-state.js', library: 'ahooksUseUrlState', path: path.resolve(__dirname, './dist'), }, }); ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - "packages/*" ================================================ FILE: public/style.css ================================================ .__dumi-default-navbar-logo { color: transparent !important; } .__dumi-default-layout-hero img { width: 250px; } a[title="站长统计"] { display: none; } input, button { padding: 4px; } input[type="number"] { border: 1px solid rgb(118, 118, 118); } [data-prefers-color="dark"] button { color: #141414; } [data-prefers-color="dark"] input { color: #141414; } #logo-version { flex: 1; margin-top: 10px; margin-left: -30px; color: #a8a8ad; font-size: 16px; } ================================================ FILE: public/useExternal/bootstrap-badge.css ================================================ .badge { display: inline-block; padding: 0.25em 0.4em; font-size: 75%; font-weight: 700; line-height: 1; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25rem; transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; margin-right: 0.24rem; } .badge-pill { padding-right: 0.6em; padding-left: 0.6em; border-radius: 10rem; } .badge-primary { color: #fff; background-color: #007bff; } .badge-secondary { color: #fff; background-color: #6c757d; } .badge-success { color: #fff; background-color: #28a745; } .badge-danger { color: #fff; background-color: #dc3545; } .badge-warning { color: #212529; background-color: #ffc107; } .badge-info { color: #fff; background-color: #17a2b8; } .badge-light { color: #212529; background-color: #f8f9fa; } .badge-dark { color: #fff; background-color: #343a40; } ================================================ FILE: public/useExternal/test-external-script.js ================================================ window.TEST_SCRIPT = { start: function () { return 'Hello World'; }, }; ================================================ FILE: scripts/build-with-relative-paths.js ================================================ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const configPath = path.join(__dirname, '../config/config.ts'); const backupPath = path.join(__dirname, '../config/config.ts.backup'); // 备份原配置 fs.copyFileSync(configPath, backupPath); try { // 读取配置文件 let config = fs.readFileSync(configPath, 'utf8'); // 修改配置 config = config.replace(/publicPath: ['"].*['"],/, "publicPath: '/hooks/',"); config = config.replace( /{ rel: 'stylesheet', href: '\/style\.css' }/, "{ rel: 'stylesheet', href: '/hooks/style.css' }", ); config = config.replace(/logo: '\/logo\.svg',/, "logo: '/hooks/logo.svg',"); // 写入修改后的配置 fs.writeFileSync(configPath, config); // 运行构建命令 execSync('pnpm run build:doc', { stdio: 'inherit' }); // 进入 dist 目录 process.chdir(path.join(__dirname, '../dist')); // 初始化 git 仓库(如果不存在) try { execSync('git init', { stdio: 'inherit' }); } catch (e) { // 如果已经初始化过,忽略错误 } // 添加所有文件 execSync('git add .', { stdio: 'inherit' }); // 提交更改 execSync('git commit -m "chore: update gh-pages"', { stdio: 'inherit' }); // 添加远程仓库(如果不存在) try { execSync('git remote add origin git@github.com:alibaba/hooks.git', { stdio: 'inherit' }); } catch (e) { // 如果远程仓库已存在,忽略错误 } // 强制推送到 gh-pages 分支 execSync('git push -f origin HEAD:gh-pages', { stdio: 'inherit' }); // 返回到项目根目录 process.chdir(path.join(__dirname, '..')); } finally { // 恢复原配置 fs.copyFileSync(backupPath, configPath); fs.unlinkSync(backupPath); } ================================================ FILE: tsconfig.base.json ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { // 模块解析 "lib": ["ESNext", "DOM"], // 使用的库的声明 "jsx": "react-jsx", // JSX 语法支持 "target": "ESNext", // 生成的目标代码规范 "module": "ESNext", // 生成的模块规范 "moduleResolution": "bundler", // 可以在使用用 exports 声明类型的同时,使用相对路径模块可以不写扩展名 "verbatimModuleSyntax": true, // 禁止混用 ESM 或 CommonJS 的导入导出语法 "resolveJsonModule": true, // 允许导入 JSON 文件 "noEmit": true, // 路径解析 "baseUrl": "${configDir}", "paths": { "src/*": ["${configDir}/src/*"] }, "types": [], "rootDir": "${configDir}", "outDir": "${configDir}/dist", "sourceMap": true, // 生成源映射文件 "sourceRoot": "${configDir}", // 源文件的根目录 "declaration": true, // 生成 .d.ts 文件 // 检查 "strict": true, "allowJs": true, "skipLibCheck": true, "noUnusedLocals": true, "noFallthroughCasesInSwitch": true, "forceConsistentCasingInFileNames": true }, "include": [ "${configDir}/src", "${configDir}/types", "${configDir}/scripts", "${configDir}/docs", "${configDir}/tests", "${configDir}/e2e", "${configDir}/demo", "${configDir}/example", "${configDir}/site" ] } ================================================ FILE: tsconfig.pro.json ================================================ { "compilerOptions": { "target": "ES5", "moduleResolution": "node", "jsx": "react", "esModuleInterop": true, "downlevelIteration": true, "sourceMap": true, "baseUrl": ".", "paths": { "@/*": ["src/*"], "@ahooksjs/use-url-state": ["./packages/use-url-state/src/index.ts"] }, "types": [], "allowJs": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, "declaration": false, "strictNullChecks": true, "importHelpers": true }, "exclude": [ "node_modules", "lib", "es", "dist", "**/__tests__", "**/__test__", "**/demo", "example", "gulpfile.js", "vitest.config.ts" ] } ================================================ FILE: umd.html ================================================ Hello World
================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: ['packages/*/vitest.config.ts'], coverage: { provider: 'istanbul', include: ['packages/*/src/**/*.ts'], reporter: ['text', 'lcov'], }, }, }); ================================================ FILE: webpack.common.js ================================================ module.exports = { output: { libraryTarget: 'umd', globalObject: 'this', }, mode: 'production', resolve: { extensions: ['.json', '.js'], }, // module: { // rules: [ // { // test: /\.jsx?$/, // use: { // loader: 'babel-loader', // }, // } // ], // }, externals: [ { react: 'React', }, ], };