Applying Clean Architecture to Front-End Migration: Refactoring a Large-Scale Review Platform from Vue2 to Vue3
The team refactored a high‑traffic Vue2 detail page to Vue3 using Clean Architecture, separating entities, use‑cases, adapters, and framework layers, while employing AST‑based tooling, comprehensive unit/E2E/visual tests, and a gray‑release strategy, resulting in reduced duplication, better test coverage, and a reusable migration pipeline.
Background: The review platform serves 50+ business lines, millions of page views daily, and is critical for content safety. After a progressive upgrade to support both Vue2 and Vue3 via micro‑frontend, the Vue3 codebase has become stable and the team wants to refactor and migrate remaining active code to a new repository.
Case selection: The most visited configurable detail page (≈20% of platform traffic) was chosen because its logic is complex, highly coupled with UI, and frequently changed.
Clean Architecture: Introduced by Robert C. Martin, it separates software into four concentric layers—Entity, Use‑case, Interface Adapter, and Framework & Driver—enforcing dependency direction inward. Benefits include testability without UI/database, reduced coupling, but also higher learning cost.
Refactoring plan:
Entity layer: define Todo, Task, Resource classes (code example).
Use‑case layer: pure functions handling data cleaning, submission, etc. (code example).
Adapter layer: store and UI code that transforms raw data into “clean” inputs for use‑case layer.
Framework layer: Vue3, Pinia, etc.
// entities/todo.js
export default class Todo {
todoId
businessId
todoConfig
...
constructor() {}
async getTodoConfig() {
// 获取配置
}
}
// entities/task.js
export default class Task {
dispatch_conf
listData
timeCount
...
constructor() {}
async getTaskDetail({ getTask, taskFormat, afterGetTask}) {
// 抽象封装核心任务流程
}
// 计时逻辑
startTimer() {}
clearTimers() {}
}
// entities/resource.js
export default class Resource {
detail
dataReady
constructor() {}
async getResourceDetail({ getResource, resourceFormat, afterGetResource }) {
// 抽象封装资源模式核心流程
}
} // usecase/use-single-submit
import { get } from 'lodash-es'
import { ANNOTATION_SINGLE_OPER_PASS_NAME } from '@/constants'
import { workbenchApi } from '@/api'
import { setLogData } from '@/utils/xx'
export function getSingleSubmitParams({ data, state.xxx }) {
//... 逻辑处理
return params
}
export function submitAuditSingle({ data, afterTaskSubmit }) {
const params = ...
workbenchApi.submit(params).then((res) => {
if(res.code = xxx){
afterTaskSubmit() // 调用钩子函数
}
})
} // store/todoConfigDetail
import { getTodoInfo } from '@/struct/TodoConfigDetailStruct/usecase/use-todo'
import { getTaskInfo, getTask, taskDispatchListFormat } from '@/struct/TodoConfigDetailStruct/usecase/use-task'
import { getSingleSubmitParams, submitAuditSingle } from '@/struct/TodoConfigDetailStruct/usecase/use-single-submit'
const todo = ref({})
async function init({ $route }) {
const query = $route.query
const todoId = +query.todo_id
...
set(todo, getTodoInfo({ todoId, ... }))
await get(todo).getTodoConfig()
...
}
function getTaskDetail() {
get(task).getTaskDetail({
getTask: async ({ noSeize, drillTaskIds }) => await getTask({ todo: get(todo), noSeize, drillTaskIds }),
taskFormat: async (data) => await taskDispatchListFormat({ data, schema: get(todo).schema }),
afterGetTask: (res) => { ... }
})
}
async function submit(data) {
if (single) {
const params = getSingleSubmitParams({ data, todo: get(todo) })
submitAuditSingle({ params, afterTaskSubmit: () => { ... } })
} else {
...
}
} // Audit.vue
const todoConfigDetailStore = useTodoConfigDetailStore()
const { todo, task, multipleSelection } = storeToRefs(todoConfigDetailStore)
const { getTaskDetail, submit } = todoConfigDetailStoreTooling: The team evaluated Gogocode vs vue2‑to‑composition‑api. Gogocode offers AST‑level manipulation and custom plugins, though its default plugin does not emit Vue3 setup syntax. The final solution combines Gogocode with additional scripts to convert data, props, lifecycle hooks, and “this” references to Vue3 composition API.
// Replace data
scriptAst.replace("data() {return {$$$};}", `const $data = reactive({$$$})`);
// Replace props
scriptAst.replace("props:{$$$}", "const props = defineProps({$$$})");
// Replace lifecycle
scriptAst.replace("created(){$$$}", "onBeforeMount(()=>{$$$})")
.replace("mounted(){$$$}", "onMounted(()=>{$$$})")
// ... other hooks // Collect keys
getDataKeys() {
const keys = new Set();
this.scriptAst.find('data() {$$$}').find('$_$:$_$', { deep: 1 }).each(node => {
if (node.match[0] && node.match[0][0].node.type === 'Identifier') {
keys.add(node.match[0][0].value);
}
});
return Array.from(keys);
} // Replace this
handlThis(code) {
code = code.replace(/this\.([_$0-9a-zA-Z]+)/g, (match, $1) => {
if (this.dataKeys.includes($1)) {
return `$data.${$1}`;
} else if (this.methodsKeys.includes($1)) {
return `methods.${$1}`;
} else if ($1 && $1[0] === '$') {
return `$vm.${$1}`;
} else if (this.computedKeys.includes($1)) {
return $1;
} else if (this.propsKeys.includes($1)) {
return `props.${$1}`;
}
return `$vm.${$1}`;
});
code = code.replace(/this\[(.+)\]/g, (match, $1) => {
return `methods[${$1}]`;
});
return code;
}Testing strategy: Unit tests (Jest + vue‑test‑utils) for the use‑case layer, end‑to‑end tests using Playwright with Python, and visual regression using SSIM, SIFT, and LPIPS. LPIPS gave the best correlation with human perception, so it was chosen for UI comparison after Vue2→Vue3 migration.
def compare_images(url1, url2):
loss_fn = lpips.LPIPS(net = 'alex')
img1 = cv2.imread(url1)
img2 = cv2.imread(url2)
if img1 is not None and img2 is not None and img1.size > 0 and img2.size > 0:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
cv2.imwrite(url2, img2)
combined_image = cv2.hconcat([img1, img2])
ex_img1 = lpips.im2tensor(lpips.load_image(url1))
ex_img2 = lpips.im2tensor(lpips.load_image(url2))
d = loss_fn.forward(ex_img1, ex_img2)
if d is not None:
cv2.putText(combined_image,'score: %.3f'%(1 - d.mean()), (20, 20), cv2.FONT_ITALIC, 0.4, (255, 0, 255))
return d, combined_image
else:
return NoneDeployment: Gradual gray‑release by configuring pages to route to the new Vue3 implementation, with unit tests integrated into CI, manual E2E checks during the gray phase, and monitoring via analytics and user feedback. No production incidents were reported.
Benefits: Reduced repository duplication, lower cognitive load, cleaner separation of concerns, improved test coverage, and a reusable automated migration toolchain for future projects.
Conclusion: Clean Architecture proved effective for large‑scale front‑end refactoring, though it introduces complexity and learning overhead. Ongoing work includes performance evaluation, continuous debt remediation, and extending the visual‑assisted UI testing framework.
Bilibili Tech
Provides introductions and tutorials on Bilibili-related technologies.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.