Menskalakan Aplikasi Redux Anda dengan bebek

Bagaimana skala aplikasi front-end Anda? Bagaimana Anda memastikan bahwa kode yang Anda tulis dapat dipertahankan 6 bulan dari sekarang?

Redux menggemparkan dunia pengembangan front-end pada tahun 2015 dan memantapkan dirinya sebagai standar - bahkan di luar cakupan React.

Di perusahaan tempat saya bekerja, baru-baru ini kami menyelesaikan pemfaktoran ulang basis kode React yang cukup besar, menambahkan redux alih-alih refluks.

Kami melakukannya karena bergerak maju tidak mungkin tanpa aplikasi yang terstruktur dengan baik dan seperangkat aturan yang baik.

Basis kode sudah lebih dari dua tahun, dan refluks sudah ada sejak awal. Kami harus mengubah kode yang tidak tersentuh selama lebih dari setahun dan cukup kusut dengan komponen React.

Berdasarkan pekerjaan yang kami lakukan pada proyek tersebut, saya mengumpulkan repo ini, menjelaskan pendekatan kami dalam mengatur kode redux kami.

Saat Anda mempelajari tentang redux dan peran tindakan dan reduksi, Anda mulai dengan contoh yang sangat sederhana. Sebagian besar tutorial yang tersedia saat ini tidak melanjutkan ke level berikutnya. Tetapi jika Anda membuat sesuatu dengan Redux yang lebih rumit daripada daftar yang harus dilakukan, Anda akan memerlukan cara yang lebih cerdas untuk menskalakan basis kode Anda dari waktu ke waktu.

Seseorang pernah berkata bahwa memberi nama adalah salah satu pekerjaan tersulit dalam ilmu komputer. Saya sangat setuju. Tetapi menyusun folder dan mengatur file adalah hal yang kedua.

Mari jelajahi bagaimana kita mendekati organisasi kode di masa lalu.

Fungsi vs Fitur

Ada dua pendekatan yang mapan untuk menyusun aplikasi: fungsi pertama dan fitur pertama .

Di kiri bawah Anda dapat melihat struktur folder dengan fungsi pertama. Di sebelah kanan Anda bisa melihat pendekatan yang mengutamakan fitur.

Fungsi-pertama berarti bahwa direktori tingkat atas Anda diberi nama sesuai dengan tujuan file di dalamnya. Jadi Anda memiliki: wadah , komponen , tindakan , reduksi , dll.

Ini sama sekali tidak berskala. Saat aplikasi Anda berkembang dan Anda menambahkan lebih banyak fitur, Anda menambahkan file ke dalam folder yang sama. Jadi Anda harus menggulir ke dalam satu folder untuk menemukan file Anda.

Masalahnya juga tentang menggabungkan folder bersama. Satu aliran melalui aplikasi Anda mungkin memerlukan file dari semua folder.

Satu keuntungan dari pendekatan ini adalah ia mengisolasi - dalam kasus kami - Bereaksi dari redux. Jadi jika Anda ingin mengubah pustaka manajemen negara, Anda tahu folder mana yang perlu Anda sentuh. Jika Anda mengubah perpustakaan tampilan, Anda dapat menjaga folder redux Anda tetap utuh.

Feature-first artinya direktori tingkat atas diberi nama sesuai dengan fitur utama aplikasi: product , cart , session .

Pendekatan ini berskala jauh lebih baik, karena setiap fitur baru hadir dengan folder baru. Tapi, Anda tidak memiliki pemisahan antara komponen React dan redux. Mengubah salah satunya dalam jangka panjang adalah pekerjaan yang sangat rumit.

Selain itu, Anda memiliki file yang bukan milik fitur apa pun. Anda akan mendapatkan folder umum atau bersama, karena Anda ingin menggunakan kembali kode di banyak fitur di aplikasi Anda.

Yang terbaik dari dua dunia

Meskipun tidak termasuk dalam cakupan artikel ini, saya ingin menyentuh satu ide ini: selalu pisahkan file Manajemen Status dari file UI.

Pikirkan tentang aplikasi Anda dalam jangka panjang. Bayangkan apa yang terjadi dengan basis kode saat Anda beralih dari React ke library lain. Atau pikirkan bagaimana basis kode Anda akan menggunakan ReactNative secara paralel dengan versi web.

Pendekatan kami dimulai dari kebutuhan untuk mengisolasi kode React ke dalam satu folder - disebut views - dan kode redux ke dalam folder terpisah - disebut redux.

Pembagian level pertama ini memberi kami fleksibilitas untuk mengatur dua bagian aplikasi yang berbeda sama sekali.

Di dalam folder views, kami lebih memilih pendekatan function-first dalam menyusun file. Ini terasa sangat alami dalam konteks React: halaman , tata letak , komponen, peningkat, dll.

Agar tidak tergila-gila dengan jumlah file dalam folder, kami mungkin memiliki pemisahan berbasis fitur di dalam masing-masing folder ini.

Lalu, di dalam folder redux…

Masukkan bebek ulang

Setiap fitur aplikasi harus memetakan ke tindakan dan reduksi yang terpisah, jadi masuk akal untuk menggunakan pendekatan yang mengutamakan fitur.

Pendekatan modular itik asli adalah penyederhanaan yang bagus untuk redux dan menawarkan cara terstruktur untuk menambahkan setiap fitur baru di aplikasi Anda.

Namun, kami ingin menjelajahi sedikit apa yang terjadi saat aplikasi berskala. Kami menyadari bahwa satu file untuk sebuah fitur menjadi terlalu berantakan dan sulit untuk dipertahankan dalam jangka panjang.

Beginilah re-bebek lahir. Solusinya adalah membagi setiap fitur menjadi folder bebek .

duck/ ├── actions.js ├── index.js ├── operations.js ├── reducers.js ├── selectors.js ├── tests.js ├── types.js ├── utils.js

Folder bebek HARUS:

  • berisi seluruh logika untuk menangani hanya SATU konsep di aplikasi Anda, misalnya: produk , keranjang , sesi , dll.
  • memiliki index.jsfile yang mengekspor sesuai dengan aturan bebek asli.
  • simpan kode dengan tujuan yang sama di file yang sama, seperti reduksi , penyeleksi , dan tindakan
  • berisi tes yang berhubungan dengan bebek.

Untuk contoh ini, kami belum menggunakan abstraksi yang dibangun di atas redux. Saat membuat perangkat lunak, penting untuk memulai dengan jumlah abstraksi yang paling sedikit. Dengan cara ini, Anda memastikan bahwa biaya abstraksi Anda tidak melebihi manfaatnya.

Jika Anda perlu meyakinkan diri sendiri bahwa abstraksi bisa berdampak buruk, tontonlah ceramah luar biasa oleh Cheng Lou ini.

Mari kita lihat apa yang masuk ke setiap file.

Jenis

File jenis berisi nama tindakan yang Anda kirimkan dalam aplikasi Anda. Sebagai praktik yang baik, Anda harus mencoba menentukan lingkup nama berdasarkan fiturnya. Ini membantu saat men-debug aplikasi yang lebih kompleks.

const QUACK = "app/duck/QUACK"; const SWIM = "app/duck/SWIM"; export default { QUACK, SWIM };

Tindakan

File ini berisi semua fungsi pembuat tindakan.

import types from "./types"; const quack = ( ) => ( { type: types.QUACK } ); const swim = ( distance ) => ( { type: types.SWIM, payload: { distance } } ); export default { swim, quack };

Notice how all the actions are represented by functions, even if they are not parametrized. A consistent approach is more than needed in a large codebase.

Operations

To represent chained operations you need a redux middleware to enhance the dispatch function. Some popular examples are: redux-thunk, redux-saga or redux-observable.

In our case, we use redux-thunk. We want to separate the thunks from the action creators, even with the cost of writing extra code. So we define an operation as a wrapper over actions.

If the operation only dispatches a single action — doesn’t actually use redux-thunk — we forward the action creator function. If the operation uses a thunk, it can dispatch many actions and chain them with promises.

import actions from "./actions"; // This is a link to an action defined in actions.js. const simpleQuack = actions.quack; // This is a thunk which dispatches multiple actions from actions.js const complexQuack = ( distance ) => ( dispatch ) => { dispatch( actions.quack( ) ).then( ( ) => { dispatch( actions.swim( distance ) ); dispatch( /* any action */ ); } ); } export default { simpleQuack, complexQuack };

Call them operations, thunks, sagas, epics, it’s your choice. Just find a naming convention and stick with it.

At the end, when we discuss the index, we’ll see that the operations are part of the public interface of the duck. Actions are encapsulated, operations are exposed.

Reducers

If a feature has more facets, you should definitely use multiple reducers to handle different parts of the state shape. Additionally, don’t be afraid to use combineReducers as much as needed. This gives you a lot of flexibility when working with a complex state shape.

import { combineReducers } from "redux"; import types from "./types"; /* State Shape { quacking: bool, distance: number } */ const quackReducer = ( state = false, action ) => { switch( action.type ) { case types.QUACK: return true; /* ... */ default: return state; } } const distanceReducer = ( state = 0, action ) => { switch( action.type ) { case types.SWIM: return state + action.payload.distance; /* ... */ default: return state; } } const reducer = combineReducers( { quacking: quackReducer, distance: distanceReducer } ); export default reducer;

In a large scale application, your state tree will be at least 3 level deep. Reducer functions should be as small as possible and handle only simple data constructs. The combineReducers utility function is all you need to build a flexible and maintainable state shape.

Check out the complete example project and look how combineReducers is used. Once in the reducers.js files and then in the store.js file, where we put together the entire state tree.

Selectors

Together with the operations, the selectors are part of the public interface of a duck. The split between operations and selectors resembles the CQRS pattern.

Selector functions take a slice of the application state and return some data based on that. They never introduce any changes to the application state.

function checkIfDuckIsInRange( duck ) { return duck.distance > 1000; } export default { checkIfDuckIsInRange };

Index

This file specifies what gets exported from the duck folder. It will:

  • export as default the reducer function of the duck.
  • export as named exports the selectors and the operations.
  • export the types if they are needed in other ducks.
import reducer from "./reducers"; export { default as duckSelectors } from "./selectors"; export { default as duckOperations } from "./operations"; export { default as duckTypes } from "./types"; export default reducer;

Tests

A benefit of using Redux and the ducks structure is that you can write your tests next to the code you are testing.

Testing your Redux code is fairly straight-forward:

import expect from "expect.js"; import reducer from "./reducers"; import actions from "./actions"; describe( "duck reducer", function( ) { describe( "quack", function( ) { const quack = actions.quack( ); const initialState = false; const result = reducer( initialState, quack ); it( "should quack", function( ) { expect( result ).to.be( true ) ; } ); } ); } );

Inside this file you can write tests for reducers, operations, selectors, etc.

I could write a whole different article about the benefits of testing your code, there are so many of them. Just do it!

So there it is

The nice part about re-ducks is that you get to use the same pattern for all your redux code.

The feature-based split for the redux code is much more flexible and scalable as your application codebase grows. And the function-based split for views works when you build small components that are shared across the application.

You can check out a full react-redux-example codebase here. Just keep in mind that the repo is still under active development.

How do you structure your redux apps? I’m looking forward to hearing some feedback on this approach I’ve presented.

If you found this article useful, click on the green heart below and I will know my efforts are not in vain.