前端基础回顾:HTML、CSS、JS 到底学到什么程度才够
概述
前端技术日新月异,但 HTML、CSS、JavaScript 这三个基础始终不变。框架会过时,构建工具会迭代,但 DOM 操作、布局原理、事件机制这些东西,十年后仍然有用。
这篇文章不教你怎么用 React 或 Vue,而是回到基础——浏览器到底怎么理解你的代码。
一、HTML — 语义与结构
1.1 HTML 不是”写标签”
很多人觉得 HTML 就是把内容用标签包起来。这种理解太浅了。HTML 的核心是语义——用正确的标签表达内容的含义,而不是它的样式。
<!-- 错误:用 div 模拟标题 --><div class="heading">欢迎来到我的网站</div><div class="subtitle">这是一个个人博客</div>
<!-- 正确:使用语义标签 --><h1>欢迎来到我的网站</h1><p>这是一个个人博客</p>浏览器、搜索引擎、屏幕阅读器都依赖 HTML 的语义来理解页面。<h1> 不只让文字变大变粗,它告诉浏览器”这是本页最重要的标题”。搜索引擎会给它更高的权重,屏幕阅读器会单独朗读。
1.2 文档结构
<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>页面标题</title> <meta name="description" content="页面描述,SEO 用"> <link rel="stylesheet" href="style.css"></head><body> <header> <nav> <ul> <li><a href="/">首页</a></li> <li><a href="/about">关于</a></li> </ul> </nav> </header> <main> <article> <h1>文章标题</h1> <section> <h2>章节标题</h2> <p>内容...</p> </section> </article> <aside> <h2>侧边栏</h2> </aside> </main> <footer> <p>© 2026</p> </footer></body></html>HTML5 引入了语义化标签(<header>、<nav>、<main>、<article>、<section>、<aside>、<footer>),这些标签和 <div> 在渲染上没有区别,但它们表达了结构。
1.3 常用标签速查
| 标签 | 用途 | 注意 |
|---|---|---|
<h1>~<h6> | 标题 | 一级标题每页只有一个 |
<p> | 段落 | 不要用 <br> 换段落 |
<a> | 链接 | href 必填,target="_blank" 加 rel="noopener" |
<img> | 图片 | alt 属性必填(无障碍) |
<ul> / <ol> | 无序/有序列表 | <li> 是直接子元素 |
<table> | 表格 | 只用表格数据,不要用来布局 |
<form> | 表单 | action + method |
<input> | 输入框 | type 很重要(text/email/number/password/date/checkbox/radio) |
<button> | 按钮 | 比 <div> 做按钮更好(可访问性) |
<div> | 无语义容器 | 最后一个选择,先用语义标签 |
<span> | 行内容器 | 包裹文本片段 |
1.4 表单与输入
表单是 Web 交互的基础。理解表单的提交方式是关键:
<!-- GET 提交:参数在 URL 上,用于搜索 --><form action="/search" method="GET"> <label for="q">搜索:</label> <input type="text" id="q" name="q" required> <button type="submit">搜索</button></form>
<!-- POST 提交:参数在请求体中,用于提交数据 --><form action="/api/register" method="POST"> <label for="email">邮箱:</label> <input type="email" id="email" name="email" required>
<label for="password">密码:</label> <input type="password" id="password" name="password" minlength="6" required>
<label> <input type="checkbox" name="agree" required> 同意用户协议 </label>
<button type="submit">注册</button></form><label> 的 for 属性关联到 <input> 的 id,点击标签时自动聚焦输入框。这不仅提升可用性,也是无障碍的基本要求。
二、CSS — 布局与样式
2.1 CSS 的本质
CSS 是”选择元素 → 设置属性”的声明式语言。它告诉浏览器页面应该长什么样,而不是怎么画出这个样。
/* 选择器 { 属性: 值; } */h1 { color: #333; font-size: 2rem; margin-bottom: 1em;}2.2 选择器
CSS 选择器决定了样式应用在哪些元素上。掌握选择器是写好 CSS 的第一步。
/* 基本选择器 */* /* 通配符,选中所有元素 */div /* 标签选择器 */.class-name /* 类选择器 */#id-name /* ID 选择器(慎用,优先级太高) */
/* 组合选择器 */div p /* 后代选择器(div 里面的所有 p) */div > p /* 子代选择器(div 的直接子 p) */h1 + p /* 相邻兄弟选择器(h1 后面紧挨着的 p) */h1 ~ p /* 通用兄弟选择器(h1 后面的所有 p) */
/* 属性选择器 */[type="text"] /* type 等于 text */[href^="https"] /* href 以 https 开头 */[src$=".jpg"] /* src 以 .jpg 结尾 */[class*="btn"] /* class 包含 btn */
/* 伪类(元素的状态) */a:hover /* 鼠标悬停 */a:active /* 鼠标按下 */a:visited /* 已访问 */input:focus /* 聚焦 */li:first-child /* 第一个子元素 */li:last-child /* 最后一个子元素 */li:nth-child(odd) /* 奇数行 */li:nth-child(3n+1) /* 每 3 个的第一个 */:not(.disabled) /* 排除 */
/* 伪元素(元素的特定部分) */::before /* 元素前插入内容 */::after /* 元素后插入内容 */::first-line /* 第一行 */::selection /* 选中部分 */2.3 盒模型——CSS 最核心的概念
每一个 HTML 元素都是一个矩形盒子。盒模型决定了这个盒子在页面中占多大空间。
┌──────────────────────────────────┐│ margin(外边距) ││ ┌────────────────────────────┐ ││ │ border(边框) │ ││ │ ┌──────────────────────┐ │ ││ │ │ padding(内边距) │ │ ││ │ │ ┌────────────────┐ │ │ ││ │ │ │ content │ │ │ ││ │ │ │ (内容区域) │ │ │ ││ │ │ └────────────────┘ │ │ ││ │ └──────────────────────┘ │ ││ └────────────────────────────┘ │└──────────────────────────────────┘/* box-sizing 决定了宽度的计算方式 */box-sizing: content-box; /* 默认:width = content 宽度 */box-sizing: border-box; /* 推荐:width = content + padding + border */
/* 一个好实践:全局设置 border-box */*, *::before, *::after { box-sizing: border-box;}content-box 和 border-box 的区别:
/* content-box(默认) *//* width: 200px → 内容区 200px *//* 实际占用宽度 = 200 + 10*2 + 2*2 = 224px */.box { width: 200px; padding: 10px; border: 2px solid black;}
/* border-box(推荐) *//* width: 200px → 总宽度 = 200px *//* 内容区宽度 = 200 - 10*2 - 2*2 = 176px */.box { box-sizing: border-box; width: 200px; padding: 10px; border: 2px solid black;}2.4 布局方案演变
CSS 的布局能力经历了三个阶段:
传统:display: block / inline / table经典:float + clear + position现代:Flexbox + GridFlexbox(一维布局)
Flexbox 解决的是”在一行或一列中排列元素”的问题。它是最常用、最好用的布局方案。
/* 容器设置 */.container { display: flex; /* 开启 flex 布局 */ flex-direction: row; /* 主轴方向:row | column */ justify-content: center; /* 主轴对齐方式 */ align-items: center; /* 交叉轴对齐方式 */ flex-wrap: wrap; /* 是否换行 */ gap: 16px; /* 项目间距(现代写法) */}
/* 项目设置 */.item { flex: 1; /* 等分剩余空间 */ flex: 0 0 auto; /* flex-grow flex-shrink flex-basis */ align-self: center; /* 单独对齐方式 */ order: -1; /* 改变顺序 */}常用 flex 布局模式:
/* 水平垂直居中 */.center { display: flex; justify-content: center; align-items: center; min-height: 100vh; /* 相对于视口高度 */}
/* 等分布局 */.equal-cols { display: flex; gap: 20px;}.equal-cols > * { flex: 1;}
/* 两端对齐导航 */.nav { display: flex; justify-content: space-between; align-items: center;}
/* 右侧固定,左侧自适应 */.layout { display: flex;}.sidebar { width: 240px; flex-shrink: 0;}.main { flex: 1;}Grid(二维布局)
Grid 是 CSS 最强布局方案,适合做页面整体框架和复杂网格。
.container { display: grid; grid-template-columns: 1fr 2fr 1fr; /* 三列,比例 1:2:1 */ grid-template-rows: auto 1fr auto; /* 三行 */ gap: 20px; /* 简写 */ grid-template: "header header header" auto "sidebar main aside" 1fr "footer footer footer" auto / 1fr 2fr 1fr;}.header { grid-area: header; }.sidebar { grid-area: sidebar; }.main { grid-area: main; }.aside { grid-area: aside; }.footer { grid-area: footer; }
/* 自动填充网格 */.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px;}2.5 定位
/* 静态定位(默认) */position: static;
/* 相对定位:相对于自身原本位置偏移 */position: relative;top: 10px;left: 10px;
/* 绝对定位:相对于最近的 position:relative/absolute/fixed 的父元素 */position: absolute;top: 0;right: 0;
/* 固定定位:相对于视口 */position: fixed;bottom: 20px;right: 20px;
/* 粘性定位:滚动到某个位置后固定 */position: sticky;top: 0; /* 到顶部时固定 */2.6 响应式设计
/* 媒体查询 *//* 手机:< 768px *//* 平板:768px ~ 1024px *//* 桌面:> 1024px */
/* 移动优先:先写手机样式,再覆盖更大的屏幕 */.container { padding: 16px; max-width: 100%;}
@media (min-width: 768px) { .container { padding: 24px; max-width: 720px; margin: 0 auto; }}
@media (min-width: 1024px) { .container { max-width: 960px; padding: 32px; }}
/* 响应式图片 */img { max-width: 100%; height: auto;}
/* 响应式字体 */html { font-size: 16px; /* 基准 */}h1 { font-size: 2rem; } /* 32px */p { font-size: 1rem; } /* 16px */
@media (min-width: 768px) { html { font-size: 18px; } /* 整体放大 */}
/* 使用 clamp 实现流体字体 */h1 { font-size: clamp(1.5rem, 4vw, 3rem);}三、JavaScript — 行为与交互
3.1 JavaScript 在浏览器中的角色
HTML 负责结构,CSS 负责样式,JavaScript 负责行为。JS 在浏览器中的核心能力:
- 操作 DOM:增删改查 HTML 元素
- 处理事件:响应用户的点击、输入、滚动等操作
- 网络请求:通过 Fetch/XMLHttpRequest 与服务器通信
- 存储数据:cookie、localStorage、sessionStorage
- 操作画布:Canvas、WebGL 绘图
- 处理异步:Promise、async/await
3.2 变量与类型
// 声明变量var old = '不要用 var' // 函数作用域,有提升(hoisting)let name = 'ES6 写法' // 块级作用域,推荐const PI = 3.14159 // 常量,引用类型不可重新赋值
// 值类型typeof 42 // 'number'typeof 'hello' // 'string'typeof true // 'boolean'typeof undefined // 'undefined'typeof null // 'object'(语言 bug,不要纠结)typeof Symbol() // 'symbol'typeof 9007199254740991n // 'bigint'
// 引用类型typeof {} // 'object'typeof [] // 'object'(数组也是对象)typeof function() {} // 'function'类型转换是 JS 最常见的坑:
// 隐式转换(能避免就避免)'5' - 3 // 2(字符串转数字)'5' + 3 // '53'(数字转字符串,+ 有字符串优先)'5' == 5 // true(== 会做类型转换)'5' === 5 // false(=== 不做类型转换,推荐)
// 显式转换Number('42') // 42String(42) // '42'Boolean(0) // falseBoolean('') // falseBoolean(null) // falseBoolean(undefined) // falseBoolean('false') // true(非空字符串都是 true!)3.3 函数
// 函数声明(会被提升)function add(a, b) { return a + b;}
// 函数表达式(不会被提升)const add = function(a, b) { return a + b;};
// 箭头函数(ES6)const add = (a, b) => a + b;const square = x => x * x;const noArgs = () => console.log('no params');
// 箭头函数的 this 指向外层作用域(重要!)const obj = { name: 'obj', fn: function() { console.log(this.name); // 'obj',this 指向 obj }, arrowFn: () => { console.log(this.name); // undefined,this 指向外层(window/global) }};
// 默认参数function greet(name = '游客') { return `你好,${name}`;}
// 剩余参数function sum(...numbers) { return numbers.reduce((a, b) => a + b, 0);}
// 解构参数function createUser({ name, age = 18, email }) { return { name, age, email };}createUser({ name: '张三', email: 'z@example.com' });3.4 DOM 操作
// 查询元素document.getElementById('id')document.querySelector('.class') // 返回第一个匹配document.querySelectorAll('div span') // 返回 NodeList(可遍历)document.getElementsByClassName('cls') // 返回 HTMLCollection(动态)document.getElementsByTagName('div')
// 创建元素const div = document.createElement('div');div.textContent = 'Hello';div.className = 'box';div.id = 'myBox';div.setAttribute('data-index', '1');
// 插入元素parent.appendChild(div);parent.insertBefore(div, referenceChild);parent.prepend(div); // 插到最前面parent.append(div); // 插到最后面element.insertAdjacentHTML('beforeend', '<span>new</span>');
// 删除元素element.remove(); // 直接删除(推荐)parent.removeChild(element);
// 修改元素element.textContent = '纯文本'; // 安全,不解析 HTMLelement.innerHTML = '<b>富文本</b>'; // 有 XSS 风险element.style.color = 'red';element.style.display = 'flex';element.classList.add('active');element.classList.remove('hidden');element.classList.toggle('expanded');element.classList.contains('active'); // true/false
// 遍历 DOMelement.children // 子元素(HTMLCollection)element.childNodes // 子节点(包含文本节点)element.parentElement // 父元素element.nextElementSibling // 下一个兄弟元素element.previousElementSibling // 上一个兄弟元素element.closest('.wrapper') // 向上查找最近的匹配元素3.5 事件
// 添加事件button.addEventListener('click', function(event) { console.log('点击了', event.target);});
// 移除事件(必须使用同一个函数引用)function handler(e) { console.log(e); }button.addEventListener('click', handler);button.removeEventListener('click', handler);
// 事件对象element.addEventListener('click', (e) => { e.target // 触发事件的元素 e.currentTarget // 绑定事件的元素 e.preventDefault() // 阻止默认行为 e.stopPropagation() // 阻止事件冒泡});
// 事件委托:利用冒泡机制,在父元素上统一处理document.querySelector('ul').addEventListener('click', (e) => { if (e.target.tagName === 'LI') { console.log('点击了 li', e.target.textContent); }});
// 常用事件click // 点击dblclick // 双击mouseenter // 鼠标进入mouseleave // 鼠标离开mousemove // 鼠标移动input // 输入框内容变化change // 输入框失去焦点时值变化submit // 表单提交keydown // 键盘按下keyup // 键盘抬起scroll // 滚动focus // 聚焦blur // 失焦load // 页面/图片加载完成DOMContentLoaded // DOM 解析完成(不等图片加载)
// 自定义事件const event = new CustomEvent('app:update', { detail: { id: 1 } });window.dispatchEvent(event);
window.addEventListener('app:update', (e) => { console.log('收到更新:', e.detail);});3.6 异步 JavaScript
// 回调(Callback)—— 传统写法function fetchData(callback) { setTimeout(() => { callback({ id: 1, name: 'data' }); }, 1000);}fetchData((data) => { console.log(data);});
// Promise —— 解决回调地狱function fetchData() { return new Promise((resolve, reject) => { setTimeout(() => { resolve({ id: 1, name: 'data' }); // reject(new Error('失败')); // 错误情况 }, 1000); });}
fetchData() .then(data => { console.log(data); return fetchData(); // 链式调用 }) .then(moreData => { console.log(moreData); }) .catch(error => { console.error(error); }) .finally(() => { console.log('无论如何都会执行'); });
// async/await —— 最优雅的异步写法async function getData() { try { const data = await fetchData(); console.log(data); const moreData = await fetchData(); console.log(moreData); } catch (error) { console.error(error); }}
// 并发请求async function getMultipleData() { const [users, posts, comments] = await Promise.all([ fetch('/api/users'), fetch('/api/posts'), fetch('/api/comments') ]); return { users, posts, comments };}3.7 网络请求
// Fetch API(现代写法)async function getUsers() { const response = await fetch('https://api.example.com/users');
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
const data = await response.json(); return data;}
// POST 请求async function createUser(userData) { const response = await fetch('https://api.example.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(userData) }); return response.json();}
// 传统 XMLHttpRequestfunction loadData(url, callback) { const xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.onload = () => { if (xhr.status === 200) { callback(null, JSON.parse(xhr.responseText)); } else { callback(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => callback(new Error('网络错误')); xhr.send();}3.8 数组方法
数组操作是前端开发中最频繁的任务:
const arr = [1, 2, 3, 4, 5];
// 遍历arr.forEach(item => console.log(item));
// 映射const doubled = arr.map(x => x * 2);// [2, 4, 6, 8, 10]
// 过滤const evens = arr.filter(x => x % 2 === 0);// [2, 4]
// 查找const found = arr.find(x => x > 3); // 4(第一个匹配)const index = arr.findIndex(x => x > 3); // 3
// 判断const allPositive = arr.every(x => x > 0); // trueconst hasNegative = arr.some(x => x < 0); // false
// 累计const sum = arr.reduce((acc, cur) => acc + cur, 0); // 15const max = arr.reduce((a, b) => Math.max(a, b)); // 5
// 扁平化const nested = [1, [2, 3], [4, [5, 6]]];nested.flat(); // [1, 2, 3, 4, [5, 6]]nested.flat(2); // [1, 2, 3, 4, 5, 6]
// 排序(注意:默认按字符串排序)arr.sort((a, b) => a - b); // 升序arr.sort((a, b) => b - a); // 降序
// 去重const unique = [...new Set([1, 2, 2, 3, 3, 4])]; // [1, 2, 3, 4]
// 不可变操作(重要!)const added = [...arr, 6]; // 添加const removed = arr.filter(x => x !== 3); // 删除const updated = arr.map(x => x === 3 ? 99 : x); // 更新3.9 对象与原型
// 对象字面量const user = { name: '张三', age: 25, greet() { console.log(`你好,我是${this.name}`); }};
// 属性简写const name = '张三';const user = { name }; // 等价于 { name: name }
// 计算属性名const key = 'email';const user = { [key]: 'z@example.com' };
// 可选链(ES2020)const city = user?.address?.city ?? '未知'; // ?. 避免层层判断// ?? 是空值合并运算符:只有当 left 是 null/undefined 时才用 right
// 对象解构const { name, age, ...rest } = user;
// 展开const clone = { ...user };const merged = { ...obj1, ...obj2 };
// Object 静态方法Object.keys(obj) // 键数组Object.values(obj) // 值数组Object.entries(obj) // [键, 值] 数组Object.assign(target, source) // 合并(浅拷贝)3.10 模块
// 导出(ES Module)export const PI = 3.14159;export function add(a, b) { return a + b; }export default class Calculator { ... }
// 导入import Calculator, { PI, add as sum } from './math.js';import * as Math from './math.js';四、三者的协作
4.1 浏览器渲染流程
当浏览器加载一个页面时,它的工作流程:
HTML 解析 → DOM 树CSS 解析 → CSSOM 树 ↓DOM + CSSOM → Render Tree ↓Layout(布局计算) ↓Paint(绘制像素) ↓Composite(合成图层)JavaScript 在这中间扮演的角色:
- 解析 HTML 时遇到
<script>标签会阻塞解析(默认行为) defer属性让 JS 在 HTML 解析完成后执行(顺序执行)async属性让 JS 下载完立即执行(不保证顺序)
<script src="script.js"></script><!-- 阻塞 HTML 解析,下载并执行完才继续解析 -->
<script defer src="script.js"></script><!-- 与 HTML 解析并行下载,解析完成后按顺序执行 -->
<script async src="script.js"></script><!-- 与 HTML 解析并行下载,下载完立即执行 -->4.2 DOMContentLoaded vs Load
// DOM 树构建完成(图片可能还没加载)document.addEventListener('DOMContentLoaded', () => { console.log('DOM 就绪');});
// 所有资源(图片、样式、字体)加载完成window.addEventListener('load', () => { console.log('全加载完成');});4.3 回流与重绘
// 回流(Reflow):布局尺寸变化,性能代价大// 触发条件:改变宽高、增删 DOM、改变字体、窗口缩放element.style.width = '200px'; // 触发回流
// 重绘(Repaint):外观变化不涉及布局,代价较小// 触发条件:改变颜色、背景、visibilityelement.style.color = 'red'; // 只触发重绘
// 优化:批量修改 DOM// 坏做法const el = document.getElementById('box');el.style.width = '100px';el.style.height = '100px';el.style.margin = '10px';// 每次修改都可能触发回流
// 好做法:使用 classel.className = 'box';// 或:使用 cssTextel.style.cssText = 'width: 100px; height: 100px; margin: 10px;';
// 好做法:文档片段const fragment = document.createDocumentFragment();for (let i = 0; i < 1000; i++) { const li = document.createElement('li'); li.textContent = `Item ${i}`; fragment.appendChild(li);}list.appendChild(fragment); // 只触发一次回流五、这三者在博达网站群 FreeMarker 中的实际应用
前四节是通用的前端基础。这一节专门讲——在博达网站群平台中,HTML、CSS、JS 是怎么和 FreeMarker 模板结合使用的。
5.1 FreeMarker 是”骨架增强器”
博达的 .ftl 模板本质就是 HTML,只是多了 FreeMarker 的占位符和控制指令。浏览器最终拿到的是纯 HTML——FreeMarker 在服务端渲染时已经把变量和标签都替换掉了。
<#-- 博达模板中典型的 HTML 结构 --><!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${channel.name} - ${site.name}</title> <meta name="keywords" content="${channel.keywords!site.keywords!}"> <meta name="description" content="${channel.description!site.description!}">
<#-- CSS 引入:存放在发布目录 _resources 下 --> <link rel="stylesheet" href="${site.contextPath}/_resources/css/main.css"> <link rel="stylesheet" href="${site.contextPath}/_resources/css/${channel.templateStyle!'default'}.css"></head><body> <#-- 页头(公共模板片段) --> <#include "/_common/header.ftl">
<#-- 导航栏(用 FreeMarker 循环生成 HTML) --> <nav class="main-nav"> <ul> <@cms.channelList siteId="${site.id}" parentId="0"> <#list channels as ch> <li class="${(ch.id == channel.id)?string('active', '')}"> <a href="${ch.url}">${ch.name}</a> <#if ch.hasChildren> <ul class="sub-nav"> <@cms.channelList siteId="${site.id}" parentId="${ch.id}"> <#list channels as sub> <li><a href="${sub.url}">${sub.name}</a></li> </#list> </@cms.channelList> </ul> </#if> </li> </#list> </@cms.channelList> </ul> </nav>
<#-- 正文区域 --> <main class="container"> <#-- 面包屑 --> <div class="breadcrumb"> <a href="${site.contextPath}/">首页</a> <#list breadcrumb as crumb> > <a href="${crumb.url}">${crumb.name}</a> </#list> </div>
<#-- 内容列表 --> <div class="content-list"> <@cms.contentList siteId="${site.id}" channelId="${channel.id}" pageNo="${pageNo!1}" pageSize="${channel.pageSize!20}"> <#list contents as item> <article class="news-item"> <#if item.isTop> <span class="tag top">置顶</span> </#if> <h2><a href="${item.url}">${item.title}</a></h2> <p class="meta"> <span>${item.author!"佚名"}</span> <span>${item.publishDate?string("yyyy-MM-dd")}</span> <#if item.source??> <span>来源:${item.source}</span> </#if> </p> <p class="summary">${item.summary!""}</p> </article> </#list> </@cms.contentList> </div>
<#-- 分页 --> <#if totalPages gt 1> <div class="pagination"> <#if pageNo gt 1> <a href="${channel.url}${pageNo - 1}.html" class="prev">上一页</a> </#if> <#list 1..totalPages as p> <a href="${channel.url}${p}.html" class="${(p == pageNo)?string('current', '')}">${p}</a> </#list> <#if pageNo lt totalPages> <a href="${channel.url}${pageNo + 1}.html" class="next">下一页</a> </#if> </div> </#if> </main>
<#-- 侧边栏 --> <aside class="sidebar"> <#include "/_common/sidebar.ftl"> </aside>
<#-- 页脚 --> <#include "/_common/footer.ftl">
<#-- JS 引入 --> <script src="${site.contextPath}/_resources/js/main.js"></script> <script src="${site.contextPath}/_resources/js/${channel.templateScript!'common'}.js"></script>
<#-- 站点统计代码(后台配置的第三方统计) --> ${site.statCode!}</body></html>关键的对应关系:
| 前端基础 | 博达 FreeMarker 中的体现 |
|---|---|
| HTML 语义标签 | 模板中用 <article>、<nav>、<main>、<aside> 等,FTL 负责动态填充内容 |
| CSS 选择器与盒模型 | CSS 文件放在 _resources/css/,模板通过 ${site.contextPath} 引用路径 |
| CSS Flexbox/Grid 布局 | 前台页面布局完全靠 CSS 实现,FTL 只负责输出结构化的 HTML |
| CSS 响应式设计 | 媒体查询同样适用,模板中可用 FreeMarker 条件判断输出不同样式类 |
| JS DOM 操作 | 前台页面 JS 放在 _resources/js/,操作渲染后的静态 DOM |
| JS 事件处理 | 同样使用 addEventListener,但要注意静态页面的事件绑定时机 |
| JS 网络请求 | 博达前台页面极少用 AJAX(因为是静态页面),后台管理大量使用 |
5.2 CSS 在博达模板中的典型用法
路径问题是博达前端开发最需要注意的地方。由于博达支持多站点和静态发布,CSS 路径不能用绝对路径或相对路径,必须用 ${site.contextPath} 动态拼接。
<#-- 正确:使用 contextPath 动态路径 --><link rel="stylesheet" href="${site.contextPath}/_resources/css/main.css"><link rel="stylesheet" href="${site.contextPath}/_resources/css/pages/news.css">
<#-- 错误:硬编码路径,站点迁移或更换域名后会失效 --><link rel="stylesheet" href="/_resources/css/main.css"><link rel="stylesheet" href="css/main.css">多模板风格切换是博达常见的需求,通过 FreeMarker 条件判断实现:
<#-- 根据栏目的模板风格配置加载不同 CSS --><#if channel.templateStyle == 'blue'> <link rel="stylesheet" href="${site.contextPath}/_resources/css/theme-blue.css"><#elseif channel.templateStyle == 'red'> <link rel="stylesheet" href="${site.contextPath}/_resources/css/theme-red.css"><#else> <link rel="stylesheet" href="${site.contextPath}/_resources/css/theme-default.css"></#if>响应式设计在博达中同样需要。因为高校和政府网站的访客大量来自手机端:
/* 博达站点的典型响应式布局 */
/* 桌面优先 */.container { max-width: 1200px; margin: 0 auto; padding: 0 20px;}
.main-content { display: flex; gap: 30px;}
.main-content .primary { flex: 1; min-width: 0; /* 防止 flex 溢出 */}
.main-content .sidebar { width: 300px; flex-shrink: 0;}
@media (max-width: 768px) { .container { padding: 0 12px; }
.main-content { flex-direction: column; }
.main-content .sidebar { width: 100%; }
/* 移动端导航切换 */ .main-nav ul { display: none; } .main-nav.open ul { display: block; } .nav-toggle { display: block; }}
@media (max-width: 480px) { html { font-size: 14px; } .news-item h2 { font-size: 1.1rem; }}5.3 JavaScript 在博达前台的应用场景
博达前台是静态页面(发布后是 .html 文件),所以 JS 的使用场景和传统动态站点不同:
场景一:导航菜单交互
// 移动端导航切换document.addEventListener('DOMContentLoaded', function() { var toggle = document.querySelector('.nav-toggle'); var nav = document.querySelector('.main-nav');
if (toggle && nav) { toggle.addEventListener('click', function() { nav.classList.toggle('open'); }); }});
// 高亮当前页面的导航项document.addEventListener('DOMContentLoaded', function() { var currentPath = window.location.pathname; document.querySelectorAll('.main-nav a').forEach(function(link) { if (link.getAttribute('href') === currentPath) { link.classList.add('active'); } });});场景二:图片懒加载
博达站点通常有大量图片,懒加载可以显著提升性能:
// 利用 IntersectionObserver 实现图片懒加载document.addEventListener('DOMContentLoaded', function() { var lazyImages = document.querySelectorAll('img[data-src]');
if ('IntersectionObserver' in window) { var observer = new IntersectionObserver(function(entries) { entries.forEach(function(entry) { if (entry.isIntersecting) { var img = entry.target; img.src = img.dataset.src; img.removeAttribute('data-src'); observer.unobserve(img); } }); });
lazyImages.forEach(function(img) { observer.observe(img); }); } else { // 降级处理:直接加载所有图片 lazyImages.forEach(function(img) { img.src = img.dataset.src; }); }});场景三:搜索框自动补全(少量场景使用)
博达前台搜索通常是 GET 提交到搜索页面,但有些站点会做 AJAX 搜索建议:
// 搜索建议(需要后端提供 JSON API)var searchInput = document.querySelector('.search-input');var suggestBox = document.querySelector('.search-suggestions');
if (searchInput) { var debounceTimer; searchInput.addEventListener('input', function() { clearTimeout(debounceTimer); var keyword = this.value.trim();
if (keyword.length < 2) { suggestBox.innerHTML = ''; suggestBox.style.display = 'none'; return; }
debounceTimer = setTimeout(function() { fetch('/api/search/suggest?q=' + encodeURIComponent(keyword)) .then(function(res) { return res.json(); }) .then(function(data) { if (data.length > 0) { suggestBox.innerHTML = data.map(function(item) { return '<div class="suggest-item">' + item.title + '</div>'; }).join(''); suggestBox.style.display = 'block'; } else { suggestBox.style.display = 'none'; } }); }, 300); });
// 点击外部关闭建议框 document.addEventListener('click', function(e) { if (!e.target.closest('.search-wrap')) { suggestBox.style.display = 'none'; } });}场景四:TAB 切换
博达前台常用 TAB 切换展示不同分类的内容:
<#-- 模板中的 TAB 结构 --><div class="tab-wrap"> <div class="tab-nav"> <a class="tab-item active" data-tab="notice">通知公告</a> <a class="tab-item" data-tab="news">新闻动态</a> <a class="tab-item" data-tab="download">下载中心</a> </div> <div class="tab-content"> <div class="tab-panel active" id="tab-notice"> <@cms.contentList siteId="${site.id}" channelId="1" pageSize="10"> <#list contents as item> <li><a href="${item.url}">${item.title}</a></li> </#list> </@cms.contentList> </div> <div class="tab-panel" id="tab-news"> <@cms.contentList siteId="${site.id}" channelId="2" pageSize="10"> <#list contents as item> <li><a href="${item.url}">${item.title}</a></li> </#list> </@cms.contentList> </div> <div class="tab-panel" id="tab-download"> <@cms.contentList siteId="${site.id}" channelId="3" pageSize="10"> <#list contents as item> <li><a href="${item.url}">${item.title}</a></li> </#list> </@cms.contentList> </div> </div></div>// TAB 切换document.querySelectorAll('.tab-nav').forEach(function(nav) { nav.addEventListener('click', function(e) { var target = e.target.closest('.tab-item'); if (!target) return;
// 切换 TAB 高亮 nav.querySelectorAll('.tab-item').forEach(function(item) { item.classList.remove('active'); }); target.classList.add('active');
// 切换内容面板 var tabId = 'tab-' + target.dataset.tab; var content = target.closest('.tab-wrap').querySelector('.tab-content'); content.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.remove('active'); }); document.getElementById(tabId).classList.add('active'); });});5.4 博达模板中 CSS/JS 的发布与缓存
这是博达开发的常见坑。修改了 CSS 或 JS 文件后,浏览器可能缓存了旧版本,导致前台看不到修改效果。
方案一:版本号参数(推荐)
<#-- 在模板中给 CSS/JS 加版本号 --><#-- 版本号可以从站点配置中读取,或用时间戳 --><link rel="stylesheet" href="${site.contextPath}/_resources/css/main.css?v=${site.staticVersion!'20260601'}"><script src="${site.contextPath}/_resources/js/main.js?v=${site.staticVersion!'20260601'}"></script>每次修改静态资源后,在后台更新 staticVersion 的值,所有引用自动刷新缓存。
方案二:文件名哈希(自动化程度高)
如果能修改博达的发布流程,可以在发布时对 CSS/JS 文件做哈希命名:
<#-- 使用哈希文件名,修改后文件名自动变化 --><link rel="stylesheet" href="${site.contextPath}/_resources/css/main.${staticHash('main.css')}.css">5.5 博达后台管理中的 JS
博达后台(JSP 页面)比前台用到了更多的 JS,包括:
| 场景 | 技术 | 说明 |
|---|---|---|
| 栏目树展开/折叠 | 原生 JS / jQuery | 树形控件操作 |
| 资料批量操作 | AJAX | 批量删除、移动、发布 |
| 模板在线编辑 | CodeMirror / Monaco | 代码编辑器 |
| 文件上传 | FormData + AJAX | 附件上传带进度条 |
| 富文本编辑 | CKEditor / UEditor | 资料正文编辑 |
| 数据表格排序 | 原生 JS | 列表排序 |
| 弹窗/对话框 | 自定义 JS | 确认、提示、警告 |
后台 JSP 页面中典型的 AJAX 请求:
<script>function batchPublish() { var ids = []; document.querySelectorAll('input[name="ids"]:checked').forEach(function(cb) { ids.push(cb.value); });
if (ids.length === 0) { alert('请选择要发布的内容'); return; }
if (!confirm('确定发布选中的 ' + ids.length + ' 条内容?')) { return; }
// 发送发布请求 fetch('${pageContext.request.contextPath}/admin/content/batchPublish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ids }) }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.code === 200) { alert('发布任务已提交,请在发布队列查看进度'); location.reload(); } else { alert('操作失败:' + data.message); } }) .catch(function() { alert('网络错误'); });}</script>5.6 四者的关系总结
在博达模板开发中,四者的分工非常清晰:
FreeMarker(服务端渲染) │ 生成静态 HTML ▼HTML(结构骨架)────── CSS(样式皮肤) │ └──── JS(浏览器端交互)- FreeMarker 在服务器上运行,生成最终的 HTML 文件。它负责把数据库中的栏目、资料数据填入 HTML 模板,输出纯静态页面。
- HTML 是 FreeMarker 的输出格式,也是浏览器接收的内容。FreeMarker 的标签(
<@cms.channelList>等)最终都会被替换成纯 HTML。 - CSS 完全不变,直接放在
_resources/css/下由 HTML 引用。FreeMarker 只负责输出正确的<link>标签和路径。 - JS 同样不变,放在
_resources/js/下。但要注意:博达前台页面是静态的,JS 只做纯客户端的交互增强,不依赖后端接口(搜索等少数场景除外)。后台管理 JSP 页面中的 JS 则大量使用 AJAX 与后端交互。
理解了这个分工,你就能在博达模板开发中清楚地判断——某个功能应该用 FreeMarker 标签实现,还是写在 HTML/CSS/JS 里。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!