1. 项目概述:为什么一个“疫情信息页面”值得用 Flex + 原生 JS 重写?
Flex 布局和纯原生 JavaScript——这两个词在2024年听起来有点“复古”,但恰恰是这种看似基础的组合,构成了前端工程中最扎实的肌肉记忆。我最近在带某高校前端实训课时,布置了一个小任务:不许用任何框架、不许引入第三方库,仅靠 HTML + CSS(必须用 Flex 实现响应式)+ 原生 JS(ES6+,禁用 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 class="loading">元素; - 在 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: wrap+flex-basis: calc(25% - 1rem)+ 媒体查询微调,比 Grid 的grid-template-columns: repeat(auto-fit, minmax(...)))更易调试; - 地区列表 + 详情面板:经典侧边栏布局,左侧固定宽度(300px),右侧自适应填充剩余空间——
flex: 0 0 300px+flex: 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 * 1000(5 分钟),若未超时则直接返回缓存,模拟“本地缓存有效”场景。第 2 层:可控延迟与错误注入
封装mockFetch(url, options = {})函数,支持传入delay: 800(毫秒)和errorRate: 0.1(10% 概率返回 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-start+margin-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,其实际顺序仍是0(list)→1(detail),媒体查询的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: 1rem,50%就是width - 2rem的一半,再乘以 2,就少了2rem,导致子项总宽小于父容器,出现间隙。
解法:用box-sizing: border-box+flex-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 lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>疫情信息实时看板</title> <link rel="stylesheet" href="style.css"> </head> <body> <!-- 全局加载遮罩 --> <div id="global-loader" class="loader hidden"></div> <!-- 主容器:Flex column,全高 --> <div class="app"> <!-- 顶部导航栏 --> <header class="header"> <div class="header-logo">疫控看板</div> <div class="header-search"> <input type="text" id="search-input" placeholder="搜索省份..."> <button id="search-btn">🔍</button> </div> <div class="header-refresh" id="refresh-btn">🔄 刷新</div> </header> <!-- 主内容区:左右布局 --> <main class="main"> <!-- 左侧:地区列表 --> <aside class="list-panel"> <h2>全国疫情概览</h2> <div class="list-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 =>