Todolist di React Hooks + TypeScript: dari build hingga pengujian

Dimulai dengan versi 16.9, fungsionalitas baru tersedia di perpustakaan React JS - hooks . Mereka memungkinkan untuk menggunakan state dan fungsi React lainnya, membebaskan Anda dari keharusan untuk menulis kelas. Menggunakan komponen fungsional dalam hubungannya dengan hook memungkinkan Anda untuk mengembangkan aplikasi klien yang lengkap.



Mari pertimbangkan untuk membuat versi Todolist dari aplikasi React Hooks menggunakan TypeScript .



Majelis



Struktur proyek adalah sebagai berikut:



├── src

| ├── komponen

| ├── index.html

| ├── index.tsx

├── package.json

├── tsconfig.json

├── webpack.config.json



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



File 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».







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



Perkembangan dari



Di file index.html, kami menulis wadah tempat aplikasi akan dirender:



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


Di direktori komponen, buat komponen kosong pertama kita, App.tsx.

File index.tsx:



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

import App from "./components/App";

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


Aplikasi Todolist akan memiliki fungsi berikut:



  • tambahkan tugas
  • hapus tugas
  • ubah status tugas (selesai / belum selesai)


Ini akan terlihat seperti ini: bidang teks untuk input + tombol Tambahkan tugas, dan di bawah ini adalah daftar tugas yang ditambahkan. Anda dapat menghapus tugas dan mengubah statusnya.







Untuk tujuan ini, Anda dapat membagi aplikasi menjadi dua komponen - membuat tugas baru dan daftar semua tugas. Oleh karena itu, App.tsx pada tahap awal akan terlihat seperti ini:



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

const App = () => {

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

export default App;


Di direktori saat ini, buat dan ekspor komponen NewTask dan TasksList kosong. Karena kita perlu memastikan hubungan di antara mereka, kita perlu menentukan bagaimana ini akan terjadi. Ada dua pendekatan untuk komunikasi antar komponen di React:



  1. Menyimpan status aplikasi saat ini dan semua metodenya di komponen induk (dalam kasus kita, di App.tsx) dan meneruskannya ke komponen anak melalui props (cara klasik);
  2. Memisahkan metode manajemen negara bagian dan negara bagian. Dalam kasus ini, aplikasi perlu dibungkus dengan komponen khusus - penyedia, dan metode serta properti yang diperlukan untuk komponen turunan harus diteruskan ke sana (menggunakan hook useContext).


Kami akan menggunakan metode kedua dan dalam contoh ini kami akan sepenuhnya membuang alat peraga.



TypeScript saat meneruskan props
* , TypeScript :



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


React.FC, , ( ) :



interface MyProps {
    taskName: String;
}




useContext



Jadi, untuk mentransfer status, kita akan menggunakan hook useContext. Ini memungkinkan Anda untuk mendapatkan dan mengubah data di salah satu komponen yang dibungkus penyedia.



Contoh 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



Anda juga akan membutuhkan useReducer untuk pekerjaan yang lebih nyaman dengan penyimpanan negara.



Selengkapnya tentang 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



Pengganti yang menarik untuk pustaka Redux adalah penggunaan konteks dalam hubungannya dengan useReducer. Dalam kasus ini, hasil hook useReducer - status yang dikembalikan dan fungsi untuk memperbaruinya - akan diteruskan ke konteks. Mari tambahkan pengait ini ke aplikasi:



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;


Hasilnya, kami berhasil membuat status independen dari komponen root, yang dapat diterima dan diubah dalam komponen dalam penyedia.



Ketikan. Menambahkan tipe ke aplikasi



Di file stateType, kami menulis tipe TypeScript untuk aplikasi:



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


Menggunakan konteks



Sekarang status sudah siap dan dapat digunakan dalam komponen. Mari kita mulai dengan 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;


Aplikasinya sudah siap! Itu tetap untuk mengujinya.



Menguji



Untuk pengujian, Jest + Enzyme akan digunakan serta @ testing-library / react .

Anda perlu menginstal dependensi 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",


Tambahkan pengaturan untuk bercanda ke package.json:



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


dan di blok "scripts", tambahkan skrip untuk menjalankan pengujian:



"test": "jest"


Buat direktori __tests__ baru di direktori src dan di dalamnya file setup.ts dengan konten berikut:



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

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


Mari buat file todoReducer.test.ts di mana kita akan menguji reducer:



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


Untuk menguji peredam, cukup dengan meneruskan status dan tindakan saat ini, dan kemudian menangkap hasil dari eksekusinya.



Menguji komponen App.tsx, berbeda dengan reducer, membutuhkan penggunaan metode tambahan dari pustaka yang berbeda. File uji 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('');
    });
})


Di komponen TasksList, periksa apakah status yang diteruskan ditampilkan dengan benar. File 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);
    });

})


Pemeriksaan serupa pada bidang newTask dapat dilakukan untuk komponen NewTask dengan memeriksa nilai elemen input.



Proyek ini dapat diunduh dari repositori GitHub .



Sekian terima kasih atas perhatiannya.



Sumber daya



Bereaksi JS. Hooks

Bekerja dengan React Hooks dan TypeScript



All Articles