Русский
Русский
English
Статистика
Реклама

React hooks

React.js формошлепство или работа с формами при помощи пользовательских хуков

06.01.2021 20:05:30 | Автор: admin


В этом посте будет реализован простой пользовательский хук для работы с состояниями формы и будут разобраны способы работы с объектами и массивами. В завершение разберем валидацию форм и реализуем соответствующие функции.


В процессе работы часто приходится решать примерно одинаковые задачи. Для ускорения решения некоторых из них, а также для сокращения кода в целом принято делить код на переиспользуемые фрагменты. В React с версии 16.8.0 появилась возможность использовать хуки. Данная статья затрагивает написание пользовательских хуков. Заранее хочу предупредить: примеры кода и подходы, описанные ниже, не являются оптимальными, их можно и нужно дорабатывать в рамках своих проектов в случае необходимости. Данный код это небольшая демонстрация работы с инпутами.


К слову, в React для удобной работы с формами уже набрали популярность 3 отличных библиотеки. Это Formik, Redux Form и React Hook Form. На сайте последнего представлены плюсы перед конкурентами.


Для начала


Для начала нам нужно создать React приложение. Сделаем это через Create React App. Если информации по ссылке будет недостаточно, то github.


Так как я являюсь поклонником TypeScript, я использовал готовый шаблон для работы с ним используя команду:


npx create-react-app react-custom-forms-article  --template typescript

Наш кастомный хук не должен потребовать серьезных доработок готовых компонентов, особенно если в них не присутствует ни одна из библиотек для работы с формами. Поэтому его вполне можно будет внедрить и в готовый проект.


Приступая к реализации хука


Перед созданием хука надо определиться, как мы будем работать с формой и ее полями. Для удобства можно использовать один объект инициализации формы, в котором изначально будут описываться необходимые поля и их настройки. Объявление этого объекта вынесено за код компонента и выглядит так.


// App.tsxconst formInputs = {  firstName: {},  lastName: '',}

В компоненте инициализируем форму.


// App.tsxconst App = () => {  const { fields, handleSubmit } = useForm(formInputs);  const { firstName } = fields;  return <></>;}

handleSubmit метод, принимающий callback-функцию и возвращающий значение всех полей.
fields структура вида "ключ-значение", в ней будут храниться все описанные ранее поля формы с дополнительной информацией, например: свойство с текстом ошибки, значение поля, флаг валидности поля и те свойства, что будут переданы в объекте при создании формы.


Компонент вернет примерно такую верстку.


// App.tsx// Метод выполнения формы при срабатывание onSubmitconst onSubmit = ({ values }: { values: IValues }) => {  console.log(values, 'submit');}return (  <div className="App">    <form onSubmit={handleSubmit(onSubmit)}>      <input type="text" value={firstName.value} onChange={firstName.setState}/>    </form>  </div>);

useForm


Приступим к реализации хука. Для начала необходимо пройтись по объекту инициализации формы. Во время этого процесса будет происходить проверка значений и объявление необходимых далее свойств.


// hooks/useForm.tsexport const useForm = (initialFields: any = {}) => {  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {    const isString = typeof value === 'string';    const field = {      [name]: {        value: (isString && value) || ((!isString && value.value) || ''),        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),        ...(!isString && value),      }    }    return { ...fields, ...field };  }, {});

В этом примере кода для удобства итераций используется метод объектов entries, который возвращает массив вида [[propName, propValue], [propName, propValue]], и метод для работы с массивами reduce, который помогает собрать объект заново. В целом все выглядит неплохо, но не хватает методов для обновления значения полей. Добавим состояний с использованием React Hook.


// hooks/useForm.ts...const [fields, setState] = useState<any>(form);const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {  const input = fields[name];  const value = element.target.value;  const field = { ...input,  value };  setState(prevState => ({ ...prevState, [name]: field });}

Здесь заводится состояние для полей формы, и в качестве начального значения используется готовая структура формы. Функция handleInput будет необходима для редактирования данных. Как видно из кода, стейт будет обновляться полностью. Это специфика хука useState и текущей реализации. Если бы для работы с состояниями использовалась библиотека RxJs вместо хука useState, то была бы возможность обновлять состояние частично, не провоцируя повторный рендер компонента. В setState в данном примере состояние обновляется также через функцию обратного вызова. При использовании записи вида setState({ ...fields, [name]: field }) изменение другого поля провоцировало бы возврат остальных полей к исходным значениям.


Следующий пример кода проиллюстрирует применение формы.


// hooks/useForm.ts...const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {    if (e && e.preventDefault) {      e.preventDefault();    }    const values = Object.entries(fields).reduce(((prev: any, [name, { value }]: any) => ({ ...prev, [name]: value })), {});    onSubmit({ values });  }

При помощи [каррирования] (https://learn.javascript.ru/currying-partials) принимается переданная из компонента функция и далее при сабмите вызывается с аргументами из хука. Каррирование в примере выше используется для того, чтобы иметь возможность вызвать метод в верстке, не выполняя его при рендере компонента.


Таким образом у нас получился минимальный хук для обычных форм.


Весь код хука
import { ChangeEvent, FormEvent,, useState } from 'react';export const useForm = (initialFields = {}) => {  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {    const isString = typeof value === 'string';    const field = {      [name]: {        value: (isString && value) || ((!isString && value.value) || ''),        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),        ...(!isString && value),      },    };    return { ...fields, ...field };  }, {});  const [fields, setState] = useState<any>(form);  const handleInput = useCallback(    (element: ChangeEvent<HTMLInputElement>, name: string) => {      const input = fields[name];      const value = element.target.value;      const field = { ...input, value };      setState(prevState => ({ ...prevState, [name]: field });    }, [fields, setState]);  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {    if (e && e.preventDefault) {      e.preventDefault();    }    const values = Object.entries(fields).reduce(((prev: any, [name, {value}]: any) => ({ ...prev, [name]: value })), {});    onSubmit({ values });  }  return {    handleSubmit,    fields,  }}

Типизация


Я не хочу останавливаться на этой теме, так как данный код можно спокойно использовать и без TypeScript, поэтому в коде могут внезапно появляться какие-то типы, чтобы все корректно собрать, в конце статьи будет предоставлена ссылка на github.


Валидация


Теперь, когда наш минимальный хук готов, хочется позаботиться о еще одной проблеме валидации полей. Валидация будет реализована через массив функций.


Итак, немного перепишем схему. Здесь указан массив validators для обоих полей и добавлен флаг обязательности поля.


// App.tsx...const formInputs = {  firstName: {    required: true,    validators: [      (s: string) => !s.length && 'Поле обязательно для заполнения',      (s: string) => s.length < 2 && 'Минимальная длина строки 2',      (s: string) => s.length <= 2 && 'А теперь 3',      (s: string) => parseInt(s) < 2 && 'Должна быть цифра больше 1',    ]  },  datetime: {    validators: [      (s: string) => new Date(s).getUTCFullYear() > new Date().getUTCFullYear() && 'Год рождения не может быть больше текущего',    ],  },}

Помимо обновленной схемы добавим в хук переменную isValid, которая будет запрещать/разрешать отправку формы по кнопке. Рядом с полями также будет выводиться ошибка. К слову, ошибку будем выводить только для тронутых полей.


// App.tsx...const { fields, isValid, handleSubmit } = useForm(formInputs);const { firstName, datetime }: Form = fields;return (    <div className="App">      <form onSubmit={handleSubmit(onSubmit)}>        <input type="text" value={firstName.value} onChange={firstName.setState}/>        <span>{firstName.touched && firstName.error}</span>        <input type="date" value={datetime.value} onChange={datetime.setState}/>        <span>{datetime.touched && datetime.error}</span>        <button disabled={!isValid}>Send form</button>      </form>    </div>);

Разберем этот код. В показанной функции ожидается 2 аргумента, field поле инпута, второй опциональный, в нем будут храниться дополнительные свойства для поля. Далее при помощи деструктуризации объекта заводятся переменные value, required и массив валидаций. Чтобы не менять свойства аргумента, заводятся новые переменные error и valid. Я объявил их через let, так как меняю их в процессе. В коде до обхода массива валидаторов стоит проверка на required, в теле условия проверяется значение поля, и там же прокидывается ошибка.


Мы подошли к условию, где проверяем переменную validators. Она должна быть массивом. Далее по коду создаем массив результатов выполнения функций валидации при помощи метода массива map. validateFn здесь хранится функция, в которую передается значение поля из свойства value. Результат выполнения функции валидации должен быть строкой, так как выводить мы будем именно текст ошибки. Если результат не строка, то возвращаться будет что-то другое. Конкретно здесь пустая строка, но там может быть и другое значение, например false. В любом случае далее происходит фильтрация массива результатов для удаления пустых значений. Таким образом, поле ошибки могло бы быть и массивом ошибок. Но здесь я решил выводить лишь одну ошибку, поэтому далее стоит условие проверки массива result, где присваивается ошибка и меняется состояние valid. В конце выполнения функция fieldValidation возвращает новый объект поля, где записаны все переданные ранее значения + новые, модифицированные.


Далее этот метод будет использоваться в функции handleInput и handleSubmit. В обоих случаях будет прогоняться валидация.


Валидация работает также в связке с общим флагом isValid, который заводится через useState. Сделаем отдельную функцию для обхода полей.


// hooks/useForm.tsconst [isValid, setFormValid] = useState<boolean>(true);const getFormValidationState = (fields) => Object.entries(fields).reduce((isValid: any, [_, value]: any) => Boolean(isValid * value.isValid), true);const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {    const input = fields[name];    const value = element.target.value;    const field = {      ...input,      value,      touched: true,      isValid: true,    };    const validatedField = fieldValidation(field);    setState((prevState) => {      const items = {...prevState, [name]: validatedField};      setFormValid(getFormValidationState(items));      return items;    });  }  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {    if (e && e.preventDefault) {      e.preventDefault();    }    const fieldsArray = Object.entries(fields);    // Забираем только значения    const values = fieldsArray.reduce(((prev: any, [name, {value}]: any) => ({ ...prev, [name]: value })), {});    // Валидируем каждый инпут    const validatedInputs = fieldsArray.reduce((prev: any, [name, value]: any) => ({ ...prev, [name]: fieldValidation(value, { touched: true }) }), {});    // Изменяем значение стейта isValid    setFormValid(getFormValidationState(validatedInputs));    setState(validatedInputs);    onSubmit({ values });  }

Теперь у нас полностью готов кастомный хук для простых форм с возможностью валидировать значения ввод пользователя. После некоторых манипуляций на выходе получим такой код:


useForm.ts
import { ChangeEvent, FormEvent, useState } from 'react';type IValidatorFN = (s: string) => {};export interface IField {  value?: string;  type?: string;  label?: string;  error?: string;  isValid?: boolean;  required?: boolean;  touched?: boolean;  setState?: (event: ChangeEvent<HTMLInputElement>) => {};  validators?: IValidatorFN[];}export type ICustomField<T = {}> = IField & T;export type ICustomObject<T = {}> = {  [key: string]: ICustomField & T;}export type IValues = {  [key: string]: string | number;}export type IForm = {  fields: ICustomObject;  isValid: boolean;  handleSubmit: (onSubmit: Function) => (e: FormEvent) => void;}type IOptions = {  [key: string]: any;}export const useForm = (initialFields: ICustomObject): IForm => {  const form = Object.entries(initialFields).reduce((fields, [name, value]: any[]) => {    const isString = typeof value === 'string';    const field = {      [name]: {        type: 'text',        value: (isString && value) || ((!isString && value.value) || ''),        error: (!isString && value.error) || null,        validators: (!isString && value.validators) || null,        isValid: (!isString && value.isValid) || true,        required: (!isString && value.required) || false,        touched: false,        setState: (value: ChangeEvent<HTMLInputElement>) => handleInput(value, name),        ...(!isString && value),      },    };    return {...fields, ...field};  }, {});  const [fields, setState] = useState<ICustomObject>(form);  const [isValid, setFormValid] = useState<boolean>(true);  const getFormValidationState = (fields: ICustomObject): boolean =>    Object.entries(fields).reduce((isValid: boolean, [_, value]: any) => Boolean(Number(isValid) * Number(value.isValid)), true);  const fieldValidation = (field: ICustomField, options: IOptions = {}) => {    const { value, required, validators } = field;    let isValid = true, error;    if (required) {      isValid = !!value;      error = isValid ? '' : 'field is required';    }    if (validators && Array.isArray(validators)) {      const results = validators.map(validateFn => {        if (typeof validateFn === 'string') return validateFn;        const validationResult = validateFn(value || '');        return typeof validationResult === 'string' ? validationResult : '';      }).filter(message => message !== '');      if (results.length) {        isValid = false;        error = results[0];      }    }    return { ...field, isValid, error, ...options };  };  const handleInput = (element: ChangeEvent<HTMLInputElement>, name: string) => {    const input = fields[name];    const value = element.target.value;    const field = {      ...input,      value,      touched: true,      isValid: true,    };    const validatedField = fieldValidation(field);    setState((prevState: ICustomObject) => {      const items = {...prevState, [name]: validatedField};      setFormValid(getFormValidationState(items));      return items;    });  }  const handleSubmit = (onSubmit: Function) => (e: FormEvent) => {    if (e && e.preventDefault) {      e.preventDefault();    }    const fieldsArray = Object.entries(fields);    const values = fieldsArray.reduce(((prev: ICustomObject, [name, { value }]: any) => ({ ...prev, [name]: value })), {});    const validatedInputs = fieldsArray.reduce((prev: ICustomObject, [name, value]: any) => ({ ...prev, [name]: fieldValidation(value, { touched: true }) }), {});    setFormValid(getFormValidationState(validatedInputs));    setState(validatedInputs);    onSubmit({ values });  }  return {    fields,    isValid,    handleSubmit,  }}

App.tsx
import React from 'react';import { useForm, IValues } from './hooks/useForm';const formInputs = {  firstName: {    required: true,    validators: [      (s: string) => !s.length && 'Поле обязательно для заполнения',      (s: string) => s.length < 2 && 'Минимальная длина строки 2',      (s: string) => s.length <= 2 && 'А теперь 3',      (s: string) => parseInt(s) < 2 && 'Должна быть цифра, больше 1',    ],    label: 'First Name',  },  datetime: {    type: 'date',    label: 'Birth Date',    validators: [      (s: string) => new Date(s).getUTCFullYear() > new Date().getUTCFullYear() && 'Год рождения не может быть больше текущего',    ],  },  lastName: {    label: 'Last Name',  },}const App = () => {  const { fields, isValid, handleSubmit } = useForm(formInputs);  const { firstName, datetime, lastName } = fields;  const onSubmit = ({ values }: { values: IValues }) => {    console.log(values, 'submit');  }  const formFields = [firstName, lastName, datetime];  return (    <div className="App">      <form onSubmit={handleSubmit(onSubmit)}>        {formFields.map((field, index) => (          <div key={index}>            <input              type={field.type}              placeholder={field.label}              value={field.value}              onChange={field.setState}            />            <span>{field.touched && field.error}</span>          </div>        ))}        <div>          <button disabled={!isValid}>Send form</button>        </div>      </form>    </div>  );}export default App;

Итог


В данной статье мне хотелось привести пример создания собственного хука для работы с формами. Необязательно для работы с ними ограничиваться локальными состояниями или реакт-хуками. Как я упоминал выше, есть возможности и способы оптимизации текущего примера. Основная проблема в этом коде в том, что он вызывает рендер всего компонента при взаимодействии с полями. От этого можно избавиться, если написать реализацию без контроля состояния полей. Однако не всегда неуправляемый вариант подходит для решения той или иной задачи. На помощь может прийти библиотека rxjs или другие, использующие ее возможности, например, такие как focal или rixio. Но это тема для другой статьи.
Спасибо, что дочитали до конца. Я надеюсь, что после ознакомления с этими примерами у вас улучшилось понимание темы кастомных хуков, в частности, работа с формами.
Проект на github

Подробнее..

Из песочницы Todolist на React Hooks TypeScript от сборки до тестирования

13.07.2020 00:19:13 | Автор: admin
Начиная с версии 16.9, в библиотеке React JS доступен новый функционал хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение.

Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.

Сборка


Структура проекта следующая:

src
| components
| index.html
| index.tsx
package.json
tsconfig.json
webpack.config.json

Файл package.json:
{  "name": "todo-react-typescript",  "version": "1.0.0",  "description": "",  "main": "index.tsx",  "scripts": {    "start": "webpack-dev-server --port 3000 --mode development --open --hot",    "build": "webpack --mode production"  },  "author": "",  "license": "ISC",  "devDependencies": {    "ts-loader": "^5.2.1",    "html-webpack-plugin": "^3.2.0",    "typescript": "^3.8.2",    "webpack": "^4.41.6",    "webpack-cli": "^3.3.11",    "webpack-dev-server": "^3.10.3"  },  "dependencies": {    "@types/react": "^16.9.23",    "@types/react-dom": "^16.9.5",    "react": "^16.12.0",    "react-dom": "^16.12.0"  }}

Для поддержки TypeScript, помимо пакета typescript, необходим ts-loader, скомпилирующий исходные tsx-файлы в js-код, а также пакеты со специальными типами данных для React @types/react и @types/react-dom. Дополнительно ставим html-webpack-plugin, он обеспечит корректную работу dev-сервера при отсутствии index.html файла в корне проекта, и создаст этот файл автоматически для production-сборки в нужном месте.

Файл tsconfig.json:
{  "compilerOptions": {    "sourceMap": true,    "noImplicitAny": false,    "module": "commonjs",    "target": "es6",    "lib": [      "es2015",      "es2017",      "dom"    ],    "removeComments": true,    "allowSyntheticDefaultImports": false,    "jsx": "react",    "allowJs": true,    "baseUrl": "./",    "paths": {      "components/*": [        "src/components/*"      ]    }  }}

Поле jsx задаёт режим компиляции исходного кода. Всего есть 3 режима: preserve, react и react-native.



Файл webpack.config.json:
const path = require('path');const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = {    entry: './src/index.tsx',    resolve: {        extensions: ['.ts', '.tsx', '.js']    },    output: {        path: path.join(__dirname, '/dist'),        filename: 'bundle.min.js'    },    module: {        rules: [            {                test: /\.ts(x?)$/,                exclude: /node_modules/,                use: [                    {                        loader: "ts-loader"                    }                ]            }        ]    },    plugins: [        new HtmlWebpackPlugin({            template: './src/index.html'        })    ]};

Точка входа приложения ./src/index.tsx. С помощью resolve.extensions разрешаем обрабатывать ts/tsx/js файлы. Добавляем ts-loader и html-webpack-plugin. Сборка готова.

Разработка


В файле index.html прописываем контейнер, куда будет рендериться приложение:

<div id="root"></div>

В директории components создаем наш первый пока что пустой компонент App.tsx.
Файл index.tsx:

import * as React from 'react';import * as ReactDOM from 'react-dom';import App from "./components/App";ReactDOM.render (    <App/>,    document.getElementById("root"));

Todolist-приложение будет иметь следующую функциональность:

  • добавить задачу
  • удалить задачу
  • изменить статус задачи (выполнена / не выполнена)

Выглядеть это будет так: текстовое поле для ввода + кнопка Добавить задачу, и ниже список добавленных задач. Задачи можно удалять и менять им статус.



Для этих целей можно разделить приложение всего на два компонента создание новой задачи и список всех задач. Поэтому App.tsx на начальном этапе будет иметь следующий вид:

import * as React from 'react';import NewTask from "./NewTask";import TasksList from "./TasksList";const App = () => {    return (        <>            <NewTask />            <TasksList />        </>    )}export default App;

В текущей директории создадим и экспортируем пустые компоненты NewTask и TasksList. Так как нам необходимо обеспечить взаимосвязь между ними, нужно определить, как это будет происходить. В React существуют два подхода к взаимодействию между компонентами:

  1. Хранение текущего состояния приложения и всех его методов в родительском компоненте (в нашем случае в App.tsx) и передача дочерним компонентам через пропсы (классический способ);
  2. Хранение состояния и методов управления состоянием отдельно. В этом случае приложение нужно обернуть специальным компонентом провайдером, и передать в него необходимые для дочерних компонентов методы и свойства (использование хука useContext).

Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.

TypeScript при передаче пропсов
* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента:

const NewTask: React.FC<MyProps> = ({taskName}) => {...

Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:

interface MyProps {    taskName: String;}


useContext


Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.

Пример использования useContext
import * as React from 'react';import {useContext} from "react";interface Person {    name: String,    surname: String}export const PersonContext = React.createContext<Partial<Person>>({});const PersonWrapper = () => {    const person: Person = {        name: 'Spider',        surname: 'Man'    }    return (        <>            <PersonContext.Provider value={ person }>                <PersonComponent />            </PersonContext.Provider>        </>    )}const PersonComponent = () => {    const person = useContext(PersonContext);    return (        <div>            Hello, {person.name} {person.surname}!        </div>    )}export default PersonWrapper;

В примере создаём интерфейс для контекста будем передавать поля name и surname, оба типа String.

Создаём контекст методом createContext и передаём в него пока что пустой объект. Для того, чтобы TypeScript не ругался на отсутствие обязательных полей интерфейса, есть специальный тип Partial он допускает отсутствие передаваемых полей.

Далее в созданный контекст передаём данные объект person, и внутрь провайдера помещаем компонент. Теперь контекст будет доступен в любом компоненте, добавленном внутрь провайдера. Вызвать его можно как раз с помощью хука useContext.

useReducer


Также понадобится useReducer для более удобной работы с хранилищем состояния.

Подробнее о useReducer
Хук useReducer позволяет управлять стейтом посредством вызова одной единственной функции, но с разными параметрами: по соглашению, название действия передаётся в поле type, а данные в поле payload. Пример реализации:

import * as React from 'react';import {useReducer} from "react";interface PersonState {    name: String,    surname: String}interface PersonAction {    type: 'CHANGE',    payload: PersonState}const personReducer = (state: PersonState, action: PersonAction): PersonState => {    switch (action.type) {        case 'CHANGE':            return action.payload;        default: throw new Error('Unexpected action');    }}const PersonComponent = () => {    const initialState = {        name: 'Unknown',        surname: 'Guest'    }    const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);    return (        <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>            Hello, {person.name} {person.surname}!        </div>    )}export default PersonComponent;

В useReducer передаём функцию-редьюсер personReducer, которая будет отрабатывать при вызове changePerson.

В переменной person изначально будет записан initialState, который по ходу вызовов changePerson будет заменяться возвращаемым редьюсером значением.

В данном примере обновления будут происходить только на действие CHANGE, но плюс редьюсера состоит в том, что логику можно легко и быстро расширить:

case 'CHANGE':   return action.payload;case 'CLEAR':   return {      name: 'Undefined',      surname: 'Undefined'   };


useContext + useReducer


Интересной заменой библиотеки Redux может быть использование контекста в связке с useReducer. В этом случае в контекст будет передаваться результат выполнения хука useReducer возвращаемый им стейт и функция для его обновления. Добавим эти хуки в приложение:

import * as React from 'react';import {useReducer} from "react";import {Action, State, ContextState} from "../types/stateType";import NewTask from "./NewTask";import TasksList from "./TasksList";// Начальные значения стейтаexport const initialState: State = {    newTask: '',    tasks: []}// <Partial> позволяет создать контекст без дефолтных значенийexport const ContextApp = React.createContext<Partial<ContextState>>({});// Создаём редьюсер, принимающий на вход текущий стейт и объект Action с полями type и payload, тип возвращаемого редьюсером значения - Stateexport const todoReducer = (state: State, action: Action):State => {    switch (action.type) {        case ActionType.ADD: {            return {...state, tasks: [...state.tasks, {                    name: action.payload,                    isDone: false                }]}        }        case ActionType.CHANGE: {            return {...state, newTask: action.payload}        }        case ActionType.REMOVE: {            return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}        }        case ActionType.TOGGLE: {            return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}        }        default: throw new Error('Unexpected action');    }};const App:  React.FC = () => {// Используем созданный todoReducer, передав его аргументом в useReduser. Изначально в стейт попадёт initialState, и далее при диспатче (changeState) будет обновляться.    const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);    const ContextState: ContextState = {        state,        changeState    };// Передаём в контекст результаты работы useReducer - стейт и метод его изменения    return (        <>            <ContextApp.Provider value={ContextState}>                <NewTask />                <TasksList />            </ContextApp.Provider>        </>    )}export default App;

В результате удалось сделать независимый от корневого компонента стейт, который можно получать и менять в компонентах внутри провайдера.

Typescript. Добавление типов в приложение


В файле stateType прописываем TypeScript-типы для приложения:

import {Dispatch} from "react";// Созданная задача имеет название и статус готовностиexport type Task = {    name: string;    isDone: boolean}export type Tasks = Task[];// В состоянии хранится записываемая в инпут новая задача, а также массив уже созданных задачexport type State = {    newTask: string;    tasks: Tasks}// Все возможные варианты действий со стейтомexport enum ActionType {    ADD = 'ADD',    CHANGE = 'CHANGE',    REMOVE = 'REMOVE',    TOGGLE = 'TOGGLE'}// Для действий ADD и CHANGE доступна передача только строковых значенийtype ActionStringPayload = {    type: ActionType.ADD | ActionType.CHANGE,    payload: string}// Для действий TOGGLE и REMOVE доступна передача только объекта типа Tasktype ActionObjectPayload = {    type: ActionType.TOGGLE | ActionType.REMOVE,    payload: Task}// Объединяем предыдущие две группы для использования в редьюсереexport type Action = ActionStringPayload | ActionObjectPayload;// Наш контекст состоит из стейта и функции-редьюсера, в которую будут передаваться Action. Тип Dispatch импортируется из библиотеки reactexport type ContextState = {    state: State;    changeState: Dispatch<Action>}

Использование контекста


Теперь state готов и может быть использован в компонентах. Начнём с NewTask.tsx:

import * as React from 'react';import {useContext} from "react";import {ContextApp} from "./App";import {TaskName} from "../types/taskType";import {ActionType} from "../types/stateType";const NewTask: React.FC = () => {// получаем state и dispatch-метод    const {state, changeState} = useContext(ContextApp);// отправляем два действия редьюсеру todoReducer - добавление задачи и изменение инпута. После их успешной обработки переменная state обновится. Для уточнения интерфейса передаваемого события можно воспользоваться расширенными React-интерфейсами    const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {        event.preventDefault();        changeState({type: ActionType.ADD, payload: task})        changeState({type: ActionType.CHANGE, payload: ''})    }// аналогично - отправим изменение значения в инпуте    const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {        changeState({type: ActionType.CHANGE, payload: event.target.value})    }    return (        <>            <form onSubmit={(event)=>addTask(event, state.newTask)}>                <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>                <button type="submit">Add a task</button>            </form>        </>    )};export default NewTask;

TasksList.tsx:

import * as React from 'react';import {Task} from "../types/taskType";import {ActionType} from "../types/stateType";import {useContext} from "react";import {ContextApp} from "./App";const TasksList: React.FC = () => {// Получаем состояние и диспатч (названный changeState)    const {state, changeState} = useContext(ContextApp);    const removeTask = (taskForRemoving: Task) => {        changeState({type: ActionType.REMOVE, payload: taskForRemoving})    }    const toggleReadiness = (taskForChange: Task) => {        changeState({type: ActionType.TOGGLE, payload: taskForChange})    }    return (        <>            <ul>                {state.tasks.map((task,i)=>(                    <li key={i} className={task.isDone ? 'ready' : null}>                        <label>                            <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>                        </label>                        <div className="task-name">                            {task.name}                        </div>                        <button className='remove-button' onClick={()=>removeTask(task)}>                            X                        </button>                    </li>                ))}            </ul>        </>    )};export default TasksList;

Приложение готово! Осталось протестировать его.

Тестирование


Для тестирования будут использоваться Jest + Enzyme, а также @testing-library/react.
Необходимо установить dev-зависимости:

"@testing-library/react": "^10.4.3","@testing-library/react-hooks": "^3.3.0","@types/enzyme": "^3.10.5","@types/jest": "^24.9.1","enzyme": "^3.11.0","enzyme-adapter-react-16": "^1.15.2","enzyme-to-json": "^3.3.4","jest": "^26.1.0","ts-jest": "^26.1.1",

В package.json добавляем настройки для jest:

 "jest": {    "preset": "ts-jest",    "setupFiles": [      "./src/__tests__/setup.ts"    ],    "snapshotSerializers": [      "enzyme-to-json/serializer"    ],    "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",    "moduleFileExtensions": [      "ts",      "tsx",      "js",      "jsx",      "json",      "node"    ]  },

и в блоке scripts добавляем скрипт запуска тестов:

"test": "jest"

Создаём в директории src новый каталог __tests__ и в нем файл setup.ts с таким содержимым:

import {configure} from 'enzyme';import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';const adapter = ReactSixteenAdapter as any;configure({ adapter: new adapter() });

Создадим файл todoReducer.test.ts, в котором протестируем редьюсер:

import {todoReducer} from "../reducers/todoReducer";import {ActionType, Action, State} from "../types/stateType";import {Task} from "../types/taskType";describe('todoReducer',()=>{    it('returns new state for "ADD" type', () => {// Создаём стейт с пустым массивом задач        const initialState: State = {newTask: '', tasks: []};// Создаём действие 'ADD' и передаём в него текст 'new task'        const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};// Вызываем редьюсер с переданными стейтом и экшеном        const updatedState = todoReducer(initialState, updateAction);// Ожидаем получить в стейте добавленную задачу        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]});    });    it('returns new state for "REMOVE" type', () => {        const task: Task = {name: 'new task', isDone: false}        const initialState: State = {newTask: '', tasks: [task]};        const updateAction: Action = {type: ActionType.REMOVE, payload: task};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: '', tasks: []});    });    it('returns new state for "TOGGLE" type', () => {        const task: Task = {name: 'new task', isDone: false}        const initialState: State = {newTask: '', tasks: [task]};        const updateAction: Action = {type: ActionType.TOGGLE, payload: task};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});    });    it('returns new state for "CHANGE" type', () => {        const initialState: State = {newTask: '', tasks: []};        const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};        const updatedState = todoReducer(initialState, updateAction);        expect(updatedState).toEqual({newTask: 'new task', tasks: []});    });})

Для тестирования редьюсера достаточно передать ему текущий стейт и действие, и затем ловить результат его выполнения.

Тестирование компонента App.tsx, в отличие от редьюсера, требует использования дополнительных методов из разных библиотек. Тестовый файл App.test.tsx:

import * as React from 'react';import {shallow} from 'enzyme';import {fireEvent, render, cleanup} from "@testing-library/react";import App from "../components/App";describe('<App />', () => {// jest-функция afterEach с переданным коллбеком cleanup вызывается после каждого теста и очищает среду тестирования    afterEach(cleanup);    it('hasn`t got changes', () => {//  метод shallow библиотеки enzyme позволяет производить юнит-тестирование, без отрисовки дочерних компонентов.         const component = shallow(<App />);// При первом запуске теста будет создан снимок компонента. При последующих тестированиях будет проверяться идентичность снимка с текущим содержимым компонента. Для обновления snapshots необходимо запустить тест с флагом -u: jest -u        expect(component).toMatchSnapshot();    });// Так как в компоненте будут происходить асинхронные действия (вызываться события на DOM-элементах), оборачиваем тест в async    it('should render right input value',  async () => {// render() функция доступна в библиотеке @testing-library/react" и отличается от shallow() тем, что строит настоящее DOM-дерево для тестируемого компонента. Переменная container   это элемент div, в который будет выведен компонент.        const { container } = render(<App/>);        expect(container.querySelector('input').getAttribute('value')).toEqual('');// вызываем событие изменения инпута и передаём туда значение 'test'        fireEvent.change(container.querySelector('input'), {            target: {                value: 'test'            },        })// ожидаем получить в инпуте значение 'test'        expect(container.querySelector('input').getAttribute('value')).toEqual('test');// вызываем событие клика на кнопку. При этом событии поле инпута должно очищаться        fireEvent.click(container.querySelector('button'))// ожидаем получить в инпуте пустое значение атрибута value        expect(container.querySelector('input').getAttribute('value')).toEqual('');    });})

В TasksList компоненте проверим, правильно ли отображается передаваемый стейт. Файл TasksList.test.tsx:

import * as React from 'react';import {ContextApp, initialState} from "../components/App";import {shallow} from "enzyme";import {cleanup, render} from "@testing-library/react";import TasksList from "../components/TasksList";import {State} from "../types/stateType";describe('<TasksList />',() => {    afterEach(cleanup);// Создаём тестовый стейт    const testState: State = {        newTask: '',        tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]    }// Передаём в ContextApp созданный тестовый стейт    const Wrapper = () => {        return (            <ContextApp.Provider value={{state: testState}}>                <TasksList/>            </ContextApp.Provider>            )    }    it('should render right tasks length', async () => {        const {container} = render(<Wrapper/>);// Проверяем длину отображаемого списка        expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);    });})

Аналогичную проверку поля newTask можно сделать для компонента NewTask, проверяя value у элемента input.

Проект можно скачать с GitGub-репозитория.

На этом всё, спасибо за внимание.

Ресурсы


React JS. Хуки
Working with React Hooks and TypeScript
Подробнее..

Concurrent Mode в React адаптируем веб-приложения под устройства и скорость интернета

06.08.2020 10:11:52 | Автор: admin
В этой статье я расскажу о конкурентном режиме в React. Разберёмся, что это: какие есть особенности, какие новые инструменты появились и как с их помощью оптимизировать работу веб-приложений, чтобы у пользователей всё летало. Конкурентный режим новая фишка в React. Его задача адаптировать приложение к разным устройствам и скорости сети. Пока что Concurrent Mode эксперимент, который может быть изменён разработчиками библиотеки, а значит, новых инструментов нет в стейбле. Я вас предупредил, а теперь поехали.

Сейчас для отрисовки компонентов есть два ограничения: мощность процессора и скорость передачи данных по сети. Когда требуется что-то показать пользователю, текущая версия React пытается отрисовать каждый компонент от начала и до конца. Неважно, что интерфейс может зависнуть на несколько секунд. Такая же история с передачей данных. React будет ждать абсолютно все необходимые компоненту данные, вместо того чтобы рисовать его по частям.



Конкурентный режим решает перечисленные проблемы. С ним React может приостанавливать, приоритизировать и даже отменять операции, которые раньше были блокирующими, поэтому в конкурентном режиме можно начинать отрисовывать компоненты независимо от того, были ли получены все данные или только часть.

Concurrent Mode это Fiber-архитектура


Конкурентный режим это не новая штука, которую разработчики внезапно решили добавить, и всё тут же заработало. К его выходу готовились заранее. В 16-й версии движок React переключили на Fiber-архитектуру, которая по принципу работы напоминает планировщик задач в операционной системе. Планировщик распределяет вычислительные ресурсы между процессами. Он способен переключаться в любой момент времени, поэтому у пользователя возникает иллюзия, что процессы работают параллельно.

Fiber-архитектура делает то же самое, но с компонентами. Несмотря на то, что она уже есть в React, Fiber-архитектура будто находится в анабиозе и не использует свои возможности по максимуму. Конкурентный режим включит её на полную мощность.

При обновлении компонента в обычном режиме приходится целиком рисовать новый кадр на экране. Пока обновление не завершится, пользователь ничего не увидит. В этом случае React работает синхронно. Fiber использует другую концепцию. Каждые 16 мс происходит прерывание и проверка: изменилось ли виртуальное дерево, появились ли новые данные? Если да, пользователь увидит их сразу.

Почему 16 мс? Разработчики React стремятся, чтобы перерисовка экрана происходила на скорости, близкой к 60 кадрам в секунду. Чтобы уложить 60 обновлений в 1000 мс, нужно осуществлять их примерно каждые 16 мс. Отсюда и цифра. Конкурентный режим включается из коробки и добавляет новые инструменты, которые делают жизнь фронтендера лучше. Расскажу о каждом подробно.

Suspense


Suspense появился в React 16.6 в качестве механизма динамической загрузки компонентов. В конкурентном режиме эта логика сохраняется, но появляются дополнительные возможности. Suspense превращается в механизм, который работает в связке с библиотекой загрузки данных. Мы запрашиваем специальный ресурс через библиотеку и читаем из него данные.

Suspense в конкурентном режиме читает данные, которые ещё не готовы. Как? Запрашиваем данные, а пока они не пришли целиком, уже начинаем читать их по небольшим кусочкам. Самая крутая фишка для разработчиков это управление порядком отображения загруженных данных. Suspense позволяет отображать компоненты страницы как одновременно, так и независимо друг от друга. Он делает код понятным: достаточно взглянуть на структуру Suspense, чтобы узнать, в каком порядке запрашиваются данные.

Типичное решение для загрузки страниц в старом React Fetch-On-Render. В этом случае мы запрашиваем данные после render внутри useEffect или componentDidMount. Это стандартная логика в случае, когда нет Redux или другого слоя, работающего с данными. Например, мы хотим нарисовать 2 компонента, каждому из которых нужны данные:

  • Запрос компонента 1
  • Ожидание
  • Получение данных отрисовка компонента 1
  • Запрос компонента 2
  • Ожидание
  • Получение данных отрисовка компонента 2

В таком подходе запрос следующего компонента происходит только после отрисовки первого. Это долго и неудобно.

Рассмотрим другой способ, Fetch-Then-Render: сначала запрашиваем все данные, потом отрисовываем страничку.

  • Запрос компонента 1
  • Запрос компонента 2
  • Ожидание
  • Получение компонента 1
  • Получение компонента 2
  • Отрисовка компонентов

В этом случае мы выносим состояние запроса куда-то наверх делегируем библиотеке для работы с данными. Способ работает здорово, но есть нюанс. Если один из компонентов грузится гораздо дольше, чем другой, пользователь ничего не увидит, хотя мы могли бы ему уже что-то показать. Рассмотрим пример кода из демки с 2 компонентами: User и Posts. Оборачиваем компоненты в Suspense:

const resource = fetchData() // где-то выше в дереве Reactfunction Page({ resource }) {    return (        <Suspense fallback={<h1>Loading user...</h1>}>            <User resource={resource} />            <Suspense fallback={<h1>Loading posts...</h1>}>                <Posts resource={resource} />            </Suspense>        </Suspense>    )}

Может показаться, что такой подход близок к Fetch-On-Render, когда мы запрашивали данные после отрисовки первого компонента. Но на самом деле с использованием Suspense данные придут гораздо быстрее. Всё благодаря тому, что оба запроса отправляются параллельно.

В Suspense можно указать fallback, компонент, который хотим отобразить, и внутрь компонента передать ресурс, реализуемый библиотекой получения данных. Мы используем его as is. Внутри компонентов запрашиваем данные с ресурса и вызываем метод чтения. Это promise, который делает за нас библиотека. Suspense поймёт, загрузились ли данные, и если да покажет их.

Обратите внимание, что компоненты пробуют читать данные, которые ещё находятся в процессе получения:

function User() {    const user = resource.user.read()    return <h1>{user.name}</h1>}function Posts() {    const posts = resource.posts.read()    return // список постов}

В текущих демках Дэна Абрамова в качестве заглушки для ресурса используется такая штука.

read() {    if (status === 'pending') {        throw suspender    } else if (status === 'error') {        throw result    } else if (status === 'success') {        return result    }}


Если ресурс ещё загружается, мы выбрасываем объект Promise в качестве исключения. Suspense ловит это исключение, понимает, что это Promise, и продолжает грузиться. Если же вместо Promise прилетит исключение с любым другим объектом, станет понятно, что запрос завершился ошибкой. Когда же вернётся готовый результат, Suspense его отобразит. Нам важно получить ресурс и вызвать у него метод. Как это реализовано внутри решение разработчиков библиотеки, главное, чтобы их реализацию понимал Suspense.

Когда запрашивать данные? Запрашивать наверху дерева не лучшая идея, потому что они могут никогда не потребоваться. Более подходящий вариант делать это сразу при переходе внутри обработчиков событий. Например, получить начальное состояние через хук, а дальше делать запрос за ресурсами, как только пользователь нажмёт на кнопку.

Вот как это будет выглядеть в коде:

function App() {    const [resource, setResource] = useState(initialResource)    return (        <>            <Button text='Далее' onClick={() => {                setResource(fetchData())            }}>            <Page resource={resource} />        </>    );}

Suspense невероятно гибкая штука. С его помощью можно отображать компоненты друг за другом.

return (    <Suspense fallback={<h1>Loading user...</h1>}>        <User />        <Suspense fallback={<h1>Loading posts...</h1>}>            <Posts />        </Suspense>    </Suspense>)

Или одновременно, тогда оба компонента нужно обернуть в один Suspense.

return (    <Suspense fallback={<h1>Loading user and posts...</h1>}>        <User />        <Posts />    </Suspense>)

Или же грузить компоненты отдельно друг от друга, обернув их в независимые Suspense. Ресурс будет загружаться через библиотеку. Это очень здорово и удобно.

return (    <>        <Suspense fallback={<h1>Loading user...</h1>}>            <User />        </Suspense>        <Suspense fallback={<h1>Loading posts...</h1>}>            <Posts />        </Suspense>    </>)

Кроме того, компоненты Error Boundary поймают ошибки внутри Suspense. Если что-то пошло не так, мы сможем показать, что пользователь загрузился, а посты нет, и выдать ошибку.

return (    <Suspense fallback={<h1>Loading user...</h1>}>        <User resource={resource} />        <ErrorBoundary fallback={<h2>Could not fetch posts</h2>}>            <Suspense fallback={<h1>Loading posts...</h1>}>                <Posts resource={resource} />            </Suspense>        </ErrorBoundary>    </Suspense>)

А теперь давайте взглянем на другие инструменты, благодаря которым преимущества конкурентного режима раскрываются полностью.

SuspenseList


SuspenseList в конкурентном режиме помогает управлять порядком загрузки Suspense. Если бы нам потребовалось загрузить несколько Suspense строго друг за другом без него, их бы пришлось вложить друг в друга:

return (    <Suspense fallback={<h1>Loading user...</h1>}>        <User />        <Suspense fallback={<h1>Loading posts...</h1>}>            <Posts />            <Suspense fallback={<h1>Loading facts...</h1>}>                <Facts />            </Suspense>        </Suspense>    </Suspense>)

SuspenseList позволяет сделать это гораздо проще:

return (    <SuspenseList revealOrder="forwards" tail="collapsed">        <Suspense fallback={<h1>Loading posts...</h1>}>            <Posts />        </Suspense>        <Suspense fallback={<h1>Loading facts...</h1>}>            <Facts />        </Suspense>    </Suspense>)

Гибкость SuspenseList поражает. Можно как угодно вкладывать SuspenseList друг в друга и настраивать порядок загрузки внутри так, как будет удобно для отображения виджетов и любых других компонентов.

useTransition


Специальный хук, который откладывает обновление компонента до полной готовности и убирает промежуточное состояние загрузки. Для чего это нужно? React при изменении состояния стремится сделать переход как можно быстрее. Но иногда важно не торопиться. Если на действие пользователя подгружается часть данных, то обычно в момент загрузки мы показываем лоадер или скелетон. Если данные придут очень быстро, то лоадер не успеет совершить даже пол-оборота. Он моргнёт, затем исчезнет, и мы нарисуем обновлённый компонент. В таких случаях разумнее вообще не показывать лоадер.

Здесь на помощь придёт useTransition. Как он работает в коде? Вызываем хук useTransition и указываем тайм-аут в миллисекундах. Если данные не придут за указанное время, то мы всё равно покажем лоадер. Но если получим их быстрее, произойдёт моментальный переход.

function App() {    const [resource, setResource] = useState(initialResource)    const [startTransition, isPending] = useTransition({ timeoutMs: 2000 })    return <>        <Button text='Далее' disabled={isPending} onClick={() => {            startTransition(() => {                setResource(fetchData())            })        }}>        <Page resource={resource} />    </>}

Иногда при переходе на страницу мы не хотим показывать лоадер, но всё равно нужно что-то изменить в интерфейсе. Например, на время перехода заблокировать кнопку. Тогда придётся кстати свойство isPending оно сообщит, что мы находимся в стадии перехода. Для пользователя обновление будет моментальным, но здесь важно отметить, что магия useTransition действует только на компоненты, обёрнутые в Suspense. Сам по себе useTransition не заработает.

Переходы часто встречаются в интерфейсах. Логику, отвечающую за переход, было бы здорово зашить в кнопку и интегрировать в библиотеку. Если есть компонент, ответственный переходы между страницами, можно обернуть в handleClick тот onClick, который передаётся через пропсы в кнопку, и показать состояние isDisabled.

function Button({ text, onClick }) {    const [startTransition, isPending] = useTransition({ timeoutMs: 2000 })    function handleClick() {        startTransition(() => {            onClick()        })    }    return <button onClick={handleClick} disabled={isPending}>text</button>}

useDeferredValue


Итак, есть компонент, с помощью которого мы делаем переходы. Иногда возникает такая ситуация: пользователь хочет уйти на другую страницу, часть данных мы получили и готовы показать. При этом страницы между собой отличаются незначительно. В этом случае было бы логично показывать пользователю устаревшие данные, пока не подгрузится всё остальное.

Сейчас React так не умеет: в текущей версии на экране пользователя могут отображаться только данные из актуального состояния. Но useDeferredValue в конкурентном режиме умеет возвращать отложенную версию значения, показывать устаревшие данные вместо мигающего лоадера или fallback во время загрузки. Этот хук принимает значение, для которого мы хотим получить отложенную версию, и задержку в миллисекундах.

Интерфейс становится супер текучим. Производить обновления можно с минимальным количеством данных, а всё остальное грузится постепенно. У пользователя создаётся впечатление, что приложение работает быстро и плавно. В действии useDeferredValue выглядит так:

function Page({ resource }) {    const deferredResource = useDeferredValue(resource, { timeoutMs: 1000 })    const isDeferred = resource !== deferredResource;    return (        <Suspense fallback={<h1>Loading user...</h1>}>            <User resource={resource} />            <Suspense fallback={<h1>Loading posts...</h1>}>                <Posts resource={deferredResource} isDeferred={isDeferred}/>            </Suspense>        </Suspense>    )}

Можно сравнить значение из пропсов с тем, что получено через useDeferredValue. Если они отличаются, значит страница ещё находится в состоянии загрузки.

Интересно, что useDeferredValue позволит повторить фокус с отложенной загрузкой не только для данных, которые передаются по сети, но и для того, чтобы убрать фриз интерфейса из-за больших вычислений.

Почему это здорово? Разные устройства работают по-разному. Если запустить приложение, использующее useDeferredValue, на новом iPhone, переход со страницы на страницу будет моментальным, даже если страницы тяжёлые. Но при использовании debounced задержка появится даже на мощном устройстве. UseDeferredValue и конкурентный режим адаптируются к железу: если оно работает медленно, инпут всё равно будет летать, а сама страничка обновляться так, как позволяет устройство.

Как переключить проект в Concurrent Mode?


Конкурентный режим это именно режим, поэтому его потребуется включить. Как тумблер, который заставляет Fiber работать на полную мощность. С чего же начать?

Убираем легаси. Избавляемся от всех устаревших методов в коде и убеждаемся, что их нет в библиотеках. Если приложение без проблем работает в React.StrictMode, то всё в порядке переезд будет простым. Потенциальная сложность проблемы внутри библиотек. В этом случае нужно либо перейти на новую версию, либо сменить библиотеку. Или же отказаться от конкурентного режима. После избавления от легаси останется только переключить root.

С приходом Concurrent Mode будет доступно три режима подключения root:

  • Старый режим
    ReactDOM.render(<App />, rootNode)
    Рендер после выхода конкурентного режима устареет.
  • Блокирующий режим
    ReactDOM.createBlockingRoot(rootNode).render(<App />)
    В качестве промежуточного этапа будет добавлен блокирующий режим, который даёт доступ к части возможностей конкурентного режима на проектах, где есть легаси или другие трудности с переездом.
  • Конкурентный режим
    ReactDOM.createRoot(rootNode).render(<App />)
    Если всё хорошо, нет легаси, и проект можно сразу переключить, замените в проекте рендер на createRoot и вперёд в светлое будущее.

Выводы


Блокирующие операции внутри React превращаются в асинхронные за счёт переключения на Fiber. Появляются новые инструменты, с которыми легко адаптировать приложение и к возможностям устройства, и к скорости сети:

  • Suspense, благодаря которому можно указывать порядок загрузки данных.
  • SuspenseList, с которым это ещё удобнее.
  • useTransition для создания плавных переходов между компонентами, обёрнутыми в Suspense.
  • useDeferredValue чтобы показывать устаревшие данные во время операций ввода-вывода и обновления компонентов

Попробуйте поэкспериментировать с конкурентным режимом, пока он ещё не вышел. Concurrent Mode позволяет добиться впечатляющих результатов: быстрой и плавной загрузки компонентов в любом удобном порядке, сверхтекучести интерфейса. Подробности описаны в документации, там же лежат демки с примерами, которые стоит поизучать самостоятельно. А если вам любопытно, как работает Fiber-архитектура, вот ссылка на интересный доклад.

Оцените свои проекты что можно улучшить, используя новые инструменты? А когда конкурентный режим выйдет, смело переезжайте. Всё будет супер!
Подробнее..

Все ли вы знаете о useCallback

01.12.2020 06:16:56 | Автор: admin

Привет, Хабр!

Начиная с версии ReactJS 16.8 в наш обиход вошли хуки. Этот функционал вызвал много споров, и на это есть свои причины. В данной статье мы рассмотрим одно из самых популярных заблуждений использования хуков и заодно разберемся стоит ли писать компоненты на классах (данная статья является расшифровкой видео).

Два пути

Как вы знаете в реакте есть 2 вариант написания компонента, с помощью классов и с помощью функций. И каждый вариант по своему взаимодействует с методами. Давайте рассмотрим оба варианта:

Метод в классе

Первый вариант, это использовать классы:

class Test extends Component {  onClick = () => {    console.log('onClick');  }  render() {    return (      <button onClick={this.onClick}>        test      </button>    )  }}

В данном варианте мы добавили метод onClick классу Test и при создании инстанса класса, этот метод создается 1 раз и в рендере мы уже используем ссылку на этот метод onClick={this.onClick}, таким образом при каждом рендере мы обращаемся всегда к одной и той же ссылке и не пересоздаем метод класса. Эта конструкция всем, кто давно в профессии, привычна и понятна даже если вы недавно пришли в React с другого языка программирования.

Метод в функции

Второй способ создания компонента является использование функции:

const Test = () => {  const onClick = () => {    console.log('onClick');  }    return (    <button onClick={onClick}>      test    </button>  )}

В таком подходе, чтобы создать обработчик onClick, мы описываем тело функции прямо внутри render, потому что все тело функции и есть render, другого варианта в принципе не существует, если вы хотите использовать props.

И тут у нас начинает зудеть в боку, да как это так, мы же теперь заново создаем функцию, абсолютно на каждый рендер. По сравнению с классами это как будто шаг назад.

Классы лучше чем функции?

Чтобы разобраться с этим вопросом я полез в React документацию в секцию вопросы и ответы и нашел там следующий вопрос:

Судя по документации создание инстанса класса для реакта настолько дорогостоящая операция, что создавать функцию на каждый рендер на порядок дешевле. Да и тот факт, что дерево становится глубже при использовании компонента высшего порядка connect от redux или бесконечных observer от mobX совсем не радует.

Кажется есть один "вариантик" сэкономить

Идею, создавать на каждый рендер новую функцию и думать что это дешевле, чем один раз создать инстанс класса, немного сложно принять разработчикам, потому что с нашей стороны мы должны писать код хуже, и верить что приложение ускорится. Звучит крайне противоречиво, а мы привыкли все оптимизировать.

Как результат, мы начинаем искать пути, как выйти на прежний уровень оптимизации с нашей стороны. И первое что гуглится, это начать использовать хук useCallback. И многие особо не вникая в суть происходящего начинают его активно использовать. Чтобы разобраться во всем этом давайте устроим небольшую викторину

Викторина!

Сейчас мы рассмотрим 2 примера и Вы попытаетесь ответить кто круче!

В одном углу ринга находится уже изученный нами ранее вариант написания обработчика события someFunction:

const Test = ({ title }) => {  const someFunction = () => {    console.log(title);  }    return (    <button onClick={someFunction}>      click me!    </button>  )}

В другом углу ринга находится точно такой же компонент, но уже решили завернуть функцию в useCallback.

const Test = () => {  const someFunction = useCallback((title) => {    console.log(title);  }, [title])    return (    <button onClick={someFunction}>      click me!    </button>  )}

Для пользователя ничего не изменилось, console.log(title), точно так же вызывается при нажатии на кнопку.

Внимание вопрос

В каком из вариантов написания компонента функция присваемая переменной someFunction создается реже?

Даем минутку подумать...

Аккуратно ответ!

Ответ

И правильный ответ ни в каком! Да именно, никакой оптимизации useCallback нам не дал, функция создается ровно столько же раз, как и до оптимизации. Более того, мы наоборот ухудшили перфоманс нашего компонента.

Разбираем ответ

То что многих вводит в заблуждение - это представление что useCallback, это какой-то черный ящик, в который ты отдаешь функцию, с ней что-то происходит и после будет тебе счастье. Но давайте рассмотри как это работает на самом деле. Для начала сделаем typeof этого черного ящика:

Естественно мы получим результат function. По синтаксису это было очевидно. Чтобы понять как работает черный ящик, давайте сами напишем имплементацию useCallback.

Пишем свой useCallback

useCallback - это функция, которая принимает 2 параметра, callback и deps.

function useCallback (callback, deps) {}

Далее нам надо хранить где-то этот callback и deps, чтобы иметь возможность при очередном вызове вернуть ту же самую функцию callback.

const prevState = {  callback: null,  deps: null,}function useCallback(callback, deps) {}

Теперь рассмотрим разные случаи: если deps не существует либо в prevState, либо в новых данных, тогда нужно сохранить текущие параметры и вернуть callback.

const prevState = {  callback: null,  deps: null,}function useCallback(callback, deps) {  if (!prevState.deps || !deps) {    prevState.callback = callback;    prevState.deps = deps;        return callback;  }}

Если же deps существуют. Тогда сравниваем какой-либо функцией массивы и если они совпадают, тогда возвращаем мемоизированную функцию.

const prevState = {  callback: null,  deps: null,}function useCallback(callback, deps) {  if (!prevState.deps || !deps) {    prevState.callback = callback;    prevState.deps = deps;        return callback;  }    if (shallowEqual(deps, prevState.deps)) {    return prevState.callback;  }  }

Ну и если deps не совпадают, тогда снова сохраняем параметры и возвращаем текущий callback.

const prevState = {  callback: null,  deps: null,}function useCallback(callback, deps) {  if (!prevState.deps || !deps) {    prevState.callback = callback;    prevState.deps = deps;      return callback;  }    if (shallowEqual(deps, prevState.deps)) {    return prevState.callback;  }    prevState.callback = callback;  prevState.deps = deps;    return callback;}

Вроде бы мы покрыли все кейсы

Какие выводы из этого мы можем сделать?

Функция useCallback как и любая другая функция вызывается на каждый рендер и в качестве параметра callback каждый рендер приходит новая функция и новый массив зависимостей. Которые мы либо выбрасываем, если зависимости до и после совпадают, либо сохраняем в хранилище, для будущего использования.

Давайте теперь посмотрим на эту функцию со стороны компонента. Мы знаем, что useCallback это просто функция и мы можем извлечь передаваемые параметры в отдельные переменные.

const Test = ({ title }) => {  const callback = () => {    console.log(title);  }    const deps = [title];    const someFunction = useCallback(callback, deps);    return (    <button onClick={someFunction}>      click me!    </button>  )}

Тут становится совсем очевидно, что мы на каждый рендер создаем не то что функцию, а еще и массив с зависимостями, а потом еще и прокручиваем все это через useCallback.

Если мы просто закомментируем создание зависимостей и вызов useCallback и передадим параметр callback напрямую в onClick, тогда кажется перфоманс компонента должен улучшиться, ведь мы убрали посредника, который не нес никакой пользы для перфоманса.

const Test = ({ title }) => {  const callback = () => {    console.log(title);  }    // const deps = [title];    // const someFunction = useCallback(callback, deps);    return (    <button onClick={callback}>      click me!    </button>  )}

По итогу мы вернулись к начальной ситуации. Когда просто создавали функцию на каждый рендер.

Получается, в данном случае использовать хук useCallback - это не значит улучшить перфоманс, а скорее совсем наоборот, ухудшить перфоманс.

А для чего тогда нужен useCallback ?

Получается мы как то не так используем useCallback. Чтобы разобраться в этом, давайте обратимся к документации:

Получается основная идея не в улучшении перформанса в конкретном компоненте, а скорее использование useCallback выгодно только в случае передачи функции как props. Давайте рассмотрим еще один пример.

Допустим у нас есть список машин, который мы хотим отобразить:

const Cars = ({ cars }) => {  return cars.map((car) => {    return (      <Car key={car.id} car={car} />    )  });}

Тут нам понадобилось добавить обработчик клика на машину. Мы создаем метод onCarClick и передаем его в компонент Car.

const Cars = ({ cars }) => {  const onCarClick = (car) => {    console.log(car.model);  }    return cars.map((car) => {    return (      <Car key={car.id} car={car} onCarClick={onCarClick} />    )  });}

В такой ситуации на каждый рендер компонента Cars у нас создается новая функция onCarClick, соответственно, не важно Car это PureComponent или обернут в memo, все машины всегда будут заново рендерится, т.к. получают каждый раз новую ссылку на функцию.

Для этого и нужен useCallback, если мы завернем функцию в хук, то у нас в переменной onCarClick будет уже возвращаться мемоизированая функция, хоть мы в нее на каждый рендер и передаем новую функцию

const Cars = ({ cars }) => {  const onCarClick = useCallback((car) => {    console.log(car.model);  }, []);    return cars.map((car) => {    return (      <Car key={car.id} car={car} onCarClick={onCarClick} />    )  });}

Таким образом все компоненты Car не будут рендериться лишний раз, т.к. ссылка на функцию останется прежней.

А если заглянуть внутрь компонента Car. Там мы создадим еще одну функцию, которая свяжет onCarClick и объект car.

const Car = ({ car, onCarClick }) => {  const onClick = () => onCarClick(car);    return (    <button onClick={onClick}>{car.model}</button>  )}

В этом случае нет никакой пользы оборачивать метод в useCallback, т.к. нам не важно, ссылка это на функцию с прошлого рендера или с текущего рендера, а useCallback как мы уже знаем не бесплатный.

Итоги

Подытожить данную статью можно следующими словами. React хоть и поддерживает компоненты в виде классов, но имеет больше маневров над ускорением именно компонентов в виде функций. Да и сама экосистема все больше в качестве API предоставляет вам именно хуки, что невозможно использовать в классах:

import { useLocation } from "react-router-dom";import { useSelector } from "react-redux";import { useLocalObservable } from "mobx-react-lite";import { useTranslation } from "react-i18next";

И конечно, доверяйте реакту, если они сказали лучше создавать функцию на каждый рендер, так и делайте, ведь они заинтересованы только в улучшении перформанса вашего проекта.

А если вам понравилось данная статья, то здесь есть еще немного интересного

Чао

Подробнее..
Категории: Javascript , React , Reactjs , React hooks , Usecallback

В чем разница между useLayoutEffect, componentDidMount, useEffect

15.12.2020 06:18:19 | Автор: admin

Привет хабр!

В React на смену эпохи классов, пришла эпоха функциональных компонентов. И нам показали хуки, как замена методам жизненного цикла. Но многие так и не задумывались, а равнозначный ли обмен componentDidMount на useEffect. Эта статья направлена как раз на таких людей, чтобы закрыть ваш пробел, в том как работают componentDidMount, useEffect и useLayoutEffect. (данная статья является расшифровкой видео)

Викторина

Для того чтобы разобраться в этом вопросе, проведем небольшую викторину!

Постановка задачи

Допустим у нас есть красный блок с некоторой высотой и шириной. И у нас есть задача вывести ширину этого блока на экран:

На классах код будет выглядеть примерно следующим образом:

const App extends Component {  state = { width: 0 };  ref = React.createRef();  componentDidMount() {    this.setState({ width: this.ref.current.clientWidth });  }  render() {    return (      <div className="app">        <div className="block" ref={this.ref} />        <span className="result">          width: <b>{this.state.width}</b>        </span>      </div>    )  }}

Интерес данной ситуации состоит в том, что изначально у нас в state хранится ширина равная нолю (state = { width: 0 };). Потом происходит render. И уже только после рендера в componentDidMount вызывается this.setState({ ... }) с реальным значением ширины блока. И снова происходит render с уже обновленным значением this.state.width

Вопрос

Что увидит пользователь в браузере? Сначала ширина будет ноль, а потом быстро изменится на реальное значение? Или сразу увидим реальное значение ширины блока?

Как всегда даем минутку подумать .

Ответ

И правильный ответ: width сразу отобразит цифру 220, без промежуточного значения 0. Результат достаточно интересный, чтобы лучше разобраться в текущей ситуации проведем еще один тест.

Анализ происходящего

Давайте создадим функцию sleep. Только не асинхронную с помощью setTimeout, а наоборот синхронную, чтобы блокировала поток, для этого зациклим while на присланное количество миллисекунд:

sleep(duration) {  const start = new Date().getTime();  let end = start;  while(end < start + duration) {    end = new Date();getTime();  }}

И теперь добавим sleep с 3000 миллисекунд до присвоения значения width в state

componentDidMount() {  this.sleep(3000);  this.setState({ width: this.ref.current.clientWidth });}

Как думаете что теперь пользователь увидит?

Результат снова немного неожиданный, у нас страница просто фризится на 3 секунды и только после этого браузер отрисует красный квадрат и сразу же выдаст цифру с шириной квадрата. Из этого уже можно сделать какие то выводы

Выводы

Получается на основе render создается виртуальное дерево, но перед тем как отдать виртуальное дерево на отрисовку в браузер, вызывается componentDidMount и даже более того блокирует отрисовку в браузере, в нашем случае на 3 секунды. Три, два, один и setState заново перестраивает виртуальное дерево, и только после всего этого браузер рисует страницу. И даже если указать задержку не 3 секунды, а 30 секунд, результат не изменится мы увидим как страница зависнет на 30 секунд.

useEffect

Давайте теперь сравним с тем как работает useEffect. Напишем такой же код на функциональном компоненте:

const App = () => {  const [width, setWidth] = useState(0);  const ref = useRef();    useEffect(() => {    let start = new Date().getTime();    let end = start;    while (end < start + 3000) {      end = new Date().getTime();    }        setWidth(ref.current.clientWidth);  }, []);    return (    <div className="app">      <div className="block" ref={ref} />      <span className="result">        width: <b>{width}</b>      </span>    </div>  )}

Результат как вы уже догадались будет отличаться. Мы увидим сначала значение ноль, а только через 3 секунды цифра обновится до ширины блока. Таким образом можно предположить что useEffect работает по следующему сценарию:

Сначала на основе return создается виртуальное дерево, далее оно отдается на отрисовку в браузер и только после этого вызывается функция переданная в useEffect, в которой уже идет блокировка потока на 3 секунды, три, два, один и после спячки виртуальное дерево заново строится с новым значением в state и это дерево передается на отрисовку в браузер

Документация

Теперь мы точно знаем, что componentDidMount отличается от useEffect. Чтобы понять, это отличие вызвано просто особенностями реализации хуков или же умышленно, давайте как всегда обратимся к документации.

Из этого можно сделать вывод, что React разработчики умышленно дали нам, абсолютно новый API, позволяющий отрисовать контент в браузере до того как запуститься функция переданная в useEffect. Этот API просто не существует при написании компонента на классах. Его можно сымитировать разве что использовав setTimeout внутри componentDidMount.

componentDidMount() {  setTimeout(() => {    ...  }, 0);}

Но это скорее выглядит как костыль чем решение.

useLayoutEffect

А с другой стороны, если нам нужно выполнить какой то код, до отрисовки в браузере, на предоставили хук useLayoutEffect, интерфейс которого полностью совпадает с useEffect, но по очередности выполнения полностью совпадает с componentDidMount.

Мысли в слух

На что хотелось бы обратить внимание. Несмотря на то что React разработчики не собираются удалять поддержку классов, мне кажется и развивать их они так же не собираются. Поэтому вряд ли мы увидим аналог useEffect на классах, но с другой стороны, точно увидим недостающие методы жизненного цикла существующие на классах и пока не существующие на хуках.

Надеюсь данная статья помогла некоторым из вас восполнить пробел, на который вечно не хватает времени ;-)

Подробнее..

CreateRef, setRef, useRef и зачем нужен current в ref

05.02.2021 10:09:34 | Автор: admin

Привет хабр!

В этой статье попробуем разобрать большинство непонятных базовых принципов при взаимодействии с ref. Например чем детально отличается createRef от useRef, зачем в этих объектах отдельное свойство current и многое другое. Одним словом попытаемся открыть много черных ящиков по работе с ref, из-за которых у вас возможно накопились вопросы. (Данная статья, является расшифровкой видео)

Вспоминаем setRef

Начнем с примера. Допустим у нас есть небольшой класс, где нам нужно работать с ref. В классовом компоненте, моим любимым способом работы с ref - является передача именно функции в атрибут ref:

class App extends Component {  setRef = (ref) => {    this.ref = ref;  };  componentDidMount() {    console.log(this.ref); // div  }  render() {    return <div ref={this.setRef}>test</div>;  }}

Функция setRef первым параметром получит ноду этого элемента и у вас есть возможность сохранить ее в this. Я называю такой метод в своих проектах setRef, т.к. он визуально мне напоминает классический setter.

Почему такой подход мне нравится, в нем почти отсутствует магия. Вы контролируете почти весь код, разве что кроме момента передачи функции в атрибут ref и кто-то ее там вызывает, но и это выглядит вполне себе привычно, когда мы хотим получить значение после асинхронной операции с помощью callback.

function someFunction(callback) {  doSomething()    .then((data) => callback(data));}

Вопросы к createRef

А теперь, давайте сравним с альтернативным подходом. Будем использовать createRef для создания инстанса ref и хранить все там же в this.

class App extends Component {  constructor(props) {    super(props);    this.ref = createRef();  }  componentDidMount() {    console.log(this.ref.current); // div  }  render() {    return <div ref={this.ref}>test</div>;  }}

Этот код, на мое мнение, немного сложнее предыдущего. Для начала, createRef() сохраняет, что-то неизвестное в this.ref. После добавления console.log, мы видим там объект с одним свойством current равным null.

this.ref = createRef();console.log(this.ref); // { current: null }

Далее мы этот объект { current: null } засовываем в атрибут ref и уже в componentDidMount имеем доступ к ноде.

Тут у меня возникает сразу несколько вопросов:

  • Зачем нам вообще свойство current?

  • И если оно есть, почему тогда в ref мы не передаем this.ref.current?

  • А вообще гарантировано ли существование свойства current?

Да и вообще подход передачи объекта в ref, чтобы его там мутировали, не очень популярен в React, особенно если вы используете redux в своем проекте, где мутирование не приветствуется.

Изучаем исходники createRef

Чтобы во всем этом разобраться, я предлагаю изучить исходники реакта. Начнем с метода createRef()(ссылка).

export function createRef(): RefObject {  const refObject = {    current: null,  };  if (__DEV__) {    Object.seal(refObject);  }  return refObject;}

Здесь мы видим, что создается объект с одним свойством current равным null, и он же возвращается. Крайне простой метод.

Таким образом, мы можем даже заменить метод createRef на просто создание объекта со свойством current. И это будет работать точно так же.

class App extends Component {  constructor(props) {    super(props);    this.ref = { current: null, count: 2 } // createRef();  }  componentDidMount() {    console.log(this.ref.current); // div    console.log(this.ref.count); // 2  }  render() {    return <div ref={this.ref}>test</div>;  }}

Более того для эксперимента я добавил в этот объект и другое свойство count и оно не исчезло после прокидывания в ref.

Изучаем исходники работы атрибута ref

Чтобы разобраться что происходит внутри атрибута ref, мы опять обратимся к исходникам. Я какое-то время подебажил, чтобы найти место где происходит работа с присваиванием ref. И это место - функция commitAttachRef. Она находится в пакете react-reconciler в файле ReactFiberCommitWork.new.js.

function commitAttachRef(finishedWork: Fiber) {  const ref = finishedWork.ref; // получаем то что мы передали в атрибут ref  if (ref !== null) { // Если в ref ничего не передавали, то и делать дальше нечего    const instanceToUse = finishedWork.stateNode; // достаем саму ноду        // ...        if (typeof ref === 'function') { // проверка на тип переданного нами ref      // ...      ref(instanceToUse);    } else {      // ...      ref.current = instanceToUse;    }  }}

Изучив этот код становится понятно, что полный сценарий работы с ref достаточно примитивный

this.ref = createRef(); // { current: null }<div ref={this.ref}>test</div>if (typeof ref !== 'function') {  ref.current = instanceToUse;}

Исходя из этих знаний мы можем предположить, что скорей всего проверка на существование свойства current избыточна. Чтобы уже точно подтвердить это, я полез в типы:

export type RefObject = {|  current: any,|};

И действительно поле current не имеет вопросительного знака, а это значит оно обязательное. Поэтому можете смело использовать поле current без дополнительных проверок.

А что происходит в commitAttachRef при 2-ом рендере?

Код который мы изучили, описывает только первый рендер, давайте разберемся что происходит с ref на второй и последующие рендеры.

Для этого было решено провести еще один эксперимент. В начале метода commitAttachRef я добавил console.log

function commitAttachRef(finishedWork: Fiber) {  console.log('commitAttachRef !!!');  // ...}

А с другой стороны, доработал предыдущий пример с ref, а именно добавил счетчик внутрь div-а. И описал классический метод incerement.

class App extends Component {  constructor(props) {    super(props);        this.ref = createRef();    this.state = { counter: 0 };  }    // ...    increment = () => {    this.setState((prevState) => ({      counter: prevState.counter + 1,    }));  }  render() {    return (      <div ref={this.ref}>        <button onClick={this.increment}>+</button>        <span>{this.state.counter}</span>      </div>    );  }}

В браузере же мы увидим следующую картину:

При первом рендере вызывается console.log(commitAttachRef !!!). И дальше мы нажимаем несколько раз на кнопку увеличения счетчика, но метод commitAttachRef больше не вызывается.

Давайте еще доработаем пример и попробуем вставить значение счетчика в className этого div-а

return (  <div ref={this.ref} className={`class-${this.state.counter}`}>    <button onClick={this.increment}>+</button>    <span>{this.state.counter}</span>  </div>);

И в браузере ничего не изменилось от такой доработки.

Поэтому было решено еще доработать код, а именно, положили кнопку и счетчик рядом с div-ом, который маунтим, только если число четное.

return (  <>    <button onClick={this.increment}>+</button>    <span>{this.state.counter}</span>    {this.state.counter % 2 === 0 <div ref={this.ref}>test</div>}  </>)

Перейдем теперь в браузер. И понажимаем снова на кнопку плюс. И в консоли видим, каждый раз когда число четное, перед did update вызывается commitAttachRef !!!.

В принципе понять это не сложно. Пока мы меняем атрибуты, нода мутирует в виртуальном дереве и обновлять ref реакту смысла нет, т.к. ссылка в виртуальном дереве одна и та же, а когда по какой-либо причине происходит Mount / Unmount, ссылка на ноду обновляется и соответственно нужно перезаписать ref. Таким образом на первый взгляд метод commitAttachRef вызывается только, если нода полностью меняется, но это не единственный случай. Рассмотрим для этого другие ситуации.

А что если использовать createRef вместо useRef?

В предыдущих экспериментах мы разбирали примеры использования createRef() на классах, а что будет если createRef() использовать в функциональных компонентах?

Поэтому я решил переписать предыдущий пример на хуках. Получилась следующая картина:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = createRef(); // useRef();    const increment = () => setCounter(counter => counter + 1);    useEffect(() => {    console.log("[useEffect] counter = ", counter, ref.current);  }, [counter]);    return (    <div ref={ref}>      <button onClick={increment}>+</button>      <span>{counter}</span>    </div>  );}

Вместо привычного useRef() мы подставим createRef(). И в useEffect на каждое изменения counter будем выводить значение counter и ref. Перейдем в браузер.

Мы видим, что абсолютно на каждый рендер вызывается метод commitAttachRef. Хотя ноду, как в предыдущих примерах, мы не меняли. При этом в useEffect нода вполне себе валидная, и указывает на правильный <div>

Конечно же, если мы заменим createRef на useRef, тогда commitAttachRef будет вызываться только один раз. Чтобы понять почему это так работает нужно изучить исходники обоих методов и сравнить

Изучаем исходники useRef

Исходники createRef мы уже смотрели, давайте бегло изучим исходники useRef. Если вы читали мою предыдущую статью Первое погружение в исходники хуков вы знаете, что за одним хуком useRef кроется несколько методов, а именно mountRef, updateRef. Они достаточно примитивные.

Первым рассмотрим mountRef:

function mountRef<T>(initialValue: T): {|current: T|} {  const hook = mountWorkInProgressHook();  if (_DEV_) {    // ...  } else {    const ref = {current: initialValue};    hook.memoizedState = ref;    return ref;  }}  

В mountRef достается инстанса hook. И далее видим проверку на dev окружение. И когда мы проскролим весь этот большой блок для дев окружения, мы увидим, что для прод режима будут выполняться всего 3 строки: создать ref, сохранить его внутри хука и вернуть его.

Метод updateRef еще проще:

function updateRef<T>(initialValue: T): {|current: T|} {  const hook = updateWorkInProgressHook();  return hook.memoizedState;}

Нужно достать тот же хук из метода updateWorkInProgressHook() и вернуть сохраненный в нем ref.

Сравниваем поведение при createRef и useRef

Изучив хук useRef, мы можем наконец-то разобраться, почему useRef лучше подходит для функционального компонента, чем createRef. Перед тем как начнем сравнивать, я хочу ввести кое какие обозначения.

Для того чтобы показать, что создается именно новый объект, я решил ввести обозначение буквами. Если после какого-то действия буква возле объекта остаётся прежней, значит это та же ссылка на объект. Если же буква изменится, значит это полностью новый объект.

А теперь, построим временную сравнительную шкалу для сравнения useRef() и createRef()

Как мы видим, при первом рендере поведение у обоих методов абсолютно одинаковое, а вот второй рендер уже отличается. В случае useRef() в current мы имеем все ту же ноду указывающую на div, да и сам объект содержащий current все тот же "a". В случае createRef() создается как новый объект "c" (в первом рендере был "b") так и свойство current снова равняется null. И как следствие вызывается commitAttachRef и на 2-ом рендере.

Возникает резонный вопрос. А что именно заставляет вызвать commitAttachRef? Это то что ссылка на объект поменялась с "b" на "с" или это, потому что при втором рендере current снова стал null?

Для разгадки этой тайны, проведем 2 мелких эксперимента:

Эксперимент 1. Сохраняем ту же ссылку на объект и обнуляем current

Рассмотрим следующий код:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = useRef();  // ...  ref.current = null;  return (    <div ref={ref}>      <button onClick={increment>+</button>      <span>{counter}</span>    </div>  )}

Суть идеи в том, чтобы при 1-ом, 2-ом и последующих рендерах, в атрибут ref передавать current равный null. И посмотреть, будет ли вызываться commitAttachRef при каждом ренедере.

РЕЗУЛЬТАТ: commitAttachRef вызывается лишь 1 раз, при маунте компонент

Эксперимент 2. Изменяем ссылку на объект и сохраняем current

Рассмотрим следующий код:

const App = () => {  const [counter, setCounter] = useState(0);  const ref = useRef();  const testRef = { current: ref.current };    // ...    useEffect(() => {    ref.current = testRef.current;  });  return (    <div ref={testRef}>      <button onClick={increment}>+</button>      <span>{counter}</span>    </div>  )}

Идея заключается в том, чтобы при каждом рендере менялась ссылка на объект передаваемый в атрибут ref, но при этом приходило валидное значение current.

РЕЗУЛЬТАТ: commitAttachRef вызывается на каждый рендер

Паттерн единого объекта с мутирующим свойством

Из этого можно сделать вывод, что на вызов commitAttachRef влияет именно ссылка на объект, который мы послали в атрибут ref. И сделано это не просто так. Более того можно проследить паттерн, который закладывали React Core разработчики. С одной стороны useRef всегда создает объект лишь единожды, при первой инициализации. И эту ссылку можно использовать например в useEffect, и даже eslint, который заставляет нас дописывать в зависимости, все что мы используем внутри. Абсолютно не требует дописывать ref в зависимости т.к. ссылка всегда одинаковая, хоть current может и меняться, а это значит, мы не получим дополнительных вызовов useEffect, если current изменится, но при желании можем в зависимости и добавить ref.current и получим дополнительные вызовы useEffect (но eslint на такое использование ref.current ругается, т.к. в большинстве случаев, это приведет скорее к багам, чем к осознанной пользе). Получается данный патерн дает нам определенную дополнительную гибкость.

Так же ref удобно использовать и как props. И при изменении current, ваш компонент не будет перерисовываться, если вы этого не хотите. Поэтому конструкция объекта с дополнительным свойством current, это не просто так исторически сложилось, а осознанный паттерн, которым предлагают пользоваться нам React Core разработчики.

Мысли вслух

Идея данной статьи - это чуть лучше разобраться в том, как работает инструмент, которым мы вкалачиваем гвозди каждый в свой проект. Да, после этой статьи маловероятно, что вы начнете писать свой проект как то иначе, но однозначно станете более уверенно писать привычные вам конструкции, т.к. будете осознавать, что именно происходит под капотом. И конечно, я надеюсь вас немного меньше станет раздражать наличие current в ref, т.к. это жертва во имя гибкости.
Если вам нравится, то что я делаю, можете поддержать здесь :)

Подробнее..
Категории: Javascript , React , Reactjs , React.js , React hooks , Useref

Что выбрать глобальные переменные или useThis?

16.02.2021 10:21:44 | Автор: admin

Привет Хабр!

Как вы знаете при переходе с компонентов классов на функциональные, у нас отняли такую полезную вещь как this, которая указывает на текущий экземпляр компонента. И конечно у нас возник вопрос: а где же тогда хранить timeoutId?. И я видел как люди по разному выкручивались из этой проблемы (Данная статья, является расшифровкой видео)

Например, если timeoutId используется только в рамках одного useEffect можно набросать следующий вариант:

useEffect(() => {  const timeout = setTimeout(() => {    // do some action  }, 3000);    return () => {    clearTimeout(timeout);  }}, [...]);

Данный подход работает, но когда появляется нужда очищать timeout по клику на кнопку, этот подход уже не работает.

Поэтому многие решили, просто создавать переменную вне компонента и хранить в ней id этого тайм-аута, чтобы например по клику иметь возможность его отменить:

let timeout;const Test = () => {  const onClick = () => clearTimeout(timeout);    useEffect(() => {    timeout = setTimeout(() => {      // do some action    }, 3000);  }, [...]);      return (...);}

И это работает в большинстве случаев без каких-либо проблем. Но как всегда есть НО.

Проблема глобальных переменных

Давайте рассмотрим пример. Допустим у нас есть компонент Counter, в котором есть локальный счетчик и глобальный счетчик определенный вне компонента:

let globalCounter = 0;const Counter = () => {  const [stateCounter, setStateCounter] = useState(0);    const onClick = () => {    globalCounter++;    setStateCounter((stateCounter) => stateCounter + 1);  };    return (    <div>      <p>global counter - <b>{globalCounter}</b></p>      <p>state counter - <b>{stateCounter}</b></p>      <button onClick={onClick}>increment</button>    </div>  );}

Компонент достаточно простой. Теперь добавим родительский компонент:

const App = () => {  const [countersNumber, setCountersNumber] = useState(0);    return (    <div>      <button onClick={setCountersNumber((count) => count + 1)}>        add      </button>      <button onClick={setCountersNumber((count) => count - 1)}>        removed      </button>      {[...Array.from(countersNumber).keys()].map((index) => (        <Counter key={index} />      ))}    </div>  );};

Здесь мы храним в state количество счетчиков, и ниже имеем 2 кнопки: для увеличения количества счетчиков и для уменьшения. И собственно вставляем сами счетчики в таком количество, как у нас указано в переменной countersNumber.

Смотрим результат

Перейдем в браузер и выполним следующие действия:

  • Добавим один счетчик;

  • Внутри появившегося счетчика, нажмем "increment" три раза;

  • Добавим второй счетчик.

Как вы видите у второго глобального каунтера значение три. И не важно сколько счетчиков вы создадите, они все замыкаются на одну и ту же глобальную переменную. И даже если вы удалите все счетчики, а потом добавите новый счетчик, конечно же глобальный каунтер все равно будет равен трем, так как глобальная переменная создается при запуске сайта и очищается при полном закрытии табы в браузере с вашим сайтом.

Таким образом, хранение timeoutId в такого рода глобальной переменной, может привести к странным багам. Да и если у вас в проекте огромное количество таких переменных, вы бесполезно засоряете память вашего компьютера, хоть это возможно и экономия на спичках.

Рассмотрим альтернативу

Решением данной проблемы является использование хука useRef(). Именно это и рекомендует React документация:

Они прямо упомянули, что useRef() нужно использовать как аналог this. И более того, для удобства добавили в useRef() возможность передачи начального значения. Поэтому вариант с timeout может выглядеть следующим образом:

const Test = () => {  const timeout = useRef();    const onClick = () => clearTimeout(timeout.current);    useEffect(() => {    timeout.current = setTimeout(() => {      // do some action    }, 3000);  }, [...]);    return (...);}

Возможно в этом решении вас смущает, то что в timeout начинает хранится свойство current, это действительно выглядит немного странно, но у этого есть разумное объяснение, о котором мы рассказывали в предыдущей статье createRef, setRef, useRef и зачем нужен current в ref.

prevProps не исчезли вместе с классами

Использование useRef() для хранения timeout это конечно же очень полезно. Но есть и более интересные способы использования. Например, в компонентах в виде классов есть удобный метод жизненного цикла componentDidUpdate. В качестве первого параметра нам предоставляют prevProps, т.е. props из предыдущей итерации. Это давало нам возможность, сравнивать props из текущей итерации с props из предыдущей. На основании этого выполнять какие-то действия.

componentDidUpdate(prevProps) {  if (this.props.id !== prevProps.id) {    // do some action  }}

Если вам кажется, что эту функциональность у вас отняли безвозвратно, тогда вы ошибаетесь.

Давайте напишем хук, который будет возвращать props из предыдущей итерации:

const useGetPrevValue = (value) => {  const prevValueRef = useRef();  useEffect(() => {    prevValueRef.current = value;  });    return prevValueRef.current;};

Здесь мы получаем value из текущей итерации, после создадим ref для хранения данных между итерациями. И в рамках текущей итерации мы вернем текущее значение current равное null. Но перед началом следующей итерации мы обновим current значение, таким образом в следующей итерации в ref у нас будет хранится значение из предыдущей.

И осталось только использовать этот хук:

const CounterView = ({ counter }) => {  const prevCount = useGetPrevValue(counter);    const classes = classNames({    [styles.greenCounter]: counter < prevCounter,    [styles.redCounter] counter > prevCounter,  });    ...}

В итоге, имея значение counter из предыдущей итерации и из текущей итерации, мы можем покрасить в зеленый цвет, если counter уменьшился, или в красный, если counter увеличился.

Расширяйте сознание

Мы упомянули лишь несколько базовых вариантов использования ref для решения нетипичных для ref задач. Но их гораздо больше. Например, если вы откроете телеграм десктоп версии, и будете скролить вверх, то у вас будет отображаться дата, а если вниз, она исчезнет. Мы можем хранить предыдущее значение скрола в ref, для того чтобы вычислить в какую же сторону скролит пользователь.

Как вы уже догадались, использование ref ограничено лишь вашей фантазией и вы найдете ему гораздо больше вариантов применения, чем мы упомянули в этой статье.

И если вы знаете еще какие-то интересные варианты использования ref обязательно пишите в комментариях

Подробнее..
Категории: Javascript , React , Reactjs , Hook , React hooks , Memoization

Фреймворк-независимое браузерное SPA

14.03.2021 16:17:43 | Автор: admin

1. Но... зачем?

  1. Существует огромное количество фреймворков для разработкиSPA(Single Page Application).

  2. Существует огромное количество документации, иллюстрирующей как создавать приложение набазе конкретного фреймворка.

  3. Однако всякая подобная документация ставит фреймворк воглаву угла. Тем самым превращая фреймворк издетали реализации вопределяющий фактор. Таким образом значительная часть кода пишется недля удовлетворения нужд бизнеса адля удовлетворения нужд фреймворка.

Учитывая насколько hype-driven является разработка софта внаше время, можно быть уверенным втом что через несколько лет будут существовать новые модные фреймворки для фронтенд разработки. Вмомент когда фреймворк набазе которого построено приложение выходит измоды вывынуждены либо поддерживать устаревшую (legacy) кодовую базу либо стартовать процесс перевода приложения нановый фреймворк.

Оба варианта сопряжены сущербом для бизнеса. Поддержка устаревшей кодовой базы означает проблемы снаймом новых имотивацией текущих разработчиков. Перевод приложения нановый фреймворк стоит времени (следственно денег) ноненесет никакой пользы для бизнеса.

Данная статья является примером построения SPA сиспользованием высокоуровневых принципов дизайна архитектуры. При этом конкретные библиотеки ифреймворки выбираются для удовлетворения ответственностей, определённых желаемой архитектурой.

2. Архитектурные цели иограничения

Цели:

  1. Новый разработчик может понять назначение приложения при поверхностном взгляде наструктуру кода.

  2. Стимулируется разделение ответственностей (separation ofconcerns) иследовательно модульность кода так что:

    • Модули легко поддаются тестированию

    • Интеграции свнешними сервисами (boundaries) атакже грязные хаки иворкэраунды вынесены вотдельные модули инепротянуты через несколько различных файлов. Таким образом смена реализации интеграции ссервисом или отказ отхака становится реалистичной задачей анедолгосрочным рефакторингом

  3. Логически связанные вещи размещены недалеко друг отдруга вструктуре файлов.Вот хорошая статья, которая иллюстрирует разницу между структурированием кода потехническим слоямипофункциональным ответственностям.

  4. Позволяет реализовать слабую связность между модулями. Как следствие изменение реализации модуля (или его замена) недолжна приводить кизменению кода, использующего данный модуль.

  5. Механики взаимодействия модулей неприводят кнедопустимым проблемам спроизводительностью.

  6. Зависимости отбиблиотек неторчат сквозь всю кодовую базу. Они удерживаются внутри ограниченного числа модулей, ответственных заинтеграцию сконкретной библиотекой.

Ограничения:

Приложение должно работать вбраузере. Следовательно оно должно быть написано сиспользованием (или скомпилированов) HTML+CSS для определения статического интерфейса иJavaScript для добавления динамического поведения.

3. Ограничим тему данной статьи

Существует большое количество архитектурных подходов кструктурированию кода. Наиболее распространенные наданный момент: слоеная (layered), луковичная (onion) ишестигранная (hexagonal). Беглое сравнение было дано вмоей предыдущейстатье.

Данная статья ограничивается слоем представления втерминологии слоеной/луковичной архитектур поскольку большинство SPA занимается исключительно отображением данных. Таким образом слои домена (domain) иприложения (application) могут быть проигнорированы. Как следствие, наиболее естественный способ понять назначение такого приложения получить обзорное представление ослое представления.

Тем неменее данная статья определяет механики взаимодействия сослоями приложения идомена, вслучае когда такие слои присутствуют.

Интересно отметить что вслучае отсутствия вышеупомянутых слоев приложение напоминает классическую шестигранную структуру (также называемуюPorts and Adapters) вкоторой представлениеявляетсяприложением. Взгляните наинтеграцию сlocalStorage вTodoMVCпримере созданном вкачестве иллюстрации кданной статье (папкаboundaries/local-storage).

4. Структура файлов. Как заставить SPAкричать?

Будем исходить из терминологии дяди Боба.

Рассмотрим типичный онлайн магазин. Приблизительно так онмогбы быть нарисован насалфетке владельцем бизнеса:

Рисунок1: типичный онлайн магазин, нарисованный насалфетке

Каким может быть наиболее кричащий способ структурировать кодовую базу? Нарисунке 2все страницы отражены как папки.

Рисунок2: структура папок верхнего уровня, отражающая страницы определённые нарисунке 1

Заметим что мыдобавили папку shared как место где будут определены общие UIблоки, такие как шаблон, панель навигации, корзина.

Наши страницы построены излогических (ивидимых) частей. Пока что назовем их блоками иположим впапку сименем parts. Посмотрим что получилось (рисунок 3).

Рисунок3: размещение вложенных блоков внутри подпапки parts

Как видно, вложенность выглядит отвратительно уже для второго уровня для страницы goods catalogue. Путь goods-catalogue/parts/goods-list/parts/good-details.jsуже награнице адекватной длины пути кфайлу. При том что вреальных приложениях два уровня вложенности далеко непредел.

Давайте избавимся отпапок parts вфайловой структуре. Посмотрим нарисунок 4.

Рисунок4: вложенные блоки вынесены изпапок parts

Теперь внутри пути goods-catalogue/goods-listнаходится три файла.goods-list.js(родительский) расположен между файлами, определяющими вложенные внего блоки. Вреальных проектах, учитывая кол-во разнородных файлов (js, html, css) это приводит кневозможности разделить файлы, определяющие текущий блок ифайлы, отвечающими завложенные внего блоки.

Решение:

  1. Если конкретный блок определяется несколькими файлам создаем для него папку.

    • goods-listявляется блоком исостоит изболее чем одного файла, потому для него создана папка.

    • filtersявляется блоком состоящим изодного файла, потому для него несоздана отдельная папка.

  2. Если конкретный блок (неважно изодного файла или изнескольких) являетсявложенным блоком добавим кназванию файла префикс _. Таким образом все вложенные блоки будут подняты кверху папки вфайловом обозревателе.

    • _goods-list folderявляется вложенным блоком относительноgoods-catalogueсоответственно кназванию папки добавлен префикс.

    • goods-list.jsявляется частью определения блока_goods-listсоответственно префикс недобавлен.

    • _good-details.jsявляется вложенным блоком относительно_goods-listсоответственно префикс добавлен.

Рисунок5: использование префикса _ для разделения вложенных блоков отихродителей

Готово! Теперь открывая папку сблоком мыможем сразуже увидеть иоткрыть основной файл, определяющий данный блок. После чего при необходимости перейти квложенному блоку. Обратите внимание что папкаpagesбыла переименована вcomponentsнарисунке 5. Так сделано поскольку страницы иблоки логически являются разными вещами новтерминологии HTML итоидругое можетбы представлено какcomponent. Сэтого момента папкаcomponentsявляется основной папкой нашего приложения, домом для слоя представления.

5. Язык разработки. JavaScript?

Единственный язык который может быть выполнен вбраузере это JavaScript. Существует множество статей посвященных его несуразности. Выможетепосмеяться онем (тайм код1-20), ноэто только веселая часть...

Важнеето, что новые возможности постоянно добавляются кязыку.Спецификацияобновляется каждый год. Новый фичи проходят4-этапныйпроцесс ревьюперед тем как попасть вспецификацию. Однако зачастую, они реализуются браузерами ещё допрохождения через все 4этапа. Идовольно часто сообщество иавторы библиотек начинают использовать определенные фичи дотого как они попадают вспецификацию. Кпримеру,декораторыстали широко применяться в2015году, нодосих пор неявляются частью спецификации. Сдругой стороны, зачастую бизнес требует работоспособности приложения вустаревших браузерах, которые априори неподдерживают новых языковых возможностей.

Потому даже при использовании чистого JavaScript разработчик вынужден использовать транспилятор (babel) стем чтобы получить JavaScript, совместимый сбраузерами изсовременного имодного JavaScript. Поскольку использование транспилятора исоответствующее замедление сборки приложения неизбежно нет причин игнорировать другие, более предсказуемые иболее функциональные языки программирования.

Глубокий анализ возможных опций лежит вне границ данной статьи, номой персональный выборTypeScriptпосколькуон:

  • Обеспечивает проверку типов наэтапе компиляции

  • Будучи над-множеством JavaScript, может выполнять JavaScript код без дополнительного интеграционного кода

  • Определения типов (typings) могут быть добавлены поверх существующего JavaScript кода без его изменения. Благодаря простоте этой возможности, большинство существующих npm пакетов уже покрыты тайпингами. Таким образом выможете использовать эти пакеты так, как будтобы они являются TypeScript пакетами. Соответственно ихиспользование также является типо-безопасным.

Хинт: рекомендую посмотреть всторонуasm.js,blazorиelmесли вызаинтересованы вдругих опциях

6. Требования кдизайну приложения

Давайте вспомним ограничения, накладываемые браузерами: HTML, CSS, JavaScript. Также вспомним структуру файлов, определенную вразделе4: дерево директорий, отражающее дерево визуальных элементов.

Таким образомпервой целью [6.1]будет возможность определения компонентов средствами HTML иCSS иихпоследующее переиспользование другими компонентами.

Существенный недостаток чистого HTML состоит втом что оннетипизирован. Существует достаточное количество движков шаблонизации, таких какunderscore.js,handlebars.js. Однако все они принимают навход строки, что ограничивает нас впроверке корректности используемых вшаблоне данных наэтапе компиляции приложения.

Таким образомвторой целью [6.2]является возможность определить TypeScript интерфейсы отражающие все свойства, используемые вшаблоне (компоненте).После чего наэтапе компиляции выбросить исключение вслучае если вразметке компонента происходит обращение кнеопределенному свойству.

Каждый UIэлемент может выглядеть поразному взависимости отпереданных ему данных. HTML элементы принимают данные ввидеHTMLатрибутов. Этого достаточно для статической разметки. Для динамически изменяемой разметки нам необходимы некоторые хранилища для данных. Вэтих хранилищах данные будут изменяться взависимости отдействий пользователя настранице. Втоже время, мынедолжны потерять возможность передавать данные вкомпоненты ввиде атрибутов.

Таким образомтретьей целью [6.3]является возможность компонентов принимать данные изатрибутов иизхранилищ одновременно. Компоненты должны быть перерисованы при изменении любой части принимаемых данных.

Четвертой целью [6.4]станет определение требований ктаким хранилищам:

  • Должна существовать возможность использовать хранилище несколькими компонентами, каждый изкоторых отображает лишь часть общего набора данных.

  • Должна существовать возможность создавать хранилища вместе сконкретным компонентом. Это необходимо для случая когда различные экземпляры компонента автономны друг отдруга идолжны иметь различные наборы данных.

  • Хранилища должны иметь возможность использовать сервисы ифункции слоев Domain иApplication. Воизбежание сильной связности между хранилищем играницами приложения, сервисы должны быть использованы спомощью механизмаDependency Injection. Хранилища должны ссылаться только наинтерфейсы.

Ипоследнее мынехотим чтобы данные внутри хранилищ были публичными воизбежание нежелательного изменения данных впроцессе рендеринга. Хранилища должны быть ответственны засвою целостность. Компонентыже, всвою очередь, должны быть ничем большим чем строго-типизированные-и-оптимизированные-html-шаблоны. Для достижения подобного разделения хранилища должны инкапсулировать данные внутри себя ипредоставлять методы для работы сэтими данными. Другими словами, хранилища должны быть классическими экземплярами классов.

Однако, как замечено ранее, хранилище может быть использовано большим количеством мелких компонентов. Былобы полезно определять срез данных, используемый конкретным компонентом. Таким образом мыдостигнем:

  • Лучшей читаемости кода, т.к. разработчик может предположить назначение компонента изнабора данных принимаемых этим компонентом.

  • Лучшей производительности т.к. можно избежать перерисовки компонента вслучае изменения неиспользуемых данных.

Таким образом,пятая цель [6.5] позволить хранилищам данных быть определенными как классические TypeScript классы. Обозначить механику определения среза данных, используемого конкретным компонентом.

Держа эти цели вголове, давайте перечислим необходимые логические блоки кода:

  • Компоненты (Components) строго типизированные HTML шаблоны + CSS стили

  • Модели вида (ViewModels) классы, инкапсулирующие состояние данных, используемое компонентом (ивсей иерархией компонентов под ним).

  • Фасады моделей вида (ViewModel facades) ограничивают видимость свойств модели вида теми, которые используются вконкретном компоненте.

Рисунок6: желаемая структура кода вслое представления

  • Не-пунктирные стрелки отражают рендеринг компонентов родительскими компонентами. Направление стрелки отражает направление передачи атрибутов.

  • Пунктирные линии отражают зависимости одних логических кусков кода отдругих (ссылки).

  • Блоки сзеленой рамкой границы модуля. Каждый модуль/подмодуль отражен выделенной под него папкой. Общие модули лежат впапке shared.

  • Голубые блоки модели вида. Модели вида определены поштуке намодуль/подмодуль.

Что упущено? Заметьте как модели вида нарисунке 6не имеют никаких параметров. Это всегда справедливо для модулей верхнего уровня (страниц) иглобальных моделей вида. Ноподмодули зачастую зависят отпараметров, определённых впроцессе работы сприложением.

Обозначимшестую цель [6.6] позволить атрибутам подмодуля быть использованными моделью вида этого подмодуля.

Рисунок7: атрибуты передаются нетолько вкорневой компонент модуля ноивего модель вида

7. Техническая реализация

Ябуду использовать популярные библиотеки стем чтобы сделать статью легче для восприятия. Поэтой причине тут небудет сравнения различных вариантов для реализации конкретной ответственности всегда будет использована наиболее популярная библиотека.

7.1. Компоненты

Для отрисовки строго-типизированной разметки можно использовать синтаксис tsx (типизированныйjsx). Рендеринг tsx поддерживается различными библиотеками, такими какReact,PreactandInferno. TsxНЕявляется чистым HTML, тем неменее онможет быть автоматически сконвертирован в/из HTML. Потому зависимость отtsx мне кажется допустимой т.к.вслучае миграции начистый HTML, значительная часть работы может быть выполнена автоматически.

Для уменьшения зависимости отконкретной библиотеки для рендера, мыограничим компоненты так, чтобы они были чистыми функциями, принимающими атрибуты ивозвращающими JSXузел. Этотподходбыл популярен иотлично себя зарекомендовал вранний период развития React.

Хинт: впоследние годы функциональные компоненты ввиде чистых функций вышли измоды всообществе React. Использованиеreact hooksнаделяет функциональные реакт компоненты сайд-еффектами ипоощряет смешивание рендера слогикой управления состоянием. Хуки являются специфическим API для React инедолжны использоваться при разработке вподходе, описанном вданной статье.

Другими словами,компонентылишены состояния. Представим ихчерез выражение UI=F(S) где

  • UI видимая разметка

  • F определение компонента

  • S текущее значение данных внутри модели вида (здесь идалее вьюмодели)

Пример компонента может выглядет так:

interfaceITodoItemAttributes{name:string;status:TodoStatus;toggleStatus:()=>void;removeTodo:()=>void;}constTodoItemDisconnected=(props:ITodoItemAttributes)=>{constclassName=props.status===TodoStatus.Completed?'completed':'';return(<liclassName={className}><divclassName="view"><inputclassName="toggle"type="checkbox"onChange={props.toggleStatus}checked={props.status===TodoStatus.Completed}/><label>{props.name}</label><buttonclassName="destroy"onClick={props.removeTodo}/></div></li>)}

Этот компонент отвечает заотрисовку одного todo элемента внутриTodoMVCприложения.

Единственная зависимость вэтом коде это зависимость отсинтаксиса JSX. Соответственно этот компонент может быть отрисован различными библиотеками. Стаким подходом замена библиотеки для отрисовки все еще неявляется бесплатной, ноявляется реалистичной.

Итого мыдостигли целей[6.1]и[6.2].

Хинт: яиспользую react дляTodoMVC приложенияприведенного вкачестве примера.

7.2. Модели Вида (вьюмодели)

Как было сказано ранее, мыхотим чтобы вьюмодели были написаны ввиде TypeScript классов стем что-бы:

  • Обеспечивать инкапсуляцию данных.

  • Предоставлять возможность взаимодействия сослоями domain/application посредством механизма dependency injection.

Однако, классы непредоставляют встроенных механик перерисовки компонентов использующих данные, инкапсулированные экземпляром класса.

Применим принципы реактивного интерфейса (reactive UI). Подробное описание этих принципов приведено вэтом документе. Данный подход был впервые представлен вWPF (C#) иназванModel-View-ViewModel. ВJavaScript сообществе, объекты предоставляющие доступ кобозреваемым (observable) данным чаще называются хранилищами (stores) следуя терминологииflux. Отмечу чтохранилищеэто очень абстрактный термин, онможет определять:

  • Глобальное хранилище данных для всего приложения.

  • Доменный объект, инкапсулирующий логику логику бизнеса инепривязанный кконкретному компоненту ноинеявляющийся глобальным.

  • Локальное хранилище данных для конкретного компонента или иерархии компонентов.

Таким образом любая вьюмодель является хранилищем, нонекаждое хранилище является вьюмоделью.

Определим ограничения креализации вьюмоделей:

  • Код, обеспечивающий реактивность, недолжен быть смешан скодом реализации конкретных бизнес функций.

  • Вьюмодель недолжна ссылаться накомпоненты инедолжна знать отом что существуют конкретные компоненты, ссылающиеся наэту вьюмодель.

Яиспользуюmobxдекораторы для того, чтобы сделать поля класса обозреваемыми. Пример вьюмодели:

classTodosVM{@mobx.observableprivatetodoList:ITodoItem[];//use"poormanDI",butintherealapplicationstodoDaowillbeinitializedbythecalltoIoCcontainerconstructor(props:{status:TodoStatus},privatereadonlytodoDao:ITodoDAO=newTodoDAO()){this.todoList=[];}publicinitialize(){this.todoList=this.todoDao.getList();}@mobx.actionpublicremoveTodo=(id:number)=>{consttargetItemIndex=this.todoList.findIndex(x=>x.id===id);this.todoList.splice(targetItemIndex,1);this.todoDao.delete(id);}publicgetTodoItems=(filter?:TodoStatus)=>{returnthis.todoList.filter(x=>!filter||x.status===filter)asReadonlyArray<Readonly<ITodoItem>>;}///...othermethodssuchascreationandstatustogglingoftodoitems...}

Обратите внимание что мыссылаемся наmobx напрямую, однако декораторы неприсутствуют втеле методов.

Япродемонстрирую как абстрагировать реактивность иубрать зависимость отmobx вследующей статье. Пока что посчитаем достаточным обращаться кдекораторам через пространство имен mobx. При таком ограничении заменить декораторы надекораторы издругой библиотеки можно будет спомощью автоматизированного скрипта.

Также обратите внимание что конструктор вьюмодели принимает первый аргумент типа{status:TodoStatus}. Это позволяет удовлетворитьцели [6.6]. Тип должен совпадать стипом определяющим атрибутыкорневого компонентамодуля. Ниже обобщенный интерфейс вьюмодели:

interfaceIVMConstructor<TProps,TVMextendsIViewModel<TProps>>{new(props:TProps,...dependencies:any[]):TVM;}interfaceIViewModel<IProps=Record<string,unknown>>{initialize?:()=>Promise<void>|void;cleanup?:()=>void;onPropsChanged?:(props:IProps)=>void;}

Все методы вьюмодели необязательны. Они могут быть определены для:

  • Выполнения кода при создании вьюмодели

  • Выполнения кода при удалении вьюмодели

  • Выполнения кода при изменении атрибутов (под-)модуля.

Вотличии откомпонентов, вьюмодель хранит свое состояние (является statefull). Она должна быть создана когда модуль появляется настранице иудалена как только модуль исчезает состраницы.

Как показано нарисунке7, точкой входа для модуля является его корневой компонент. Таким образом вьюмодель должна быть создана когда корневой компонент модуля добавлен вструктуру DOM(mounted) иудалена когда онудаляется состраницы(unmounted). Решить эту задачу можно спомощью техники компонентов высшего порядка (higher order components).

Определим тип функции:

typeTWithViewModel=<TAttributes,TViewModelProps,TViewModel>(moduleRootComponent:Component<TAttributes&TViewModelProps>,vmConstructor:IVMConstructor<TAttributes,TViewModel>,)=>Component<TAttributes>

Эта функция возвращает компонент высшего порядка над moduleRootComponent, который:

  • Должен обеспечить создание вьюмодели перед созданием имонтированием (mount) компонента.

  • Должен обеспечить зачистку(удаление) вьюмодели при демонтировании (unmount).

Просмотритереализациюиспользованную для приведенного вкачестве примераTodoMVCприложения. Эта реализация немного более сложна т.к. учитываем возможность использования автономного IoCконтейнера, что выходит зарамки данной статьи.

Пример использования данной функции:

constTodoMVCDisconnected=(props:{status:TodoStatus})=>{return<sectionclassName="todoapp"><Header/><TodoListstatus={props.status}/><FooterselectedStatus={props.status}/></section>};constTodoMVC=withVM(TodoMVCDisconnected,TodosVM);

Вразметку корневой страницы приложения (либо роутера, зависит оттого что как построено ваше приложение), результирующий компонент будет вставлен как<TodoMVCstatus={statusReceivedFromRouteParameters}/>. После чего, экземплярTodosVMстановится доступным для всех под-компонентов внутри компонентаTodoMVC.

Важно что реализация создания вьюмодели, атакже доступности вьюмодели для дочерних компонентов, скрыта внутри withVM.

  • TodoMVCDisconnected компонент независит отбиблиотеки рендера

  • TodoMVC компонент может быть прорендерен вкомпоненте, независящем отбиблиотеки рендера

  • TodosVM ссылается только надекораторы. Потому, как описано выше, еёотвязка отmobx реальна.

Хинт: вреализации изпримера, функцияwithVMзависит отreact context API. Выможете попробовать реализовать аналогичное поведение вобход контекст апи. Важно, что реализация должна быть синхронизирована среализацией доступа квьюмодели изфасадов вьюмоделей смотрите описание функцииconnectFnвследующем разделе.

7.3. Фасады вьюмоделей

Фасадопределяет класс, выставляющий для публичного доступа ограниченное количество функций/данных модуля. Изрисунка 6видно что мыхотим иметь пофасаду накаждый компонент. Однако создание дополнительного класса накаждый компонент будет излишне многословным.

Попробуем вместо классических фасадов использовать функции, принимающие вьюмодель (или несколько вьюмоделей) ивозвращающие набор функций/данных, необходимых конкретному компоненту. Назовем ихфункциями среза (slicing function). Что если такая функция будет получать атрибуты компонента, который она обслуживает, вкачестве последнего аргумента?

Рисунок8: передача атрибутов компонента фасаду вьюмодели (функции среза/slicing function)

Посмотрим насинтаксис (вслучае одной вьюмодели):

typeTViewModelFacade=<TViewModel,TOwnProps,TVMProps>(vm:TViewModel,ownProps?:TOwnProps)=>TVMProps

Выглядит очень похоже нафункцию connectизбиблиотеки Redux. Стой лишь разницей что вместо аргументовmapStateToProps,mapDispatchToActionsиmergePropsмы имеем один аргумент функцию среза, которая должна вернуть данные иметоды одним объектом. Ниже пример функции среза для компонентаTodoItemDisconnectedивьюмоделиTodosVM.

constsliceTodosVMProps=(vm:TodosVM,ownProps:{id:string,name:string,status:TodoStatus;})=>{return{toggleStatus:()=>vm.toggleStatus(ownProps.id),removeTodo:()=>vm.removeTodo(ownProps.id),}}

Заметка: Яназвал аргумент функции, содержащий атрибуты компонента OwnProps что-бы приблизить его ктерминологии применяемой вreact/redux.

Такая схожесть диктует наиболее естественный способ использования функций среза спомощью компонентов высшего порядка. Представим что вьюмодель уже хостится компонентом выше вдереве компонентов спомощью функцииwithVM. Давайте определим тип функции, которая принимает функцию среза икомпонент, авозвращает компонент высшего порядка, привязанный квьюмодели:

typeconnectFn=<TViewModel,TVMProps,TOwnProps={}>(ComponentToConnect:Component<TVMProps&TOwnProps>,mapVMToProps:TViewModelFacade<TViewModel,TOwnProps,TVMProps>,)=>Component<TOwnProps>constTodoItem=connectFn(TodoItemDisconnected,sliceTodosVMProps);

Отрисовка такового компонента всписке todo элементов:<TodoItemid={itemId}name={itemName}status={itemStatus}/>

Заметьте чтоconnectFnскрывает детали реализации реактивности:

  • Она берёт компонентTodoItemDisconnectedифункцию срезаsliceTodosVMProps обе незнающие ничего ореактивности иобиблиотеке для рендеринга JSX.

  • Она возвращает компонент, который будет перерисован реактивно как только данные, инкапсулированные вьюмоделью, изменяться.

Смотрите нареализациюфункции connectFnдля TodoMVCприложения, сделанного вкачестве примера.

8. Заключение

Итого весь код, относящийся кконкретным бизнес задачам приложения, независим отфреймворков. TypeScript объекты, функции, TSX это все кчему мыпривязаны.

Надеюсь что прочтение этой статьи продемонстрировало пользу проработки архитектуры SPA приложения вперёд старта разработки. Буду счастлив если майндсет хотябы одного разработчика настарте разработки SPA изменится сберем свежий фреймворк ивсе должно быть хорошо наподумаем что конкретно нужно сделать ивыберем подходящие инструменты.

Всеже, можетли слой представления быть полностью независим отфреймворков вреальном приложении?

Для того что-бы убрать ссылки наmobx, react иmobx-react изслоя представления, нужно сделать немного больше:

  • Абстрагироваться отmobx декораторов

  • Абстрагировать все фреймворко-зависимые библиотеки, используемые слоем представления. КпримеруTodoMVCзависит отбиблиотек react-router иreact-router-dom.

  • Абстрагироваться отсинтетических событий, специфичных для конкретной библиотеки, отрисовывающей JSX.

Первые два пункта могут быть легко реализованы, что ясобираюсь продемонстрировать вследующей статье.
Ноабстракция синтетических событий фактически означает что нам нужно будет написать еще один полноценный фреймворк, который при этом будет лишен поддержки комьюнити.

Япрагматично продолжаю ссылаться насинтетические события реакта всвоих проектах. Таким образом замена React надругую библиотеку рендера потребует определенной работы, пусть эта работа ибудет локализована внутри компонентов инезатронет вьюмодели ифункции среза.

P.S. Сравнение рассмотренной структуры иеереализации спопулярными фреймворками для разработки SPA:

  • Всравнении сосвязкойReact/Redux: вьюмодели заменяютreducers,action creatorsиmiddlewares. Вьюмодели содержат состояние (являются stateful). Нет time-travel. Множество хранилищ. Отсутствие просадки производительности вызванной наличием большого числа использований функции connect скакой тологикой внутри. Redux-dirven приложения становятся все медленнее имедленнее стечением времени иззадобавления новых connected компонентов вприложение. При этом несуществует какого токонкретного ботлнека, устранением которого можно былобы исправить ситуацию.

  • Всравнении сvue: строго типизированные представления благодаря TSX. Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками. Vue.js заставляет определять состояниевнутри определенной структурыимеющей свойства data,methods, ит.д. Отсутствие vue-специфических директив исинтаксиса привязки кмодели.

  • Всравнении сangular: строго типизированные представления благодаря TSX. Отсутствие angular-специфических директив исинтаксиса привязки кмодели. Инкапсуляция данных внутри вьюмоделей вотсутствие двусторонней привязки данных (two-way data binding).Хинт: для определенных сценариев, таких как формы, двусторонняя привязка данных удобна иполезна.

  • Всравнении счистым react где управление состоянием выполняется спомощью хуков (hooks, такие какuseState/useContext):Лучшее разделение ответственностей. Вьюмодели могут восприниматься втерминологии реакта как контейнер компоненты, которые лишены возможность рендерить что-либо иявляются ответственными исключительно заработу сданными. Нет необходимости:

    • следить запоследовательностью вызова хуков.

    • отслеживать зависимости хуков useEffect внутри deps массива.

    • проверять смонтированли все еще компонент после каждого асинхронного действия.

    • следить что замыкания изпредыдущих рендеров неиспользуются внутри обработчика хука эффекта.

    Как любая технология, хуки (ивчастности useEffect) требует разработчика следовать некоторым рекомендациям. Эти рекомендации неявляются частью интерфейсов, ноприняты как подход, модель мышления (mental model) или стандартные практики (best practices). Прекраснаястатья про использование хуковотчлена команды разработки react. Прочитайте ееиответьте себе надва вопроса:

    • Что выполучаете используя хуки?

    • Как много правил, неконтролируемых компилятором/линтером исложно отслеживаемых наручном код ревью, нужно соблюдать чтобы использование хуков оставалось предсказуемым?Если второй список получился больше первого это хороший сигнал что относится кхукам стоит сбольшой осторожностью.Хинт: примерфрустрацииотхуков

  • Всравнении сreact-mobx интеграцией. Структура кода неопределяется пакетом react-mobx инепредлагается документацией кнему. Разработчик должен придумать подход кструктурированию кода сам. Рассмотренную встатье структуру можно считать таким подходом.

  • Всравнении сmobx-state-tree: Вьюмодели являются обычными классами инетребуют использования функций сторонних библиотек, равно как необязаны удовлетворять интерфейсу, определенному сторонними фреймворками.Определение типавнутри mobx-state-tree опирается наспецифические функции этого пакета. Использование mobx-state-tree всвязке сTypeScript провоцирует дублирование информации поля типа объявляются как отдельный TypeScript интерфейс нопри этом обязаны быть перечислены вобъекте, используемом для определения типа.

Оригинал статьи наанглийском языке вблоге автора (меня же)

Подробнее..

Чего мне не хватало в функциональных компонентах React.js

12.04.2021 18:13:34 | Автор: admin

За последние годы о React hooks не писал разве что ленивый. Решился и я.

Помню первое впечатление - WOW-эффект. Можно не писать классы. Не нужно описывать тип состояния, инициализировать состояния в конструкторе, теснить всё состояние в одном объекте, помнить о том, как setState сливает новое состояние со старым. Не придется больше насиловать методы componentDidMount и componentWillUnmount запутанной логикой инициализации и освобождения ресурсов.

Вот простой компонент: управляемое текстовое поле и счетчик, который увеличивается на 1 по таймеру и уменьшается на 10 по нажатию кнопки;

import * as React from "react";interface IState {    numValue: number;    strValue: string;}export class SomeComponent extends React.PureComponent<{}, IState> {        private intervalHandle?: number;    constructor() {        super({});        this.state = { numValue: 0, strValue: "" };    }    render() {        const { numValue, strValue } = this.state;        return <div>            <span>{numValue}</span>            <input type="text" onChange={this.onTextChanged} value={strValue} />            <button onClick={this.onBtnClick}>-10</button>        </div>;    }    private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({ strValue: e.target.value });    private onBtnClick = () => this.setState(({ numValue }) => ({ numValue: numValue - 10 }));    componentDidMount() {        this.intervalHandle = setInterval(            () => this.setState(({ numValue }) => ({ numValue: numValue + 1 })),            1000        );    }    componentWillUnmount() {        clearInterval(this.intervalHandle);    }}

превращается в ещё более простой:

import * as React from "react";export function SomeComponent() {    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    React.useEffect(() => {        const intervalHandle = setInterval(() => setNumValue(v => v - 10), 1000);        return () => clearInterval(intervalHandle);    }, []);    const onBtnClick = () => setNumValue(v => v - 10);    const onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => setStrValue(e.target.value);    return <div>        <span>{numValue}</span>        <input type="text" onChange={onTextChanged} value={strValue} />        <button onClick={onBtnClick}>-10</button>    </div>;}

Функциональный компонент не только в два раза короче, он понятнее: функция умещается в один экран, всё перед глазами, конструкции лаконичны и ясны. Красота.

Но в реальном мире далеко не все компоненты получаются такими простыми. Давайте добавим нашему компоненту возможность сигнализировать родителю об изменении числа и строки, а элементы input и button заменим компонентами Input и Button, которые потребуют обернуть обработчики событий хуком useCallback.

interface IProps {    numChanged?: (sum: number) => void;    stringChanged?: (concatRezult: string) => void;}export function SomeComponent(props: IProps) {    const { numChanged, stringChanged } = props;    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    const setNumValueAndCall = React.useCallback((diff: number) => {        const newValue = numValue + diff;        setNumValue(newValue);        if (numChanged) {            numChanged(newValue);        }    }, [numValue, numChanged]);    React.useEffect(() => {        const intervalHandle = setInterval(() => setNumValueAndCall(1), 1000);        return () => clearInterval(intervalHandle);    }, [setNumValueAndCall]);    const onBtnClick = React.useCallback(        () => setNumValueAndCall(- 10),        [setNumValueAndCall]);    const onTextChanged = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {        setStrValue(e.target.value);        if (stringChanged) {            stringChanged(e.target.value);        }    }, [stringChanged]);    return <div>        <span>{numValue}</span>        <Input type="text" onChange={onTextChanged} value={strValue} />        <Button onClick={onBtnClick}>-10</Button>    </div>;}

Некрасиво: useCallback уродует код, приходится следить за списком зависимостей. Во избежание дублирования я вынес общий код из обработчика onBtnClick и useEffect в функцию setNumValueAndCall, которую также обернул useCallback, и далее опирался на её (setNumValueAndCall) экземпляр как на зависимость. Возможно, зависимость функции от функции - не лучшее решение, но поставить в зависимость onBtnClick и useEffect список зависимостей setNumValueAndCall тоже наглядностью не выделяется.

Вдобавок к эстетическим проблемам в новой версии таймер устанавливается заново при каждом нажатии кнопки. Возможно это и не проблема, но вряд ли мы этого хотели.

А классовый компонент переносит тоже расширение функциональности без осложнений.

export class SomeComponent extends React.PureComponent<IProps, IState> {    private intervalHandle?: number;    constructor() {        super({});        this.state = { numValue: 0, strValue: "" };    }    render() {        const { numValue, strValue } = this.state;        return <div>            <span>{numValue}</span>            <Input type="text" onChange={this.onTextChanged} value={strValue} />            <Button onClick={this.onBtnClick}>-10</Button>        </div>;    }    private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => {        this.setState({ strValue: e.target.value });        const { stringChanged } = this.props;        if (stringChanged) {            stringChanged(e.target.value);        }    }    private onBtnClick = () => this.setNumValueAndCall(- 10);    private setNumValueAndCall(diff: number) {        const newValue = this.state.numValue + diff;        this.setState({ numValue: newValue });        const { numChanged } = this.props;        if (numChanged) {            numChanged(newValue);        }    }    componentDidMount() {        this.intervalHandle = setInterval(            () => this.setNumValueAndCall(1),            1000        );    }    componentWillUnmount() {        clearInterval(this.intervalHandle);    }}

Что же делать? В сложных случаях возвращаться к компонентам-классам? Ну уж нет, нам слишком нравятся возможности, привнесенные хуками.

Предлагаю выносить загромождающие код обработчики в объект класса вместе с зависимостями. Разве так не лучше?

export function SomeComponent(props: IProps) {    const [numValue, setNumValue] = React.useState(0);    const [strValue, setStrValue] = React.useState("");    const { onTextChanged, onBtnClick, intervalEffect } =           useMembers(Members, { props, numValue, setNumValue, setStrValue });    React.useEffect(intervalEffect, []);    return <div>        <span>{numValue}</span>        <Input type="text" onChange={onTextChanged} value={strValue} />        <Button onClick={onBtnClick}>-10</Button>    </div>;}type SetState<T> = React.Dispatch<React.SetStateAction<T>>;interface IDeps {    props: IProps;    numValue: number;    setNumValue: SetState<number>;    setStrValue: SetState<string>;}class Members extends MembersBase<IDeps> {    intervalEffect = () => {        const intervalHandle = setInterval(() => this.setNumValueAndCall(1), 1000);        return () => clearInterval(intervalHandle);    };    onBtnClick = () => this.setNumValueAndCall(- 10);    onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => {        const { props: { stringChanged }, setStrValue } = this.deps;        setStrValue(e.target.value);        if (stringChanged) {            stringChanged(e.target.value);        }    };    private setNumValueAndCall(diff: number) {        const { props: { numChanged }, numValue, setNumValue } = this.deps;        const newValue = numValue + diff;        setNumValue(newValue);        if (numChanged) {            numChanged(newValue);        }    };}

Код компонента снова прост и изящен. Обработчики событий вместе с зависимостями мирно ютятся в классе.

Хук useMembers и базовый класс тривиальны:

export class MembersBase<T> {    protected deps: T;    setDeps(d: T) {        this.deps = d;    }}export function useMembers<D, T extends MembersBase<D>>(ctor: (new () => T), deps:  (T extends MembersBase<infer D> ? D : never)): T {    const ref = useRef<T>();    if (!ref.current) {        ref.current = new ctor();    }    const rv = ref.current;    rv.setDeps(deps);    return rv;}

Код на Github

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru