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
包裝Button
和MuiMenu
( 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;
};
搜索 (Search)
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 arrayproducts
初始化為空數組isLoading
initialized as falseisLoading
初始化為falsehasError
initialized as falsehasError
初始化為falseThe
fetchProducts
is an async function that callsgetProducts
from theapi
module. As we don't have a proper API for products yet, thisgetProducts
would return a mock data.fetchProducts
是一個異步函數,該函數從api
模塊調用getProducts
。 由于我們尚無適用于產品的API,因此此getProducts
將返回模擬數據。When the
fetchProducts
is executed, we set theisLoading
to true, fetch the products, and then set theisLoading
to false, because the fetching finished, and the set the fetched products intoproducts
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 thehasError
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 andhasError
is false.FETCH_INIT
:isLoading
為true,hasError
為false。FETCH_SUCCESS
:hasError
is false,isLoading
is false, and the data (products) is updated.FETCH_SUCCESS
:hasError
為false,isLoading
為false,并且數據(產品)已更新。FETCH_ERROR
:hasError
is true andisLoading
is false.FETCH_ERROR
:hasError
為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: isLoading
, hasError
和要在我們的組件中使用的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_INIT
, FETCH_SUCCESS
和FETCH_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
.
很簡單 它接收初始狀態和操作,并且我們期望適當的返回值: isLoading
為true
的新狀態。
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
.
但是我們通過了一個不同的操作,并期望hasError
為true
。
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
是該產品對象的列表。 在這種情況下, hasError
和isLoading
為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 products
和isLoading
。
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
。 我們將傳遞三個數據到該組件。 label
, isVisible
和isLoading
。 當它不可見時,我們只返回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
組件。 它收到一些道具: name
, description
和isLoading
。
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
:
由于我們具有name
和description
的類型定義的Product
類型,因此我想重用它。 我嘗試了不同的方法-您可以在這里查看更多詳細信息 -并且找到了Pick
類型。 這樣,我可以從ProductType
獲得name
和description
:
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 itPriceWithDiscount
:我們將折扣應用于原始價格并進行渲染OriginalPrice
: it just renders the product priceOriginalPrice
:僅呈現產品價格Discount
: it renders the discount percentage when the product has a discountDiscount
:當產品有折扣時,它將顯示折扣百分比
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:
但是在開始實現這些組件之前,我想構造要使用的數據。 price
和discount
值是數字。 因此,讓我們構建一個名為getPriceInfo
的函數,該函數接收price
和discount
,并將返回此數據:
{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.
道具類型具有hasDiscount
和price
。 并且該組件僅基于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.
imageUrl
, imageAlt
和isLoading
道具由產品組件傳遞。 width
是骨架和圖像標簽的屬性。 imageWrapperStyle
和imageStyle
是在圖像組件中具有默認值的道具。 我們稍后再討論。
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
。 屬性類型將具有imageAlt
和width
。 圖像狀態為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
,我們可以將其作為useIntersectionObserver
的target
傳遞,并期望結果為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/