如何使用 Context 编写高性能的 React 应用

码道人 2022-06-23 13:36:45 阅读数:570

性能使用高性能编写context

当你使用 Context 时,React 会无缘无故地重新渲染一切!有时我有一种感觉,开发人员将 Context 视为一个神奇的小精灵,它会随机自发地重新渲染整个应用程序以供自己娱乐。

在本文中,我不打算说服任何人放弃自己心爱的状态管理库,转而使用 Context。它们的存在是有原因的。本文的主要目标是揭开 Context 的神秘面纱并提供一些有趣的编码模式,这可以帮助我们最大限度地减少与 Context 相关的重新渲染并提高 React 应用程序的性能。

在 React 中实现一个表单

我们的表格将非常复杂,首先,它包括:

  • “个人信息”部分,人们可以在其中设置一些个人信息,即姓名、电子邮件等
  • “价值计算”部分,人们可以在其中设置他们的货币偏好、他们的首选折扣、添加一些优惠券等
  • 选择的折扣应该以表情符号的形式在个人部分突出显示(不要问,设计师有一种奇怪的幽默感)
  • 带有操作按钮的“操作”部分(即“保存”、“重置”等)

我们从应用的组件结构开始。我知道这个表格很快就会变得相当复杂,所以我想马上把它分成更小、更包含的组件。

在根目录下将有主要的Form组件,它将呈现三个必需的部分:

const Form = () => {
return (
<>
<PersonalInfoSection />
<ValueCalculationsSection />
<ActionsSection />
</>
);
};

“个人信息”部分将呈现另外三个组件:折扣表情符号、名称输入和国家选择

const PersonalInfoSection = () => {
return (
<Section title="Personal information">
<DiscountSituation />
<NameFormComponent />
<SelectCountryFormComponent />
</Section>
);
};

他们三个都将包含这些组件的实际逻辑(它们的代码将在下面),并且Section只是封装了一些样式。

“价值计算”部分将只有一个组件(目前),折扣栏:

const ValueCalculationSection = () => {
return (
<Section title="Value calculation">
<DiscountFormComponent />
</Section>
);
};

“Actions”部分现在也只有一个按钮:带有 onSave 回调的保存按钮。

const ActionsSection = ({ onSave }: { onSave: () => void }) => {
return (
<Section title="Actions">
<button onClick={onClick}>Save form</button>
</Section>
);
};

现在有趣的部分来了:我们需要使这个表单具有交互性。考虑到整个表单只有一个“保存”按钮,而不同的部分需要来自其他部分的数据,状态管理的自然位置是在Form组件的根目录中。我们将在那里有 3 条数据:名称、国家和折扣,一种设置所有这三种数据的方法,以及一种“保存”它的方法:

type State = {
name: string;
country: Country;
discount: number;
};
const Form = () => {
const [state, setState] = useState<State>(defaultState as State);
const onSave = () => {
// send the request to the backend here
};
const onDiscountChange = (discount: number) => {
setState({ ...state, discount });
};
const onNameChange = (name: string) => {
setState({ ...state, name });
};
const onCountryChange = (country: Country) => {
setState({ ...state, country });
};
// the rest as before
};

现在我们需要将相关数据和回调传递给需要它的组件。在我们的PersonalInfoSection

  • DiscountSituation组件应该能够根据discount值显示表情符号。
  • NameFormComponent应该能够控制name
  • SelectCountryFormComponent应该能够设置选定的country

考虑到这些组件不是Form直接渲染的,而是 PersonalInfoSection 的子组件,是时候使用 prop 了:

DiscountSituation将接受discount作为 prop:

export const DiscountSituation = ({ discount }: { discount: number }) => {
// some code to calculate the situation based on discount
const discountSituation = ...;
return <div>Your discount situation: {discountSituation}</div>;
};

NameFormComponent将接受nameonChange回调:

export const NameFormComponent = ({ onChange, name }: { onChange: (val: string) => void; name: string }) => {
return (
<div>
Type your name here: <br />
<input onChange={() => onChange(e.target.value)} value={name} />
</div>
);
};

SelectCountryFormComponent将接受onChange回调:

export const SelectCountryFormComponent = ({ onChange }: { onChange: (country: Country) => void }) => {
return <SelectCountry onChange={onChange} />;
};

我们的PersonalInfoSection必须将它们全部从父Form组件传递给它的子组件:

export const PersonalInfoSection = ({
onNameChange,
onCountryChange,
discount,
name,
}: {
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
discount: number;
name: string;
}) => {
return (
<Section title="Personal information">
<DiscountSituation discount={discount} />
<NameFormComponent onChange={onNameChange} name={name} />
<SelectCountryFormComponent onChange={onCountryChange} />
</Section>
);
};

同样的,ValueCalculationSection需要将onDiscountChangediscount值从Form到组件传递到它的子组件:

export const ValueCalculationsSection = ({ onDiscountChange }: { onDiscountChange: (val: number) => void }) => {
console.info('ValueCalculationsSection render');
return (
<Section title="价值计算">
<DiscountFormComponent onDiscountChange={onDiscountChange} />
</Section>
);
};

并且DiscountFormComponent只是使用“外部”库DraggingBar来渲染工具栏,并通过它提供的回调捕获更改:

export const DiscountFormComponent = ({ onDiscountChange }: { onDiscountChange: (value: number) => void }) => {
console.info('DiscountFormComponent render');
return (
<div>
请在此处选择您的折扣:<br />
<DraggingBar onChange={(value: number) => onDiscountChange(value)} />
</div>
);
};

而且,我们的Form组件的渲染看起来像这样:

const Form = () => {
return (
<div>
<PersonalInfoSection onNameChange={onNameChange} onCountryChange={onCountryChange} discount={state.discount} name={state.name} />
<ValueCalculationsSection onDiscountChange={onDiscountChange} />
<ActionsSection onSave={onSave} />
</div>
);
};

不幸的是,由几个组件和一个简单的状态组成的结果比你预期的要糟糕得多 随着 CPU 节流,它们基本上无法使用。所以发生了什么事?

表单性能调查

首先,让我们看看控制台输出。如果我在输入中键入一个键Name,我会看到:

Form render
PersonalInfoSection render
Section render
Discount situation render
NameFormComponent render
SelectCountryFormComponent render
ValueCalculationsSection render
Section render
DiscountFormComponent render
ActionsSection render
Section render

我们表单中的每个组件都会在每次击键时重新渲染!拖动也是同样的情况 —— 每次鼠标移动时,整个表单及其所有组件都会重新渲染。而且我们已经知道, SelectCountryFormComponent的速度非常慢,我们对它的性能无能为力。所以我们在这里唯一能做的就是确保它不会在每次按键或鼠标移动时重新渲染。

而且,正如我们所知,组件将在以下情况下重新渲染:

  • 组件状态改变
  • 父组件重新渲染

这正是这里发生的事情:当输入中的值发生变化时,我们的Form组件通过回调链将该值传播到根组件,在那里我们更改根状态,这会触发组件重新渲染Form,然后级联到该组件的每个子节点和子节点(即所有子节点)。

为了解决这个问题,我们当然可以使用useMemouseCallback,但这只是掩盖了问题,并没有真正解决它。当将来我们引入另一个缓慢的组件时,还是会有相同的问题。更不用说它会使代码更加复杂和难以维护。在理想情况中,当我在Name组件中键入内容时,我只想要NameFormComponent和实际使用name值的组件重新渲染,其余的应该只是闲置并等待进行交互。

而 React 实际上为我们提供了一个完美的工具来做到这一点 —— Context

向表单添加 Context

根据 React 文档,Context 提供了一种通过组件树传递数据的方法,而无需在每个级别手动向下传递 prop。例如,如果我们将 Form 状态提取到 Context 中,我们可以摆脱所有通过中间部分的 props。

为了实现这一点,首先,我们创建Context自身,它将拥有我们的状态和用于管理此状态的 API(即我们的回调):

type State = {
name: string;
country: Country;
discount: number;
};
type Context = {
state: State;
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
onDiscountChange: (price: number) => void;
onSave: () => void;
};
const FormContext = createContext<Context>({} as Context);

然后我们应该将我们在组件Form中的所有状态逻辑移动到FormDataProvider组件中,并将状态和回调附加到新创建的Context:

export const FormDataProvider = ({ children }: { children: ReactNode }) => {
const [state, setState] = useState<State>({} as State);
const value = useMemo(() => {
const onSave = () => {
// send the request to the backend here
};
const onDiscountChange = (discount: number) => {
setState({ ...state, discount });
};
const onNameChange = (name: string) => {
setState({ ...state, name });
};
const onCountryChange = (country: Country) => {
setState({ ...state, country });
};
return {
state,
onSave,
onDiscountChange,
onNameChange,
onCountryChange,
};
}, [state]);
return <FormContext.Provider value={value}>{children}</FormContext.Provider>;
};

然后暴露钩子让其他组件使用这个 Context 而不直接访问它:

导出 const useFormState = () => useContext(FormContext);

并将我们的组件包装FormFormDataProvider

export default function App() {
return (
<FormDataProvider>
<Form />
</FormDataProvider>
);
}

之后,我们可以摆脱整个应用中的所有 prop,并通过 useFormState 钩子直接在需要它的组件中使用所需的数据和回调。

例如,我们的根Form组件将变成这样:

const Form = () => {
// no more props anywhere!
return (
<div className="App">
<PersonalInfoSection />
<ValueCalculationsSection />
<ActionsSection />
</div>
);
};

并且NameFormComponent将能够像这样访问所有数据:

export const NameFormComponent = () => {
// accessing the data directly right where it's needed!
const { onNameChange, state } = useFormState();
const onValueChange = (e: ChangeEvent<HTMLInputElement>) => {
onNameChange(e.target.value);
};
return (
<div>
Type your name here: <br />
<input onChange={onValueChange} value={state.name} />
</div>
);
};

新形式的性能如何?

从性能的角度来看,我们还没有做到这一点:输入名称并拖动栏仍然滞后。但是,如果我开始在控制台中输入 NameFormComponent,我现在将看到:

Discount situation render
NameFormComponent render
SelectCountryFormComponent render
DiscountFormComponent render
ActionsSection render
Section render

现在有一半的组件不会重新渲染,包括我们的父Form组件。发生这种情况是因为 Context 的工作原理:当 Context 值更改时,此 context 的每个使用者都将重新渲染,无论他们是否使用更改后的值。而且,那些被 Context 绕过的组件根本不会重新渲染。

现在,如果我们仔细查看我们的组件实现,特别是SelectCountryComponent,我们会发现它实际上并没有使用state自身。它所需要的只是onCountryChange回调:

export const SelectCountryFormComponent = () => {
const { onCountryChange } = useFormState();
console.info('SelectCountryFormComponent render');
return <SelectCountry onChange={onCountryChange} />;
};

这让我们有机会尝试一个非常酷的技巧:我们可以分开state部分和API部分。

拆分状态和 API

基本上,我们在这里要做的是将我们的“整体”状态分解为两个“微状态”。不使用一个 包含所有内容的 context,而是使用两个上下文,一个用于数据,一个用于 API:

type State = {
name: string;
country: Country;
discount: number;
};
type API = {
onNameChange: (name: string) => void;
onCountryChange: (name: Country) => void;
onDiscountChange: (price: number) => void;
onSave: () => void;
};
const FormDataContext = createContext<State>({} as State);
const FormAPIContext = createContext<API>({} as API);

我们将在其中将状态直接传递给FormDataContext.Provider

const FormDataProvider = () => {
// state logic
return (
<FormAPIContext.Provider value={api}>
<FormDataContext.Provider value={state}>{children}</FormDataContext.Provider>
</FormAPIContext.Provider>
);
};

现在最有趣的部分是api的值。

如果我们保持原样,整个“分解”的想法就行不通,因为我们仍然必须依赖钩子state中的依赖项:useMemo

const api = useMemo(() => {
const onDiscountChange = (discount: number) => {
// this is why we still need state here - in order to update it
setState({ ...state, discount });
};
// all other callbacks
return { onSave, onDiscountChange, onNameChange, onCountryChange };
// still have state as a dependency
}, [state]);

这将导致api值随着每次状态更新而改变,并在每次状态更新时触发重新渲染。我们希望我们api的 state 保持不变,这样消费者就不会重新渲染。

幸运的是,我们可以在这里应用另一个巧妙的技巧:将状态提取到setState中,而不是调用回调,我们只需触发 reducer 操作。

首先,创建 action 和 reducer 本身:

type Actions =
| { type: 'updateName'; name: string }
| { type: 'updateCountry'; country: Country }
| { type: 'updateDiscount'; discount: number };
const reducer = (state: State, action: Actions): State => {
switch (action.type) {
case 'updateName':
return { ...state, name: action.name };
case 'updateDiscount':
return { ...state, discount: action.discount };
case 'updateCountry':
return { ...state, country: action.country };
}
};

使用 reducer 代替useState

export const FormProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(reducer, {} as State);
// ...
};

并将我们的api迁移到dispatch而不是setState

export const FormProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(reducer, {} as State);
// ...
};

外部状态管理

context 不再是一个谜,使用这些技术,如果需要,你可以轻松地使用纯上下文编写高性能应用程序,如果你想转换到任何其他框架,你只需对代码进行最少的更改即可完成。当你在设计应用时考虑到 context,状态管理框架并不重要。

我们现在不妨把它移到旧的 Redux 上。我们唯一需要做的事情是:摆脱 Context 和 Providers,将 React reducer 转换为 Redux store,并将我们的 hooks 转换为使用 Redux 选择器和调度。

const store = createStore((state = {}, action) => {
switch (action.type) {
case 'updateName':
return { ...state, name: action.payload };
case 'updateCountry':
return { ...state, country: action.payload };
case 'updateDiscount':
return { ...state, discount: action.payload };
default:
return state;
}
});
export const FormDataProvider = ({ children }: { children: ReactNode }) => {
return <Provider store={store}>{children}</Provider>;
};
export const useFormDiscount = () => useSelector((state) => state.discount);
export const useFormCountry = () => useSelector((state) => state.country);
export const useFormName = () => useSelector((state) => state.name);
export const useFormAPI = () => {
const dispatch = useDispatch();
return {
onCountryChange: (value) => {
dispatch({ type: 'updateCountry', payload: value });
},
onDiscountChange: (value) => dispatch({ type: 'updateDiscount', payload: value }),
onNameChange: (value) => dispatch({ type: 'updateName', payload: value }),
onSave: () => {},
};
};

其他一切都保持不变,并且完全按照我们的设计工作。

版权声明:本文为[码道人]所创,转载请带上原文链接,感谢。 https://markdowner.net/v1/article/by_user/327497387637489664