Skip to content

Commit

Permalink
feat: new component Image with built-in lazy loading option (#395)
Browse files Browse the repository at this point in the history
* feat(intersectionobserver): add IntersectionObserver hook

* feat(intersectionobserver): update IntersectionObserver unit test

* feat(useintersectionobserver): use ref and add freezeOnceVisible

* feat(useintersectionobserver): update test unit

* feat(image): add image component

* feat(image): update review comment and mock window.Image

* feat(image): add image test
  • Loading branch information
LuckyFBB authored and mumiao committed Nov 15, 2023
1 parent 1791dd2 commit 9be7898
Show file tree
Hide file tree
Showing 12 changed files with 509 additions and 0 deletions.
108 changes: 108 additions & 0 deletions src/image/__test__/__snapshots__/index.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`test Image should render correct 1`] = `
{
"asFragment": [Function],
"baseElement": <body>
<div>
<div
aria-busy="true"
aria-live="polite"
class="ant-spin ant-spin-spinning"
>
<span
class="ant-spin-dot ant-spin-dot-spin"
>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
</span>
</div>
</div>
</body>,
"container": <div>
<div
aria-busy="true"
aria-live="polite"
class="ant-spin ant-spin-spinning"
>
<span
class="ant-spin-dot ant-spin-dot-spin"
>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
<i
class="ant-spin-dot-item"
/>
</span>
</div>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;
62 changes: 62 additions & 0 deletions src/image/__test__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { render, waitFor } from '@testing-library/react';

import Image from '..';
let originalImage: any = null;

describe('test Image', () => {
beforeEach(() => {
originalImage = window.Image;
// @ts-ignore
window.Image = class {
constructor() {
setTimeout(() => {
// @ts-ignore
this.onload();
}, 100);
}
};
});

afterEach(() => {
window.Image = originalImage;
});

it('should render correct', async () => {
const wrapper = render(<Image src="https://example.com/image.jpg" />);
await waitFor(() => {
expect(wrapper).toMatchSnapshot();
});
});

it('renders img when src is available', async () => {
render(<Image src="https://example.com/image.jpg" />);
await waitFor(() => {
// 检查是否正确渲染 img 元素
const imgDom = document.querySelector('img');
expect(imgDom).not.toBeNull();
expect(imgDom?.src).toEqual('https://example.com/image.jpg');
});
});

it('renders img with correct props', async () => {
const { container } = render(
<Image
src="https://example.com/image.jpg"
height={200}
width={100}
className="test"
style={{ backgroundColor: 'red' }}
/>
);
await waitFor(() => {
// 检查是否正确渲染 img 元素
const imgDom = container.querySelector('img');
expect(imgDom).not.toBeNull();
expect(imgDom?.height).toEqual(200);
expect(imgDom?.width).toEqual(100);
expect(imgDom?.className).toEqual('test');
expect((imgDom?.style as any)._values).toEqual({ 'background-color': 'red' });
});
});
});
16 changes: 16 additions & 0 deletions src/image/demos/basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

import Image from '..';

export default function Component() {
return (
<div style={{ height: 200 }}>
<Image
height={200}
width={200}
src="https://dtstack.github.io/dt-react-component/static/empty_overview.43b0eedf.png"
style={{ borderColor: 'red' }}
/>
</div>
);
}
17 changes: 17 additions & 0 deletions src/image/demos/lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';

import Image from '..';

export default function Component() {
return (
<div style={{ height: 200, overflow: 'scroll' }}>
<div style={{ height: 300 }}>占位</div>
<Image
height={200}
width={200}
lazy
src="https://dtstack.github.io/dt-react-component/static/empty_permission.35e2808b.png"
/>
</div>
);
}
30 changes: 30 additions & 0 deletions src/image/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
title: Image 图片组件
group: 组件
toc: content
---

# Image

## 何时使用

展示图片

## 示例

<code src="./demos/basic.tsx" title="基础使用"></code>
<code src="./demos/lazy.tsx" title="图片懒加载"></code>

## API

### Props

| 参数 | 说明 | 类型 | 默认值 |
| --------- | ------------------ | --------------------- | ------ |
| src | 图片资源 | `string` | - |
| lazy | 是否开启图片懒加载 | `boolean` | - |
| className | 图片样式名 | `string` |
| style | 图片样式 | `React.CSSProperties` |
| width | 图片样式 | `number` |
| height | 图片样式 | `number` |
| loader | 图片样式 | `JSX.Element \| null` |
77 changes: 77 additions & 0 deletions src/image/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import React, { CSSProperties, useRef } from 'react';
import { Spin } from 'antd';

import useIntersectionObserver from '../useIntersectionObserver';

interface IProps {
src: string;
lazy?: boolean;
alt?: string;
className?: string;
style?: CSSProperties;
width?: number;
height?: number;
loader?: JSX.Element | null;
}

function loadImage(src: string) {
return new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(true);
i.onerror = reject;
i.src = src;
});
}

export function useImage({ src }: { src: string }): {
src: string | undefined;
isLoading: boolean;
} {
const [loading, setLoading] = React.useState(true);
const [value, setValue] = React.useState<string | undefined>(undefined);

React.useEffect(() => {
loadImage(src)
.then(() => {
setLoading(false);
setValue(src);
})
.finally(() => {
setLoading(false);
});
}, [src]);

return { isLoading: loading, src: value };
}

const ImageComponent = (props: IProps) => {
const { lazy } = props;
if (lazy) return <LazyImage {...props} />;
return <NormalImage {...props} />;
};

const LazyImage = (props: IProps) => {
const { src, ...rest } = props;
const imgRef = useRef<HTMLImageElement>(null);
useIntersectionObserver(([entry]) => {
const { target, isIntersecting } = entry;
if (isIntersecting) {
const _target = target as HTMLImageElement;
_target.src = _target.dataset['src'] ?? '';
_target.onload = () => {
_target.style.opacity = '1';
};
}
}, imgRef);
return <img ref={imgRef} {...rest} data-src={src} />;
};

const NormalImage = (props: IProps) => {
const { src: originSrc, loader = <Spin spinning />, ...rest } = props;
const { src, isLoading } = useImage({ src: originSrc });
if (src) return <img {...rest} src={src} />;
if (isLoading) return loader;
return null;
};

export default ImageComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { RefObject } from 'react';
import { act, renderHook } from '@testing-library/react-hooks';

import useIntersectionObserver from '../index';

describe('useIntersectionObserver', () => {
let observeMock: jest.Mock;
let disconnectMock: jest.Mock;

beforeEach(() => {
observeMock = jest.fn();
disconnectMock = jest.fn();
jest.spyOn(window, 'IntersectionObserver').mockImplementation(
(
_callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit | undefined
) => ({
observe: observeMock,
disconnect: disconnectMock,
root: null,
rootMargin: '',
thresholds: [],
takeRecords: jest.fn(),
unobserve: jest.fn(),
})
);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should observe target element and disconnect on unmount', () => {
const ref = { current: document.createElement('div') };
const callback = jest.fn();
const options = { threshold: 0, root: null, rootMargin: '0%' };
const { unmount } = renderHook(() => useIntersectionObserver(callback, ref, options));
expect(window.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), options);
expect(observeMock).toHaveBeenCalledWith(ref.current);
act(() => {
unmount();
});
expect(disconnectMock).toHaveBeenCalled();
});

it('should not observe target element if not provided', () => {
const callback = jest.fn();
const options = { threshold: 0, root: null, rootMargin: '0%' };
const { unmount } = renderHook(() =>
useIntersectionObserver(callback, null as unknown as RefObject<Element>, options)
);
expect(window.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), options);
expect(observeMock).not.toHaveBeenCalled();
act(() => {
unmount();
});
expect(callback).not.toHaveBeenCalled();
});
});
Loading

0 comments on commit 9be7898

Please sign in to comment.