原生HTML项目重构实战:Vue与React双框架对比与踩坑记录
2026/9/9 21:01:06 网站建设 项目流程

接手过不少烂摊子项目,但最让我头疼的,往往不是那些用了什么冷门框架的代码,反而是看起来“最简单”的原生HTML项目。一个典型的老古董项目:一堆HTML文件,内联CSS,每个页面底部挂着一大段JavaScript,jQuery一把梭,全局变量满天飞。代码跑得动,但没人敢动。产品提个新需求,开发改一个点,另外三个功能莫名其妙跟着出问题。

这其实就是很多前端团队遇到的共同困境:原生HTML项目在业务变复杂、人员变动之后,维护成本会指数级上升。而重构这个事,大家都知道早晚要做,但怎么切入、选什么框架、怎么保证业务不中断,很多人心里没底。折腾过几次之后,我干脆把同一个旧项目用VueReact各重构了一遍。这篇文章,就是这次双框架实战的完整记录,从拆解思路到具体踩坑,给正打算动手重构的朋友一个参考。

1. 内容整体设计与思路拆解

1.1 原生HTML项目为什么会“病”

很多人觉得原生HTML项目乱,但乱在哪,得说清楚。我手里这个项目,规模不算大,20来个页面,核心业务是后台管理系统,附带一些数据展示页。但代码结构是典型的“原始社会”形态:公共函数塞在common.js里,互相调用靠全局变量,页面间传参靠URL带query,DOM操作靠jQuery选择器到处飞。

这种项目有几个致命伤:

  • 全局命名空间污染严重var a = {}这种代码到处都是,谁后加载谁覆盖,排查一个问题得全局搜索变量名。
  • 代码复用基本靠“复制粘贴”。同样是表格导出Excel的功能,六个页面里粘贴了六份几乎一样的代码,其中三份还被人魔改过,行为已经不一致了。
  • 变更影响无法评估。因为不存在模块边界,改一个工具函数,所有引用的地方都可能在射程内,测试范围无法收敛。
  • 协作效率极低。新来的开发看这种代码,上手成本很高。没有组件概念,没有状态管理,业务逻辑和DOM操作全混在一起。

这些问题的本质,是原生HTML的开发模式只适合“一次性”或“极简”的场景。当业务开始持续迭代,这种模式就开始反向拖累生产力。

1.2 为什么是Vue和React——双框架选型的决策依据

很多人问,既然重构,选一个框架不就行了,为什么两个都要写?我的回答是:选型这事,不能靠拍脑袋,得靠事实。Vue和React作为目前国内使用最广的两个框架,各有各的生态和拥趸。团队可能有的人熟Vue,有的人熟React;有的项目后续可能要上React Native做App,有的项目则可能要用Vite + Vue做轻量级后台。

与其争论“哪个好”,不如同一个项目两边都跑一遍。这样一来能得到几个很实际的产出:

  • 团队内部可以对照着看,哪个框架的学习成本和维护成本更适合自己。
  • 业务方可以直观看到重构后的效果,而不是听你空谈架构。
  • 对开发者自己也是一次深度对比,你会发现两个框架解决问题的思路差异,这对写代码的思维广度非常有帮助。

所以这个项目我定下的基调是:业务逻辑完全一致,但各自用“最框架化”的方式去实现。不搞“Vue写法写React”或“React写法写Vue”那种别扭操作,而是逼着自己用各自生态里最地道的解法去处理问题。

2. 重构前的准备工作:先摸清家底

2.1 给老项目做一次“体检”

重构最忌讳的就是上来就写代码。原生HTML项目文档又通常约等于零,所以我把第一周时间全花在“考古”上。具体做了几件事:

  • 梳理完整页面清单:把项目里所有HTML页面列出来,标记每个页面是“高频使用”“低频使用”还是“已废弃”。已废弃的页面直接砍掉,不为它们浪费时间。
  • 抓取所有接口请求:打开浏览器控制台,把Network面板里的XHR请求逐个过一遍。整理接口URL、请求方法、参数格式、响应结构,四张表格下来,项目的后端依赖就清晰了。这一步做细了,后面写API调用层完全不慌。
  • 盘点公共逻辑:把common.js和各个页面内联脚本里的公共函数、工具方法列个清单。哪些是真正全局使用的,哪些其实只服务某一个页面,心里要有数。真正全局的才放进公共模块,只服务单页的,重构时直接收编到对应组件里。

体检的产出是一份《重构范围说明书》,里面明确列出:保留哪些功能、删除哪些功能、接口依赖关系、公共模块清单。这份文档既是给领导看的,也是给自己划的施工红线。

2.2 双框架工程化环境搭建

环境搭建是第二个步骤。Vue和React的工程化脚手架现在都挺成熟,但版本选择有讲究。

Vue这边,我选用的是Vite+Vue 3+JavaScript(没上TypeScript,考虑到老业务团队的接受度,先用JS平滑过渡)。创建命令很简单:

npm create vite@latest admin-vue -- --template vue

装依赖,然后按需加vue-routerpinia

cd admin-vue npm install vue-router@4 pinia

React这边,同样用Vite做构建工具,模板是react

npm create vite@latest admin-react -- --template react

然后装react-router-dom,状态管理我选用了zustand。原因后面细说:

npm install react-router-dom zustand

两个项目的目录规划完全是同一套思路:

  • src/api目录:放所有接口请求封装。
  • src/components目录:放公共组件。
  • src/routersrc/routes目录:路由配置。
  • src/store目录:状态管理。
  • src/utils目录:公共工具函数。
  • src/viewssrc/pages目录:页面级组件。

同一个项目,两套代码,目录结构完全对齐。这样对比起来特别方便,照着一个项目的思路去对照另一个项目,有条不紊。

3. 核心细节解析与实操要点

3.1 从“全局函数”到组件化:搜索框迁移实战

组件化是框架重构的核心动作。老项目里最典型的场景是列表页顶部的搜索区域:一堆输入框、一个查询按钮、一个重置按钮,逻辑大概是这样:

// 原生HTML时代代码,每个页面复制一份,改改ID就上 function doSearch() { var keyword = document.getElementById('keyword').value; var status = document.getElementById('status').value; var startDate = document.getElementById('startDate').value; // 拼接参数,发起ajax请求 $.ajax({ url: '/api/list', data: { keyword: keyword, status: status, startDate: startDate }, success: function (res) { // 渲染表格,手动拼HTML字符串 var html = ''; res.data.list.forEach(function (item) { html += '<tr><td>' + item.name + '</td></tr>'; }); $('#tableBody').html(html); } }); }

这段代码的典型问题:字符串拼HTML,XSS风险大;手动操作DOM,代码一多就乱;逻辑无法复用,换个页面又得重新写一遍。

Vue里,这个搜索组件被改造成这样:

<template> <div class="search-bar"> <input v-model="searchForm.keyword" placeholder="请输入关键词" /> <select v-model="searchForm.status"> <option value="">全部状态</option> <option value="1">启用</option> <option value="0">禁用</option> </select> <input v-model="searchForm.startDate" type="date" /> <button @click="handleSearch">查询</button> <button @click="handleReset">重置</button> </div> </template> <script setup> import { reactive } from 'vue' const searchForm = reactive({ keyword: '', status: '', startDate: '' }) const emit = defineEmits(['search', 'reset']) function handleSearch() { emit('search', { ...searchForm }) } function handleReset() { searchForm.keyword = '' searchForm.status = '' searchForm.startDate = '' emit('search', { ...searchForm }) } </script>

Vue最爽的地方在于v-model双向绑定。以前要手动获取每个输入框的值,现在表单数据和searchForm对象自动同步,代码简洁了不止一个量级。组件通过emit把搜索参数抛给父组件,父组件拿到参数再去调接口,职责边界非常清晰。

React这边,同样的组件用函数组件加Hooks来实现:

import { useState } from 'react' function SearchBar({ onSearch }) { const [searchForm, setSearchForm] = useState({ keyword: '', status: '', startDate: '' }) const handleChange = (e) => { const { name, value } = e.target setSearchForm(prev => ({ ...prev, [name]: value })) } const handleSearch = () => { onSearch({ ...searchForm }) } const handleReset = () => { setSearchForm({ keyword: '', status: '', startDate: '' }) onSearch({}) } return ( <div className="search-bar"> <input name="keyword" value={searchForm.keyword} onChange={handleChange} placeholder="请输入关键词" /> <select name="status" value={searchForm.status} onChange={handleChange}> <option value="">全部状态</option> <option value="1">启用</option> <option value="0">禁用</option> </select> <input name="startDate" type="date" value={searchForm.startDate} onChange={handleChange} /> <button onClick={handleSearch}>查询</button> <button onClick={handleReset}>重置</button> </div> ) }

React这边没有双向绑定,数据和视图同步靠的是valueonChange的受控组件模式。初看好像比Vue啰嗦,但真正用起来,这种“数据流单向”的模式反而更利于追踪状态变化。特别是表单复杂时,handleChange里的一行setSearchForm可以统一处理所有字段,反而成了优点。

两个框架写下来,我的感觉是:Vue把“简单易用”做到了极致,React把“数据可控性”做到了极致。没有好坏之分,关键是看团队更适应哪种思维模式。

3.2 路由与状态管理接管

老项目里的页面跳转靠window.location.href,同时参数靠URL查询字符串传递,比如/detail.html?id=123。这种方式的硬伤是页面刷新后状态丢失。框架重构后,路由管理是标配。

Vue Router配置思路:

import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/', redirect: '/dashboard' }, { path: '/dashboard', component: () => import('../views/Dashboard.vue') }, { path: '/list', component: () => import('../views/List.vue') }, { path: '/detail/:id', component: () => import('../views/Detail.vue') } ] const router = createRouter({ history: createWebHistory(), routes })

页面传参在这里变成了一个很有价值的话题。老项目用?id=123,刷新没问题,但参数类型只能字符串。框架里我推荐用params方式,配上name路由:

// 跳转 router.push({ name: 'detail', params: { id: 123 } }) // 接收 const route = useRoute() const id = route.params.id

这样参数类型可以保持数字或对象,刷新后依然存在。不过有个点要注意:params传对象时,刷新页面会丢失,这点和query不同。所以我的经验是:核心业务参数(如ID)用params,辅助展示参数(如来源页)用query

React Router这边路由思想类似,但配置上更灵活:

import { createBrowserRouter, RouterProvider } from 'react-router-dom' import Dashboard from './pages/Dashboard' import ListPage from './pages/ListPage' import DetailPage from './pages/DetailPage' const router = createBrowserRouter([ { path: '/', element: <Dashboard /> }, { path: '/list', element: <ListPage /> }, { path: '/detail/:id', element: <DetailPage /> } ]) function App() { return <RouterProvider router={router} /> }

接收参数用useParams

import { useParams } from 'react-router-dom' function DetailPage() { const { id } = useParams() // 拿到id后请求接口 }

状态管理方面,Vue侧我用Pinia。选它不单是因为它是Vue官方推荐的新一代状态管理,更重要的是它够轻、够直接。写起来和Vuex相比简单太多,没有那么多概念,一个defineStore搞定:

// stores/userStore.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ name: '', role: '' }), actions: { setUser(userInfo) { this.name = userInfo.name this.role = userInfo.role } } })

React侧我选zustand,理由是在函数组件大行其道的今天,Redux的样板代码相对繁琐。Zustand的思想更接近“原子的状态库”,API极简:

// stores/userStore.js import { create } from 'zustand' export const useUserStore = create((set) => ({ name: '', role: '', setUser: (userInfo) => set((state) => ({ name: userInfo.name, role: userInfo.role })) }))

对比下来,两个库的写法意外地相似,使用体验也接近。选型结论是:不是选“最热门”的,而是选“够用且不别扭”的。Pinia之于Vue、Zustand之于React,都是“少废话、直接干活”的类型。

4. 实操过程与核心环节实现

4.1 一个典型业务模块的完整重构:登录注册系统

登录注册系统几乎每个后台项目都有,并且非常适合用来展示框架重构的完整链路。原生的登录页怎么写的?ID为loginForm的表单、$(’#loginBtn’).on(’click’, doLogin)绑定事件、$.ajax提交数据、成功后跳转。代码现场看着还行,但一旦要加入“记住密码”“第三方登录”“登录后回跳”这些需求,就开始寸步难行。

Vue版本的重构,我在登录页用reactive定义表单,加上简单的校验和loading状态管理:

<template> <div class="login-page"> <form @submit.prevent="handleLogin"> <input v-model.trim="loginForm.username" placeholder="用户名" /> <input v-model.trim="loginForm.password" type="password" placeholder="密码" /> <button type="submit" :disabled="loading"> {{ loading ? '登录中...' : '登 录' }} </button> </form> </div> </template> <script setup> import { reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { useUserStore } from '../stores/userStore' const router = useRouter() const userStore = useUserStore() const loginForm = reactive({ username: '', password: '' }) const loading = ref(false) async function handleLogin() { if (!loginForm.username || !loginForm.password) { alert('请输入用户名和密码') return } loading.value = true try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) }) const data = await res.json() userStore.setUser(data.user) router.push('/dashboard') } finally { loading.value = false } } </script>

这里有个关键细节:v-model.trim,可以用这个修饰符自动去除输入内容首尾空格,这是原生时代要自己写trim()的活,框架里一个修饰符就搞定了。另一个细节是@submit.prevent阻止表单默认刷新行为,避免整页刷新导致状态丢失。

React版本重写同一逻辑:

import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useUserStore } from '../stores/userStore' function LoginPage() { const [loginForm, setLoginForm] = useState({ username: '', password: '' }) const [loading, setLoading] = useState(false) const navigate = useNavigate() const setUser = useUserStore((state) => state.setUser) const handleChange = (e) => { const { name, value } = e.target setLoginForm(prev => ({ ...prev, [name]: value })) } const handleLogin = async (e) => { e.preventDefault() if (!loginForm.username.trim() || !loginForm.password) { alert('请输入用户名和密码') return } setLoading(true) try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) }) const data = await res.json() setUser(data.user) navigate('/dashboard') } finally { setLoading(false) } } return ( <div className="login-page"> <form onSubmit={handleLogin}> <input name="username" value={loginForm.username} onChange={handleChange} placeholder="用户名" /> <input name="password" type="password" value={loginForm.password} onChange={handleChange} placeholder="密码" /> <button type="submit" disabled={loading}> {loading ? '登录中...' : '登 录'} </button> </form> </div> ) }

两个版本放在一起看,业务逻辑完全一样,但组织方式差异明显。Vue的模板部分更接近HTML本身,写起来自然;React的组件则完全“JavaScript化”,一切都是函数和表达式。对于老项目团队来说,前者的上手曲线会更平缓一些。

4.2 特殊需求处理:m3u8视频播放与Webview兼容

老项目重构往往不只改页面结构,还会带出一些平时用不到但关键时刻卡脖子的需求。比如我这次就遇到了两个:

m3u8视频流播放。项目里有几个监控回放页面,视频格式是HLS(m3u8)。原生HTML时代,直接用一个<video>标签,代码大概是这样:

<video src="https://xxx/live.m3u8" controls></video>

但在实测时发现,除Safari浏览器外,Chrome、Firefox对m3u8的支持并不好,很多版本直接无法播放。原生时代我们靠的是PC端装插件、或者找各种活跃的hack。重构时我用了一个相对标准的解法,hls.js,一个用JavaScript实现HLS播放的库。在Vue里封装成播放器组件的思路:

<template> <video ref="videoRef" controls></video> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ src: { type: String, required: true } }) const videoRef = ref(null) let hlsInstance = null onMounted(() => { if (videoRef.value.canPlayType('application/vnd.apple.mpegurl')) { // Safari原生支持,直接用 videoRef.value.src = props.src } else { // 其他浏览器用hls.js import('hls.js').then(({ default: Hls }) => { if (!Hls.isSupported()) { console.error('当前环境不支持HLS播放') return } hlsInstance = new Hls() hlsInstance.loadSource(props.src) hlsInstance.attachMedia(videoRef.value) }) } }) onBeforeUnmount(() => { if (hlsInstance) { hlsInstance.destroy() } }) </script>

核心判断逻辑是:先检测浏览器原生是否支持mp4hls等格式,支持就直接用video标签的src,不支持再动态引入hls.js处理。这个方案在React里同样可以复制,只是把onMounted换成useEffectref不再是响应式数据,而是useRef钩子。

Webview兼容问题。项目重构前,部分页面已经嵌入了原生App的Webview里,这次重构继续保留了Webview加载的方式。这里遇到一个关键问题:打包后的前端项目用createWebHistory,路由路径是/list这样的“干净”URL,但Webview里用file://协议或原生壳加载本地文件时,这类路径会失效。

我的解决思路:判断当前运行环境,动态选择路由模式。

// Vue Router示例 const isWebview = navigator.userAgent.includes('CustomAppName') const router = createRouter({ history: isWebview ? createWebHashHistory() : createWebHistory(), routes })

Webview环境使用createWebHashHistory,URL里会带上#,如index.html#/listfile://协议下也能正常加载。这是一个很容易被忽视但影响巨大的细节。热词里“ios能否通过加载本地vue打包的文件打开项目”,真实答案就是——能,但路由得用hash模式,并且资源路径要设成相对路径或绝对路径指向本地服务器。react配置的资源路径默认是根路径/,直接打包给Webview用会白屏,需要在vite.config.js里把base改成./,这样资源就是相对路径了。这两个坑踩过之后,Webview加载框架重构项目才算真正打通。

5. 双框架共性问题与排查技巧实录

5.1 生命周期映射与DOM操作“改邪归正”

原生HTML时代,页面加载逻辑挂在window.onload里,销毁逻辑几乎不写。到框架里,这一切变成了组件的“生命周期”。Vue里是onMountedonBeforeUnmount,React里是useEffect的清理函数。刚开始写原生转框架的代码,最别扭的就是它。

有一次在React里,我需要监听一个全局自定义事件window.addEventListener('some-event', handler)。我直接把它写在组件里,然后页面切走再切回来,发现事件绑定了两次,触发一次业务跑了两遍。这就是忘了在清理函数里移除监听。正确的写法必须是:

useEffect(() => { const handler = () => {} window.addEventListener('some-event', handler) return () => { window.removeEventListener('some-event', handler) } }, [])

Vue里的对应操作:

onMounted(() => { window.addEventListener('some-event', handler) }) onBeforeUnmount(() => { window.removeEventListener('some-event', handler) })

另一个高频问题,是所有从jQuery时代过来的人都会犯的“手痒”动作:用框架的refuseRef拿到了DOM,然后直接操作它的styleinnerHTMLvalue。框架的特性是数据驱动视图,你手动改了DOM,一刷新,数据变化,DOM又被框架重写了,改了个寂寞。当然,框架也不是完全不让你碰DOM,只是要求“最小化干预”——非必要不做,必要的话通过命令式API(如Vue的nextTick、React的flushSync)配合。

长时间和原生项目死磕之后,你会形成一套肌肉记忆:遇到一个DOM操作,先停一下问自己“这个操作能用数据表达吗”。能,就绝不动手;不能,再考虑命令式方案。

5.2 老项目迁移时的5个高频坑速查

两套代码写下来,我把踩过的、以及反复听人提及的坑整理成了一张速查表,重构前对照着看一眼,能避开不少弯路。

问题现象根因分析解决方案
列表渲染后,排序或筛选状态丢失直接修改了item对象的属性,没有用不可变数据方式更新Vue里用reactive配合解构,React里用setState生成新数组,不原地修改
页面切换后,定时器还在跑setInterval写在组件外或忘了清理组件卸载时统一清理,Vue的onBeforeUnmount,React的useEffect清理函数
输入框输入一个字,卡一下未防抖的搜索请求,每次输入都触发封装useDebouncewatchdebounce
资源加载404,页面白屏打包后资源路径用了绝对路径/,部署在子路径或Webview里找不到vite.config里把base设为./,或改为相对路径
Webview内路由刷新白屏createWebHistory模式刷新时请求了不存在的后端路径检测环境切换为createWebHashHistory,用hash路由

这张表里的每一项,我在这次重构中都亲自撞过。尤其是第5项,当时排查白屏原因花了大半天,最后发现不是资源加载失败,而是history模式在Webview刷新时,原生拦不住服务器返回404。把路由模式切到hash后,问题迎刃而解。经验就是:如果项目要嵌入Webview,路由模式请在第一天就决定好,后端无配置空间就直接选hash

5.3 调试工具是效率分水岭

原生HTML时代调试,基本靠console.log打天下。到框架时代,调试工具的差异非常巨大。Vue有Vue Devtools,React有React Developer Tools,这两个浏览器扩展是质的飞跃。

Vue Devtools可以实时查看组件树、props、state、Pinia store,还能像时间旅行一样回溯状态变化。排查“为什么页面某个数据没更新”这种问题,以前得自己在代码里找变量,现在直接看工具里组件的响应式数据,一目了然。

React Devtools同样强大,组件树、hooks状态、props钻取看,还能直接打断点。我在调试React路由跳转后状态丢失的问题时,就是靠Devtools里看zustand store的实时值,才发现是在组件卸载时把store里不该清的数据清了。

所以给个建议:重构期间,趁早把对应的Devtools装好、看熟。框架提供的开发体验里,调试工具的价值经常被新人低估,实际用起来才知道有多香。

6. 复盘与最终建议

这次双框架重构,前后花了差不多三周时间。不算快,但产出非常扎实:一套完整可运行、带鉴权带权限带多个业务模块的Vue版后台系统,一套功能完全对等的React版后台系统。

我个人的体会是,原生HTML转Vue,团队上手速度明显更快,模板语法贴近HTML,Vue官方中文文档又非常友好;而原生HTML转React,刚开始的思维转换有点痛,主要是不习惯“一切都是函数”的风格,但一旦跨过那个坎,写起来会非常顺手,特别是在逻辑复杂、需要灵活组装场景时,React的函数式组合非常有优势。

最后再分享一个小技巧。如果你也打算同时跑两套框架做对照,建议从同一个最小的功能点开始,比如“搜索框”或“登录页”,先把两个框架的最小闭环打通。后面再逐步扩展功能时,就相当于拿着一个参考模板去填其他业务,效率会高很多,也不会在中途迷失方向。重构这种事,怕的不是框架选错,而是方向没定清楚就闷头开工。小步快跑,一步步来,才是原生项目平稳过渡到现代框架的正确姿势。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询