原生HTML项目重构实战:Vue与React双框架对比与踩坑记录
接手过不少烂摊子项目但最让我头疼的往往不是那些用了什么冷门框架的代码反而是看起来“最简单”的原生HTML项目。一个典型的老古董项目一堆HTML文件内联CSS每个页面底部挂着一大段JavaScriptjQuery一把梭全局变量满天飞。代码跑得动但没人敢动。产品提个新需求开发改一个点另外三个功能莫名其妙跟着出问题。这其实就是很多前端团队遇到的共同困境原生HTML项目在业务变复杂、人员变动之后维护成本会指数级上升。而重构这个事大家都知道早晚要做但怎么切入、选什么框架、怎么保证业务不中断很多人心里没底。折腾过几次之后我干脆把同一个旧项目用Vue和React各重构了一遍。这篇文章就是这次双框架实战的完整记录从拆解思路到具体踩坑给正打算动手重构的朋友一个参考。1. 内容整体设计与思路拆解1.1 原生HTML项目为什么会“病”很多人觉得原生HTML项目乱但乱在哪得说清楚。我手里这个项目规模不算大20来个页面核心业务是后台管理系统附带一些数据展示页。但代码结构是典型的“原始社会”形态公共函数塞在common.js里互相调用靠全局变量页面间传参靠URL带queryDOM操作靠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这边我选用的是ViteVue 3JavaScript没上TypeScript考虑到老业务团队的接受度先用JS平滑过渡。创建命令很简单npm create vitelatest admin-vue -- --template vue装依赖然后按需加vue-router和piniacd admin-vue npm install vue-router4 piniaReact这边同样用Vite做构建工具模板是reactnpm create vitelatest admin-react -- --template react然后装react-router-dom状态管理我选用了zustand。原因后面细说npm install react-router-dom zustand两个项目的目录规划完全是同一套思路src/api目录放所有接口请求封装。src/components目录放公共组件。src/router或src/routes目录路由配置。src/store目录状态管理。src/utils目录公共工具函数。src/views或src/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 trtd item.name /td/tr; }); $(#tableBody).html(html); } }); }这段代码的典型问题字符串拼HTMLXSS风险大手动操作DOM代码一多就乱逻辑无法复用换个页面又得重新写一遍。在Vue里这个搜索组件被改造成这样template div classsearch-bar input v-modelsearchForm.keyword placeholder请输入关键词 / select v-modelsearchForm.status option value全部状态/option option value1启用/option option value0禁用/option /select input v-modelsearchForm.startDate typedate / button clickhandleSearch查询/button button clickhandleReset重置/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 }) } /scriptVue最爽的地方在于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 classNamesearch-bar input namekeyword value{searchForm.keyword} onChange{handleChange} placeholder请输入关键词 / select namestatus value{searchForm.status} onChange{handleChange} option value全部状态/option option value1启用/option option value0禁用/option /select input namestartDate typedate value{searchForm.startDate} onChange{handleChange} / button onClick{handleSearch}查询/button button onClick{handleReset}重置/button /div ) }React这边没有双向绑定数据和视图同步靠的是value加onChange的受控组件模式。初看好像比Vue啰嗦但真正用起来这种“数据流单向”的模式反而更利于追踪状态变化。特别是表单复杂时handleChange里的一行setSearchForm可以统一处理所有字段反而成了优点。两个框架写下来我的感觉是Vue把“简单易用”做到了极致React把“数据可控性”做到了极致。没有好坏之分关键是看团队更适应哪种思维模式。3.2 路由与状态管理接管老项目里的页面跳转靠window.location.href同时参数靠URL查询字符串传递比如/detail.html?id123。这种方式的硬伤是页面刷新后状态丢失。框架重构后路由管理是标配。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 })页面传参在这里变成了一个很有价值的话题。老项目用?id123刷新没问题但参数类型只能字符串。框架里我推荐用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} / }接收参数用useParamsimport { 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 classlogin-page form submit.preventhandleLogin input v-model.trimloginForm.username placeholder用户名 / input v-model.trimloginForm.password typepassword placeholder密码 / button typesubmit :disabledloading {{ 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 classNamelogin-page form onSubmit{handleLogin} input nameusername value{loginForm.username} onChange{handleChange} placeholder用户名 / input namepassword typepassword value{loginForm.password} onChange{handleChange} placeholder密码 / button typesubmit disabled{loading} {loading ? 登录中... : 登 录} /button /form /div ) }两个版本放在一起看业务逻辑完全一样但组织方式差异明显。Vue的模板部分更接近HTML本身写起来自然React的组件则完全“JavaScript化”一切都是函数和表达式。对于老项目团队来说前者的上手曲线会更平缓一些。4.2 特殊需求处理m3u8视频播放与Webview兼容老项目重构往往不只改页面结构还会带出一些平时用不到但关键时刻卡脖子的需求。比如我这次就遇到了两个m3u8视频流播放。项目里有几个监控回放页面视频格式是HLSm3u8。原生HTML时代直接用一个video标签代码大概是这样video srchttps://xxx/live.m3u8 controls/video但在实测时发现除Safari浏览器外Chrome、Firefox对m3u8的支持并不好很多版本直接无法播放。原生时代我们靠的是PC端装插件、或者找各种活跃的hack。重构时我用了一个相对标准的解法hls.js一个用JavaScript实现HLS播放的库。在Vue里封装成播放器组件的思路template video refvideoRef 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核心判断逻辑是先检测浏览器原生是否支持mp4、hls等格式支持就直接用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环境使用createWebHashHistoryURL里会带上#如index.html#/listfile://协议下也能正常加载。这是一个很容易被忽视但影响巨大的细节。热词里“ios能否通过加载本地vue打包的文件打开项目”真实答案就是——能但路由得用hash模式并且资源路径要设成相对路径或绝对路径指向本地服务器。react配置的资源路径默认是根路径/直接打包给Webview用会白屏需要在vite.config.js里把base改成./这样资源就是相对路径了。这两个坑踩过之后Webview加载框架重构项目才算真正打通。5. 双框架共性问题与排查技巧实录5.1 生命周期映射与DOM操作“改邪归正”原生HTML时代页面加载逻辑挂在window.onload里销毁逻辑几乎不写。到框架里这一切变成了组件的“生命周期”。Vue里是onMounted和onBeforeUnmountReact里是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时代过来的人都会犯的“手痒”动作用框架的ref或useRef拿到了DOM然后直接操作它的style、innerHTML、value。框架的特性是数据驱动视图你手动改了DOM一刷新数据变化DOM又被框架重写了改了个寂寞。当然框架也不是完全不让你碰DOM只是要求“最小化干预”——非必要不做必要的话通过命令式API如Vue的nextTick、React的flushSync配合。长时间和原生项目死磕之后你会形成一套肌肉记忆遇到一个DOM操作先停一下问自己“这个操作能用数据表达吗”。能就绝不动手不能再考虑命令式方案。5.2 老项目迁移时的5个高频坑速查两套代码写下来我把踩过的、以及反复听人提及的坑整理成了一张速查表重构前对照着看一眼能避开不少弯路。问题现象根因分析解决方案列表渲染后排序或筛选状态丢失直接修改了item对象的属性没有用不可变数据方式更新Vue里用reactive配合解构React里用setState生成新数组不原地修改页面切换后定时器还在跑setInterval写在组件外或忘了清理组件卸载时统一清理Vue的onBeforeUnmountReact的useEffect清理函数输入框输入一个字卡一下未防抖的搜索请求每次输入都触发封装useDebounce或watch加debounce资源加载404页面白屏打包后资源路径用了绝对路径/部署在子路径或Webview里找不到vite.config里把base设为./或改为相对路径Webview内路由刷新白屏createWebHistory模式刷新时请求了不存在的后端路径检测环境切换为createWebHashHistory用hash路由这张表里的每一项我在这次重构中都亲自撞过。尤其是第5项当时排查白屏原因花了大半天最后发现不是资源加载失败而是history模式在Webview刷新时原生拦不住服务器返回404。把路由模式切到hash后问题迎刃而解。经验就是如果项目要嵌入Webview路由模式请在第一天就决定好后端无配置空间就直接选hash。5.3 调试工具是效率分水岭原生HTML时代调试基本靠console.log打天下。到框架时代调试工具的差异非常巨大。Vue有Vue DevtoolsReact有React Developer Tools这两个浏览器扩展是质的飞跃。Vue Devtools可以实时查看组件树、props、state、Pinia store还能像时间旅行一样回溯状态变化。排查“为什么页面某个数据没更新”这种问题以前得自己在代码里找变量现在直接看工具里组件的响应式数据一目了然。React Devtools同样强大组件树、hooks状态、props钻取看还能直接打断点。我在调试React路由跳转后状态丢失的问题时就是靠Devtools里看zustand store的实时值才发现是在组件卸载时把store里不该清的数据清了。所以给个建议重构期间趁早把对应的Devtools装好、看熟。框架提供的开发体验里调试工具的价值经常被新人低估实际用起来才知道有多香。6. 复盘与最终建议这次双框架重构前后花了差不多三周时间。不算快但产出非常扎实一套完整可运行、带鉴权带权限带多个业务模块的Vue版后台系统一套功能完全对等的React版后台系统。我个人的体会是原生HTML转Vue团队上手速度明显更快模板语法贴近HTMLVue官方中文文档又非常友好而原生HTML转React刚开始的思维转换有点痛主要是不习惯“一切都是函数”的风格但一旦跨过那个坎写起来会非常顺手特别是在逻辑复杂、需要灵活组装场景时React的函数式组合非常有优势。最后再分享一个小技巧。如果你也打算同时跑两套框架做对照建议从同一个最小的功能点开始比如“搜索框”或“登录页”先把两个框架的最小闭环打通。后面再逐步扩展功能时就相当于拿着一个参考模板去填其他业务效率会高很多也不会在中途迷失方向。重构这种事怕的不是框架选错而是方向没定清楚就闷头开工。小步快跑一步步来才是原生项目平稳过渡到现代框架的正确姿势。

相关新闻

最新新闻

日新闻

周新闻

月新闻