Recently I took over a SPA(Single Page Application) web project. Apps built by this project will be delivered to different enterprise users. All users’ features are mostly similar while each user has his own customized feature. In other words, the majority of codes among apps are the same while the rest exist differences. But the engineering of this project before refactoring was totally a disaster. It largely increased the development costs and slowed down the delivery efficiency. So the purpose of refactoring this project is to enhance code scalability and maintainability.
Refactoring is a disciplined technique for restructuring an existing body of code, altering its internal structure without changing its external behavior.
In this article, I’m not gonna talk about specific code refactoring details but some critical issues of this project.
Whatever you gonna do in your refactoring process, make sure it won’t change app behavior. If you are refactoring a function, use unit tests to avoid bugs; If you are refactoring a feature or even a full source code, use e2e tests to prevent bugs. Before I started to refactor this project, it took me a while to collect e2e test cases for all pages. The following actions will be performed under these e2e test case constraints.
Let’s take a look at the partial code directories of this project. This project is made by react, webpack, and typescript. The /src was divided into 3 major parts:
/root
|-- package.json
|-- /config # webpack config
|-- /src
|-- /commons # common modules
|-- /components # common react components
|-- /app1
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentD.tsx
|-- /app2
|-- ComponentA.tsx
|-- ComponentE.tsx
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentC.tsx
|-- /utils # common util tools
|-- request.ts # basic request function
|-- /apps # apps business logic
|-- /components # apps pages
|-- /app1
|-- PageA.tsx
|-- PageB.tsx
|-- PageD.tsx
|-- /app2
|-- PageA.tsx
|-- PageE.tsx
|-- PageA.tsx
|-- PageB.tsx
|-- PageC.tsx
|-- /utils # common util tools
|-- request.ts # remote server HTTP APIs
|-- index_app1.tsx # app1 entry file
|-- index_app2.tsx # app2 entry file
The disadvantages of this project organization are that:
Here I’ll give a new code organization for this project. Common components, pages, and utils were put in the same layer commons while apps specific code files stay in their own directory context.
/root
|-- package.json
|-- /config # webpack config
|-- /src
|-- /commons # common modules
|-- /components # common react components
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentC.tsx
|-- /pages
|-- PageA.tsx
|-- PageB.tsx
|-- PageC.tsx
|-- /utils # common util tools
|-- request.ts # basic request function
|-- service.ts # remote server HTTP APIs
|-- /apps # apps business logic
|-- /app1
|-- /components # app1 components
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentD.tsx
|-- /pages # app1 pages
|-- PageA.tsx
|-- PageB.tsx
|-- PageD.tsx
|-- index.tsx # app1 entry file
|-- /app2
|-- /components # app2 components
|-- ComponentA.tsx
|-- ComponentE.tsx
|-- /pages # app2 pages
|-- PageA.tsx
|-- PageE.tsx
|-- index.tsx # app2 entry file
As you can see, components such as ComponentA、ComponentB、PageA abstraction degree were too low. There were the commons version, app1 version, and app2 version in these components. When I dug deeper into the code, I found that these components’ internal logic was very similar and the differences among apps could be covered through elements props. So after merging different component versions into one single file, the code organization became
/root
|-- package.json
|-- /config # webpack config
|-- /src
|-- /commons # common modules
|-- /components # common react components
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentC.tsx
|-- /pages
|-- PageA.tsx
|-- PageB.tsx
|-- PageC.tsx
|-- /utils # common util tools
|-- request.ts # basic request function
|-- service.ts # remote server HTTP APIs
|-- /apps # apps business logic
|-- /app1
|-- /components # app1 components
|-- ComponentD.tsx
|-- /pages # app1 pages
|-- PageD.tsx
|-- index.tsx # app1 entry file
|-- /app2
|-- /components # app2 components
|-- ComponentE.tsx
|-- /pages # app2 pages
|-- PageE.tsx
|-- index.tsx # app2 entry file
Different apps have different iteration cycles due to user needs. The development and delivery of each app should be independent, which means app2 code should not be affected when developing app1 features, especially when commons code is involved. In other words, app1 and app2 should depend on a different version of commons. A simple strategy is that abstract commons into an npm package and references it in each app. Thus, each app directory should become an independent npm package too. To ensure development efficiency, I choose pnpm workspaces to manage commons, app1, app2. Finally, the project code organization becomes
/root
|-- package.json
|-- /config # webpack config
|-- /src
|-- /commons # common modules
|-- /components # common react components
|-- ComponentA.tsx
|-- ComponentB.tsx
|-- ComponentC.tsx
|-- /pages
|-- PageA.tsx
|-- PageB.tsx
|-- PageC.tsx
|-- /utils # common util tools
|-- request.ts # basic request function
|-- service.ts # remote server HTTP APIs
|-- package.json
|-- /apps # apps business logic
|-- /app1
|-- /components # app1 components
|-- ComponentD.tsx
|-- /pages # app1 pages
|-- PageD.tsx
|-- index.tsx # app1 entry file
|-- package.json
|-- /app2
|-- /components # app2 components
|-- ComponentE.tsx
|-- /pages # app2 pages
|-- PageE.tsx
|-- index.tsx # app2 entry fil
|-- package.json
This article describes how I refactored a multi-version code coexistence project: