Frontend Development 23 min read

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.

Bilibili Tech
Bilibili Tech
Bilibili Tech
Applying Clean Architecture to Front-End Migration: Refactoring a Large-Scale Review Platform from Vue2 to Vue3

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

Tooling: 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 None

Deployment: 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.

frontendmigrationautomationtestingclean architecturerefactoringVue3
Bilibili Tech
Written by

Bilibili Tech

Provides introductions and tutorials on Bilibili-related technologies.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.