Todolist on React Hooks + TypeScript: vom Build bis zum Testen

Ab Version 16.9 sind neue Funktionen in der React JS-Bibliothek verfügbar - Hooks . Sie ermöglichen die Verwendung von Status- und anderen Reaktionsfunktionen, sodass Sie keine Klasse schreiben müssen. Durch die Verwendung von Funktionskomponenten in Verbindung mit Hooks können Sie eine vollständige Clientanwendung entwickeln.



Betrachten wir die Erstellung einer Todolist-Version einer React Hooks-App mit TypeScript .



Versammlung



Die Projektstruktur ist wie folgt:



├── src

| ├── Komponenten

| ├── index.html

| ├── index.tsx

├── package.json

├── tsconfig.json

├── webpack.config.json



Package.json-Datei:
{
  "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-Datei:
{
  "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».







Datei 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. .



Entwicklung von



In die Datei index.html schreiben wir den Container, in dem die Anwendung gerendert wird:



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


Erstellen Sie im Komponentenverzeichnis unsere erste leere Komponente, App.tsx.

Index.tsx-Datei:



import * as React from 'react';
import * as ReactDOM from 'react-dom';

import App from "./components/App";

ReactDOM.render (
    <App/>,
    document.getElementById("root")
);


Die Todolist-App verfügt über folgende Funktionen:



  • Aufgabe hinzufügen
  • Aufgabe löschen
  • Aufgabenstatus ändern (abgeschlossen / nicht abgeschlossen)


Es sieht folgendermaßen aus: Ein Textfeld für die Eingabe + die Schaltfläche Aufgabe hinzufügen und unten eine Liste der hinzugefügten Aufgaben. Sie können Aufgaben löschen und ihren Status ändern.







Zu diesem Zweck können Sie die Anwendung in nur zwei Komponenten aufteilen: Erstellen einer neuen Aufgabe und einer Liste aller Aufgaben. Daher sieht App.tsx im Anfangsstadium folgendermaßen aus:



import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";

const App = () => {

    return (
        <>
            <NewTask />
            <TasksList />
        </>
    )
}

export default App;


Erstellen und exportieren Sie leere NewTask- und TasksList-Komponenten im aktuellen Verzeichnis. Da wir die Beziehung zwischen ihnen sicherstellen müssen, müssen wir bestimmen, wie dies geschehen wird. Es gibt zwei Ansätze für die Kommunikation zwischen Komponenten in React:



  1. Speichern des aktuellen Status der Anwendung und aller ihrer Methoden in der übergeordneten Komponente (in unserem Fall in App.tsx) und Übergeben an untergeordnete Komponenten über Requisiten (auf klassische Weise);
  2. Halten Sie die Methoden für Status und Statusverwaltung getrennt. In diesem Fall muss die Anwendung mit einer speziellen Komponente - einem Anbieter - umschlossen werden, und die für die untergeordneten Komponenten erforderlichen Methoden und Eigenschaften müssen an sie übergeben werden (mithilfe des useContext-Hooks).


Wir werden die zweite Methode verwenden und in diesem Beispiel Requisiten vollständig verwerfen.



TypeScript beim Übergeben von Requisiten
* , TypeScript :



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


React.FC, , ( ) :



interface MyProps {
    taskName: String;
}




useContext



Um den Status zu übertragen, verwenden wir den useContext-Hook. Sie können Daten in einer der vom Anbieter eingeschlossenen Komponenten abrufen und ändern.



UseContext-Beispiel
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



Sie benötigen außerdem useReducer, um bequemer mit dem State Store arbeiten zu können.



Weitere Informationen zu 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



Ein interessanter Ersatz für die Redux-Bibliothek kann die Verwendung von Kontext in Verbindung mit useReducer sein. In diesem Fall wird das Ergebnis des useReducer-Hooks - der zurückgegebene Status und die Funktion zum Aktualisieren - an den Kontext übergeben. Fügen wir der Anwendung diese Hooks hinzu:



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,     - State
export 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;


Infolgedessen ist es uns gelungen, einen Status unabhängig von der Stammkomponente zu machen, der in Komponenten innerhalb des Anbieters empfangen und geändert werden kann.



Typoskript. Hinzufügen von Typen zur Anwendung



In die stateType-Datei schreiben wir die TypeScript-Typen für die Anwendung:



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      Task
type ActionObjectPayload = {
    type: ActionType.TOGGLE | ActionType.REMOVE,
    payload: Task
}

//        
export type Action = ActionStringPayload | ActionObjectPayload;

//       -,     Action.  Dispatch    react
export type ContextState = {
    state: State;
    changeState: Dispatch<Action>
}


Kontext verwenden



Jetzt ist state bereit und kann in Komponenten verwendet werden. Beginnen wir mit 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;


Die App ist fertig! Es bleibt zu testen.



Testen



Zum Testen wird Jest + Enzyme sowie @ testing-library / react verwendet .

Sie müssen Entwicklungsabhängigkeiten installieren:



"@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",


Fügen Sie package.json Einstellungen für jest hinzu:



 "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"
    ]
  },


und fügen Sie im Block "Skripte" ein Skript zum Ausführen von Tests hinzu:



"test": "jest"


Erstellen Sie ein neues __tests__ -Verzeichnis im src-Verzeichnis und darin eine setup.ts-Datei mit folgendem Inhalt:



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

configure({ adapter: new adapter() });


Erstellen wir eine Datei todoReducer.test.ts, in der wir den Reduzierer testen:



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: []});
    });
})


Um den Reduzierer zu testen, reicht es aus, ihm den aktuellen Status und die aktuelle Aktion zu übergeben und dann das Ergebnis seiner Ausführung abzufangen.



Das Testen der App.tsx-Komponente erfordert im Gegensatz zum Reduzierer die Verwendung zusätzlicher Methoden aus verschiedenen Bibliotheken. Testdatei 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('');
    });
})


Überprüfen Sie in der TasksList-Komponente, ob der übergebene Status korrekt angezeigt wird. TasksList.test.tsx-Datei:



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

})


Eine ähnliche Überprüfung des newTask-Felds kann für die NewTask-Komponente durchgeführt werden, indem der Wert des Eingabeelements überprüft wird.



Das Projekt kann aus dem GitHub-Repository heruntergeladen werden .



Das ist alles, danke für Ihre Aufmerksamkeit.



Ressourcen



Reagiere JS. Hooks

Arbeiten mit React Hooks und TypeScript



All Articles