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;
};

搜索 (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 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/

如何使用React,TypeScript和React测试库创建出色的用户体验相关推荐

  1. PMCAFF | 阿里PM的可用性测试秘籍:有理有据的用户体验优化

    微信公众号|卿说 原创作者|周卿 周卿,现任阿里巴巴商家业务事业部产品经理,负责大数据业务相关商业产品.硕士毕业于清华大学,曾任有道词典产品经理. 对于产品经理而言,有几个场景怕是最难熬的: 第一,产 ...

  2. RF Python扩展测试库

    Python模块作为测试库 创建一个MyLibrary.py,内容为 Def  returnList(): Return[1,2] Def  _returnList():      *** 定义在py ...

  3. React组件库实践:React + Typescript + Less + Rollup + Storybook

    背景 原先在做低代码平台的时候,刚好有搭载React组件库的需求,所以就搞了一套通用的React组件库模版.目前通过这套模板也搭建过好几个组件库. 为了让这个模板更干净和通用,我把所有和低代码相关的代 ...

  4. react jest测试_如何使用React测试库和Jest开始测试React应用

    react jest测试 Testing is often seen as a tedious process. It's extra code you have to write, and in s ...

  5. react 路由重定向_如何测试与测试库的路由器重定向React

    react 路由重定向 React testing-library is very convenient to test React components rendering from props, ...

  6. React TypeScript 从零实现 Popup 组件发布到 npm

    本文转载自掘金<从0到1发布一个Popup组件到npm>,作者「海秋」. 点击下方阅读原文去点个赞吧! 上篇文章[1]中介绍了如何从 0 到 1 搭建一个 React 组件库架子,但为了一 ...

  7. next.js+react+typescript+antd+antd-mobile+axios+redux+sass react服务端渲染构建项目,从构建到发布,兼容pc+移动端

    简介:该教程兼容pc+移动端,如只需一端,可忽略兼容部分教程,根据需要运行的客户端构建项目 antd官网:https://ant.design/components/overview-cn/ antd ...

  8. 7 款最棒的 React 移动端 UI 组件库 - 特别针对国内使用场景推荐

    本文完整版:<7 款最棒的 React 移动端 UI 组件库 - 特别针对国内使用场景推荐> React 移动端 UI 组件库 1. Taro UI for React - 京东出品,多端 ...

  9. antd + react model自定义footer_使用ESLint+Prettier规范React+Typescript项目

    项目开发过程中,大多数时候我们使用别人搭建好的脚手架编写代码,是项目的参与者.对于一些细节往往被忽略了.其中代码检测本身是一类非常简单的配置,但涉及不同框架和语言组合使用的时候,可能比想象中要困难,本 ...

最新文章

  1. 漫画 | TCP,一个悲伤的故事
  2. Linux 命令[3]:cd
  3. 在OnLButtonDown获取其他控件被点击的消息
  4. Java中常用的集合
  5. pip install scrpy 报错: command 'gcc' failed with exit status 1
  6. scala写入mysql_spark rdd转dataframe 写入mysql的实例讲解
  7. Python-Matplotlib可视化(8)——图形的输出与保存
  8. 【Verilog】verilog实现奇数次分频
  9. 20200719每日一句
  10. ai 道德_AI如何提升呼叫中心的道德水平?
  11. 服务器的server2016系统怎么装,windowsserver2016安装桌面教程
  12. 无线路由器的设置方法
  13. Solidjs 简介
  14. php mail 垃圾邮件,如何避免我的邮件从PHP邮件()被标记为垃圾邮件? - 程序园
  15. Linux平台卸载MySQL总结
  16. 数据结构—二叉树线索化(线索化的先序、中序、后序遍历)
  17. wangEditor 初始化设置行高、字体和字体大小
  18. 学习C++编程的必备软件
  19. 飞腾cpu服务器浪潮信息,推动产业进程 浪潮发国产飞腾CPU服务器
  20. 小程序助力博物馆餐厅,用“艾”打造品牌

热门文章

  1. 这么香的技术还不快点学起来,不吃透都对不起自己
  2. idea出现找不到实体类
  3. 多亏了这篇文章,我的开发效率远远领先于我的同事
  4. getBoundingClientRect说明
  5. 【思考】一次交付项目小结
  6. 【托管服务qin】WEB网站压力测试教程详解
  7. 关于重构之Switch的处理【二】
  8. oppo5.0以上机器(亲测有效)激活Xposed框架的教程
  9. 中国移动IM-飞信-0802上线新版本 试用手记
  10. Oil Deposit