先说一个我推进到一半差点绕回去的场景。我手上有一份生活垃圾焚烧发电厂的清单,里面是几十个厂名和地址,要做的事是让这些厂一个不差地出现在网页地图上,并且支持按省份筛选。一开始我想的是老老实实把坐标手工填进 HTML,后来发现不管是几十个还是几百个点位,只要数据源给的是结构化的 JSON,完全可以让页面自己去取——关键动作就一个:发 GET 请求。做完这个页面后,我对 GET 的理解也彻底变了:它不只是浏览器地址栏里那个“网址”,更是 HTML 页面从外部拿到数据的入口。
这篇我会按自己的实现顺序写清楚:为什么这个项目适合用 GET、数据里的坐标系要注意什么、页面骨架怎么搭、fetch 那几行代码怎么组织,以及最后把点位渲染到地图时踩的坑。适合刚学完 HTML 表单、想找一个真实项目练手的前端初学者,也适合需要快速搭建“数据展示页”的人参考。
1. 这个页面本质上是在问服务器“要数据”,而 GET 就是那个问话动作
1.1 从一个最简单的需求说起
页面要显示生活垃圾焚烧发电厂的名称、地址、经纬度。这些信息如果写死在前端 HTML 里,每次更新都得改文件,而且数据一多,文件里全是 <li> 和 <tr>,根本不好维护。于是得有个数据源,前端通过 HTTP 请求把数据“要”过来再渲染。
HTTP 里最常用的方法就是 GET,它从一开始就是为“拿数据”设计的。浏览器里打开一个普通网页,本质就是发了一个 GET 请求;我这次做查询页,从数据源拉 JSON,同样是发一个 GET 请求。区别只在于:打开网页时浏览器帮我们发了请求,而代码里需要手写请求逻辑。
1.2 GET 把查询条件放在 URL 里,比想象中更讲究
GET 请求的特征是“没有请求体”,所有信息都拼在 URL 上。比如我想拿浙江省的焚烧厂,可以拼成这样:
text复制https://example.com/api/incineration-plants?province=%E6%B5%99%E6%B1%9F&page=1
这里 ? 后面就是查询参数,多个参数用 & 连接。province=%E6%B5%99%E6%B1%9F 不是乱码,而是“浙江”两个字的 URL 编码。如果直接把中文写进去,某些服务端会解析失败,我自己用的是 URLSearchParams 来拼参数,它会自动处理编码,省去手动 encodeURIComponent 的麻烦。
这也解释了 HTML <form> 里为什么会有 method="get"。表单一旦用 GET 提交,浏览器会把每个输入项按 name=value 拼到地址栏问号后面。早些年没有前后端分离的 AJAX 时,大家就是这么靠页面跳转来传查询词的。
1.3 什么时候不该用 GET
做项目时还有个反面经验。GET 虽然方便,但它不适合干两件事:一是传大量数据,二是修改服务端数据。
同一个问题:如果你想在页面里添加一个“新建焚烧厂”的功能,那就不应该用 GET 了。GET 会把参数留在服务器日志和浏览器历史里,也不适合携带敏感信息。那种“新增、修改、删除”操作应该用 POST、PUT、DELETE。我这个查询项目只做检索,所以 GET 是最合适的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开工前最该确认的是数据源结构,不是先写装饰性代码
2.1 我用一份同目录 plants.json 作为数据源
做演示时,我不会一上来就去对接那种不知道什么时候会挂的外网接口,而是在项目根目录放一个 plants.json。这样做的好处有两个:第一,数据格式自己可控,方便理解请求返回后的结构;第二,本地服务器跑起来后,页面通过 GET 请求它,和请求远程接口的流程没本质区别。
我设计的精简字段如下:
json复制{
"list": [
{
"id": "zj-hz-001",
"name": "杭州某生活垃圾焚烧发电厂",
"province": "浙江省",
"city": "杭州市",
"address": "杭州市某工业园区",
"capacity": 3000,
"lat": 30.27,
"lng": 119.9
}
]
}
字段里最需要注意的是 lat 和 lng。这里我用的是 WGS-84 坐标系,也就是全球通用的经纬度标准,OpenStreetMap 地图上能直接对齐。
2.2 坐标系不一致,地图点位会偏几百米
这个小节是我实际踩过之后才补上的。如果页面后续要接国内图商的数据,或者从一些开放接口拉数据,得到的坐标不一定是 WGS-84。国内很多地图服务商返回的是 GCJ-02 坐标系,也叫“火星坐标”。它的经纬度和真实 WGS-84 坐标相比会有几百米的偏移。
| 坐标系 | 常见使用场景 | 和 OpenStreetMap 叠加时 |
|---|---|---|
| WGS-84 | 多数国际地图、GPS 原始数据、GeoJSON | 可以直接显示 |
| GCJ-02 | 国内互联网地图服务 | 不转换会偏移 100-700 米 |
| BD-09 | 个别国内地图厂商 | 需要二次转换 |
我给自己定了一个规矩:从数据源拿到坐标后,先问一句“这坐标是从哪来的”,绝不直接假设它就是标准经纬度。否则地图上点位看起来在城区里,实际可能偏到另一条街。
2.3 字段里的空值也要提前想好
真实名单里总有几个厂没有填“处理能力”或者“投运日期”。如果你直接在页面里把这个值渲染出来,就会出现“undefined”或一个空位。我的处理方式是在渲染函数里加一个兜底函数,为空时显示“暂无数据”:
javascript复制function showValue(value) {
return value === undefined || value === null || value === '' ? '暂无数据' : value;
}
这一步不复杂,但能直接决定表格看起来是否完整。
3. 从空文件开始搭页面:doctype、结构与顺手的交互
3.1 为什么每个 HTML 文件都要从那行声明开始
我在练习项目里见过不少“html文件无法预览”的问题,最后检查,十有八九是文件开头少了 <!doctype html>,或者编码声明位置不对。一个规范的空页面应该长这样:
html复制<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>生活垃圾焚烧发电厂位置信息</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- 页面内容 -->
<script src="app.js"></script>
</body>
</html>
<!doctype html> 是告诉浏览器“请用现代标准模式解析”,不是可有可无的注释。<html lang="zh-CN"> 是为了让浏览器、翻译插件和无障碍工具知道页面语言。<meta charset="utf-8"> 则必须放在 <head> 的最前面,因为浏览器需要尽早知道该按什么编码去读后面的字符。这几个元素写对了,后面出乱码和样式错乱的概率会小很多。
3.2 把筛选区、地图区、表格区当成三个盒子来排
这个页面主要分三块:
- 顶部筛选区:一个省份下拉菜单、一个关键词输入框、一个查询按钮。
- 中部地图区:用 Leaflet 显示点位。
- 下方列表区:用表格展示每个厂的详细字段。
我实际写 HTML 时,不会急着做布局,而是先搭语义化结构:
html复制<main>
<section class="filter-bar">
<select id="province">
<option value="">全部省份</option>
<option value="浙江省">浙江省</option>
<option value="江苏省">江苏省</option>
</select>
<input type="search" id="keyword" placeholder="输入厂名关键词">
<button id="searchBtn">查询</button>
</section>
<section class="map-container">
<div id="map"></div>
</section>
<section class="list-container">
<table>
<thead>
<tr>
<th>厂名</th>
<th>地区</th>
<th>处理能力(吨/日)</th>
<th>经度</th>
<th>纬度</th>
</tr>
</thead>
<tbody id="plantTableBody"></tbody>
</table>
</section>
</main>
这里我刻意没有直接用 <div> 套 <div>,而是拆成 <section>,好处是无论你自己看代码还是后面配合 CSS 选择器,都不会迷失在层层嵌套里。
3.3 列表很长时的“返回顶部”按钮,实现其实很机械
数据多起来后,表格往下滚动很费劲,所以我在页面右下方加了一个“返回顶部”按钮。这个需求听起来有点香,实际上就是一个滚动监听加一个滚动动作:
javascript复制const topBtn = document.getElementById('goTop');
window.addEventListener('scroll', function () {
if (window.scrollY > 400) {
topBtn.style.display = 'block';
} else {
topBtn.style.display = 'none';
}
});
topBtn.addEventListener('click', function () {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
});
判断阈值我取的是 400,也就是页面滚动超过 400 像素时按钮才出现,避免一打开页面就挡视线。如果你想要一个能“从底部连续回卷到顶部”的动画,可以用 requestAnimationFrame 自己控制一段距离逐渐归零;但浏览器原生 scrollTo({ behavior: 'smooth' }) 已经能覆盖大多数场景,没必要过度设计。
4. 核心代码:用 fetch 发 GET 请求,然后处理三种结果
4.1 fetch 默认就是 GET,所以别画蛇添足
很多人以为发请求前要先声明 method: 'GET',实际上 fetch 的默认请求方法就是 GET。只要没有加 POST、PUT 之类的配置,写 fetch(url) 就是发了一个 GET 请求。
我的核心函数是这样写的:
javascript复制const BASE_URL = './plants.json';
async function loadPlants(keyword = '', province = '') {
const params = {};
if (keyword) params.keyword = keyword;
if (province) params.province = province;
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${BASE_URL}?${queryString}` : BASE_URL;
// 请求开始前显示 loading
document.getElementById('loading').style.display = 'block';
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return data.list || [];
} catch (error) {
console.error('GET 请求出错:', error);
return [];
} finally {
document.getElementById('loading').style.display = 'none';
}
}
注意我在 url 后面拼接了查询参数。用静态文件 plants.json 时,这些参数会被本地服务器忽略,但当你把 BASE_URL 换成真实接口后,服务端就能按参数过滤返回结果了。这就是 GET 查询参数的意义。
4.2 不能只写 fetch 成功的那一行,要处理失败和异常
写第一个版本时,我以为只要拿到 response 就万事大吉。后来故意把 plants.json 改名,页面直接白屏,控制台却只有一个不起眼的红色报错。正确的做法是至少检查两层:
response.ok:代表 HTTP 状态码是否为 200-299 范围内。response.json():代表返回内容能否被正确解析成 JSON。
如果接口返回的是错误提示文本,比如 {"code": 500},但 HTTP 码是 200,那 response.ok 判断不出来。更严谨的做法是服务端约定 code 字段,前端要再判断一次。我这个项目简化了,只判断 HTTP 状态码。
4.3 把表格渲染出来,但少用 innerHTML 拼外部数据
数据拿到后要填充到 <tbody> 里。很多教程喜欢用字符串拼接 innerHTML,这在数据完全可信时很方便,但真实名单来自外部接口,字段里如果包含 <img onerror="..."> 一类内容,就可能导致意外的脚本执行。所以我渲染时尽量用 createElement 和 textContent:
javascript复制function renderTable(plants) {
const tbody = document.getElementById('plantTableBody');
tbody.innerHTML = '';
plants.forEach(function (plant) {
const tr = document.createElement('tr');
const nameCell = document.createElement('td');
nameCell.textContent = plant.name;
const areaCell = document.createElement('td');
areaCell.textContent = `${plant.province} ${plant.city}`;
const capacityCell = document.createElement('td');
capacityCell.textContent = plant.capacity ? plant.capacity + ' 吨/日' : '暂无数据';
const lngCell = document.createElement('td');
lngCell.textContent = plant.lng;
const latCell = document.createElement('td');
latCell.textContent = plant.lat;
tr.append(nameCell, areaCell, capacityCell, lngCell, latCell);
tbody.appendChild(tr);
});
}
这里没有用那种长串拼接模板,写起来会稍微啰嗦,但思路很清晰:每个单元格要么是文本节点,要么是空值占位。项目越往后迭代,这种安全写法越值得坚持。
4.4 把查询动作接上按钮和回车事件
光有加载函数还不够,页面要有触发入口。我给查询按钮绑定了点击事件,也让输入框支持直接按回车:
javascript复制document.getElementById('searchBtn').addEventListener('click', handleSearch);
document.getElementById('keyword').addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
handleSearch();
}
});
async function handleSearch() {
const keyword = document.getElementById('keyword').value.trim();
const province = document.getElementById('province').value;
const plants = await loadPlants(keyword, province);
const filtered = filterPlants(plants, keyword, province);
renderTable(filtered);
renderMapMarkers(filtered);
}
我特意让 loadPlants 和 filterPlants 分成两个函数:一个负责“从服务器拿数据”,一个负责“在前端列表里过滤”。这样即使以后改成服务端过滤,前端的按钮事件也不用大面积改动。
5. 从经纬度到地图点位,我选择了 Leaflet 而不是从零画地图
5.1 为什么用 Leaflet:它把最重复的瓦片拼接工作接走了
要在网页上画地图,最简单的方式不是自己写图形引擎,而是引入 Leaflet 这样的开源库。Leaflet 能加载瓦片底图、把经纬度转成屏幕坐标、管理弹窗和标记,我只用管数据本身。
地图区域需要一个固定高度,否则 Leaflet 初始化不到正确大小:
html复制<div id="map"></div>
CSS 里要让 #map 有高度,比如:
css复制#map {
height: 400px;
width: 100%;
}
然后初始化地图,并设置一个全国范围的初始视野:
javascript复制const map = L.map('map').setView([35, 105], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
[35, 105] 是中国的近似中心经纬度,4 是缩放级别,能让全国点都出现在一个视口里。
5.2 marker 不能无限叠加,先清理再添加
第二次点击查询时,我发现地图上的红点没有消失,和旧点叠在一起。原因很简单:每次查询都往地图上加了新的 marker,没有删掉旧的。于是我把 marker 收集到一个数组里,查询前先遍历删除:
javascript复制let markers = [];
function clearMarkers() {
markers.forEach(function (marker) {
map.removeLayer(marker);
});
markers = [];
}
function renderMapMarkers(plants) {
clearMarkers();
const points = [];
plants.forEach(function (plant) {
if (typeof plant.lat !== 'number' || typeof plant.lng !== 'number') {
return;
}
const marker = L.marker([plant.lat, plant.lng]);
marker.bindPopup(
`<strong>${escapeHtml(plant.name)}</strong><br>${escapeHtml(plant.address || '')}`
);
marker.addTo(map);
markers.push(marker);
points.push([plant.lat, plant.lng]);
});
if (points.length > 1) {
map.fitBounds(points);
} else if (points.length === 1) {
map.setView(points[0], 12);
}
}
这里我又遇到了一个必须处理的问题:bindPopup 支持 HTML 字符串,但如果厂名或地址字段里混入了特殊标签,弹窗会把它当 HTML 执行。所以我补了一个极简的 escapeHtml 函数,把 <、>、引号等都转成实体字符:
javascript复制function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
这样既保留了弹窗的格式,又不会让外部数据直接插进 HTML。
5.3 地图上能同时看到表格数据,交互才算闭环
当新数据到达后,列表区显示字段,地图区显示坐标点,两者是同一份数据驱动的。表格里滚动到某个厂,地图上能快速对点位形成印象;地图上的标记点开弹窗,又能看到厂名和地址。这就是“数据展示型页面”最基本的闭环。
我建议不要在这一步急着加复杂动画,先保证:数据源一变,表格和地图同步变。这样项目才有继续优化的基础。
6. 我踩过的几个坑:本地预览、跨域、404 和“看不到数据”
6.1 双击打开 HTML 时,为什么请求经常失败
用 fetch 去拿 plants.json,如果直接双击 HTML 文件,浏览器地址栏会变成类似 file:///Users/you/project/index.html 的路径。而 file:// 协议下,浏览器出于安全限制,不允许页面去读取同目录的其他文件。这时候常见现象是:“html文件无法预览,接口请求报 CORS 错误”。
解决方式很简单:在项目目录启动一个本地静态服务器。我用的命令是:
bash复制python3 -m http.server 8000
然后访问:
text复制http://localhost:8000/
只要是同一个 HTTP 域名下请求 ./plants.json,就不会再触发 file:// 跨域限制。这个经验对任何 fetch 项目都适用。
6.2 用 Network 面板观察 GET 请求的完整过程
如果页面数据没出来,我看的不是先改代码,而是打开开发者工具里的 Network 面板,刷新页面,找到那条名字是 plants.json 的请求。这里有四列是我每次必看的:
| 字段 | 说明 |
|---|---|
| Name | 请求的资源名,确认 URL 没拼错 |
| Status | 404 就说明文件路径不对,200 才是正常 |
| Type | 应该是 fetch 或 xhr,不是 document |
| Time | 判断是接口慢还是渲染慢 |
点开请求详情后,还能看到 Headers 里的请求方法,如果上面写着 GET,就说明请求确实发出去了。下一步再去 Response 标签页看返回内容。凡是能在这里看到 JSON,说明数据链路没问题,问题多半出在渲染函数里。
6.3 接口返回的字段名变了,页面会悄悄失效
做完页面后的头几天,我一直在改的是字段名。不同接口对经纬度的叫法有 lat/lng、latitude/longitude、y/x、location 等,如果不加一层映射,换一次接口就要改一次模板。我的做法是在 normalizePlant 函数里统一收口:
javascript复制function normalizePlant(raw) {
return {
name: raw.name || raw.factoryName || '未命名',
province: raw.province || '',
city: raw.city || '',
address: raw.address || '',
capacity: raw.capacity || '',
lat: Number(raw.lat ?? raw.latitude ?? raw.y),
lng: Number(raw.lng ?? raw.longitude ?? raw.x)
};
}
这个函数的作用是把不同来源的对象转换成页面内部统一使用的结构,后续改动只需要改这一个函数,而不是在表格和地图渲染里到处找字段。
6.4 筛选逻辑里容易忽略“空列表状态”
按省份筛选时,有可能在某次查询里一个厂都匹配不上。如果页面只更新地图,不清除旧标记,用户就会看到上一次的结果,产生误判。所以我在地图函数开头就执行了 clearMarkers(),同时在表格下方放了一个隐藏的“暂无匹配结果”提示。当 filtered.length === 0 时把它显示出来,表格留空。这种状态看着不起眼,却是数据页能不能让人信任的关键。
我自己在反复测试中得到的体会是:HTML 页面能不能被叫成“应用”,不在于引入了多贵的前端框架,而在于它对数据变化的反馈是否清晰。请求阶段有 loading,成功之后有渲染,失败之后有提示,空数据有占位——把这几件事按 GET 请求的节奏串起来,页面就“活”了。
最后分享一个小技巧:把 BASE_URL 从 ./plants.json 替换成真实接口前,先在浏览器地址栏直接访问一遍接口 URL,确认它能返回 JSON,再回页面调代码。这一步能帮我提前排除 90% 的“为什么页面没数据”的误解,值得养成习惯。
