
如果你维护过一个中大型 Express 后台管理系统大概率经历过这种场景一个顶部导航栏十几个管理页面都在用。某天产品说“把导航里‘订单’改成‘交易’”你深呼吸开始打开一个又一个模板文件复制、粘贴、替换运气不好时漏掉一两个页面第二天测试就找上门了。这件事的解法早就存在但很多新手反而被“模板引擎怎么用”绕晕了。有人以为复用 Header 就是把代码写进 layout结果所有页面都被迫带上同一个登录/未登录判断有人干脆用前端组件库重写整个页面只是为了一个导航栏动静太大。实际上Express.js 搭配 Handlebars 时最正统的答案是 partials局部模板配合 express-handlebars 的自动扫描机制只需要一个{{ header }}就能解决问题Header 只写一次全站生效。本文就围绕这个最小套路展开先讲清楚 Handlebars 的 layout、partial、helper 这三层概念然后给出一个可直接运行的 Express 项目演示如何把带用户信息和菜单激活态的后台 Header 做成可复用组件最后列出真实项目中最容易踩的坑。读完你可以直接在自己的后台项目里动手改造。1. 这篇文章真正要解决的问题先说痛点。后台管理系统和官网首页有一个本质区别官网的 Header 可能只在首页、列表页、详情页之间重复而后台系统的 Header 几乎出现在每一个页面里——菜单、用户信息、退出按钮、系统消息入口全是公共区块。这意味着什么意味着 Header 一旦变更就是一次横跨所有模板的批量修改。我见过三种典型的“反面解法”。第一种是复制粘贴。每个页面模板都粘一份完整的header代码。这种做法在小项目里效果还行但页面一多就失控改一个菜单项往往要改十几个文件而且很容易漏改。漏改的结果就是用户在某些页面看到旧导航在另一些页面看到新导航数据一致性直接崩掉。第二种是把所有公共内容硬塞进 layout。新手容易走到这个方向既然 layout 是“每个页面都套一层”的模板那 Header 直接写在 layout 里不就行了行但不完全行。问题在于 layout 是全站共用的骨架一旦某类页面不需要后台 Header比如登录页、注册页、404 页你就必须为这些特殊情况单独准备 layout否则登录页也会带着导航栏出现。第三种是直接把渲染工作交给前端。为了一个 Header引入 Vue、React 或者 jQuery 组件把整站模板改成浏览器端渲染。这样做的成本远高于收益SEO 和首屏性能下降后端模板的强类型数据失去了意义项目复杂度直线上升。所以本文的观点很明确在 Express.js Handlebars 的技术栈里Header 复用应该有且只有一个主答案——partials配合 layout 做好“骨架”与“片段”的分工。这个方案真正降低的是维护成本。Header 代码只存在一份改动一处全站生效不同页面通过局部数据注入控制菜单激活态特殊页面通过独立 layout 决定“要不要带 Header”。从架构层看它没有改变渲染方式也没有引入新的运行时依赖但工程流程彻底变了。什么样的人最应该读这篇文章正在用 Express.js 写后台系统但 Header 还在靠复制粘贴维护的开发者。刚接触模板引擎分不清 layout 和 partial 边界的新手。准备把旧项目的公共导航抽成可维护模块但不确定从哪里下手的同学。如果你是其中一类人下面的内容会非常有价值。2. Handlebars 与 Partial 的核心概念在动手之前先把几个概念讲透。这些概念如果理解错位后面写多少代码都会别扭。2.1 Handlebars 是什么Handlebars 是一种无逻辑模板引擎最早脱胎于 Mustache。它的核心特点是模板里尽量不写业务逻辑只做数据展示和简单的条件、循环控制。比如{{username}}表示输出变量{{#if}}表示条件判断{{#each}}表示循环遍历。为什么强调“无逻辑”因为模板引擎的职责是把数据变成 HTML而不是处理业务流程。判断一个用户有没有权限、计算一个价格是否打折这些应该在你组织的 JavaScript、路由、服务层完成而不是堆在模板里。Handlebars 的这套设计哲学决定了我们后续做 Header 复用时要把“菜单是否高亮”“用户是否登录”这类状态提前准备好再丢给模板去展示。在 Express 项目里Handlebars 不是内建能力需要借助适配层接入。最常用的包是express-handlebars它负责把.hbs文件编译成渲染函数并把 Express 传入的数据和模板语法衔接起来。2.2 layout 与 partial 的分工这两个概念是最容易被混淆的。layout布局模板定义整页的骨架。一个后台项目通常有一个主 layout包含!DOCTYPE html、html、head、body以及页头、页脚、内容插入区。Express express-handlebars 默认会在views/layouts目录下寻找main.hbs作为默认布局。partial局部模板定义页面上可复用的片段。Header、Footer、侧边栏、分页组件、面包屑都可以做成 partial。partial 本身不是完整页面它需要被 layout 或其他模板引用才能发挥作用。这里可以类比成“骨架”和“器官”的关系layout 是人体骨架决定了身体的整体结构partial 是心、肺、肝这类器官按需装配到对应位置。骨架上的“安装槽位”就是{{{body}}}和{{ partialName}}。维度layoutpartial职责整页 HTML 骨架页面内可复用片段典型文件views/layouts/main.hbsviews/partials/header.hbs引用方式通过引擎配置自动生效{{ header }}是否可缺省每个页面都有按需引用适合承载head、body 包裹、页脚导航、卡片、分页、表单块新手最容易犯的错误是把 Header 直接写死在 layout 里然后把登录判断、菜单高亮逻辑也全堆在 layout 上。短期看没问题长期看 layout 会越来越臃肿而且无法在不同页面之间差异化组合。正确姿势是“layout 管骨架partial 管片段helper 管展示逻辑”。2.3 helper 在复用中的作用Handlebars 允许注册 helper——也就是模板中可以调用的自定义函数。比如我们想判断“当前菜单是否处于激活态”模板里最自然的写法是{{#if (eq activeMenu home)}}active{{/if}}这里的eq就是一个 helper接收两个参数返回是否相等。没有 helper模板只能写成一堆嵌套判断可读性极差。注册 helper 后模板就能保持“无逻辑”把比较这种小事交给函数。后面第 5 章的完整示例里eqhelper 会真正派上用场。3. 环境准备与基础配置这一章给出可复现的环境建议直接照做。3.1 运行环境要求Node.js建议使用 18 或 20 的 LTS 版本。npm随 Node.js 一起安装不需要单独配置。系统Windows、macOS、Linux 均可本文示例不涉及系统级依赖。如果你当前在维护老项目Node.js 16 配合 express 4.x 也能运行但依赖解析结果可能不同。这里的版本不是关键关键是npm install之后 package-lock.json 里解析出的版本组合要以实际安装结果为准。3.2 初始化项目并安装依赖mkdir express-hbs-header-demo cd express-hbs-header-demo npm init -y npm install express express-handlebarsexpress-handlebars会把 Handlebars 编译能力和 Express 的视图引擎适配层打包在一起。安装完成后项目根目录会出现node_modules和package-lock.json。注意一点如果你在安装时发现包名变成了handlebars/express说明 npm 解析到了改名后的新版。新版在配置方式上更接近官方 Handlebars 文档本文的核心思路不变但具体配置键名要参考当时版本的 README。稳妥起见老项目建议锁定express-handlebars的 7.x 版本。3.3 创建目录结构这条路径是整个方案的核心先建好express-hbs-header-demo/ ├── app.js ├── package.json └── views/ ├── home.hbs ├── users.hbs ├── layouts/ │ └── main.hbs └── partials/ └── header.hbsviews是模板根目录layouts存放布局模板partials存放可复用片段。home.hbs和users.hbs是两个页面模板它们只负责写页面的主体内容。4. 核心流程拆解Header 复用的最小链路在给出完整代码之前先把“Header 复用”这条链路拆开让你知道每一步在干什么、为什么这样做。4.1 创建 Partial 文件在views/partials目录下创建header.hbs内容就是后台顶部导航的结构。这个文件是 Header 的唯一真源后续全站所有页面的 Header 都从它渲染出来。关键 Insight在 express-handlebars 中partial 的注册名默认等于文件名。也就是说header.hbs对应headeradmin-nav.hbs对应admin-nav。这大大减少了配置成本。4.2 注册 partialsDir打开app.js配置引擎时告诉 express-handlebars“partial 放在哪个目录”const { engine } require(express-handlebars); app.engine(hbs, engine({ extname: .hbs, defaultLayout: main, layoutsDir: path.join(__dirname, views/layouts), partialsDir: path.join(__dirname, views/partials) }));partialsDir就是关键配置。express-handlebars 初始化时会扫描这个目录把所有.hbs文件注册为 partial。如果你漏配这一项模板里写{{ header }}会直接报错找不到名为 header 的 partial。4.3 在布局模板中引用 Partial打开views/layouts/main.hbs在body内的合适位置写{{ header }}这一行会被编译成“调用名为 header 的 partial 并渲染”。注意这里不带.hbs扩展名因为 partial 注册名是文件名去掉扩展名后的结果。4.4 通过 render 上下文传递数据Header 不是静态的它需要知道当前是哪个页面、当前登录用户是谁。这里有两个数据来源每个页面独有的数据比如activeMenu通过res.render(home, { activeMenu: home })传入。所有页面共享的数据比如当前登录用户通过中间件写入res.locals然后自动合并进模板上下文。Express 在渲染时会自动把res.locals、app.locals和res.render的第二个参数合并成最终的模板上下文。所以你在模板里可以直接使用activeMenu和currentUser。4.5 页面模板只写主体内容因为 layout 和 partial 已经把整页骨架和 Header 包好了home.hbs这类页面模板只需要写自己的内容区h1首页/h1 p这里不会出现任何 Header 代码。/p页面模板越干净说明复用做得越彻底。5. 完整示例带动态数据的后台 Header下面给出一个完整可运行的项目示例。代码结构按文件拆开方便你直接对照创建。5.1 package.json{ name: express-hbs-header-demo, version: 1.0.0, description: Express Handlebars Header 复用示例, main: app.js, scripts: { start: node app.js }, dependencies: { express: ^4.18.0, express-handlebars: ^7.0.0 } }版本号只是示例范围。如果你用npm install express express-handlebars安装npm 会自动解析到匹配当前 Node 版本的最新版本。5.2 app.js// 文件路径express-hbs-header-demo/app.js const path require(path); const express require(express); const { engine } require(express-handlebars); const app express(); // 1. 配置 Handlebars 视图引擎 app.engine(hbs, engine({ extname: .hbs, defaultLayout: main, layoutsDir: path.join(__dirname, views/layouts), partialsDir: path.join(__dirname, views/partials), helpers: { eq: (left, right) left right, }, })); app.set(view engine, hbs); app.set(views, path.join(__dirname, views)); // 2. 模拟登录用户实际项目中从 session / token 中解析 app.use((req, res, next) { res.locals.currentUser { username: demo_admin, role: admin, isAdmin: true, }; next(); }); // 3. 路由 app.get(/, (req, res) { res.render(home, { title: 控制台首页, activeMenu: home, }); }); app.get(/users, (req, res) { res.render(users, { title: 用户管理, activeMenu: users, }); }); app.listen(3000, () { console.log(Demo running at http://localhost:3000); });这段代码有三层值得注意第一helpers.eq注册了一个比较函数模板中的(eq activeMenu home)才能正常工作。这是 Handlebars 子表达式语法eq会把activeMenu和home两个参数传进去返回布尔值。第二res.locals.currentUser是在所有路由之前通过中间件注入的。真实项目里这里通常是从req.session.user或 JWT token 中读出登录用户后再赋值。示例用固定对象模拟登录态目的是把“公共数据注入”和“业务登录逻辑”解耦。第三每个页面通过res.render的第二个参数传入title和activeMenu。title用于title标签activeMenu用于 Header 菜单高亮。每个页面可以有不同的激活项。5.3 views/layouts/main.hbs{{! 文件路径express-hbs-header-demo/views/layouts/main.hbs }} !DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{{title}}/title /head body {{ header}} main classmain-content {{{body}}} /main /body /html注意三个语法点{{title}}是普通变量输出HTML 会被自动转义。{{ header}}是引入 partial。{{{body}}}是三花括号这里不是变量转义问题而是 layout 接收页面主体内容的标准占位语法不需要转义。body是 express-handlebars 内部提供给 layout 的保留变量代表“当前页面模板渲染后的 HTML 片段”。只要你在 layout 中写{{{body}}}页面内容就会按位置插入。5.4 views/partials/header.hbs这是全篇文章的“主角”Header 组件本体{{! 文件路径express-hbs-header-demo/views/partials/header.hbs }} header classtop-header div classheader-inner a classbrand href/后台管理系统/a nav classmain-nav ul li class{{#if (eq activeMenu home)}}active{{/if}} a href/首页/a /li li class{{#if (eq activeMenu users)}}active{{/if}} a href/users用户管理/a /li {{#if currentUser.isAdmin}} li class{{#if (eq activeMenu settings)}}active{{/if}} a href/settings系统设置/a /li {{/if}} /ul /nav div classuser-box {{#if currentUser}} span{{currentUser.username}}/span a href/logout退出/a {{else}} a href/login登录/a {{/if}} /div /div /header解读一下这 30 多行模板菜单项的高亮逻辑完全由activeMenu驱动。首页路由传入activeMenu: home则“首页”这个li会带有activeclass/users路由传入activeMenu: users则“用户管理”高亮。你不需要为每个页面写分支只需要在路由里传一个字符串。用户信息区用了两层判断。外层的{{#if currentUser}}处理登录态有用户就显示用户名和退出链接没有就显示登录链接。内层的{{#if currentUser.isAdmin}}控制菜单可见性只有管理员才显示“系统设置”。这种写法让 Header 在未登录场景下也能安全降级——虽然我们通常在后台系统的登录页会使用独立 layout但 partial 本身已经具备了这种容错能力。5.5 views/home.hbs{{! 文件路径express-hbs-header-demo/views/home.hbs }} section classpage h1控制台首页/h1 p当前登录用户{{currentUser.username}}/p p当前页面菜单激活项{{activeMenu}}/p p页面模板本身没有编写任何 Header 代码。/p /section这个模板验证了两个事实第一res.locals注入的currentUser能直接在模板中访问第二每个页面传入的activeMenu能正常输出。页面模板保持纯粹只关心自身内容。5.6 views/users.hbs{{! 文件路径express-hbs-header-demo/views/users.hbs }} section classpage h1用户管理/h1 p当前登录用户{{currentUser.username}}角色{{currentUser.role}}/p p访问该页面时“用户管理”菜单应该处于激活态。/p /section5.7 启动项目npm start启动成功后终端会输出Demo running at http://localhost:3000然后访问http://localhost:3000即可看到效果。6. 运行结果与效果验证代码能跑起来只是第一步关键是知道自己写对了没有。这一章给出可执行的验证方法。6.1 浏览器验证打开http://localhost:3000正常情况下你能看到页面顶部出现一个后台 Header包含品牌名、导航菜单、用户名和退出链接。“首页”菜单项带有active样式如果你在 CSS 里定义了.active的颜色。页面主体是一段欢迎文字没有任何重复的 Header 代码。再访问http://localhost:3000/users这次“用户管理”菜单项应该高亮页面主体变成用户管理内容。整个过程中你的home.hbs和users.hbs都没有写 Header。Header 只存在于views/partials/header.hbs这一份文件里。6.2 用 curl 验证渲染结果在终端执行curl -s http://localhost:3000 | grep -E top-header|activeMenu|控制台首页输出里会出现classtop-header这样的行以及页面标题中的“控制台首页”。如果看到classtop-header说明 Header 的 partial 确实被渲染进了 HTML。如果想确认“用户”页面菜单高亮可以执行curl -s http://localhost:3000/users | grep -E 用户管理6.3 对比实验注释掉 partial 引用这是最直观的教学实验。把views/layouts/main.hbs中这一行注释掉{{! {{ header}} }}保存后重启npm start刷新页面Header 消失了。再把注释取消Header 又回来了。这个实验说明Header 的渲染权不在页面模板手里而在 layout 的 partial 引用手里。你改一处全站生效你注释掉这一处全站 Header 消失。这就是复用机制的威力也提醒我们在生产环境操作时务必小心因为这种改动影响面是全局的。6.4 失败时的第一检查点如果页面报错或者 Header 没有出来第一件事是看终端有没有类似下面的错误Error: The partial header could not be found如果看到这行说明partialsDir指向的目录中没有header.hbs或者目录名称大小写不对。这是 Header 复用场景最常见的启动期错误。7. 常见问题与排查思路实际开发中还有一些问题不会在启动时报错但在页面上表现为“Header 样式不对”“变量为空”“重复渲染”。这些问题往往比报错更让人困扰下面整理成排查表。问题现象可能原因排查方式解决方案Header 完全没有渲染partialsDir 配置错误或文件不存在检查 app.js 配置确认 views/partials/header.hbs 存在修正 partialsDir 路径重启服务页面报错 The partial header could not be found引用名和文件名不匹配确认文件名是 header.hbs引用是{{ header}}统一文件名和引用名不要带 .hbs 后缀Header 渲染了两次layout 和页面模板都写了{{ header}}搜索项目中所有{{ header}}出现位置只保留 layout 中的引用页面模板删除菜单高亮不生效eq helper 未注册或 activeMenu 传值不一致检查 helpers 配置在页面模板输出{{activeMenu}}注册 eq helper统一路由中 activeMenu 的字符串值currentUser 显示为空res.locals 中间件未注册或位于路由之后检查中间件位置和变量名拼写将中间件放在所有需要模板渲染的路由之前修改 header.hbs 后页面没有变化生产模式下模板缓存生效检查是否设了 NODE_ENVproduction重启进程开发环境设 NODE_ENVdevelopment模板中输出 HTML 标签变成了文本使用了双花括号{{ }}输出 HTML检查模板变量是否包含 HTML确认数据安全性后对特定变量使用{{{ }}}展开说明几个高频问题。第一个是{{ header }}与{{ header.hbs }}的区别。在多数版本中两种写法都能工作因为引擎会尝试补充扩展名。但规范做法是不写扩展名让注册名保持唯一性。如果有人把同名文件放在不同目录带扩展名的引用还可能引发歧义。第二个是res.locals的注入时机。中间件必须注册在路由之前。如果某个路由在中间件之前定义这个页面的模板就拿不到currentUser。很多人排错时盯着模板看半天其实问题出在中间件顺序上。第三个是变量名不一致。res.render传的是user模板里读的是currentUser自然为空。建议在整个项目中统一命名公共用户信息一律叫currentUser页面特有数据用小驼峰命名。8. 最佳实践与工程建议把最小示例跑通之后再往生产环境靠拢下面几条建议都是过来人踩坑后总结出来的。8.1 目录规划partials 内部继续分模块项目大了以后views/partials下全是平铺文件会很乱。推荐按模块组织views/partials/ ├── common/ │ ├── header.hbs │ ├── footer.hbs │ └── breadcrumb.hbs └── admin/ ├── sidebar.hbs └──>const request require(supertest); const app require(./app); test(首页应该渲染后台 Header, async () { const res await request(app).get(/); expect(res.text).toContain(classtop-header); expect(res.text).toContain(首页); });这样以后有人不小心删掉 layout 中的{{ header}}测试会立刻提醒你不用等人工回归。9. 总结与后续学习方向这篇教程没有讲什么高深原理但把三件容易被忽视的事说透了第一partials 是 Handlebars 复用 Header 的正统方案注册名等于文件名接入成本极低第二Res.locals 是从中间件向所有页面注入公共数据的正确通道登录用户这种数据应该走这里而不是各个路由重复传第三layout 和 partial 是“骨架”和“片段”的分工关系把两者混为一谈是很多模板混乱的根源。如果你已经跑通了上面的示例下一步可以继续深入这几个方向多 layout 管理登录页、错误页使用独立 layout不含后台 Header实现更细粒度的页面组合。菜单配置化把导航菜单定义成一个 JSON 数组用{{#each}}循环渲染 Header新增菜单项时连 partial 模板都不用改只需要改配置。helper 再造在eq的基础上封装isActive、hasPermission等业务 helper让模板表达更接近自然语言。模板缓存策略深入研究cache配置和生产环境的发布流程理解模板引擎在服务端的编译与缓存机制。强烈建议你把文章里的最小项目在本地完整跑一遍然后对照自己的后台项目把第一个“复制粘贴型 Header”改成 partial 引用。这个改造动作只需要几分钟但收益是长期的以后产品再改导航你只需要打开一个文件。这个系列每篇聚焦一个 Express.js 开发中的具体小问题下一篇可以聊聊“多 layout 下如何优雅地处理登录页”感兴趣的读者可以收藏本文实际操作中随时回来对照排查。