这一节来拆一个后台项目里几乎躲不开的重复劳动:顶部 Header。后台管理页面通常有十几个甚至几十个页面,如果每个页面都把 Logo、导航菜单、用户信息写一遍,后面加一个菜单项就要全局搜索替换十几处。Express 项目搭配 Handlebars 模板时,标准解法是把 Header 抽成 partial,再用 layout 统一承担页面外壳。这套组合只需要配置一次,之后新增页面,模板文件里只写页面本身的内容。
这个系列定位是快速上手,所以本文不铺开讲大而全的后台框架,直接给一个最小可运行的后台骨架。你会看到四件事:express-handlebars 视图引擎怎么配、partials 目录怎么注册、菜单高亮怎么跟着当前页面走、用户信息和权限菜单怎么通过 res.locals 注入到所有页面。涉及代码都可以直接复制,建议开一个空目录跟着跑一遍。
运行门槛不高:这是纯 Node.js 技术栈教程,不需要 GPU,不需要显存,也不需要额外数据库。本机有 Node.js 和 npm,能安装依赖就能跑。版本上,本文以 CommonJS 写法为准,安装 express-handlebars 时建议锁定 6.x 版本,避免新版 ESM API 带来的额外报错。这是很多初学者一上来就卡住的地方,后面排查章节会展开说。
1. 本节知识点速览
| 知识点 | 作用 | 一句话结论 |
|---|---|---|
| Handlebars Partial | 复用局部模板片段 | {{> header}}注册一次,任意页面都能用 |
| Layout 布局 | 复用整页外壳 | 后台页面只写内容,head、header、footer 由布局统一输出 |
| 模板 Helper | 扩展 Handlebars 表达式 | 注册eq、starts等函数,处理菜单高亮和条件渲染 |
| res.locals | 注入每个请求的公共数据 | 用户信息、菜单列表、当前路径不用每次 res.render 重复传 |
| 服务端菜单控制 | 按角色下发导航 | 模板只渲染后端给的数据,前端隐藏不等于权限隔离 |
这套知识的核心是:把“每个页面都出现的东西”拆出来维护一次,然后把“每个页面不一样的东西”通过数据传给模板。Header 是典型的跨页面公共区块,所以非常适合用 partial 加 layout 一起处理。
从环境需求来说,本文示例不需要显卡、不需要显存,Node.js 进程本身的内存占用由系统自动管理。重点观察的是模板是否能正确渲染、路由数据是否传到位、菜单高亮是否跟随当前页面切换。下面的演示会围绕这三件事展开。
2. 适用场景与使用边界
先看清楚这个方案解决什么问题:后台管理系统、前台多页面网站、任何有统一顶部导航和统一页面外壳的 Express 应用。尤其是导航菜单需要跟随登录用户角色变化、需要统一修改产品名称和链接、需要让新增页面自动带上公共 Header 的场景,用 partial + layout 能省下大量重复代码。
哪些情况不适合强行套这套方案?如果页面之间结构差异非常大,比如某个页面是全屏数据中心,另一个页面是简单落地页,硬套同一个 layout 反而要写一堆条件判断。如果项目是纯静态站,直接用 Eleventy、Astro 这类静态站点生成器会更合适。如果前端已经用了 Vue、React 这类 SPA,Header 本身就是组件,不需要服务端模板 partial。
还有一个边界要提醒:把 Header 抽成模板片段只是展示层复用,权限控制必须在服务端完成。模板里根据currentUser.role隐藏某个菜单,只是 UI 上不显示而已。后端路由如果没有做角色校验,用户直接访问 URL 照样能拿到页面。所以菜单下发和服务端鉴权要同时做,不能只靠模板条件渲染。
3. 环境准备与最小项目结构
开始之前,先确认本机有 Node.js 和 npm。建议使用 Node.js 的 LTS 版本,具体版本以你本机安装为准。不需要安装全局工具,所有依赖都装在项目目录里。
本文示例的最终目录结构如下:
admin-header-demo/ ├─ app.js ├─ package.json ├─ public/ │ ├─ css/ │ │ └─ admin.css └─ views/ ├─ layouts/ │ └─ main.hbs ├─ partials/ │ └─ header.hbs └─ admin/ ├─ dashboard.hbs ├─ users.hbs └─ settings.hbs目录规划有一个原则:layouts 放整页外壳,partials 放复用片段,admin 放具体页面模板。这样项目变大以后,模板文件不会被塞成一个巨大的目录,维护起来更直观。
public目录用来放静态资源,比如 CSS、JS、图片。Handlebars 只负责输出 HTML 结构,样式文件依然通过 Express 的静态目录对外提供。
4. 初始化 Express + Handlebars 项目
先创建一个空目录,名字可以叫admin-header-demo,进入目录后初始化 npm 项目。
mkdir admin-header-demo cd admin-header-demo npm init -y然后安装 Express 和 express-handlebars。这里特意指定express-handlebars@6,是因为 7.x 之后包的整体导出方式改动较大,CommonJS 项目直接require会报错。用 6.x 可以让下面的代码按 CommonJS 方式稳定运行。
npm install express express-handlebars@6安装完成后,创建app.js,配置视图引擎。核心配置项有三个:extname指定模板扩展名,defaultLayout指定默认布局,layoutsDir和partialsDir分别告诉模板引擎去哪里找布局和局部模板。
const path = require('path'); const express = require('express'); const { engine } = require('express-handlebars'); const app = express(); const PORT = process.env.PORT || 3000; // ============ Handlebars 视图引擎 ============ const hbs = engine({ extname: '.hbs', defaultLayout: 'main', layoutsDir: path.join(__dirname, 'views', 'layouts'), partialsDir: path.join(__dirname, 'views', 'partials'), helpers: { eq(a, b) { return a === b; } } }); app.engine('hbs', hbs); app.set('view engine', 'hbs'); app.set('views', path.join(__dirname, 'views')); // ============ 静态资源 ============ app.use(express.static(path.join(__dirname, 'public'))); // ============ 模拟后台用户和菜单 ============ const roleMenus = { admin: [ { key: 'dashboard', label: '仪表盘', href: '/admin/dashboard' }, { key: 'users', label: '用户管理', href: '/admin/users' }, { key: 'settings', label: '系统设置', href: '/admin/settings' } ], editor: [ { key: 'dashboard', label: '仪表盘', href: '/admin/dashboard' } ] }; app.use(function (req, res, next) { // 真实项目里这里从 session 或 token 中解析登录用户 const currentUser = { name: 'Admin', role: 'admin' }; res.locals.currentUser = currentUser; res.locals.menus = roleMenus[currentUser.role] || []; res.locals.currentPath = req.path; next(); }); // ============ 后台页面路由 ============ app.get('/admin/dashboard', function (req, res) { res.render('admin/dashboard', { title: '仪表盘', activeMenu: 'dashboard' }); }); app.get('/admin/users', function (req, res) { res.render('admin/users', { title: '用户管理', activeMenu: 'users' }); }); app.get('/admin/settings', function (req, res) { res.render('admin/settings', { title: '系统设置', activeMenu: 'settings' }); }); app.get('/', function (req, res) { res.redirect('/admin/dashboard'); }); app.listen(PORT, function () { console.log(`后台 Header 复用示例已启动:http://localhost:${PORT}`); });这份代码同时演示了两个关键机制:模板引擎配置和公共数据注入。res.locals是 Express 提供的一个请求级对象,中间件里往它上面挂的数据,会在后续res.render时自动合并到模板数据里。所以菜单和当前用户信息不需要在每个路由里重复传一遍。
helpers里的eq是一个简单的比较函数,用来在模板里做菜单高亮判断。Handlebars 默认模板只能做if、unless、each,不能直接比较两个值是否相等,所以这种高频校验逻辑需要注册成 helper,后面在 Header 模板里会用到。
4.1 添加启动脚本
为了方便启动,可以把package.json里的 scripts 改为:
{ "scripts": { "start": "node app.js" } }之后直接用npm start启动服务。
5. 创建后台 Header Partial
现在创建核心文件views/partials/header.hbs。这是整个后台所有页面共享的顶部导航,包含品牌区、导航菜单和用户信息区。
<header class="admin-header"> <div class="admin-header__brand"> <a href="/admin/dashboard"> <strong>Admin Demo</strong> </a> </div> <nav class="admin-header__nav"> <ul class="admin-menu"> {{#each menus}} <li class="admin-menu__item"> <a href="{{this.href}}" class="admin-menu__link {{#if (eq this.key ../activeMenu)}}is-active{{/if}}" > {{this.label}} </a> </li> {{/each}} </ul> </nav> <div class="admin-header__user"> {{#if currentUser}} <span>{{currentUser.name}}</span> <span class="role-badge">{{currentUser.role}}</span> {{else}} <a href="/login">登录</a> {{/if}} </div> </header>这里最值得注意的地方是菜单高亮写法。{{#each menus}}会把当前上下文切换到菜单项,所以在 each 内部要拿到页面级别的activeMenu,必须用../activeMenu回到父级上下文。
{{#if (eq this.key ../activeMenu)}}的含义是:如果当前菜单项的 key 等于页面传入的 activeMenu,就给这个链接加上is-active类。样式文件里只要对.is-active做高亮,就能保证 Header 跟着页面自动切换。
用户信息区用了{{#if currentUser}}做登录态判断。登录时显示用户名称和角色,未登录时显示“登录”链接。这个判断看起来很基础,但它是后台 Header 最常见的分支逻辑,配合服务端数据注入后,模板本身不需要关心用户怎么登录。
6. 编写布局与页面模板
有了 Header partial 之后,还需要一个 layout 把页面外壳统一起来。创建views/layouts/main.hbs:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{{title}} - Admin Demo</title> <link rel="stylesheet" href="/css/admin.css"> </head> <body> {{> header}} <main class="admin-content"> {{{body}}} </main> <footer class="admin-footer"> <span>Footer 也可以做成独立 Partial</span> </footer> </body> </html>layout 的渲染机制是两阶段:Express 先用页面模板渲染出内容,再把这个内容作为body塞进 layout。所以 layout 里必须用{{{body}}}三对花括号,确保 HTML 标签不被转义。
页面模板现在变得非常干净。以views/admin/dashboard.hbs为例:
<section class="card"> <h1>仪表盘</h1> <p>当前页面由 admin/dashboard.hbs 渲染,Header 来自 views/partials/header.hbs。</p> </section>views/admin/users.hbs和views/admin/settings.hbs结构类似:
<section class="card"> <h1>用户管理</h1> <p>这里放用户管理表格和操作按钮。</p> </section><section class="card"> <h1>系统设置</h1> <p>这里放系统配置表单。</p> </section>可以看到页面模板里完全没有<html>、<head>、Header 这些重复结构。新增后台页面时,只需要创建对应模板文件并写页面内容,布局和 Header 会自动包裹上去。
7. 启动与验证效果
先给页面配一份基础样式,避免验证时看起来太乱。创建public/css/admin.css:
* { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f5f6fa; color: #333; } .admin-header { display: flex; align-items: center; justify-content: space-between; padding: 0 24px; height: 56px; background: #1f2430; color: #fff; } .admin-header__brand a { color: #fff; text-decoration: none; font-size: 18px; } .admin-menu { display: flex; list-style: none; margin: 0; padding: 0; } .admin-menu__item + .admin-menu__item { margin-left: 8px; } .admin-menu__link { display: block; padding: 8px 16px; color: #aab; text-decoration: none; border-radius: 6px; } .admin-menu__link:hover { color: #fff; background: #30364a; } .admin-menu__link.is-active { color: #fff; background: #3b82f6; } .admin-header__user { display: flex; align-items: center; gap: 12px; } .role-badge { padding: 2px 8px; border-radius: 10px; background: #3b82f6; font-size: 12px; } .admin-content { padding: 24px; } .card { padding: 24px; background: #fff; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .admin-footer { padding: 16px 24px; color: #777; text-align: center; }然后在终端启动服务:
npm start打开浏览器访问http://localhost:3000/admin/dashboard。预期能看到:
- 顶部 Header 正常显示,品牌区是 Admin Demo,右侧显示 Admin 和 admin。
- 导航菜单有三个入口:仪表盘、用户管理、系统设置。
- 当前页面对应的“仪表盘”菜单带有蓝色高亮。
- 页面主体只有仪表盘内容,没有重复的
<html>和<head>结构。
接着访问http://localhost:3000/admin/users和http://localhost:3000/admin/settings。观察导航高亮是否分别切换到“用户管理”和“系统设置”。这里就能看到 partial 加 layout 的收益:Header 只维护了一份,三个页面共享同一份代码。
如果端口被占用,可以换一个端口启动:
PORT=3001 npm start然后访问http://localhost:3001/admin/dashboard。
8. 动态数据:登录用户、当前路径与菜单权限
上面的示例已经展示了一个典型的后台数据流:中间件准备公共数据,路由准备页面数据,模板消费数据。接下来把几个最常见的动态需求补上。
8.1 使用当前路径做菜单高亮
activeMenu方案适合菜单 key 稳定的后台系统。还有一种更通用的做法是直接匹配当前路径。在app.js的 helpers 里加一个startshelper:
const hbs = engine({ extname: '.hbs', defaultLayout: 'main', layoutsDir: path.join(__dirname, 'views', 'layouts'), partialsDir: path.join(__dirname, 'views', 'partials'), helpers: { eq(a, b) { return a === b; }, starts(str, prefix) { return typeof str === 'string' && str.startsWith(prefix); } } });然后 Header 里的高亮判断可以改成:
class="admin-menu__link {{#if (starts ../currentPath this.href)}}is-active{{/if}}"这样只要当前请求路径以菜单 href 开头,菜单就高亮。对于一组子页面共用一个一级菜单的场景会更方便,比如/admin/users/list和/admin/users/detail都属于“用户管理”。
实际项目里选哪一种取决于你的导航结构。菜单层级简单、每个页面有明确 key,用activeMenu更精确;菜单层级深、需要按前缀匹配,用currentPath更省事。
8.2 未登录和角色菜单
真实项目中,currentUser不会写死在中间件里,而是从 session、cookie 或 token 解析出来。解析结果可能为空,所以菜单下发要做空值保护:
app.use(function (req, res, next) { // 真实项目:const user = await authService.getUser(req); const user = null; res.locals.currentUser = user; res.locals.menus = user ? roleMenus[user.role] || [] : []; res.locals.currentPath = req.path; next(); });当currentUser为 null 时,Header 右侧会自动切换成“登录”链接,导航菜单为空。这就实现了“未登录不显示后台菜单”的展示层控制。
注意,角色菜单在这里只是“下发什么菜单”的问题,不是权限校验。/admin/settings这个路由本身仍然需要判断当前用户是否有权限访问。模板隐藏菜单不能替代后端路由鉴权,这一点在往真实项目迁移时一定要同步实现。
8.3 公共数据别都塞进模板
res.locals适合放请求级公共数据,比如当前用户、菜单、当前路径。如果有些数据是全局不变的,比如站点名称、版本号,可以放到app.locals上:
app.locals.siteName = 'Admin Demo'; app.locals.version = 'v1.2.0';模板里直接{{siteName}}就能访问。用这种方式区分全局常量和请求级数据,代码会更清晰。
9. 从路由到模板的数据流与接口接入扩展
把整个请求链路串起来看:浏览器请求/admin/dashboard,Express 先执行公共中间件,把currentUser、menus、currentPath挂到res.locals,然后路由处理函数调用res.render('admin/dashboard', { title, activeMenu })。模板引擎先用页面模板渲染出内容,再把这个内容作为body传给main.hbs,main.hbs里通过{{> header}}渲染 Header partial。
所以 Header 能拿到三个层面的数据:
| 数据来源 | 示例 | 说明 |
|---|---|---|
| app.locals | siteName | 全局常量,所有模板可用 |
| res.locals | currentUser、menus、currentPath | 请求级公共数据,中间件注入 |
| res.render 参数 | title、activeMenu | 页面级数据,每个路由自己传 |
如果后台项目里菜单不是写死的,而是来自数据库或配置中心,可以把下发菜单的逻辑放在中间件里异步处理。下面是一个伪代码思路,真实项目需要按自己的服务实现替换:
app.use(async function (req, res, next) { try { const user = await authService.getUser(req); res.locals.currentUser = user; res.locals.menus = await menuService.getMenusByRole(user?.role); next(); } catch (err) { next(err); } });不要把菜单请求写在模板里。Handlebars 模板不负责请求数据,它只负责把传入的数据渲染成 HTML。数据准备统一放在中间件和路由层,模板保持简单,后续排查问题会容易很多。
对于页面规模很大的后台系统,还可以考虑把菜单数据做缓存,避免每个请求都查一遍数据库。菜单变更频率低,适合用内存缓存或 Redis 缓存,缓存失效时再拉取最新数据。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查与处理 |
|---|---|---|
| Header 完全不显示 | partialsDir 路径不对、文件名不一致、引用名写错 | 确认目录是views/partials,文件是header.hbs,模板里写{{> header}} |
| 报错部分模板找不到 | partials 文件不存在或扩展名不对 | 检查文件是否存在,扩展名是否为.hbs |
| 页面没有套上布局 | defaultLayout 没配置、layoutsDir 指向错误、main.hbs 不存在 | 检查 engine 配置,确认views/layouts/main.hbs存在 |
| 菜单高亮不生效 | helper 未注册、activeMenu 没传、作用域../写错 | 确认eq已注册,路由里传了activeMenu,partial 里用../activeMenu |
| 模板修改后刷新不生效 | 生产模式缓存或服务未重启 | 开发阶段重启node app.js,发布阶段按环境配置缓存策略 |
| require express-handlebars 报错 | 安装了 7.x 以上 ESM 版本 | 执行npm install express-handlebars@6重新安装 |
| CSS 样式加载不了 | 静态目录没配、文件路径不对 | 确认app.use(express.static(...))和public/css/admin.css存在 |
| 端口启动失败 | 端口被占用 | 换端口:PORT=3001 npm start |
这些坑里最容易让人困惑的是作用域问题。记住一个规则:{{#each}}、{{#if}}这类块级 helper 会改变当前上下文,块内部要访问父级数据就要用../。多层嵌套就写多个../。实际开发中如果不确定,可以在模板里临时输出一个字段验证数据是否到位,再逐步缩小问题范围。
另一个常见问题是把{{{body}}}写成{{body}}。一旦 layout 里用了双花括号,页面渲染出来的 HTML 会被转义成文本,页面会变成一整段源码。layout 中插槽位置必须用三个花括号。
11. 最佳实践与使用建议
把 Header 抽成 partial 之后,维护成本会明显降低,但这只是第一步。工程上还有几个建议可以让这套方案更稳定。
第一,菜单数据集中管理。不要在每个路由里各自构造菜单数组,而是单独维护一个配置模块,如表单展示的roleMenus对象。以后调整菜单名称、链接、权限,只需要改一处配置,页面模板不用动。
第二,模板只做展示,逻辑放进 helper。像eq、starts这种比较逻辑适合注册成 helper。模板里尽量少写嵌套很深的条件判断,遇到复杂逻辑可以先在服务端把数据整理好,再传给模板。
第三,公共区块的命名要统一。partials 目录下按区块命名,比如header.hbs、footer.hbs、sidebar.hbs。如果模板文件变多,可以在 partials 下按模块分子目录,引用时写成{{> admin/header}}。
第四,注意转义安全。Handlebars 默认会转义输出内容,普通输出用{{ }}安全。只有确实需要输出 HTML 时才用{{{ }}},而且要确保内容是可信的,否则会引入 XSS 风险。用户提交的内容、数据库里取出的富文本,都要先做清洗和校验。
第五,样式文件加版本号。CSS 文件名写成admin.css?v=20250101可以避免浏览器缓存旧样式。后台页面改动频繁,静态资源缓存问题很常见,版本号是最简单的解法。
第六,先小范围验证再铺开。首次把现有页面迁到 partial + layout 方案时,先拿两个页面跑通流程,确认 Header 高亮、用户信息、权限菜单都符合预期,再批量迁移其他页面。批量迁移时重点检查每个页面传入的activeMenu和页面标题是否对应,避免出现高亮错误。
12. 总结与下一步
这个示例的核心收获可以概括成一句话:Handlebars 的 partial 负责复用局部区块,layout 负责复用页面外壳,res.locals 负责统一注入公共数据,三者组合起来就能让后台 Header 只维护一份代码。
最先应该验证的功能是菜单高亮。打开三个后台页面,确认 Header 跟随当前路由切换高亮,同时用户信息、品牌区和导航菜单在其他页面保持一致。这个现象真正跑通后,partial 和 layout 的机制就算是理解了。
最容易踩的坑集中在两点:一是 express-handlebars 版本选择,二是模板作用域。版本问题建议直接按本文安装 6.x,省去 ESM 兼容成本;作用域问题记住../的规则,遇到数据取不到时先在模板里输出确认。
下一步可以继续延伸的方向很多:把 Footer 和侧边栏也抽成 partial,加一个统一的页面标题配置,把菜单数据接到数据库或接口,再补上登录状态和路由鉴权。等这套模板体系搭稳了,新增一个后台页面就只剩下写页面内容和注册路由两件事。建议先跑通本文示例,再按自己的项目结构调整目录和数据来源。