如何使用React,TypeScript和React測試庫創建出色的用戶體驗

I'm always willing to learn, no matter how much I know. As a software engineer, my thirst for knowledge has increased a lot. I know that I have a lot of things to learn daily.

無論我知道多少,我總是愿意學習。 作為軟件工程師,我對知識的渴望增加了很多。 我知道我每天有很多東西要學習。

But before I could learn more, I wanted to master the fundamentals. To make myself a better developer, I wanted to understand more about how to create great product experiences.

但是在我可以學習更多之前,我想掌握基礎知識。 為了使自己成為一個更好的開發人員,我想更多地了解如何創建出色的產品體驗。

This post is my attempt to illustrate a Proof of Concept (PoC) I built to try out some ideas.

這篇文章旨在說明我為嘗試一些想法而構建的概念驗證(PoC)。

I had some topics in mind for this project. It needed to:

我在這個項目中想到了一些主題。 它需要:

  • Use high-quality software

    使用高質量的軟件
  • Provide a great user experience

    提供出色的用戶體驗

When I say high-quality software, this can mean so many different things. But I wanted to focus on three parts:

當我說高質量軟件時,這可能意味著很多不同的東西。 但是我想重點關注三個部分:

  • Clean Code: Strive to write human-readable code that is easy to read and simple to maintain. Separate responsibility for functions and components.

    干凈的代碼:努力編寫易于閱讀且易于維護的人類可讀代碼。 對功能和組件負責。
  • Good test coverage: It's actually not about coverage. It's about tests that cover important parts of components' behavior without knowing too much about implementation details.

    良好的測試覆蓋率:實際上與覆蓋率無關。 它是關于覆蓋組件行為的重要部分的測試,而又不了解實施細節。
  • Consistent state management: I wanted to build with software that enables the app to have consistent data. Predictability is important.

    一致的狀態管理:我想使用使應用程序具有一致數據的軟件進行構建。 可預測性很重要。

User experience was the main focus of this PoC. The software and techniques would be the foundation that enabled a good experience for users.

用戶體驗是此PoC的主要重點。 軟件和技術將成為為用戶帶來良好體驗的基礎。

To make the state consistent, I wanted a type system. So I chose TypeScript. This was my first time using Typescript with React. This project also allowed me to build custom hooks and test it properly.

為了使狀態一致,我想要一個類型系統。 所以我選擇了TypeScript。 這是我第一次將Typescript與React結合使用。 這個項目還使我能夠構建自定義的鉤子并對其進行正確的測試。

設置項目 (Setting up the project)

I came across this library called tsdx that sets up all the Typescript configuration for you. It's mainly used to build packages. Since this was a simple side project, I didn't mind giving it a try.

我遇到了一個名為tsdx的庫,該庫為您設置了所有Typescript配置。 它主要用于構建軟件包。 由于這是一個簡單的附帶項目,所以我不介意嘗試一下。

After installing it, I chose the React template and I was ready to code. But before the fun part, I wanted to set up the test configuration too. I used the React Testing Library as the main library together with jest-dom to provide some awesome custom methods (I really like the toBeInTheDocument matcher).

安裝后,我選擇了React模板,并且可以編寫代碼了。 但是在有趣的部分之前,我也想設置測試配置。 我將React Testing庫與jest-dom一起用作主庫,以提供一些很棒的自定義方法(我真的很喜歡toBeInTheDocument匹配器)。

With all that installed, I overwrote the jest config by adding a new jest.config.js:

安裝完所有內容后,我通過添加新的jest.config.js了jest配置:

module.exports = {verbose: true,setupFilesAfterEnv: ["./setupTests.ts"],
};

And a setupTests.ts to import everything I needed.

setupTests.ts導入我需要的一切。

import "@testing-library/jest-dom";

In this case, I just had the jest-dom library to import. That way, I didn't need to import this package in my test files. Now it worked out of the box.

在這種情況下,我只有jest-dom庫要導入。 這樣,我就無需在測試文件中導入該軟件包。 現在,它開箱即用。

To test this installation and configuration, I built a simple component:

為了測試此安裝和配置,我構建了一個簡單的組件:

export const Thing = () => <h1>I'm TK</h1>;

In my test, I wanted to render it and see if it was in the DOM.

在測試中,我想渲染它,看看它是否在DOM中。

import React from 'react';
import { render } from '@testing-library/react';
import { Thing } from '../index';describe('Thing', () => {it('renders the correct text in the document', () => {const { getByText } = render(<Thing />);expect(getByText("I'm TK")).toBeInTheDocument();});
});

Now we are ready for the next step.

現在我們已準備好進行下一步。

配置路由 (Configuring routes)

Here I wanted to have only two routes for now. The home page and the search page - even though I'll do nothing about the home page.

我現在只想有兩條路線。 主頁和搜索頁面-即使我不會對主頁進行任何操作。

For this project, I'm using the react-router-dom library to handle all things router-related. It's simple, easy, and fun to work with.

對于這個項目,我正在使用react-router-dom庫來處理所有與路由器相關的事情。 使用起來非常簡單,輕松且有趣。

After installing it, I added the router components in the app.typescript.

安裝后,我在app.typescript添加了路由器組件。

import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';export const App = () => (<Router><Switch><Route path="/search"><h1>It's the search!</h1></Route><Route path="/"><h1>It's Home</h1></Route></Switch></Router>
);

Now if we enter the localhost:1234, we see the title It's Home. Go to the localhost:1234/search, and we'll see the text It's the search!.

現在,如果我們輸入localhost:1234 ,則會看到標題為It's Home 。 轉到localhost:1234/search ,我們將看到文本It's the search!

Before we continue to start implementing our search page, I wanted to build a simple menu to switch between home and search pages without manipulating the URL. For this project, I'm using Material UI to build the UI foundation.

在繼續開始實現搜索頁面之前,我想構建一個簡單的菜單來在主頁和搜索頁面之間切換而不使用URL。 對于此項目,我正在使用Material UI構建UI基礎。

For now, we are just installing the @material-ui/core.

目前,我們僅安裝@material-ui/core

To build the menu, we have the button to open the menu options. In this case they're the "home" and "search" options.

要構建菜單,我們有按鈕來打開菜單選項。 在這種情況下,它們是“主頁”和“搜索”選項。

But to build a better component abstraction, I prefer to hide the content (link and label) for the menu items and make the Menu component receive this data as a prop. This way, the menu doesn't know about the items, it will just iterate through the items list and render them.

但是,為了構建更好的組件抽象,我更喜歡隱藏菜單項的內容(鏈接和標簽),并使Menu組件作為道具接收此數據。 這樣,菜單就不會知道項目,它只會遍歷項目列表并呈現它們。

It looks like this:

看起來像這樣:

import React, { Fragment, useState, MouseEvent } from 'react';
import { Link } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import MuiMenu from '@material-ui/core/Menu';
import MuiMenuItem from '@material-ui/core/MenuItem';import { MenuItem } from '../../types/MenuItem';type MenuPropsType = { menuItems: MenuItem[] };export const Menu = ({ menuItems }: MenuPropsType) => {const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);const handleClick = (event: MouseEvent<HTMLButtonElement>): void => {setAnchorEl(event.currentTarget);};const handleClose = (): void => {setAnchorEl(null);};return (<Fragment><Button aria-controls="menu" aria-haspopup="true" onClick={handleClick}>Open Menu</Button><MuiMenuid="simple-menu"anchorEl={anchorEl}keepMountedopen={Boolean(anchorEl)}onClose={handleClose}>{menuItems.map((item: MenuItem) => (<Link to={item.linkTo} onClick={handleClose} key={item.key}><MuiMenuItem>{item.label}</MuiMenuItem></Link>))}</MuiMenu></Fragment>);
};export default Menu;

Don't panic! I know it is a huge block of code, but it is pretty simple. the Fragment wrap the Button and MuiMenu (Mui stands for Material UI. I needed to rename the component because the component I'm building is also called menu).

不要驚慌! 我知道這是一個巨大的代碼塊,但是非常簡單。 Fragment包裝ButtonMuiMenu ( Mui代表Material UI。我需要重命名該組件,因為我正在構建的組件也稱為menu)。

It receives the menuItems as a prop and maps through it to build the menu item wrapped by the Link component. Link is a component from react-router to link to a given URL.

它接收menuItems作為道具,并通過它進行映射以構建由Link組件包裝的菜單項。 鏈接是從React-Router鏈接到給定URL的組件。

The menu behavior is also simple: we bind the handleClick function to the button's onClick. That way, we can change anchorEl when the button is triggered (or clicked if you prefer). The anchorEl is just a component state that represents the Mui menu element to open the menu switch. So it will open the menu items to let the user chooses one of those.

菜單行為也很簡單:我們將handleClick函數綁定到按鈕的onClick 。 這樣,我們就可以在觸發按鈕時更改anchorEl (或根據需要單擊)。 anchorEl只是表示Mui菜單元素以打開菜單開關的組件狀態。 因此,它將打開菜單項,讓用戶選擇其中一項。

Now, how do we use this component?

現在,我們如何使用該組件?

import { Menu } from './components/Menu';
import { MenuItem } from './types/MenuItem';const menuItems: MenuItem[] = [{linkTo: '/',label: 'Home',key: 'link-to-home',},{linkTo: '/search',label: 'Search',key: 'link-to-search',},
];<Menu menuItems={menuItems} />

The menuItems is a list of objects. The object has the correct contract expected by the Menu component. The type MenuItem ensures that the contract is correct. It is just a Typescript type:

menuItems是對象列表。 該對象具有Menu組件期望的正確合同。 MenuItem類型可確保合同正確。 它只是一個Typescript type

export type MenuItem = {linkTo: string;label: string;key: string;
};

Now we are ready to build the search page with all the products and a great experience. But before building the list of products, I wanted to create a fetch function to handle the request for products. As I don't have an API of products yet, I can just mock the fetch request.

現在,我們準備好使用所有產品和豐富的經驗來構建搜索頁面。 但是在構建產品列表之前,我想創建一個提取函數來處理對產品的請求。 由于我還沒有產品的API,因此我可以模擬提取請求。

At first, I just built the fetching with useEffect in the Search component. The idea would look like this:

首先,我只是在Search組件中使用useEffect構建了useEffect 。 這個想法看起來像這樣:

import React, { useState, useEffect } from 'react';
import { getProducts } from 'api';export const Search = () => {const [products, setProducts] = useState([]);const [isLoading, setIsLoading] = useState(false);const [hasError, setHasError] = useState(false);useEffect(() => {const fetchProducts = async () => {try {setIsLoading(true);const fetchedProducts = await getProducts();setIsLoading(false);setProducts(fetchedProducts);} catch (error) {setIsLoading(false);setHasError(true);}};fetchProducts();}, []);
};

I have:

我有:

  • products initialized as an empty array

    products初始化為空數組

  • isLoading initialized as false

    isLoading初始化為false

  • hasError initialized as false

    hasError初始化為false

  • The fetchProducts is an async function that calls getProducts from the api module. As we don't have a proper API for products yet, this getProducts would return a mock data.

    fetchProducts是一個異步函數,該函數從api模塊調用getProducts 。 由于我們尚無適用于產品的API,因此此getProducts將返回模擬數據。

  • When the fetchProducts is executed, we set the isLoading to true, fetch the products, and then set the isLoading to false, because the fetching finished, and the set the fetched products into products to be used in the component.

    fetchProducts執行,我們設置了isLoading為true,取產品,然后將isLoading為假,因為取完,一組取出的產品進入products在組件中。

  • If it gets any error in the fetching, we catch them, set the isLoading to false, and the hasError to true. In this context, the component will know that we had an error while fetching and can handle this case.

    如果在獲取時發生任何錯誤,我們將捕獲它們,將isLoading設置為false,將hasError為true。 在這種情況下,組件將知道在提取時發生了錯誤,并且可以處理這種情況。

  • Everything is encapsulated into a useEffect because we are doing a side effect here.

    一切都封裝在useEffect因為我們在這里有副作用。

To handle all the state logic (when to update each part for the specific context), we can extract it to a simple reducer.

為了處理所有狀態邏輯(當針對特定上下文更新每個部分時),我們可以將其提取到簡單的化簡器中。

import { State, FetchActionType, FetchAction } from './types';export const fetchReducer = (state: State, action: FetchAction): State => {switch (action.type) {case FetchActionType.FETCH_INIT:return {...state,isLoading: true,hasError: false,};case FetchActionType.FETCH_SUCCESS:return {...state,hasError: false,isLoading: false,data: action.payload,};case FetchActionType.FETCH_ERROR:return {...state,hasError: true,isLoading: false,};default:return state;}
};

The idea here is to separate each action type and handle each state update. So the fetchReducer will receive the state and the action and it will return a new state. This part is interesting because it gets the current state and then returns a new state, but we keep the state contract by using the State type.

這里的想法是分離每種動作類型并處理每種狀態更新。 因此, fetchReducer將接收狀態和操作,并將返回新狀態。 這部分很有趣,因為它獲取當前狀態然后返回新狀態,但是我們通過使用State類型來保持狀態合同。

And for each action type, we will update the state the right way.

對于每種動作類型,我們將以正確的方式更新狀態。

  • FETCH_INIT: isLoading is true and hasError is false.

    FETCH_INITisLoading為true, hasError為false。

  • FETCH_SUCCESS: hasError is false, isLoading is false, and the data (products) is updated.

    FETCH_SUCCESShasError為false, isLoading為false,并且數據(產品)已更新。

  • FETCH_ERROR: hasError is true and isLoading is false.

    FETCH_ERRORhasError為true, isLoading為false。

In case it doesn't match any action type, just return the current state.

如果它與任何操作類型都不匹配,則只需返回當前狀態即可。

The FetchActionType is a simple Typescript enum:

FetchActionType是一個簡單的Typescript枚舉:

export enum FetchActionType {FETCH_INIT = 'FETCH_INIT',FETCH_SUCCESS = 'FETCH_SUCCESS',FETCH_ERROR = 'FETCH_ERROR',
}

And the State is just a simple type:

State只是一種簡單的類型:

export type ProductType = {name: string;price: number;imageUrl: string;description: string;isShippingFree: boolean;discount: number;
};export type Data = ProductType[];export type State = {isLoading: boolean;hasError: boolean;data: Data;
};

With this new reducer, now we can useReducer in our fetch. We pass the new reducer and the initial state to it:

有了這個新的reducer,現在我們可以在提取中使用useReducer了。 我們將新的reducer及其初始狀態傳遞給它:

const initialState: State = {isLoading: false,hasError: false,data: fakeData,
};const [state, dispatch] = useReducer(fetchReducer, initialState);useEffect(() => {const fetchAPI = async () => {dispatch({ type: FetchActionType.FETCH_INIT });try {const payload = await fetchProducts();dispatch({type: FetchActionType.FETCH_SUCCESS,payload,});} catch (error) {dispatch({ type: FetchActionType.FETCH_ERROR });}};fetchAPI();
}, []);

The initialState has the same contract type. And we pass it to the useReducer together with the fetchReducer we just built. The useReducer provides the state and a function called dispatch to call actions to update our state.

initialState具有相同的合同類型。 我們把它傳遞給useReducer連同fetchReducer我們剛剛建成。 useReducer提供狀態和一個名為dispatch的函數,以調用操作來更新狀態。

  • State fetching: dispatch FETCH_INIT

    狀態獲取:調度FETCH_INIT

  • Finished fetch: dispatch FETCH_SUCCESS with the products payload

    提取完成:使用產品有效負載調度FETCH_SUCCESS

  • Get an error while fetching: dispatch FETCH_ERROR

    提取時發生錯誤:調度FETCH_ERROR

This abstraction got very big and can be very verbose in our component. We could extract it as a separate hook called useProductFetchAPI.

這種抽象很大,在我們的組件中可能非常冗長。 我們可以將其提取為名為useProductFetchAPI的單獨鉤子。

export const useProductFetchAPI = (): State => {const initialState: State = {isLoading: false,hasError: false,data: fakeData,};const [state, dispatch] = useReducer(fetchReducer, initialState);useEffect(() => {const fetchAPI = async () => {dispatch({ type: FetchActionType.FETCH_INIT });try {const payload = await fetchProducts();dispatch({type: FetchActionType.FETCH_SUCCESS,payload,});} catch (error) {dispatch({ type: FetchActionType.FETCH_ERROR });}};fetchAPI();}, []);return state;
};

It is just a function that wraps our fetch operation. Now, in the Search component, we can import and call it.

它只是包裝我們的提取操作的函數。 現在,在Search組件中,我們可以導入并調用它。

export const Search = () => {const { isLoading, hasError, data }: State = useProductFetchAPI();
};

We have all the API: isLoading, hasError, and data to use in our component. With this API, we can render a loading spinner or a skeleton based on the isLoading data. We can render an error message based on the hasError value. Or just render the list of products using the data.

我們擁有所有API: isLoadinghasError和要在我們的組件中使用的data 。 使用此API,我們可以基于isLoading數據呈現加載微調器或骨架。 我們可以基于hasError值呈現錯誤消息。 或者只是使用data呈現產品列表。

Before starting implementing our products list, I want to stop and add tests for our custom hook. We have two parts to test here: the reducer and the custom hook.

在開始實施我們的產品列表之前,我想停止并添加針對自定義掛鉤的測試。 這里有兩個要測試的部分:reducer和自定義鉤子。

The reducer is easier as it is just a pure function. It receives value, process, and returns a new value. No side-effect. Everything deterministic.

減速器更簡單,因為它只是一個純函數。 它接收值,處理并返回新值。 無副作用。 一切都是確定性的。

To cover all the possibilities of this reducer, I created three contexts: FETCH_INIT, FETCH_SUCCESS, and FETCH_ERROR actions.

為了涵蓋此reducer的所有可能性,我創建了三個上下文: FETCH_INITFETCH_SUCCESSFETCH_ERROR操作。

Before implementing anything, I set up the initial data to work with.

在實施任何操作之前,我都會設置要使用的初始數據。

const initialData: Data = [];
const initialState: State = {isLoading: false,hasError: false,data: initialData,
};

Now I can pass this initial state for the reducer together with the specific action I want to cover. For this first test, I wanted to cover the FETCH_INIT action:

現在,我可以將減速器的初始狀態與要覆蓋的特定操作一起傳遞。 對于第一個測試,我想介紹一下FETCH_INIT動作:

describe('when dispatch FETCH_INIT action', () => {it('returns the isLoading as true without any error', () => {const action: FetchAction = {type: FetchActionType.FETCH_INIT,};expect(fetchReducer(initialState, action)).toEqual({isLoading: true,hasError: false,data: initialData,});});
});

It's pretty simple. It receives the initial state and the action, and we expect the proper return value: the new state with the isLoading as true.

很簡單 它接收初始狀態和操作,并且我們期望適當的返回值: isLoadingtrue的新狀態。

The FETCH_ERROR is pretty similar:

FETCH_ERROR非常相似:

describe('when dispatch FETCH_ERROR action', () => {it('returns the isLoading as true without any error', () => {const action: FetchAction = {type: FetchActionType.FETCH_ERROR,};expect(fetchReducer(initialState, action)).toEqual({isLoading: false,hasError: true,data: [],});});
});

But we pass a different action and expect the hasError to be true.

但是我們通過了一個不同的操作,并期望hasErrortrue

The FETCH_SUCCESS is a bit complex as we just need to build a new state and add it to the payload attribute in the action.

FETCH_SUCCESS有點復雜,因為我們只需要構建一個新狀態并將其添加到操作中的有效負載屬性中。

describe('when dispatch FETCH_SUCCESS action', () => {it('returns the the API data', () => {const product: ProductType = {name: 'iPhone',price: 3500,imageUrl: 'image-url.png',description: 'Apple mobile phone',isShippingFree: true,discount: 0,};const action: FetchAction = {type: FetchActionType.FETCH_SUCCESS,payload: [product],};expect(fetchReducer(initialState, action)).toEqual({isLoading: false,hasError: false,data: [product],});});
});

But nothing too complex here. The new data is there. A list of products. In this case, just one, the iPhone product.

但是這里沒有什么太復雜的。 新數據在那里。 產品清單。 在這種情況下,只有一款iPhone產品。

The second test will cover the custom hook we built. In these tests, I wrote three contexts: a time-out request, a failed network request, and a success request.

第二個測試將涵蓋我們構建的自定義鉤子。 在這些測試中,我編寫了三個上下文:超時請求,失敗的網絡請求和成功的請求。

Here, as I'm using axios to fetch data (when I have an API to fetch the data, I will use it properly), I'm using axios-mock-adapter to mock each context for our tests.

在這里,由于我正在使用axios來獲取數據(當我有一個API來獲取數據時,我會正確使用它),我正在使用axios-mock-adapter來模擬每個上下文以進行測試。

The set up first: Initializing our data and set up an axios mock.

首先設置:初始化數據并設置axios模擬。

const mock: MockAdapter = new MockAdapter(axios);
const url: string = '/search';
const initialData: Data = [];

We start implementing a test for the timeout request:

我們開始為超時請求實施測試:

it('handles error on timed-out api request', async () => {mock.onGet(url).timeout();const { result, waitForNextUpdate } = renderHook(() =>useProductFetchAPI(url, initialData));await waitForNextUpdate();const { isLoading, hasError, data }: State = result.current;expect(isLoading).toEqual(false);expect(hasError).toEqual(true);expect(data).toEqual(initialData);
});

We set up the mock to return a timeout. The test calls the useProductFetchAPI, wait for an update, and then we can get the state. The isLoading is false, the data is still the same (an empty list), and the hasError is now true as expected.

我們設置了模擬以返回超時。 測試調用useProductFetchAPI ,等待更新,然后我們可以獲取狀態。 isLoading為false, data仍然相同(一個空列表),并且hasError現在為true。

The network request is pretty much the same behavior. The only difference is that the mock will have a network error instead of a timeout.

網絡請求幾乎是相同的行為。 唯一的區別是該模擬將出現網絡錯誤而不是超時。

it('handles error on failed network api request', async () => {mock.onGet(url).networkError();const { result, waitForNextUpdate } = renderHook(() =>useFetchAPI(url, initialData));await waitForNextUpdate();const { isLoading, hasError, data }: State = result.current;expect(isLoading).toEqual(false);expect(hasError).toEqual(true);expect(data).toEqual(initialData);
});

And for the success case, we need to create a product object to use it as a request-response data. We also expect the data to be a list of this product object. The hasError and the isLoading are false in this case.

對于成功案例,我們需要創建一個產品對象以將其用作請求-響應數據。 我們還希望data是該產品對象的列表。 在這種情況下, hasErrorisLoading為false。

it('gets and updates data from the api request', async () => {const product: ProductType = {name: 'iPhone',price: 3500,imageUrl: 'image-url.png',description: 'Apple mobile phone',isShippingFree: true,discount: 0,};const mockedResponseData: Data = [product];mock.onGet(url).reply(200, mockedResponseData);const { result, waitForNextUpdate } = renderHook(() =>useFetchAPI(url, initialData));await waitForNextUpdate();const { isLoading, hasError, data }: State = result.current;expect(isLoading).toEqual(false);expect(hasError).toEqual(false);expect(data).toEqual([product]);
});

Great. We covered everything we needed for this custom hook and the reducer we created. Now we can focus on building the products list.

大。 我們介紹了此自定義鉤子和我們創建的減速器所需的一切。 現在我們可以集中精力構建產品列表。

產品清單 (Products list)

The idea of the products list is to list products that have some information: title, description, price, discount, and if it has free shipping. The final product card would look like this:

產品列表的想法是列出具有以下信息的產品:標題,描述,價格,折扣以及是否可以免費送貨。 最終產品卡如下所示:

To build this card, I created the foundation for the product component:

為了構建此卡,我為產品組件創建了基礎:

const Product = () => (<Box><Image /><TitleDescription/><Price /><Tag /></Box>
);

To build the product, we will need to build each component that is inside it.

要構建產品,我們將需要構建產品內部的每個組件。

But before start building the product component, I want to show the JSON data that the fake API will return for us.

但是在開始構建產品組件之前,我想顯示假API為我們返回的JSON數據。

{imageUrl: 'a-url-for-tokyo-tower.png',name: 'Tokyo Tower',description: 'Some description here',price: 45,discount: 20,isShippingFree: true,
}

This data is passed from the Search component to the ProductList component:

該數據從Search組件傳遞到ProductList組件:

export const Search = () => {const { isLoading, hasError, data }: State = useProductFetchAPI();if (hasError) {return <h2>Error</h2>;}return <ProductList products={data} isLoading={isLoading} />;
};

As I'm using Typescript, I can enforce the static types for the component props. In this case, I have the prop products and the isLoading.

當我使用Typescript時,我可以為組件prop強制使用靜態類型。 在這種情況下,我有prop productsisLoading

I built a ProductListPropsType type to handle the product list props.

我建立了一個ProductListPropsType類型來處理產品列表道具。

type ProductListPropsType = {products: ProductType[];isLoading: boolean;
};

And the ProductType is a simple type representing the product:

ProductType是表示產品的簡單類型:

export type ProductType = {name: string;price: number;imageUrl: string;description: string;isShippingFree: boolean;discount: number;
};

To build the ProductList, I'll use the Grid component from Material UI. First, we have a grid container and then, for each product, we will render a grid item.

要構建ProductList,我將使用Material UI中的Grid組件。 首先,我們有一個網格容器,然后,對于每種產品,我們將渲染一個網格項目。

export const ProductList = ({ products, isLoading }: ProductListPropsType) => (<Grid container spacing={3}>{products.map(product => (<Griditemxs={6}md={3}key={`grid-${product.name}-${product.description}-${product.price}`}><Productkey={`product-${product.name}-${product.description}-${product.price}`}imageUrl={product.imageUrl}name={product.name}description={product.description}price={product.price}discount={product.discount}isShippingFree={product.isShippingFree}isLoading={isLoading}/></Grid>))}</Grid>
);

The Grid item will display 2 items per row for mobile as we use the value 6 for each column. And for the desktop version, it will render 4 items per row.

Grid項將為移動設備每行顯示2個項目,因為我們為每列使用值6 。 對于桌面版本,它將每行呈現4個項目。

We iterate through the products list and render the Product component passing all the data it will need.

我們遍歷products列表,并使Product組件傳遞所需的所有數據。

Now we can focus on building the Product component.

現在我們可以集中精力構建Product組件。

Let's start with the easiest one: the Tag. We will pass three data to this component. label, isVisible, and isLoading. When it is not visible, we just return null to don't render it. If it is loading, we will render a Skeleton component from Material UI. But after loading it, we render the tag info with the Free Shipping label.

讓我們從最簡單的一個開始: Tag 。 我們將傳遞三個數據到該組件。 labelisVisibleisLoading 。 當它不可見時,我們只返回null而不渲染它。 如果正在加載,我們將從Material UI渲染一個Skeleton組件。 但是在加載后,我們將使用“ Free Shipping標簽來呈現標簽信息。

export const Tag = ({ label, isVisible, isLoading }: TagProps) => {if (!isVisible) return null;if (isLoading) {return (<Skeleton width="110px" height="40px" data-testid="tag-skeleton-loader" />);}return (<Box mt={1} data-testid="tag-label-wrapper"><span style={tabStyle}>{label}</span></Box>);
};

The TagProps is a simple type:

TagProps是一種簡單的類型:

type TagProps = {label: string;isVisible: boolean;isLoading: boolean;
};

I'm also using an object to style the span:

我還使用一個對象來設置span樣式:

const tabStyle = {padding: '4px 8px',backgroundColor: '#f2f3fe',color: '#87a7ff',borderRadius: '4px',
};

I also wanted to build tests for this component trying to think of its behavior:

我還想為此組件構建測試,以考慮其行為:

  • when it's not visible: the tag will not be in the document.

    當它不可見時:標簽將不在文檔中。
describe('when is not visible', () => {it('does not render anything', () => {const { queryByTestId } = render(<Tag label="a label" isVisible={false} isLoading={false} />);expect(queryByTestId('tag-label-wrapper')).not.toBeInTheDocument();});
});
  • when it's loading: the skeleton will be in the document.

    加載時:骨架將在文檔中。
describe('when is loading', () => {it('renders the tag label', () => {const { queryByTestId } = render(<Tag label="a label" isVisible isLoading />);expect(queryByTestId('tag-skeleton-loader')).toBeInTheDocument();});
});
  • when it's ready to render: the tag will be in the document.

    準備渲染時:標簽將在文檔中。
describe('when is visible and not loading', () => {it('renders the tag label', () => {render(<Tag label="a label" isVisible isLoading={false} />);expect(screen.getByText('a label')).toBeInTheDocument();});
});
  • bonus point: accessibility. I also built an automated test to cover accessibility violations using jest-axe.

    優點:可訪問性。 我還構建了一個自動化測試,以使用jest-axe覆蓋可訪問性沖突。

it('has no accessibility violations', async () => {const { container } = render(<Tag label="a label" isVisible isLoading={false} />);const results = await axe(container);expect(results).toHaveNoViolations();
});

We are ready to implement another component: the TitleDescription. It will work almost similar to the Tag component. It receives some props: name, description, and isLoading.

我們準備實現另一個組件: TitleDescription 。 它的工作原理幾乎類似于Tag組件。 它收到一些道具: namedescriptionisLoading

As we have the Product type with the type definition for the name and the description, I wanted to reuse it. I tried different things - and you can take a look here for more details - and I found the Pick type. With that, I could get the name and the description from the ProductType:

由于我們具有namedescription的類型定義的Product類型,因此我想重用它。 我嘗試了不同的方法-您可以在這里查看更多詳細信息 -并且找到了Pick類型。 這樣,我可以從ProductType獲得namedescription

type TitleDescriptionType = Pick<ProductType, 'name' | 'description'>;

With this new type, I could create the TitleDescriptionPropsType for the component:

使用這種新類型,我可以為組件創建TitleDescriptionPropsType

type TitleDescriptionPropsType = TitleDescriptionType & {isLoading: boolean;
};

Now working inside the component, If the isLoading is true, the component renders the proper skeleton component before it renders the actual title and description texts.

現在在組件內部工作,如果isLoading為true,則組件在渲染實際標題和描述文本之前先渲染適當的骨架組件。

if (isLoading) {return (<Fragment><Skeletonwidth="60%"height="24px"data-testid="name-skeleton-loader"/><Skeletonstyle={descriptionSkeletonStyle}height="20px"data-testid="description-skeleton-loader"/></Fragment>);
}

If the component is not loading anymore, we render the title and description texts. Here we use the Typography component.

如果該組件不再加載,則呈現標題和描述文本。 在這里,我們使用Typography組件。

return (<Fragment><Typography data-testid="product-name">{name}</Typography><Typographydata-testid="product-description"color="textSecondary"variant="body2"style={descriptionStyle}>{description}</Typography></Fragment>
);

For the tests, we want three things:

對于測試,我們需要三件事:

  • when it is loading, the component renders the skeletons

    加載時,組件將渲染骨架
  • when it is not loading anymore, the component renders the texts

    當不再加載時,組件將呈現文本
  • make sure the component doesn't violate the accessibility

    確保組件沒有違反可訪問性

We will use the same idea we use for the Tag tests: see if it in the document or not based on the state.

我們將使用與Tag測試相同的想法:根據狀態來查看它是否在文檔中。

When it is loading, we want to see if the skeleton is in the document, but the title and description texts are not.

加載時,我們要查看骨架是否在文檔中,但標題和描述文本不在。

describe('when is loading', () => {it('does not render anything', () => {const { queryByTestId } = render(<TitleDescriptionname={product.name}description={product.description}isLoading/>);expect(queryByTestId('name-skeleton-loader')).toBeInTheDocument();expect(queryByTestId('description-skeleton-loader')).toBeInTheDocument();expect(queryByTestId('product-name')).not.toBeInTheDocument();expect(queryByTestId('product-description')).not.toBeInTheDocument();});
});

When it is not loading anymore, it renders the texts in the DOM:

當不再加載時,它將在DOM中呈現文本:

describe('when finished loading', () => {it('renders the product name and description', () => {render(<TitleDescriptionname={product.name}description={product.description}isLoading={false}/>);expect(screen.getByText(product.name)).toBeInTheDocument();expect(screen.getByText(product.description)).toBeInTheDocument();});
});

And a simple test to cover accessibility issues:

和一個簡單的測試來解決可訪問性問題:

it('has no accessibility violations', async () => {const { container } = render(<TitleDescriptionname={product.name}description={product.description}isLoading={false}/>);const results = await axe(container);expect(results).toHaveNoViolations();
});

The next component is the Price. In this component we will provide a skeleton when it is still loading as we did in the other component, and add three different components here:

下一個組成部分是Price 。 在此組件中,我們將像在其他組件中一樣提供一個仍在加載時的框架,并在此處添加三個不同的組件:

  • PriceWithDiscount: we apply the discount into the original price and render it

    PriceWithDiscount :我們將折扣應用于原始價格并進行渲染

  • OriginalPrice: it just renders the product price

    OriginalPrice :僅呈現產品價格

  • Discount: it renders the discount percentage when the product has a discount

    Discount :當產品有折扣時,它將顯示折扣百分比

But before I start implementing these components, I wanted to structure the data to be used. The price and the discount values are numbers. So let's build a function called getPriceInfo that receives the price and the discount and it will return this data:

但是在開始實現這些組件之前,我想構造要使用的數據。 pricediscount值是數字。 因此,讓我們構建一個名為getPriceInfo的函數,該函數接收pricediscount ,并將返回此數據:

{priceWithDiscount,originalPrice,discountOff,hasDiscount,
};

With this type contract:

使用這種類型的合同:

type PriceInfoType = {priceWithDiscount: string;originalPrice: string;discountOff: string;hasDiscount: boolean;
};

In this function, it will get the discount and transform it into a boolean, then apply the discount to build the priceWithDiscount, use the hasDiscount to build the discount percentage, and build the originalPrice with the dollar sign:

在此函數中,它將獲得discount并將其轉換為boolean ,然后應用discount來構建priceWithDiscount ,使用hasDiscount來構建折扣百分比,并使用美元符號來構建originalPrice

export const applyDiscount = (price: number, discount: number): number =>price - (price * discount) / 100;export const getPriceInfo = (price: number,discount: number
): PriceInfoType => {const hasDiscount: boolean = Boolean(discount);const priceWithDiscount: string = hasDiscount? `$${applyDiscount(price, discount)}`: `$${price}`;const originalPrice: string = `$${price}`;const discountOff: string = hasDiscount ? `${discount}% OFF` : '';return {priceWithDiscount,originalPrice,discountOff,hasDiscount,};
};

Here I also built an applytDiscount function to extract the discount calculation.

在這里,我還構建了applytDiscount函數來提取折扣計算。

I added some tests to cover these functions. As they are pure functions, we just need to pass some values and expect new data.

我添加了一些測試來涵蓋這些功能。 由于它們是純函數,因此我們只需要傳遞一些值并期望有新數據即可。

Test for the applyDiscount:

測試applyDiscount

describe('applyDiscount', () => {it('applies 20% discount in the price', () => {expect(applyDiscount(100, 20)).toEqual(80);});it('applies 95% discount in the price', () => {expect(applyDiscount(100, 95)).toEqual(5);});
});

Test for the getPriceInfo:

測試getPriceInfo

describe('getPriceInfo', () => {describe('with discount', () => {it('returns the correct price info', () => {expect(getPriceInfo(100, 20)).toMatchObject({priceWithDiscount: '$80',originalPrice: '$100',discountOff: '20% OFF',hasDiscount: true,});});});describe('without discount', () => {it('returns the correct price info', () => {expect(getPriceInfo(100, 0)).toMatchObject({priceWithDiscount: '$100',originalPrice: '$100',discountOff: '',hasDiscount: false,});});});
});

Now we can use the getPriceInfo in the Price components to get this structure data and pass down for the other components like this:

現在,我們可以在Price組件中使用getPriceInfo來獲取此結構數據,并向下傳遞其他組件,如下所示:

export const Price = ({ price, discount, isLoading }: PricePropsType) => {if (isLoading) {return (<Skeleton width="80%" height="18px" data-testid="price-skeleton-loader" />);}const {priceWithDiscount,originalPrice,discountOff,hasDiscount,}: PriceInfoType = getPriceInfo(price, discount);return (<Fragment><PriceWithDiscount price={priceWithDiscount} /><OriginalPrice hasDiscount={hasDiscount} price={originalPrice} /><Discount hasDiscount={hasDiscount} discountOff={discountOff} /></Fragment>);
};

As we talked earlier, when it is loading, we just render the Skeleton component. When it finishes the loading, it will build the structured data and render the price info. Let's build each component now!

如前所述,在加載時,我們僅渲染Skeleton組件。 完成加載后,它將構建結構化數據并呈現價格信息。 讓我們現在構建每個組件!

Let's start with the OriginalPrice. We just need to pass the price as a prop and it renders with the Typography component.

讓我們從OriginalPrice開始。 我們只需要傳遞price作為道具,然后使用Typography組件進行渲染。

type OriginalPricePropsType = {price: string;
};export const OriginalPrice = ({ price }: OriginalPricePropsType) => (<Typography display="inline" style={originalPriceStyle} color="textSecondary">{price}</Typography>
);

Very simple! Let's add a test now.

很簡單! 現在添加一個測試。

Just pass a price and see it if was rendered in the DOM:

只要傳遞一個價格,看看它是否在DOM中呈現即可:

it('shows the price', () => {const price = '$200';render(<OriginalPrice price={price} />);expect(screen.getByText(price)).toBeInTheDocument();
});

I also added a test to cover accessibility issues:

我還添加了一個測試以解決可訪問性問題:

it('has no accessibility violations', async () => {const { container } = render(<OriginalPrice price="$200" />);const results = await axe(container);expect(results).toHaveNoViolations();
});

The PriceWithDiscount component has a very similar implementation, but we pass the hasDiscount boolean to render this price or not. If it has a discount, render the price with the discount. Otherwise, it won't render anything.

PriceWithDiscount組件具有非常相似的實現,但是我們傳遞hasDiscount布爾值來呈現或不呈現此價格。 如果有折扣,則用折扣呈現價格。 否則,它將不會渲染任何內容。

type PricePropsType = {hasDiscount: boolean;price: string;
};

The props type has the hasDiscount and the price. And the component just renders things based on the hasDiscount value.

道具類型具有hasDiscountprice 。 并且該組件僅基于hasDiscount值呈現事物。

export const PriceWithDiscount = ({ price, hasDiscount }: PricePropsType) => {if (!hasDiscount) {return null;}return (<Typography display="inline" style={priceWithDiscountStyle}>{price}</Typography>);
};

The tests will cover this logic when it has or doesn't have the discount. If it hasn't the discount, the prices will not be rendered.

當有或沒有折扣時,測試將涵蓋此邏輯。 如果沒有折扣,將不顯示價格。

describe('when the product has no discount', () => {it('shows nothing', () => {const { queryByTestId } = render(<PriceWithDiscount hasDiscount={false} price="" />);expect(queryByTestId('discount-off-label')).not.toBeInTheDocument();});
});

If it has the discount, it will be the rendered in the DOM:

如果有折扣,它將在DOM中呈現:

describe('when the product has a discount', () => {it('shows the price', () => {const price = '$200';render(<PriceWithDiscount hasDiscount price={price} />);expect(screen.getByText(price)).toBeInTheDocument();});
});

And as always, a test to cover accessibility violations:

和往常一樣,涵蓋可訪問性違規的測試:

it('has no accessibility violations', async () => {const { container } = render(<PriceWithDiscount hasDiscount price="$200" />);const results = await axe(container);expect(results).toHaveNoViolations();
});

The Discount component is pretty much the same as the PriceWithDiscount. Render the discount tag if the product has a discount:

Discount組件與PriceWithDiscount幾乎相同。 如果產品有折扣,則顯示折扣標簽:

type DiscountPropsType = {hasDiscount: boolean;discountOff: string;
};export const Discount = ({ hasDiscount, discountOff }: DiscountPropsType) => {if (!hasDiscount) {return null;}return (<Typographydisplay="inline"color="secondary"data-testid="discount-off-label">{discountOff}</Typography>);
};

And all the tests we did for the other component, we do the same thing for the Discount component:

我們對其他組件所做的所有測試,對Discount組件也做同樣的事情:

describe('Discount', () => {describe('when the product has a discount', () => {it('shows the discount label', () => {const discountOff = '20% OFF';render(<Discount hasDiscount discountOff={discountOff} />);expect(screen.getByText(discountOff)).toBeInTheDocument();});});describe('when the product has no discount', () => {it('shows nothing', () => {const { queryByTestId } = render(<Discount hasDiscount={false} discountOff="" />);expect(queryByTestId('discount-off-label')).not.toBeInTheDocument();});});it('has no accessibility violations', async () => {const { container } = render(<Discount hasDiscount discountOff="20% OFF" />);const results = await axe(container);expect(results).toHaveNoViolations();});
});

Now we will build an Image component. This component has the basic skeleton as any other component we've built. If it is loading, wait to render the image source and render the skeleton instead. When it finishes the loading, we will render the image, but only if the component is in the intersection of the browser window.

現在,我們將構建一個Image組件。 該組件具有基本骨架,就像我們構建的任何其他組件一樣。 如果正在加載,請等待渲染圖像源并渲染骨架。 完成加載后,我們將渲染圖像,但前提是組件位于瀏覽器窗口的交點內。

What does it mean? When you are on a website on your mobile device, you'll probably see the first 4 products. They will render the skeleton and then the image. But below these 4 products, as you're not seeing any of them, it doesn't matter if we are rendering them or not. And we can choose to not render them. Not for now. But on-demand. When you are scrolling, if the product's image is at the intersection of the browser window, we start rendering the image source.

這是什么意思? 當您在移動設備上的網站上時,可能會看到前4種產品。 他們將先渲染骨架,然后再渲染圖像。 但是在這4種產品之下,您可能看不到它們,因此我們是否渲染它們都沒有關系。 我們可以選擇不渲染它們。 現在不行。 但是按需。 滾動時,如果產品的圖像位于瀏覽器窗口的交點處,我們將開始渲染圖像源。

That way we gain performance by speeding up the page load time and reduce the cost by requesting images on demand.

這樣,我們可以通過加快頁面加載時間來提高性能,并通過按需請求圖像來降低成本。

We will use the Intersection Observer API to download images on demand. But before writing any code about this technology, let's start building our component with the image and the skeleton view.

我們將使用Intersection Observer API來按需下載圖像。 但是在編寫有關該技術的任何代碼之前,讓我們開始使用圖像和框架視圖構建組件。

Image props will have this object:

圖像道具將具有以下對象:

{imageUrl,imageAlt,width,isLoading,imageWrapperStyle,imageStyle,
}

The imageUrl, imageAlt, and the isLoading props are passed by the product component. The width is an attribute for the skeleton and the image tag. The imageWrapperStyle and the imageStyle are props that have a default value in the image component. We'll talk about this later.

imageUrlimageAltisLoading道具由產品組件傳遞。 width是骨架和圖像標簽的屬性。 imageWrapperStyleimageStyle是在圖像組件中具有默認值的道具。 我們稍后再討論。

Let's add a type for this props:

讓我們為這個道具添加一個類型:

type ImageUrlType = Pick<ProductType, 'imageUrl'>;
type ImageAttrType = { imageAlt: string; width: string };
type ImageStateType = { isLoading: boolean };
type ImageStyleType = {imageWrapperStyle: CSSProperties;imageStyle: CSSProperties;
};export type ImagePropsType = ImageUrlType &ImageAttrType &ImageStateType &ImageStyleType;

The idea here is to give meaning for the types and then compose everything. We can get the imageUrl from the ProductType. The attribute type will have the imageAlt and the width. The image state has the isLoading state. And the image style has some CSSProperties.

這里的想法是為類型賦予含義,然后組成所有內容。 我們可以從ProductType獲取imageUrl 。 屬性類型將具有imageAltwidth 。 圖像狀態為isLoading狀態。 并且圖像樣式具有一些CSSProperties

At first, the component would like this:

首先,該組件將如下所示:

export const Image = ({imageUrl,imageAlt,width,isLoading,imageWrapperStyle,imageStyle,
}: ImagePropsType) => {if (isLoading) {<Skeletonvariant="rect"width={width}data-testid="image-skeleton-loader"/>}return (<imgsrc={imageUrl}alt={imageAlt}width={width}style={imageStyle}/>);
};

Let's build the code to make the intersection observer works.

讓我們構建代碼以使相交觀察器起作用。

The idea of the intersection observer is to receive a target to be observed and a callback function that is executed whenever the observed target enters or exits the viewport. So the implementation would be very simple:

相交觀察器的思想是接收要觀察的目標,并在觀察到的目標進入或退出視口時執行回調函數。 因此,實現將非常簡單:

const observer: IntersectionObserver = new IntersectionObserver(onIntersect,options
);observer.observe(target);

Instantiate the IntersectionObserver class by passing an options object and the callback function. The observer will observe the target element.

通過傳遞選項對象和回調函數來實例化IntersectionObserver類。 observer將觀察target元素。

As it is an effect in the DOM, we can wrap this into a useEffect.

由于它是DOM中的一種效果,因此我們可以將其包裝到useEffect

useEffect(() => {const observer: IntersectionObserver = new IntersectionObserver(onIntersect,options);observer.observe(target);return () => {observer.unobserve(target);};
}, [target]);

Using useEffect, we have two different things here: the dependency array and the returning function. We pass the target as the dependency function to make sure that we will re-run the effect if the target changes. And the returning function is a cleanup function. React performs the cleanup when the component unmounts, so it will clean up the effect before running another effect for every render.

使用useEffect ,我們在這里有兩件事:依賴項數組和返回函數。 我們將target作為依賴項函數傳遞,以確保如果target發生更改,我們將重新運行效果。 返回函數是清理函數。 當組件卸載時,React會執行清理操作,因此它將在為每個渲染運行另一個效果之前清理效果。

In this cleanup function, we just stop observing the target element.

在此清理功能中,我們只是停止觀察target元素。

When the component starts rendering, the target reference is not set yet, so we need to have a guard to not observe an undefined target.

當組件開始渲染時,尚未設置target參考,因此我們需要有保護措施以免觀察undefined目標。

useEffect(() => {if (!target) {return;}const observer: IntersectionObserver = new IntersectionObserver(onIntersect,options);observer.observe(target);return () => {observer.unobserve(target);};
}, [target]);

Instead of using this effect in our component, we could build a custom hook to receive the target, some options to customize the configuration, and it would provide a boolean telling if the target is at the intersection of the viewport or not.

可以在組件中使用一個自定義鉤子來接收目標,而不是在組件中使用此效果,可以使用一些選項來自定義配置,它將提供一個布爾值來告知目標是否在視口的交叉點。

export type TargetType = Element | HTMLDivElement | undefined;
export type IntersectionStatus = {isIntersecting: boolean;
};const defaultOptions: IntersectionObserverInit = {rootMargin: '0px',threshold: 0.1,
};export const useIntersectionObserver = (target: TargetType,options: IntersectionObserverInit = defaultOptions
): IntersectionStatus => {const [isIntersecting, setIsIntersecting] = useState(false);useEffect(() => {if (!target) {return;}const onIntersect = ([entry]: IntersectionObserverEntry[]) => {setIsIntersecting(entry.isIntersecting);if (entry.isIntersecting) {observer.unobserve(target);}};const observer: IntersectionObserver = new IntersectionObserver(onIntersect,options);observer.observe(target);return () => {observer.unobserve(target);};}, [target]);return { isIntersecting };
};

In our callback function, we just set if the entry target is intersecting the viewport or not. The setIsIntersecting is a setter from the useState hook we define at the top of our custom hook.

在回調函數中,我們只是設置輸入目標是否與視口相交。 setIsIntersecting是我們在自定義鉤子頂部定義的useState鉤子中的設置器。

It is initialized as false but will update to true if it is intersecting the viewport.

它初始化為false但如果與視口相交,則將更新為true

With this new information in the component, we can render the image or not. If it is intersecting, we can render the image. If not, just render a skeleton until the user gets to the viewport intersection of the product image.

利用組件中的新信息,我們可以渲染圖像或不渲染圖像。 如果相交,則可以渲染圖像。 如果沒有,則只需渲染骨架,直到用戶到達產品圖像的視口相交處。

How does it look in practice?

實際情況如何?

First we define the wrapper reference using useState:

首先,我們使用useState定義包裝器引用:

const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();

It start as undefined. Then build a wrapper callback to set the element node:

它以undefined開始。 然后構建包裝器回調以設置元素節點:

const wrapperCallback = useCallback(node => {setWrapperRef(node);
}, []);

With that, we can use it to get the wrapper reference by using a ref prop in our div.

這樣,我們可以通過在div使用ref prop來使用它獲取包裝器引用。

<div ref={wrapperCallback}>

After setting the wrapperRef, we can pass it as the target for our useIntersectionObserver and expect a isIntersecting status as a result:

設置wrapperRef ,我們可以將其作為useIntersectionObservertarget傳遞,并期望結果為isIntersecting狀態:

const { isIntersecting }: IntersectionStatus = useIntersectionObserver(wrapperRef);

With this new value, we can build a boolean value to know if we render the skeleton or the product image.

使用這個新值,我們可以構建一個布爾值來知道是否渲染骨架或產品圖像。

const showImageSkeleton: boolean = isLoading || !isIntersecting;

So now we can render the appropriate node to the DOM.

因此,現在我們可以將適當的節點呈現給DOM。

<div ref={wrapperCallback} style={imageWrapperStyle}>{showImageSkeleton ? (<Skeletonvariant="rect"width={width}height={imageWrapperStyle.height}style={skeletonStyle}data-testid="image-skeleton-loader"/>) : (<imgsrc={imageUrl}alt={imageAlt}width={width}/>)}
</div>

The full component looks like this:

完整的組件如下所示:

export const Image = ({imageUrl,imageAlt,width,isLoading,imageWrapperStyle,
}: ImagePropsType) => {const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();const wrapperCallback = useCallback(node => {setWrapperRef(node);}, []);const { isIntersecting }: IntersectionStatus = useIntersectionObserver(wrapperRef);const showImageSkeleton: boolean = isLoading || !isIntersecting;return (<div ref={wrapperCallback} style={imageWrapperStyle}>{showImageSkeleton ? (<Skeletonvariant="rect"width={width}height={imageWrapperStyle.height}style={skeletonStyle}data-testid="image-skeleton-loader"/>) : (<imgsrc={imageUrl}alt={imageAlt}width={width}/>)}</div>);
};

Great, now the loading on-demand works well. But I want to build a slightly better experience. The idea here is to have two different sizes of the same image. The low-quality image is requested and we make it visible, but blur while the high-quality image is requested in the background. When the high-quality image finally finishes loading, we transition from the low-quality to the high-quality image with an ease-in/ease-out transition to make it a smooth experience.

太好了,現在按需加載效果很好。 但是我想建立一個更好的體驗。 這里的想法是使同一圖像具有兩個不同的大小。 我們請求了低質量的圖像,我們將其顯示出來,但是當在后臺請求高質量的圖像時,圖像就會模糊。 當高質量的圖像最終完成加載時,我們通過緩入/緩出過渡從低質量圖像過渡到高質量圖像,以提供流暢的體驗。

Let's build this logic. We could build this into the component, but we could also extract this logic into a custom hook.

讓我們建立這個邏輯。 我們可以將其構建到組件中,但也可以將該邏輯提取到自定義鉤子中。

export const useImageOnLoad = (): ImageOnLoadType => {const [isLoaded, setIsLoaded] = useState(false);const handleImageOnLoad = () => setIsLoaded(true);const imageVisibility: CSSProperties = {visibility: isLoaded ? 'hidden' : 'visible',filter: 'blur(10px)',transition: 'visibility 0ms ease-out 500ms',};const imageOpactity: CSSProperties = {opacity: isLoaded ? 1 : 0,transition: 'opacity 500ms ease-in 0ms',};return { handleImageOnLoad, imageVisibility, imageOpactity };
};

This hook just provides some data and behavior for the component. The handleImageOnLoad we talked earlier, the imageVisibility to make the low-quality image visible or not, and the imageOpactity to make the transition from transparent to opaque, that way we make it visible after loading it.

該掛鉤僅提供組件的一些數據和行為。 該handleImageOnLoad我們之前談到的,在imageVisibility使低質量的圖像可見或不可見,并且imageOpactity使從透明到不透明的過渡,這樣我們做加載它之后它是可見的。

The isLoaded is a simple boolean to handle the visibility of the images. Another small detail is the filter: 'blur(10px)' to make the low-quality-image blur and then slowly focusing while transitioning from the low-quality image to the high-quality image.

isLoaded是一個簡單的布爾值,用于處理圖像的可見性。 另一個小細節是filter: 'blur(10px)'使劣質圖像模糊,然后在從劣質圖像過渡到高質量圖像時緩慢聚焦。

With this new hook, we just import it, and call inside the component:

有了這個新的鉤子,我們只需導入它,然后在組件內部調用:

const {handleImageOnLoad,imageVisibility,imageOpactity,
}: ImageOnLoadType = useImageOnLoad();

And start using the data and behavior we built.

并開始使用我們構建的數據和行為。

<Fragment><imgsrc={thumbUrl}alt={imageAlt}width={width}style={{ ...imageStyle, ...imageVisibility }}/><imgonLoad={handleImageOnLoad}src={imageUrl}alt={imageAlt}width={width}style={{ ...imageStyle, ...imageOpactity }}/>
</Fragment>

The first one has a low-quality image, the thumbUrl. The second has the original high-quality image, the imageUrl. When the high-quality image is loaded, it calls the handleImageOnLoad function. This function will make the transition between one image to the other.

第一個圖像質量低下, thumbUrl 。 第二個具有原始的高質量圖像imageUrl 。 加載高質量圖像后,它將調用handleImageOnLoad函數。 此功能將使一個圖像與另一個圖像之間過渡。

結語 (Wrapping up)

This is the first part of this project to learn more about user experience, native APIs, typed frontend, and tests.

這是該項目的第一部分,旨在了解有關用戶體驗,本機API,類型化前端和測試的更多信息。

For the next part of this series, we are going to think more in an architectural way to build the search with filters, but keeping the mindset to bring technical solutions to make the user experience as smooth as possible.

對于本系列的下一部分,我們將以一種體系結構的方式進行更多思考,以使用過濾器構建搜索,但要保持思路以帶來技術解決方案,以使用戶體驗盡可能流暢。

You can find other articles like this on TK's blog.

您可以在TK的博客上找到其他類似的文章 。

資源資源 (Resources)

  • Lazy Loading Images and Video

    延遲加載圖像和視頻

  • Functional Uses for Intersection Observer

    交叉口觀察員的功能用途

  • Tips for rolling your own lazy loading

    滾動自己的延遲加載的提示

  • Intersection Observer API - MDN

    交叉路口觀察員API-MDN

  • React Typescript Cheatsheet

    React打字稿備忘單

翻譯自: https://www.freecodecamp.org/news/ux-studies-with-react-typescript-and-testing-library/

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/390233.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/390233.shtml
英文地址,請注明出處:http://en.pswp.cn/news/390233.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

PowerDesigner常用設置

2019獨角獸企業重金招聘Python工程師標準>>> 使用powerdesigner進行數據庫設計確實方便&#xff0c;以下是一些常用的設置 附加&#xff1a;工具欄不見了 調色板(Palette)快捷工具欄不見了 PowerDesigner 快捷工具欄 palette 不見了&#xff0c;怎么重新打開&#x…

bzoj5090[lydsy11月賽]組題

裸的01分數規劃,二分答案,沒了. #include<cstdio> #include<algorithm> using namespace std; const int maxn100005; int a[maxn]; double b[maxn]; double c[maxn]; typedef long long ll; ll gcd(ll a,ll b){return (b0)?a:gcd(b,a%b); } int main(){int n,k;s…

797. 所有可能的路徑

797. 所有可能的路徑 給你一個有 n 個節點的 有向無環圖&#xff08;DAG&#xff09;&#xff0c;請你找出所有從節點 0 到節點 n-1 的路徑并輸出&#xff08;不要求按特定順序&#xff09; 二維數組的第 i 個數組中的單元都表示有向圖中 i 號節點所能到達的下一些節點&#…

深入框架本源系列 —— Virtual Dom

該系列會逐步更新&#xff0c;完整的講解目前主流框架中底層相通的技術&#xff0c;接下來的代碼內容都會更新在 這里 為什么需要 Virtual Dom 眾所周知&#xff0c;操作 DOM 是很耗費性能的一件事情&#xff0c;既然如此&#xff0c;我們可以考慮通過 JS 對象來模擬 DOM 對象&…

網絡工程師常備工具_網絡安全工程師應該知道的10種工具

網絡工程師常備工具If youre a penetration tester, there are numerous tools you can use to help you accomplish your goals. 如果您是滲透測試人員&#xff0c;則可以使用許多工具來幫助您實現目標。 From scanning to post-exploitation, here are ten tools you must k…

configure: error: You need a C++ compiler for C++ support.

安裝pcre包的時候提示缺少c編譯器 報錯信息如下&#xff1a; configure: error: You need a C compiler for C support. 解決辦法&#xff0c;使用yum安裝&#xff1a;yum -y install gcc-c 轉載于:https://www.cnblogs.com/mkl34367803/p/8428264.html

程序編寫經驗教訓_編寫您永遠都不會忘記的有效績效評估的經驗教訓。

程序編寫經驗教訓This article is intended for two audiences: people who need to write self-evaluations, and people who need to provide feedback to their colleagues. 本文面向兩個受眾&#xff1a;需要編寫自我評估的人員和需要向同事提供反饋的人員。 For the purp…

刪除文件及文件夾命令

方法一&#xff1a; echo off ::演示&#xff1a;刪除指定路徑下指定天數之前&#xff08;以文件的最后修改日期為準&#xff09;的文件。 ::如果演示結果無誤&#xff0c;把del前面的echo去掉&#xff0c;即可實現真正刪除。 ::本例需要Win2003/Vista/Win7系統自帶的forfiles命…

BZOJ 3527: [ZJOI2014]力(FFT)

題意 給出\(n\)個數\(q_i\),給出\(Fj\)的定義如下&#xff1a; \[F_j\sum \limits _ {i < j} \frac{q_iq_j}{(i-j)^2}-\sum \limits _{i >j} \frac{q_iq_j}{(i-j)^2}.\] 令\(E_iF_i/q_i\)&#xff0c;求\(E_i\). 題解 一開始沒發現求\(E_i\)... 其實題目還更容易想了... …

c# 實現刷卡_如何在RecyclerView中實現“刷卡選項”

c# 實現刷卡Lets say a user of your site wants to edit a list item without opening the item and looking for editing options. If you can enable this functionality, it gives that user a good User Experience. 假設您網站的用戶想要在不打開列表項并尋找編輯選項的情…

批處理命令無法連續執行

如題&#xff0c;博主一開始的批處理命令是這樣的&#xff1a; cd node_modules cd heapdump node-gyp rebuild cd .. cd v8-profiler-node8 node-pre-gyp rebuild cd .. cd utf-8-validate node-gyp rebuild cd .. cd bufferutil node-gyp rebuild pause執行結果&#xff1…

sql語句中的in用法示例_示例中JavaScript in操作符

sql語句中的in用法示例One of the first topics you’ll come across when learning JavaScript (or any other programming language) are operators. 學習JavaScript(或任何其他編程語言)時遇到的第一個主題之一是運算符。 The most common operators are the arithmetic, l…

vue項目實戰總結

馬上過年了&#xff0c;最近工作不太忙&#xff0c;再加上本人最近比較懶&#xff0c;毫無斗志&#xff0c;不愿學習新東西&#xff0c;或許是要過年的緣故(感覺像是在找接口)。 就把前一段時間做過的vue項目&#xff0c;進行一次完整的總結。 這次算是詳細總結&#xff0c;會從…

Linux !的使用

轉自&#xff1a;https://www.linuxidc.com/Linux/2015-05/117774.htm 一、history    78 cd /mnt/ 79 ls 80 cd / 81 history 82 ls 83 ls /mnt/ !78 相當于執行cd /mnt !-6 也相當于執行cd /mnt 二、!$ cd /mnt ls !$ 相當于執行 ls /mnt轉載于:https://www.cnblogs.…

881. 救生艇

881. 救生艇 第 i 個人的體重為 people[i]&#xff0c;每艘船可以承載的最大重量為 limit。 每艘船最多可同時載兩人&#xff0c;但條件是這些人的重量之和最多為 limit。 返回載到每一個人所需的最小船數。(保證每個人都能被船載)。 示例 1&#xff1a; 輸入&#xff1a;…

使用python數據分析_如何使用Python提升您的數據分析技能

使用python數據分析If youre learning Python, youve likely heard about sci-kit-learn, NumPy and Pandas. And these are all important libraries to learn. But there is more to them than you might initially realize.如果您正在學習Python&#xff0c;則可能聽說過sci…

openresty 日志輸出的處理

最近出了個故障&#xff0c;有個接口的請求居然出現了長達幾十秒的處理時間&#xff0c;由于日志缺乏&#xff0c;網絡故障也解除了&#xff0c;就沒法再重現這個故障了。為了可以在下次出現問題的時候能追查到問題&#xff0c;所以需要添加一些追蹤日志。添加這些追蹤日志&…

誰是贏家_贏家的真正作品是股東

誰是贏家As I wrote in the article “5 Skills to Look For When Hiring Remote Talent,” remote work is a fast emerging segment of the labor market. Today roughly eight million Americans work remotely full-time. And among the most commonly held jobs include m…

博客園代碼黑色主題高亮設置

參考鏈接&#xff1a; https://segmentfault.com/a/1190000013001367 先發鏈接&#xff0c;有空實踐后會整理。我的GitHub地址&#xff1a;https://github.com/heizemingjun我的博客園地址&#xff1a;http://www.cnblogs.com/chenmingjun我的螞蟻筆記博客地址&#xff1a;http…

Matplotlib課程–學習Python數據可視化

Learn the basics of Matplotlib in this crash course tutorial. Matplotlib is an amazing data visualization library for Python. You will also learn how to apply Matplotlib to real-world problems.在此速成班教程中學習Matplotlib的基礎知識。 Matplotlib是一個很棒…