前端基础回顾:HTML、CSS、JS 到底学到什么程度才够

6278 字
31 分钟
前端基础回顾: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>&copy; 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-boxborder-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 + Grid

Flexbox(一维布局)#

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 在浏览器中的核心能力:

  1. 操作 DOM:增删改查 HTML 元素
  2. 处理事件:响应用户的点击、输入、滚动等操作
  3. 网络请求:通过 Fetch/XMLHttpRequest 与服务器通信
  4. 存储数据:cookie、localStorage、sessionStorage
  5. 操作画布:Canvas、WebGL 绘图
  6. 处理异步: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') // 42
String(42) // '42'
Boolean(0) // false
Boolean('') // false
Boolean(null) // false
Boolean(undefined) // false
Boolean('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 = '纯文本'; // 安全,不解析 HTML
element.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
// 遍历 DOM
element.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();
}
// 传统 XMLHttpRequest
function 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); // true
const hasNegative = arr.some(x => x < 0); // false
// 累计
const sum = arr.reduce((acc, cur) => acc + cur, 0); // 15
const 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 模块#

math.js
// 导出(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 在这中间扮演的角色:

  1. 解析 HTML 时遇到 <script> 标签会阻塞解析(默认行为)
  2. defer 属性让 JS 在 HTML 解析完成后执行(顺序执行)
  3. 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):外观变化不涉及布局,代价较小
// 触发条件:改变颜色、背景、visibility
element.style.color = 'red'; // 只触发重绘
// 优化:批量修改 DOM
// 坏做法
const el = document.getElementById('box');
el.style.width = '100px';
el.style.height = '100px';
el.style.margin = '10px';
// 每次修改都可能触发回流
// 好做法:使用 class
el.className = 'box';
// 或:使用 cssText
el.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>
&gt; <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>

响应式设计在博达中同样需要。因为高校和政府网站的访客大量来自手机端:

_resources/css/main.css
/* 博达站点的典型响应式布局 */
/* 桌面优先 */
.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 的使用场景和传统动态站点不同:

场景一:导航菜单交互

_resources/js/main.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 里

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前端基础回顾:HTML、CSS、JS 到底学到什么程度才够
https://selfstack.xiaoxiaotan.online/posts/前端基础回顾/
作者
zicai
发布于
2026-05-06
许可协议
CC BY-NC-SA 4.0
zicai
Hello, I'm zicai.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
11
分类
4
标签
56
总字数
29,238
运行时长
0
最后活动
0 天前

文章目录