├── .gitignore ├── .gitattributes ├── tsconfig.build.json ├── babel.config.js ├── jest.config.js ├── tsconfig.json ├── lib ├── index.d.ts └── index.js ├── .github └── workflows │ └── npm-publish.yml ├── LICENSE ├── README.md ├── package.json └── src ├── index.tsx └── __tests__ └── index.test.tsx /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "exclude": ["node_modules", "**/__tests__/*"] 4 | } 5 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-react", 5 | "@babel/preset-typescript", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Automatically clear mock calls and instances between every test 3 | clearMocks: true, 4 | roots: ["/src/"], 5 | watchPlugins: [ 6 | "jest-watch-typeahead/filename", 7 | "jest-watch-typeahead/testname", 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "jsx": "react", 7 | "lib": ["dom", "es5", "es2015.promise"], 8 | "module": "commonjs", 9 | "outDir": "lib", 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "target": "es5" 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 默认显示图标,Hover 时懒加载组件 3 | * @author: enoyao 4 | * @date: 2022-06-18 23:35:22 5 | */ 6 | import React, { ComponentType } from "react"; 7 | export declare type PreloadableComponentPreload = { 8 | preload: () => Promise; 9 | isLoaded: boolean; 10 | }; 11 | export declare type PreloadableComponent> = T & PreloadableComponentPreload; 12 | export declare function lazyWithPreload>(factory: () => Promise<{ 13 | default: T; 14 | }>): PreloadableComponent; 15 | export declare function LazyLoadComponent(props: { 16 | defaultLoadComponent: JSX.Element; 17 | loading: JSX.Element; 18 | children?: React.ReactChild | React.ReactChild[]; 19 | lazyLoadComponent?: PreloadableComponentPreload; 20 | }): JSX.Element; 21 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: 16 19 | - run: npm ci 20 | - run: npm test 21 | 22 | publish-npm: 23 | needs: build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: 16 30 | registry-url: https://registry.npmjs.org/ 31 | - run: npm ci 32 | - run: npm run build 33 | - run: npm publish 34 | env: 35 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eno Yao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | `lazy-preload` wraps the `React.lazy()` API and adds the ability to preload the component before it is rendered for the first time. 2 | 3 | ## Install 4 | 5 | ```sh 6 | npm install lazy-preload 7 | ``` 8 | 9 | ## Usage 10 | 11 | **Before:** 12 | 13 | ```js 14 | import { 15 | lazy, 16 | Suspense 17 | } from "react"; 18 | const LazyLoadComponent = lazy(() => import("./LazyLoadComponent")); 19 | ``` 20 | 21 | **After:** 22 | 23 | ```js 24 | import { 25 | Suspense 26 | } from "react"; 27 | import { 28 | lazyWithPreload 29 | } from "lazy-preload"; 30 | const LazyLoadComponent = lazyWithPreload(() => import("./LazyLoadComponent")); 31 | 32 | // ... 33 | LazyLoadComponent.preload(); 34 | ``` 35 | 36 | To preload a component before it is rendered for the first time, the component that is returned from `lazy()` has a `preload` function attached that you can invoke. `preload()` returns a `Promise` that you can wait on if needed. The promise is idempotent, meaning that `preload()` will return the same `Promise` instance if called multiple times. 37 | 38 | For more information about React code-splitting, `React.lazy` and `React.Suspense` , see https://reactjs.org/docs/code-splitting.html. 39 | 40 | ## Example 41 | 42 | For example, if you need to load a component when a button is pressed, you could start preloading the component when the user hovers over the button: 43 | 44 | ```tsx 45 | import { lazyWithPreload, LazyLoadComponent } from 'lazy-preload'; 46 | const Component = lazyWithPreload(() => import("./component")); 47 | 48 | export function render() { 49 | return Icon} 51 | loading={
Loading
} 52 | lazyLoadComponent={Component} 53 | > 54 | 55 |
56 | } 57 | ``` 58 | 59 | ## Acknowledgements 60 | 61 | Inspired by the preload behavior of [react-loadable](https://github.com/jamiebuilds/react-loadable) and [react-lazy-with-preload](https://github.com/ianschmitz/react-lazy-with-preload). 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lazy-preload", 3 | "version": "0.0.3", 4 | "description": "Wraps the React.lazy API with preloaded functionality", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "files": [ 8 | "lib" 9 | ], 10 | "author": "Eno Yao ", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/wscats/lazy-preload.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/wscats/lazy-preload/issues" 18 | }, 19 | "homepage": "https://github.com/wscats/lazy-preload#readme", 20 | "keywords": [ 21 | "React", 22 | "Lazy", 23 | "Preload" 24 | ], 25 | "scripts": { 26 | "build": "tsc -p tsconfig.build.json", 27 | "lint": "eslint -f codeframe --ext .js,.ts,.tsx src/", 28 | "test": "jest", 29 | "test:watch": "npm run test -- --watch" 30 | }, 31 | "dependencies": {}, 32 | "devDependencies": { 33 | "@babel/core": "^7.10.2", 34 | "@babel/preset-env": "^7.10.2", 35 | "@babel/preset-react": "^7.10.1", 36 | "@babel/preset-typescript": "^7.10.1", 37 | "@testing-library/react": "^10.2.1", 38 | "@types/jest": "^26.0.0", 39 | "@types/react": "^16.9.36", 40 | "@types/react-dom": "^16.9.8", 41 | "@typescript-eslint/eslint-plugin": "^3.2.0", 42 | "@typescript-eslint/parser": "^3.2.0", 43 | "babel-jest": "^26.0.1", 44 | "eslint": "^7.2.0", 45 | "eslint-config-prettier": "^6.11.0", 46 | "eslint-plugin-import": "^2.21.2", 47 | "eslint-plugin-react": "^7.20.0", 48 | "husky": "^4.2.5", 49 | "jest": "^26.0.1", 50 | "jest-watch-typeahead": "^0.6.0", 51 | "prettier": "^2.0.5", 52 | "pretty-quick": "^2.0.1", 53 | "react": "^16.13.1", 54 | "react-dom": "^16.13.1", 55 | "typescript": "~3.9.5" 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "pretty-quick --staged" 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 默认显示图标,Hover 时懒加载组件 3 | * @author: enoyao 4 | * @date: 2022-06-18 23:35:22 5 | */ 6 | 7 | import React, { ComponentType, createElement, forwardRef, lazy, Suspense, useState, useRef, useEffect } from "react"; 8 | 9 | export type PreloadableComponentPreload = { 10 | preload: () => Promise; 11 | isLoaded: boolean; 12 | } 13 | 14 | export type PreloadableComponent> = T & PreloadableComponentPreload; 15 | 16 | export function lazyWithPreload>( 17 | factory: () => Promise<{ default: T }> 18 | ): PreloadableComponent { 19 | const LazyComponent = lazy(factory); 20 | let factoryPromise: Promise | undefined; 21 | let LoadedComponent: T | undefined; 22 | 23 | const Component = (forwardRef(function LazyWithPreload(props, ref) { 24 | return createElement( 25 | LoadedComponent ?? LazyComponent, 26 | Object.assign(ref ? { ref } : {}, props) as any 27 | ); 28 | }) as any) as PreloadableComponent; 29 | 30 | Component.isLoaded = false; 31 | Component.preload = () => { 32 | if (!factoryPromise) { 33 | factoryPromise = factory().then((module) => { 34 | Component.isLoaded = true; 35 | LoadedComponent = module.default; 36 | }); 37 | } 38 | 39 | return factoryPromise; 40 | }; 41 | return Component; 42 | } 43 | 44 | export function LazyLoadComponent(props: { 45 | defaultLoadComponent: JSX.Element; 46 | loading: JSX.Element; 47 | children?: | React.ReactChild | React.ReactChild[]; 48 | lazyLoadComponent?: PreloadableComponentPreload; 49 | }) { 50 | const [showLazyLoadComponent, setShowLazyLoadComponent] = useState(false); 51 | const [hasClick, setHasClick] = useState(false); 52 | const lazyLoadElement = useRef(null); 53 | useEffect(() => { 54 | if (hasClick) { 55 | lazyLoadElement?.current?.click(); 56 | setHasClick(false); 57 | } 58 | }, [props.lazyLoadComponent?.isLoaded]) 59 | 60 | return ( 61 | 62 | {showLazyLoadComponent ? 63 |
64 | {props.children} 65 |
:
setHasClick(true)} 67 | onMouseOver={() => { 68 | // 这里先不 await 69 | props.lazyLoadComponent?.preload(); 70 | setShowLazyLoadComponent(true); 71 | }} 72 | > 73 | {props.defaultLoadComponent} 74 |
} 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | /** 3 | * 默认显示图标,Hover 时懒加载组件 4 | * @author: enoyao 5 | * @date: 2022-06-18 23:35:22 6 | */ 7 | var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { 8 | if (k2 === undefined) k2 = k; 9 | Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); 10 | }) : (function(o, m, k, k2) { 11 | if (k2 === undefined) k2 = k; 12 | o[k2] = m[k]; 13 | })); 14 | var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { 15 | Object.defineProperty(o, "default", { enumerable: true, value: v }); 16 | }) : function(o, v) { 17 | o["default"] = v; 18 | }); 19 | var __importStar = (this && this.__importStar) || function (mod) { 20 | if (mod && mod.__esModule) return mod; 21 | var result = {}; 22 | if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); 23 | __setModuleDefault(result, mod); 24 | return result; 25 | }; 26 | Object.defineProperty(exports, "__esModule", { value: true }); 27 | exports.LazyLoadComponent = exports.lazyWithPreload = void 0; 28 | var react_1 = __importStar(require("react")); 29 | function lazyWithPreload(factory) { 30 | var LazyComponent = react_1.lazy(factory); 31 | var factoryPromise; 32 | var LoadedComponent; 33 | var Component = react_1.forwardRef(function LazyWithPreload(props, ref) { 34 | return react_1.createElement(LoadedComponent !== null && LoadedComponent !== void 0 ? LoadedComponent : LazyComponent, Object.assign(ref ? { ref: ref } : {}, props)); 35 | }); 36 | Component.isLoaded = false; 37 | Component.preload = function () { 38 | if (!factoryPromise) { 39 | factoryPromise = factory().then(function (module) { 40 | Component.isLoaded = true; 41 | LoadedComponent = module.default; 42 | }); 43 | } 44 | return factoryPromise; 45 | }; 46 | return Component; 47 | } 48 | exports.lazyWithPreload = lazyWithPreload; 49 | function LazyLoadComponent(props) { 50 | var _a; 51 | var _b = react_1.useState(false), showLazyLoadComponent = _b[0], setShowLazyLoadComponent = _b[1]; 52 | var _c = react_1.useState(false), hasClick = _c[0], setHasClick = _c[1]; 53 | var lazyLoadElement = react_1.useRef(null); 54 | react_1.useEffect(function () { 55 | var _a; 56 | if (hasClick) { 57 | (_a = lazyLoadElement === null || lazyLoadElement === void 0 ? void 0 : lazyLoadElement.current) === null || _a === void 0 ? void 0 : _a.click(); 58 | setHasClick(false); 59 | } 60 | }, [(_a = props.lazyLoadComponent) === null || _a === void 0 ? void 0 : _a.isLoaded]); 61 | return (react_1.default.createElement(react_1.Suspense, { fallback: props.loading }, showLazyLoadComponent ? 62 | react_1.default.createElement("div", { ref: lazyLoadElement }, props.children) : react_1.default.createElement("div", { onClick: function () { return setHasClick(true); }, onMouseOver: function () { 63 | var _a; 64 | // 这里先不 await 65 | (_a = props.lazyLoadComponent) === null || _a === void 0 ? void 0 : _a.preload(); 66 | setShowLazyLoadComponent(true); 67 | } }, props.defaultLoadComponent))); 68 | } 69 | exports.LazyLoadComponent = LazyLoadComponent; 70 | -------------------------------------------------------------------------------- /src/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen, waitFor } from "@testing-library/react"; 3 | import { lazyWithPreload } from "../index"; 4 | 5 | function getTestComponentModule() { 6 | const TestComponent = React.forwardRef< 7 | HTMLDivElement, 8 | { foo: string; children: React.ReactNode } 9 | >(function TestComponent(props, ref) { 10 | return
{`${props.foo} ${props.children}`}
; 11 | }); 12 | let loaded = false; 13 | let loadCalls = 0; 14 | 15 | return { 16 | isLoaded: () => loaded, 17 | loadCalls: () => loadCalls, 18 | TestComponent: async () => { 19 | loaded = true; 20 | loadCalls++; 21 | return { default: TestComponent }; 22 | }, 23 | }; 24 | } 25 | 26 | describe("lazy", () => { 27 | it("renders normally without invoking preload", async () => { 28 | const { TestComponent, isLoaded } = getTestComponentModule(); 29 | const LazyTestComponent = lazyWithPreload(TestComponent); 30 | 31 | expect(isLoaded()).toBe(false); 32 | 33 | render( 34 | 35 | baz 36 | 37 | ); 38 | 39 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 40 | }); 41 | 42 | it("renders normally when invoking preload", async () => { 43 | const { TestComponent, isLoaded } = getTestComponentModule(); 44 | const LazyTestComponent = lazyWithPreload(TestComponent); 45 | await LazyTestComponent.preload(); 46 | 47 | expect(isLoaded()).toBe(true); 48 | 49 | render( 50 | 51 | baz 52 | 53 | ); 54 | 55 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 56 | }); 57 | 58 | it("never renders fallback if preloaded before first render", async () => { 59 | let fallbackRendered = false; 60 | const Fallback = () => { 61 | fallbackRendered = true; 62 | return null; 63 | }; 64 | const { TestComponent } = getTestComponentModule(); 65 | const LazyTestComponent = lazyWithPreload(TestComponent); 66 | await LazyTestComponent.preload(); 67 | 68 | render( 69 | }> 70 | baz 71 | 72 | ); 73 | 74 | expect(fallbackRendered).toBe(false); 75 | }); 76 | 77 | it("renders fallback if not preloaded", async () => { 78 | let fallbackRendered = false; 79 | const Fallback = () => { 80 | fallbackRendered = true; 81 | return null; 82 | }; 83 | const { TestComponent } = getTestComponentModule(); 84 | const LazyTestComponent = lazyWithPreload(TestComponent); 85 | 86 | render( 87 | }> 88 | baz 89 | 90 | ); 91 | 92 | expect(fallbackRendered).toBe(true); 93 | }); 94 | 95 | it("only preloads once when preload is invoked multiple times", async () => { 96 | const { TestComponent, loadCalls } = getTestComponentModule(); 97 | const LazyTestComponent = lazyWithPreload(TestComponent); 98 | const preloadPromise1 = LazyTestComponent.preload(); 99 | const preloadPromise2 = LazyTestComponent.preload(); 100 | 101 | await Promise.all([preloadPromise1, preloadPromise2]); 102 | 103 | // If `preload()` called multiple times, it should return the same promise 104 | expect(preloadPromise1).toBe(preloadPromise2); 105 | expect(loadCalls()).toBe(1); 106 | 107 | render( 108 | 109 | baz 110 | 111 | ); 112 | 113 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 114 | }); 115 | 116 | it("supports ref forwarding", async () => { 117 | const { TestComponent } = getTestComponentModule(); 118 | const LazyTestComponent = lazyWithPreload(TestComponent); 119 | 120 | let ref: React.RefObject | undefined; 121 | 122 | function ParentComponent() { 123 | ref = React.useRef(null); 124 | 125 | return ( 126 | 127 | baz 128 | 129 | ); 130 | } 131 | 132 | render( 133 | 134 | 135 | 136 | ); 137 | 138 | await waitFor(() => expect(screen.queryByText("bar baz")).toBeTruthy()); 139 | expect(ref?.current?.textContent).toBe("bar baz"); 140 | }); 141 | }); 142 | --------------------------------------------------------------------------------