资讯动态

纯原生JavaScript+Flex布局实现疫情信息页实战

发布时间:2026/10/17 20:10:13 来源:尧图企业网站定制
1. 项目概述为什么一个“疫情信息页面”值得用 Flex 原生 JS 重写Flex 布局和纯原生 JavaScript——这两个词在2024年听起来有点“复古”但恰恰是这种看似基础的组合构成了前端工程中最扎实的肌肉记忆。我最近在带某高校前端实训课时布置了一个小任务不许用任何框架、不许引入第三方库仅靠 HTML CSS必须用 Flex 实现响应式 原生 JSES6禁用 jQuery手写一个可运行的“疫情信息页面”。不是静态展示而是能模拟数据更新、支持区域筛选、带状态切换、有加载反馈的真实交互界面。关键词里没提“Vue”“React”也没提“Ant Design”或“ECharts”就只写了“Flex 布局”和“纯原生 JavaScript”——这本身就是一种明确的技术立场它要检验的是你对浏览器原生能力的理解深度而不是对封装层的调用熟练度。这个页面表面看是“疫情信息”实则是一块试金石能不能在零依赖前提下把 DOM 操作、事件流控制、CSS 布局逻辑、异步状态管理这些底层能力串成一条可维护、可扩展、视觉一致的链路它适合三类人刚学完 DOM API 想验证成果的新手准备技术面试想补全“手写轮子”经验的求职者还有像我这样每年都要给新人出题、反复确认“基础是否真扎实”的一线讲师。我试过让学员直接套用现成模板结果80%的人卡在“筛选后列表高度塌陷”“移动端按钮错位”“数据刷新时 loading 状态丢失”这类问题上——不是不会写 fetch而是没想清楚 flex 容器的flex-wrap和align-content在不同断点下的协作关系不是不会加 class而是没设计好状态类名的命名层级与切换时机。所以这篇内容不讲“怎么搭 Vue 项目”也不教“如何接入卫健委 API”它聚焦在用最原始的工具把一个有真实交互诉求的页面从零一砖一瓦垒出来并确保每一块砖都压得稳、看得清、改得动。2. 整体设计思路拆解为什么放弃一切“捷径”坚持原生2.1 不用框架不是为了怀旧而是为了暴露真实约束很多人第一反应是“现在谁还手写疫情页直接 clone 个开源项目改改不香吗”——香但香在表层。当你 clone 一个基于 React 的疫情页useState封装了状态变更useEffect隐藏了副作用时机List组件屏蔽了滚动容器高度计算逻辑。你改数据源、换图标、调颜色很快就能出效果但一旦需求变成“点击省份后右侧详情区需平滑滚动到对应病历条目且滚动结束前禁止二次点击”你就得钻进ref、scrollIntoView、IntersectionObserver和防抖节流的嵌套里而这时你才发现原来自己并不真正理解“浏览器何时触发重排、何时触发重绘”。我们坚持纯原生就是为了把所有中间层掀开。比如数据加载状态框架里可能就一行loading ? Spinner / : Content /而原生中你必须亲手创建并插入div classloading元素在 fetch 开始前element.classList.add(loading-active)在成功/失败回调中element.classList.remove(loading-active)同时监听transitionend事件确保动画完成才移除 DOM 节点还要考虑 Safari 对display: none下transition的兼容性补丁。这些不是“多余工作”而是浏览器渲染管线的真实切片。Flex 布局同理——不用grid-template-areas就得手动规划主轴/交叉轴方向、设置flex-basis与flex-grow的配比、处理min-width对flex-shrink的压制关系。这种“被迫思考”恰恰是构建直觉的关键。2.2 Flex 是唯一选择它解决了什么又回避了什么为什么标题强调“Flex 布局”而非“Grid”或“Float”因为疫情信息页的核心结构天然契合 Flex 的一维流式特性顶部导航栏固定高度左右两端对齐logo 左搜索框右中间区域自适应——justify-content: space-between一行解决数据总览卡片组4 张卡片在桌面端横排平板端双列手机端单列——flex-wrap: wrapflex-basis: calc(25% - 1rem) 媒体查询微调比 Grid 的grid-template-columns: repeat(auto-fit, minmax(...)))更易调试地区列表 详情面板经典侧边栏布局左侧固定宽度300px右侧自适应填充剩余空间——flex: 0 0 300pxflex: 1无须计算calc(100% - 300px)也规避了 Grid 中grid-column: 1 / -1的语义模糊病历时间线垂直堆叠的条目每条含日期、地区、新增数、累计数——flex-direction: column天然支持且align-items: flex-start可精准控制文字左对齐避免text-align: left在弹性子项中失效的陷阱。而 Grid 在这里反而会增加复杂度你需要定义行高、列宽、区域命名还要处理跨区域合并如 loading 遮罩需覆盖整个主内容区。Float 则早已被弃用其清除浮动、塌陷问题在动态数据渲染中极易引发布局错乱。Flex 的优势在于——它用最少的属性描述最清晰的流向关系且所有行为都在文档流内DOM 更新后无需强制重排。2.3 数据模拟策略不连真实 API但要模拟真实场景标题里没提“对接卫健委接口”这是刻意为之。真实 API 有跨域限制、频率限制、字段不稳定等问题会把精力引向网络调试而非布局与交互本身。我们采用分层模拟法第 0 层静态 JSON 文件data/latest.json包含 31 个省级行政区的模拟数据字段严格对齐province,confirmed,cured,dead,asymptomatic,updateTime。格式为标准 UTF-8无 BOM避免 IE 下fetch解析失败。第 1 层内存缓存 时间戳校验首次加载后数据存入window.__CACHE__ { data, timestamp }后续请求先比对Date.now() - timestamp 5 * 60 * 10005 分钟若未超时则直接返回缓存模拟“本地缓存有效”场景。第 2 层可控延迟与错误注入封装mockFetch(url, options {})函数支持传入delay: 800毫秒和errorRate: 0.110% 概率返回 500。这样既能测试 loading 状态的持续时长也能验证错误提示的显示逻辑——这才是真实网络环境的缩影。提示不要用setTimeout模拟延迟而要用fetch返回Promise.resolve().then(() response)这样才能保证.then()和.catch()的调用时机与真实请求完全一致避免新手误以为“.then总是同步执行”。3. 核心细节解析与实操要点Flex 布局的 7 个关键陷阱与解法3.1 陷阱一flex: 1不等于width: 100%它受父容器min-width制约这是最常被忽略的 Flex 行为。假设你写.container { display: flex; } .left { flex: 0 0 280px; } .right { flex: 1; }你以为.right会占满剩余空间但如果.container的父元素设置了min-width: 1200px而视口宽度只有 1024px.right就会溢出并触发水平滚动——因为flex: 1的本质是flex-grow: 1它只在有剩余空间时才放大而min-width会阻止容器收缩导致“剩余空间”为负。解法显式声明.right的最小宽度为 0.right { flex: 1; min-width: 0; /* 关键允许其压缩到 0 */ }原理min-width: 0覆盖了浏览器默认的min-width: auto即内容最小宽度使flex-shrink能正常生效。我在某次适配 iPad Pro 11 英寸分辨率 1668×2388但 Safari 报告的innerWidth为 834时就因漏掉这行导致右侧详情区被挤出屏幕调试了半小时才定位到。3.2 陷阱二align-items: center在多行 Flex 容器中失效当你的卡片组启用flex-wrap: wrap且卡片数量导致换行时align-items: center只会对每一行内部的子项做交叉轴居中而不会让所有行整体在容器中居中。结果就是两行卡片第一行居中第二行靠上视觉上严重失衡。解法改用align-content: center并确保容器有足够高度.card-grid { display: flex; flex-wrap: wrap; align-content: center; /* 控制多行在交叉轴上的分布 */ min-height: 300px; /* 必须设高度否则 align-content 无效 */ }注意align-content仅在多行 Flex 容器中生效单行时请用align-items。二者不可混用否则行为不可预测。3.3 陷阱三flex-direction: column下height: 100%失效你想让详情面板填满剩余高度于是写.main { display: flex; flex-direction: column; } .header { height: 60px; } .content { height: 100%; } /* 无效 */原因在 column 方向height: 100%需要父容器有明确高度而.main的高度由子项撑开形成循环依赖。解法用flex: 1替代height: 100%.content { flex: 1; /* 自动占据剩余空间 */ overflow-y: auto; /* 内容超长时可滚动 */ }这是 Flex 最优雅的解决方案——它不依赖具体像素值而是基于分配权重完美适配各种屏幕尺寸。3.4 陷阱四justify-content: space-between在最后一行不足时产生巨大间隙卡片网格用space-between实现等距分布很美但当最后一行只有 1 或 2 张卡片时它们会被拉到最左和最右中间空出大片空白极其难看。解法放弃space-between改用justify-content: flex-startmargin-right模拟间隙.card-grid { display: flex; flex-wrap: wrap; justify-content: flex-start; /* 左对齐 */ } .card { flex: 0 0 calc(25% - 0.75rem); /* 4列减去3个间隙 */ margin-right: 1rem; margin-bottom: 1rem; } .card:nth-child(4n) { margin-right: 0; /* 每行第4张不设右距 */ }这样无论多少张卡片布局都紧凑自然。我在某次演示中故意删掉两张卡片用space-between的版本立刻显得空洞而此方案毫无破绽。3.5 陷阱五flex-shrink: 0无法阻止图片被压缩需配合min-width你给卡片里的缩略图设了flex-shrink: 0但小屏下图片仍被压扁。这是因为img默认max-width: 100%而flex-shrink只影响 flex 项目的主轴尺寸不控制其内部内容。解法为图片容器添加min-width: 0并设置图片自身约束.card-img-wrapper { flex-shrink: 0; min-width: 0; /* 允许容器压缩 */ } .card-img { width: 100%; height: 120px; object-fit: cover; /* 保持比例裁剪 */ display: block; }object-fit: cover是关键它替代了过去常用的background-image方案语义更清晰且支持srcset响应式图片。3.6 陷阱六order属性在媒体查询中失效因优先级低于源码顺序你想在移动端把详情区移到列表上方于是写media (max-width: 768px) { .list { order: 2; } .detail { order: 1; } }但没生效。原因order的默认值是0而你在 HTML 中.list写在.detail前面即使 CSS 设了order: 1其实际顺序仍是0list→1detail媒体查询的order: 2和order: 1并未改变相对关系。解法统一初始化order再在媒体查询中调整.list, .detail { order: 0; } /* 先归零 */ media (max-width: 768px) { .list { order: 2; } .detail { order: 1; } }或者更稳妥直接用flex-direction: column-reverse颠倒流向比order更可靠。3.7 陷阱七flex-basis使用百分比时受父容器 padding 影响你设flex-basis: 50%期望两列均分但发现总宽度超出了容器。这是因为50%是相对于父容器的width计算的而如果父容器有padding: 1rem50%就是width - 2rem的一半再乘以 2就少了2rem导致子项总宽小于父容器出现间隙。解法用box-sizing: border-boxflex-basis配合padding.parent { padding: 1rem; box-sizing: border-box; /* 确保 padding 包含在 width 内 */ } .child { flex-basis: 50%; }或者更推荐放弃百分比改用flex: 0 0 50%语义更明确。4. 实操过程与核心环节实现从零搭建可运行的疫情信息页4.1 HTML 结构设计语义化与 Flex 容器的精准对应我们不追求“最小 HTML”而要让每个标签都承担明确的布局与语义职责。结构如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title疫情信息实时看板/title link relstylesheet hrefstyle.css /head body !-- 全局加载遮罩 -- div idglobal-loader classloader hidden/div !-- 主容器Flex column全高 -- div classapp !-- 顶部导航栏 -- header classheader div classheader-logo疫控看板/div div classheader-search input typetext idsearch-input placeholder搜索省份... button idsearch-btn/button /div div classheader-refresh idrefresh-btn 刷新/div /header !-- 主内容区左右布局 -- main classmain !-- 左侧地区列表 -- aside classlist-panel h2全国疫情概览/h2 div classlist-filters button>/* 重置与基础 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif; line-height: 1.6; color: #333; background-color: #f8f9fa; } /* 顶层应用容器 */ .app { display: flex; flex-direction: column; min-height: 100vh; } /* 顶部导航栏 */ .header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #1a5fb4, #2a7fcc); color: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .header-logo { font-size: 1.5rem; font-weight: bold; } .header-search { display: flex; gap: 0.5rem; } #search-input { padding: 0.5rem 0.75rem; border: none; border-radius: 4px; font-size: 0.875rem; width: 200px; } #search-btn { padding: 0.5rem 1rem; background: #ff6b35; color: white; border: none; border-radius: 4px; cursor: pointer; } .header-refresh { background: rgba(255,255,255,0.2); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; transition: background 0.2s; } .header-refresh:hover { background: rgba(255,255,255,0.3); } /* 主内容区 */ .main { display: flex; flex: 1; /* 占据剩余所有空间 */ overflow: hidden; /* 防止子项溢出 */ } /* 左侧列表面板 */ .list-panel { flex: 0 0 300px; /* 固定宽度不伸缩 */ background: white; border-right: 1px solid #e0e0e0; overflow-y: auto; display: flex; flex-direction: column; } .list-panel h2 { padding: 1rem; font-size: 1.25rem; border-bottom: 1px solid #eee; } .list-filters { padding: 0.75rem 1rem; display: flex; gap: 0.5rem; } .list-filters button { padding: 0.375rem 0.75rem; background: #e9ecef; border: none; border-radius: 4px; font-size: 0.875rem; cursor: pointer; transition: all 0.2s; } .list-filters button.active { background: #007bff; color: white; } .province-list { list-style: none; flex: 1; /* 让列表占满剩余高度 */ overflow-y: auto; } .province-list li { padding: 0.75rem 1rem; border-bottom: 1px solid #f1f1f1; cursor: pointer; transition: background 0.2s; } .province-list li:hover { background: #f8f9fa; } .province-list li.active { background: #e3f2fd; font-weight: 500; } /* 右侧详情面板 */ .detail-panel { flex: 1; /* 自适应填充 */ display: flex; flex-direction: column; overflow: hidden; background: white; } .detail-header { padding: 1rem; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; } .detail-header h2 { font-size: 1.25rem; margin: 0; } .detail-header small { color: #6c757d; font-size: 0.875rem; } .detail-stats { display: flex; flex-wrap: wrap; gap: 1rem; padding: 1rem; background: #f8f9fa; } .stat-card { flex: 1 1 calc(50% - 0.5rem); /* 响应式平板双列手机单列 */ background: white; border-radius: 4px; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); text-align: center; } .stat-label { font-size: 0.875rem; color: #6c757d; margin-bottom: 0.5rem; } .stat-value { font-size: 1.5rem; font-weight: bold; color: #28a745; } /* 时间线容器垂直流式 */ .timeline-container { flex: 1; overflow-y: auto; padding: 1rem; } .timeline-item { display: flex; padding: 0.75rem 0; border-bottom: 1px dashed #eee; } .timeline-item:last-child { border-bottom: none; } .timeline-date { flex: 0 0 100px; font-size: 0.875rem; color: #6c757d; font-weight: 500; } .timeline-content { flex: 1; padding-left: 1rem; } .timeline-content h3 { margin: 0 0 0.25rem 0; font-size: 1rem; } .timeline-content p { margin: 0; font-size: 0.875rem; color: #495057; } /* 底部状态栏 */ .footer { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; background: #343a40; color: #adb5bd; font-size: 0.875rem; } .status-indicator { display: flex; align-items: center; gap: 0.5rem; } .dot { width: 10px; height: 10px; border-radius: 50%; } .dot-green { background: #28a745; } .dot-yellow { background: #ffc107; } .dot-red { background: #dc3545; } /* 响应式断点 */ media (max-width: 768px) { .header { flex-direction: column; gap: 0.75rem; text-align: center; } .header-search { width: 100%; justify-content: center; } #search-input { width: 100%; } .main { flex-direction: column; } .list-panel, .detail-panel { flex: none; /* 取消 flex 分配 */ width: 100%; } .list-panel { border-right: none; border-bottom: 1px solid #e0e0e0; } .detail-stats { flex-direction: column; } .stat-card { flex: none; width: 100%; } } /* 加载遮罩 */ .loader { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; transition: opacity 0.3s; } .loader.hidden { opacity: 0; pointer-events: none; } .loader::before { content: ; width: 40px; height: 40px; border: 4px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: white; animation: spin 1s ease-in-out infinite; } keyframes spin { to { transform: rotate(360deg); } }实操心得所有flex相关属性都加了注释说明其作用对象主轴/交叉轴和设计意图媒体查询中flex-direction: column替代order是更可靠的移动端切换方案.stat-card的flex: 1 1 calc(50% - 0.5rem)中0.5rem是gap的一半确保双列时总宽刚好为 100%无间隙box-shadow使用rgba(0,0,0,0.05)而非#eee避免深色模式下阴影消失。4.3 原生 JavaScript 核心逻辑数据驱动与状态管理script.js是整个页面的灵魂我们摒弃全局变量污染采用立即执行函数表达式IIFE封装// 数据缓存与工具函数 const CACHE_KEY __EPIDEMIC_DATA_CACHE__; const CACHE_TTL 5 * 60 * 1000; // 5分钟 // 模拟 fetch支持延迟与错误率 function mockFetch(url, options {}) { const { delay 0, errorRate 0 } options; return new Promise((resolve, reject) { setTimeout(() { if (Math.random() errorRate) { reject(new Error(Network Error)); } else { // 模拟真实数据结构 const data [ { province: 北京市, confirmed: 32456, cured: 31200, dead: 1256, asymptomatic: 4567, updateTime: 2024-03-15T14:22:33 }, { province: 上海市, confirmed: 45678, cured: 43200, dead: 2478, asymptomatic: 5678, updateTime: 2024-03-15T14:22:33 }, // ... 其他30个省份 ]; resolve({ ok: true, json: () Promise.resolve(data) }); } }, delay); }); } // 从 localStorage 读取缓存 function getCache() { try { const cache localStorage.getItem(CACHE_KEY); if (!cache) return null; const parsed JSON.parse(cache); if (Date.now() - parsed.timestamp CACHE_TTL) { localStorage.removeItem(CACHE_KEY); return null; } return parsed.data; } catch (e) { console.warn(Cache read failed:, e); return null; } } // 写入缓存 function setCache(data) { try { localStorage.setItem(CACHE_KEY, JSON.stringify({ data, timestamp: Date.now() })); } catch (e) { console.warn(Cache write failed:, e); } } // DOM 操作封装 const $ (selector) document.querySelector(selector); const $$ (selector) document.querySelectorAll(selector); // 显示/隐藏加载遮罩 function showLoader() { $(#global-loader).classList.remove(hidden); } function hideLoader() { $(#global-loader).classList.add(hidden); } // 更新状态栏 function updateStatus(text, type success) { const statusText $(#status-text); const statusDot $(#status-dot); statusText.textContent text; statusDot.className dot; switch (type) { case success: statusDot.classList.add(dot-green); break; case warning: statusDot.classList.add(dot-yellow); break; case error: statusDot.classList.add(dot-red); break; } } // 核心业务逻辑 class EpidemicDashboard { constructor() { this.data []; this.filteredData []; this.currentProvince null; this.init(); } init() { this.bindEvents(); this.loadInitialData(); } bindEvents() { // 刷新按钮 $(#refresh-btn).addEventListener(click, () this.refreshData()); // 搜索 $(#search-btn).addEventListener(click, () this.performSearch()); $(#search-input).addEventListener(keypress, (e) { if (e.key Enter) this.performSearch(); }); // 筛选按钮 $$(.list-filters button).forEach(btn { btn.addEventListener(click, (e) { $$(.list-filters button).forEach(b b.classList.remove(active)); e.target.classList.add(active); this.applyFilter(e.target.dataset.filter); }); }); } async loadInitialData() { showLoader(); updateStatus(正在加载数据..., warning); try { // 先查缓存 let data getCache(); if (!data) { // 缓存失效重新请求 const response await mockFetch(/data/latest.json, { delay: 800, errorRate: 0.05 }); data await response.json(); setCache(data); } this.data data; this.filteredData [...data]; this.renderProvinceList(); this.updateStatus(数据加载成功, success); } catch (err) { console.error(Load failed:, err); updateStatus(加载失败${err.message}, error); // 即使失败也要渲染空列表避免白屏 this.renderProvinceList(); } finally { hideLoader(); } } refreshData() { // 清除缓存强制重新加载 localStorage.removeItem(CACHE_KEY); this.loadInitialData(); } performSearch() { const keyword $(#search-input).value.trim(); if (!keyword) return; this.filteredData this.data.filter(item item.province.includes(keyword) || item.province.toLowerCase().includes(keyword.toLowerCase()) ); this.renderProvinceList(); } applyFilter(filterType) { switch (filterType) { case all: this.filteredData [...this.data]; break; case high: this.filteredData this.data.filter(item item.confirmed 50000); break; case medium: this.filteredData this.data.filter(item

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑