资讯中心

手搓HTML代码分割:按路由动态加载模块,提升首屏性能

📅 2026/9/28 22:32:28
手搓HTML代码分割:按路由动态加载模块,提升首屏性能
1. 一个大杂烩HTML页面慢是必然的先说一个我自己的真实经历。之前维护过一个纯HTML的管理后台没有用任何前端框架页面结构像这样!DOCTYPE html html langzh-cn head meta charsetutf-8 title管理后台/title link relstylesheet href/css/app.css /head body div idapp/div script src/js/vendor.js/script script src/js/app.js/script /body /html问题出在app.js上。这个文件大概是2.8MB几乎把首页、列表、详情、报表、权限配置等所有业务逻辑全部塞了进去。用户每次打开后台不管他点的是哪个菜单浏览器都得先把这个2.8MB的JS下载完、解析完、执行完页面才能开始渲染。实测下来内网环境下平均首屏白屏时间2.4秒外网环境能拉到5秒多。更要命的是业务还在持续扩张app.js的体积每个月都在涨所有人都知道这是个问题但没人动手改——因为整体替换成框架的成本更高。后来我做了一个调整按路由把代码拆成多个独立模块用户访问哪个页面就只加载哪个页面对应的脚本。这个改动的思路就是标题里说的手搓HTML代码分割按路由动态加载模块。它不需要你重写整个项目不需要引入框架只要在原生HTML JavaScript的体系内把路由和模块的关系理清楚用一套动态加载机制替代原先的一把梭就能明显改善加载性能。这篇文章我想把这个过程完整拆开讲一遍。适合什么场景呢你手头有历史遗留的HTML项目有多个页面或单页应用所有逻辑挤在一个大脚本里或者你纯粹想理解代码分割的运行机制为以后上手Webpack、Vite的自动拆包打基础。下面所有内容都是我在实际项目中用过的方案不是理论推演。2. 手搓路由表把路径→模块→渲染三者先约定清楚2.1 为什么先定义数据结构而不是先写加载函数刚开始做代码分割的人最容易掉进一个坑里先忙着写一个loadScript函数然后见一个页面就硬编码一次调用。这样做完一两个页面没感觉做到五个页面以上就乱套了——加载逻辑、路由匹配、渲染入口、页面标题全都散落在各处。我的建议是动手写加载函数之前先把路由表这个数据结构定下来。路由表是整件事的总纲它回答三个问题用户访问的URL路径对应哪个模块文件这个模块文件加载完成后应该调用哪个全局函数来渲染页面这个页面有没有额外的CSS需要一起加载实际上这是借鉴了前端框架内部的工作方式。Vue Router、React Router在懒加载时也维护着一张路由映射表只是它们把这张表藏在框架内部你不一定看得见。手搓方案的最大价值就是让你亲手把这张表设计出来。2.2 一份可以直接抄走的配置结构我最终用的路由表结构是这样的// route-config.js const routeConfig { /: { module: /js/pages/home.js, entry: HomePage, title: 首页, css: /css/home.css }, /list: { module: /js/pages/list.js, entry: ListPage, title: 列表页, prefetch: true }, /detail/:id: { module: /js/pages/detail.js, entry: DetailPage, title: 详情页 } };字段解释如下module要动态加载的脚本文件的URL。这里要注意不能用相对路径随便写最好以根目录/开头避免页面路径不同导致相对路径解析错误。entry模块加载完成后去window上找哪个函数来执行渲染。这是模块注册到全局的约定后面会细讲。title切到该路由时同步更新document.title。css如果页面有独立的样式文件可以在加载模块的同时动态创建link标签放进去。手头项目规模大时样式也拆开能进一步降低首屏CSS体积。prefetch标记哪些路由需要预加载。比如从首页跳到列表页非常频繁那么首页加载完毕后可以悄悄把列表页模块预取下来用户点击时直接命中缓存。2.3 路由参数匹配不能只做字符串相等上面配置里有/detail/:id这种带参数的路由这意味着匹配逻辑不能简单地靠location.hash route来判断要做一层简单的路径解析。我写了一个轻量的匹配函数function matchRoute(path, routes) { for (const pattern of Object.keys(routes)) { const patternParts pattern.split(/).filter(Boolean); const pathParts path.split(/).filter(Boolean); if (patternParts.length ! pathParts.length) continue; const params {}; let matched true; for (let i 0; i patternParts.length; i) { if (patternParts[i].startsWith(:)) { params[patternParts[i].slice(1)] pathParts[i]; } else if (patternParts[i] ! pathParts[i]) { matched false; break; } } if (matched) return { pattern, route: routes[pattern], params }; } return null; }代码不复杂核心就是按/切段遇到冒号开头就当作参数捕获否则做精确匹配。这套逻辑已经足够支撑起常见的后台管理、内容站点、工具型页面的路由需求。如果项目确实需要更复杂的嵌套路由那说明你该上框架了手搓不值得。2.4 维护数据结构的几个心得第一模块文件路径尽量按页面粒度来组织目录比如/js/pages/home.js、/js/pages/list.js和路由表一一对应后期排查问题一眼就能定位。第二entry这个字段的命名要稳定我建议都用页面名大写开头比如首页就是HomePage列表就是ListPage。第三不要吝惜注释路由表里每条配置都可以把对应的开发负责人或模块说明写在旁边多人协作时这比看代码快。3. 面向路由的三种动态加载实现import()、script注入、全局注册3.1 原生 import()最省心的动态导入如果你的项目已经接入了构建工具哪怕是简单地用Vite或者Webpack打包那么直接用ES模块的import()是最省心的方案。它有几个天然优势会返回一个Promise天然支持异步流程控制在支持它的构建工具中会自动按import()的调用位置生成独立的代码分块无需手动维护分块文件依赖关系由模块系统管理不存在我加载了A但A依赖的B还没加载这种问题。举例来说用Vite构建的原生HTML项目可以这样组织路由映射const routes { /: () import(/src/pages/home.js), /list: () import(/src/pages/list.js), /detail/:id: () import(/src/pages/detail.js) }; async function renderRoute(path) { const { pattern, route, params } matchRoute(path, routes); // 注意这里路由表的值变成了加载函数需要单独处理 const mod await route(); mod.default({ app, params }); }这里import()返回的是一个模块对象如果你用的是ES模块写法export default那渲染函数就是mod.default。比起下面要讲的script注入方案import()的代码量最少而且错误处理更自然——try/catch就能接住加载异常。当然它的前提是浏览器支持原生ES模块动态导入。现代浏览器基本都没问题但如果你的用户环境有老版本WebView或极老浏览器就得用下面的方案。3.2 script标签注入不依赖构建工具的手搓方案大多数历史HTML项目没有构建流程页面里放的是普通脚本文件不是ES模块。这种情况下import()没法直接用你需要自己写一个基于script标签的动态加载器。下面是注释齐全的核心实现// loader.js const moduleCache new Map(); function getEntryName(modulePath) { // 从路径里的最后一段推导全局函数名例如 /js/pages/home.js - HomePage const fileName modulePath.split(/).pop().replace(.js, ); return fileName.replace(/^\w/, c c.toUpperCase()) Page; } function loadModule(modulePath) { if (moduleCache.has(modulePath)) { return Promise.resolve(moduleCache.get(modulePath)); } return new Promise((resolve, reject) { const existingScript document.querySelector(script[data-module${modulePath}]); if (existingScript) { existingScript.addEventListener(load, () { const mod window[getEntryName(modulePath)]; moduleCache.set(modulePath, mod); resolve(mod); }); existingScript.addEventListener(error, reject); return; } const script document.createElement(script); script.src modulePath; script.async false; // 保证按约定的顺序执行 script.dataset.module modulePath; script.onload () { const mod window[getEntryName(modulePath)]; moduleCache.set(modulePath, mod); resolve(mod); }; script.onerror () { moduleCache.delete(modulePath); reject(new Error(模块加载失败: ${modulePath})); }; document.head.appendChild(script); }); }这段代码里的几个细节值得注意moduleCache用Map做模块级别的单例缓存同一个脚本不会重复加载。script.dataset.module标记一个业务属性用来查询页面上是否已经存在这个脚本标签避免并发场景下创建出两个相同的script节点。script.async false非常关键。默认创建出来的script标签async为true会乱序执行我们要的是页面模块按路由表定义的依赖顺序来执行所以必须关掉async。成功后会从window上按约定取全局函数放进缓存。3.3 全局注册页面模块的出口约定script注入方案天然不认ES模块的export它只认全局变量。因此我们需要约定每个页面模块文件的出口统一长什么样。每个页面模块的写法类似// /js/pages/home.js (function () { function renderHomePage({ app, params }) { app.innerHTML div classpage-home h2首页/h2 p这是首页内容/p /div ; } window.HomePage { render: renderHomePage, destroy: function () { // 页面离开时清理监听器、定时器 } }; })();注意两点第一我用window.HomePage而不是window.homePage就是和路由表里的entry字段保持完全一致以大写字母开头区分普通全局变量第二每个模块内部用IIFE包裹避免模块内的临时变量泄漏到全局污染其他模块。虽然这个项目上没有用框架但如果多个模块都声明了同名函数IIFE能避免很多隐性冲突。加载完成后渲染器调用方式是这样的function renderCurrentRoute() { const hash location.hash.replace(/^#/, ) || /; const matched matchRoute(hash, routeConfig); if (!matched) { app.innerHTML div classerror404 页面未找到/div; return; } const { route, params } matched; document.title route.title; app.innerHTML div classloading页面加载中.../div; loadModule(route.module) .then((mod) { app.innerHTML ; mod.render({ app, params }); }) .catch((err) { app.innerHTML div classerror加载失败${err.message}/div; }); } window.addEventListener(hashchange, renderCurrentRoute); window.addEventListener(DOMContentLoaded, renderCurrentRoute);加上hash路由监听一个可用的路由级动态加载就闭环了。整个过程没有引入任何构建依赖直接扔进原有HTML项目里就能跑。3.4 三种方案怎么选这里给出一个实际选型对照方便你快速判断方案浏览器要求构建依赖依赖管理适用场景原生 import()现代浏览器建议有模块系统自动管理新项目、可上构建工具script标签注入IE10以上均可无手动约定/管理历史遗留HTML项目全局注册 IIFEIE8以上均可无手动约定极老浏览器需求极简如果你的项目已经适应了import()我用下来最大的感受是省心——分包、依赖、缓存都不用管编译产物自动按路由分好了。但对于纯粹的手搓场景script注入方案虽然代码量多一些可它把动态加载的每个环节都人工掌握了一遍出了问题也最容易定位。4. 动态加载最容易翻车的四个地方去重、时序、失败、预取4.1 重复请求加载器跑了一半才发现脚本重复被添加这个问题乍看不难但实际场景比想象中隐蔽。用户快速点击菜单时hashchange事件会连续触发两次renderCurrentRoute如果第一次加载还没完成第二次进入loadModule此时moduleCache里还没有缓存系统就会再次创建一个script标签产生重复请求。解决办法我在加载器里已经处理了一部分moduleCache缓存 document.querySelector查重。但还需注意一个细节用querySelector查重时如果查到了已有script不要直接resolve因为那个script可能还没加载完毕。正确做法是给这个已有script动态绑定load事件等触发后再resolve。上面代码里我已经写好了这个逻辑。4.2 依赖顺序页面模块用到了公共库但公共库还没加载script注入最大的坑就是依赖顺序。举个例子列表页模块内部用到了window.api一个封装好Ajax请求的工具库但工具的脚本没有先加载模块执行时就会报api is not defined。手搓方案里你需要维护一份公共依赖清单。我一般这样设计// route-config.js 中额外定义 const globalDeps [ /js/lib/api.js, /js/lib/utils.js ]; async function loadPageWithDeps(route) { const depsLoaded globalDeps.map(loadModule); await Promise.all(depsLoaded); // 所有公共依赖就绪再加载页面模块 return loadModule(route.module); }公共依赖的加载顺序由loadModule内部的script.async false保证虽然是用Promise.all并发发起但script标签的执行顺序是按插入顺序来的当async为false时。所以globalDeps的顺序等于你的依赖声明顺序越底层的库越往前放。注意async false的script会让页面解析阻塞所以这些公共依赖尽量只放真正必要的库不要试图把整站所有代码都塞进去。4.3 加载失败不能只给一个空白页面失败兜底是做路由级加载时最容易被忽视的点。在生产环境脚本可能因为网络抖动、CDN缓存失效、后端发布版本不匹配等原因加载失败。如果页面只是放一个loading然后什么都不显示用户感受非常糟糕。我目前的兜底策略分三层加载失败时在页面容器渲染一个错误提示并提供重试按钮。点击重试先清除moduleCache里对应缓存和页面上残留的script标签再重新走一次加载流程。重试仍失败给出明确的错误信息模块路径是什么大概哪一步出错。方便运维快速定位。如果项目有监控上报比如接入了前端日志采集在catch里把失败路由、模块路径、错误原因上报这样可以在用户反馈之前就知道哪些路由在持续失败。代码大致长这样function renderError(err, route) { app.innerHTML div classerror-box p${err.message}/p button idretryBtn重试/button /div ; document.getElementById(retryBtn).addEventListener(click, () { moduleCache.delete(route.module); const oldScript document.querySelector(script[data-module${route.module}]); if (oldScript) oldScript.remove(); renderCurrentRoute(); }); }4.4 预取让下一次路由跳变像瞬移一样快动态加载能避免首屏一次性扛全部代码但代价是用户点击新路由时模块是实时下载的会有短暂白屏。为了改善体验可以用prefetch提前把高频路由模块取下来。我用的做法是在首页渲染完毕之后遍历路由表找到prefetch: true的路由创建link relprefetch href/js/pages/list.js标签。浏览器会在空闲时预取该资源用户点击列表页时模块可能已经在HTTP缓存里了加载速度几乎感觉不到。要注意prefetch不等同于页面加载它不是必须但属于体验优化能用就用。5. 从纯手搓走向半自动Vite与Webpack如何按路由拆包5.1 手搓方案的天花板在哪里手搓方案的边界很明显你要自己管理依赖、自己处理并发、自己保证全局命名不冲突。项目只有几个页面时还好一旦页面数量上到十几个每个页面还带不同的交互逻辑手搓的动态加载器会越来越难维护。而且其中一个硬伤是你手动拆出来的模块文件是物理分块公共代码的复用粒度需要自己拿捏拆细了增加请求数拆粗了缓存利用率不高。到这个阶段正确做法不是继续硬搓而是借助构建工具分担脏活累活同时保留你对按路由拆包这个核心逻辑的控制权。5.2 Vite把路由级的import()变成分包魔法Vite项目里代码分割几乎不需要额外配置。你只需要在路由映射这里用动态import()Vite就会在构建时自动识别// src/router.js const routes { /: () import(./pages/home/index.js), /list: () import(./pages/list/index.js), /detail/:id: () import(./pages/detail/index.js) };构建产物会自动生成类似Home-xxxx.js、List-xxxx.js这样的独立分块文件。如果希望打包后的文件名更好读、更稳定可以给import()加魔法注释const routes { /: () import(/* webpackChunkName: home */ ./pages/home/index.js), };对于Vite对应的输出文件名一般会自动带上assets/页面名-哈希值.js。关键配置在vite.config.js里export default { build: { rollupOptions: { output: { chunkFileNames: assets/[name]-[hash].js, entryFileNames: assets/[name]-[hash].js, assetFileNames: assets/[name]-[hash][extname] } } } };用带[hash]的文件名可以让内容变化时生成新文件同时配合服务端的强缓存策略实现内容不变复用缓存、内容变化刷新版本。我自己的经验是Vite里拆包后需要特别关注vendor分块的粒度。默认策略是把node_modules里的共享依赖抽成一个大包这通常够用。如果项目引入了多个体积较大的第三方库且它们只被部分页面使用你可以手动配置manualChunks把大的库单独拆出来避免所有页面都共享一个巨大的vendor包。5.3 Webpack用splitChunks彻底控制路由分块Webpack是老牌打包器代码分割配置比Vite更细。核心配置是这样的module.exports { entry: ./src/index.js, output: { filename: [name].[contenthash:8].js, chunkFilename: [name].[contenthash:8].js, path: path.resolve(__dirname, dist) }, optimization: { splitChunks: { chunks: all, maxSize: 200000, cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: vendors, priority: 10 }, shared: { minChunks: 2, name: shared, priority: 5 } } } } };这里面maxSize: 200000是我个人比较喜欢的一个值它表示一个分块文件最好不要超过200KB左右。超过这个大小的模块会被进一步拆成多个更小的文件。这个值不是越细越好因为多一个文件就多一次HTTP请求。带宽够快的场景下200-300KB是一个平衡点。Webpack的splitChunks支持按import()调用点的上下文自动生成路由分块。你在路由映射里用import()加载的模块都会被当成独立的async chunk输出构建产物里能看到每个页面一个独立文件非常直观。5.4 手搓和工具不是二选一而是接力我见过一种误区觉得手搓就一定要拒绝一切构建工具。但实际上手搓动态加载器的核心价值在于你把这个机制的原理吃透了知道浏览器端异步加载脚本到底发生了什么工具的价值在于把这个机制规模化、自动化。实践中我更推荐的做法是如果是历史项目没有构建流程先用手搓方案快速止血至少把首屏性能问题解决掉。如果项目继续演进需要引入构建工具就把手搓的路由表逻辑迁移到import()的动态导入上加载器那部分代码可以直接废弃路由表设计可以沿用。迁移时保持路由路径和模块文件路径的对应关系不变这样几乎不影响已有链接和用户收藏地址。6. 上线前后的实测收益与长期习惯6.1 我最真实的一组对比数据在纯HTML项目里做完按路由动态加载之后我特意先量了一下改造前后的关键指标。改造前首屏需要加载整个app.js2.8MB完全加载完成后才能渲染实测首屏白屏时间2.4秒。改造后首页模块home.js只有大概300KB加上公共依赖总体首屏资源约900KB白屏时间降到0.7秒左右。这不是跑分就是浏览器开发者工具的网络面板和Lighthouse测出来的结果。更重要的是后续的可维护性。以前改一个列表页逻辑要重新下载、执行整个2.8MB的脚本才能验证效果现在只需重新构建对应页面模块用户下次访问该路由时自动拿到新内容其他页面依然使用本地缓存。发布环节的风险面也跟着减少。6.2 服务端配置不能拖后腿动态加载的脚本文件本质是普通JS资源如果服务端没有正确配置照样白搭。三个最基本的配置点Content-Type必须是application/javascript或text/javascript不能是application/octet-stream否则浏览器不会按脚本执行。带哈希值的产物文件建议设置Cache-Control: public, max-age604800, immutable让浏览器长缓存不带哈希的入口文件建议设置较短的缓存时间或no-cache避免用户拿到旧版本。开启gzip或更高压缩比JS文本的压缩率很高压缩能显著降低网络传输的体积。6.3 几条我踩过坑后总结的习惯第一页面模块的全局注册名不要和路由表里的字段分家。我有一段时间图省事直接在路由表的entry字段里写函数名后来为了统一又改成从路径推导结果上线时漏改了几个页面404报得莫名其妙。现在规范是路径最后一段文件名决定全局注册名entry字段只是显式引用两者必须一致。第二路由表不能只增不减。老项目迭代久了总有页面被下线或合并路由表和模块文件却还躺在仓库里白白让用户下载用不到的代码。我每隔一两个迭代就会审查一次路由表把没有流量、不再引用的模块清出去同时检查有没有脚本文件没有被任何路由引用。这种事不需要很频繁但养成习惯能避免“僵尸代码”越堆越多。第三加载器里不要贪图方便放一堆公共依赖。我之前犯过错误为了省事把公共依赖数组越加越长最后几乎所有页面都用到的工具库都被塞进了全局依赖里。这等于把代码分割的收益又还回去了因为每个路由加载时都会先拉这份大依赖。正确做法是公共依赖只放真正基础的东西其他库做成独立模块按需加载。第四记得处理模块销毁。手搓方案因为没有框架的生命周期管理页面切换时旧页面的事件监听、定时器、轮询请求很容易泄漏。我要求每个模块都提供一个destroy方法渲染新路由前先调用旧模块的destroy做清理。这个问题一开始不明显但用户长时间在后台多个页面之间切换时内存占用会肉眼可见地上升最后我自己写了个简单的activeModule全局状态来跟踪当前模块每次切换先清理再加载。最后把加载失败的上报做起来。手搓方案没有现成的错误边界一旦某个模块加载挂掉只有靠上报才知道。我的做法是在路由切换的catch分支里调用一个统一的reportError(type, message, extra)函数把失败的路由和模块路径发到后端日志系统。线上跑了两周后真的抓到了两个老页面因为服务端发布新版本时误删旧文件而持续报错的案例及时止损。代码分割这件事说到底不是上不上框架的问题而是你有没有把用户此刻真正需要的东西和整站可能用到的东西区分开。手搓HTML代码分割的价值恰恰在于逼你把这条边界亲手划出来——路由表是边界设计加载器是边界实现缓存和预取是边界优化。把这些跑通了哪怕以后迁移到Vite或Webpack你也能清楚地知道构建工具到底在替你做什么哪里该调整哪里不该动。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案