1. 项目概述:用 EJS 把 Node 应用“活”成模板引擎
你有没有遇到过这样的场景:写了一个 Node.js 的命令行工具,功能很完整,但每次想改输出格式就得硬编码拼接字符串;或者开发一个静态站点生成器,HTML 结构重复率高,改个页脚要手动同步七八个文件;又或者给客户交付一套可配置的报告系统,结果客户提需求说“能不能把标题颜色从蓝色改成深灰,字体加粗,还要支持中英文切换”——你翻出res.write('<h1 style="color: #333; font-weight: bold;">')这样的代码,手开始抖。这不是写后端,这是在 HTML 里写 Node,还是反向的。
这就是标题“Использование EJS для преобразования приложения Node в шаблон”(使用 EJS 将 Node 应用转化为模板)真正要解决的问题:它不是教你怎么“用 EJS 渲染一个页面”,而是教你如何把整个 Node.js 应用的逻辑骨架和内容表达层彻底解耦,让 Node 不再是“写死的执行体”,而变成一个可配置、可复用、可交付的模板驱动引擎。关键词 EJS、Node、шаблон(俄语“模板”)在这里不是并列关系,而是因果链:EJS 是手段,Node 是载体,шаблон 是最终形态——你的应用本身,就是一份可被实例化的模板。
我做过三个典型项目:一个是为某跨境电商 SaaS 平台定制的多语言邮件模板生成服务,客户上传 Excel 配置表,系统自动生成 12 种语言的 HTML 邮件;一个是内部使用的 API 文档自动化导出工具,输入 OpenAPI YAML,输出带交互示例的静态 HTML 站点;还有一个是嵌入式设备日志分析 CLI 工具,用户传入 JSON 日志流,工具输出带图表占位符的 Markdown 报告。这三个项目底层都是纯 Node.js,没有 Express,不跑 HTTP 服务,但都靠 EJS 实现了“一次编码,千种输出”。它们共同验证了一件事:EJS 的价值远不止于 Web 框架里的视图层,它是 Node.js 生态中最轻量、最灵活、最贴近开发者直觉的模板化操作系统内核。如果你还在用fs.readFileSync + string.replace处理配置化输出,那这篇就是为你写的实战手册——它不讲原理,只讲怎么把你的 Node 脚本,从“能跑”升级成“能卖”。
2. 核心设计思路:为什么是 EJS,而不是 Handlebars、Pug 或原生 JS 模板字面量?
选型从来不是比功能列表,而是比“谁最不拖后腿”。当你要把一个 Node 应用“转化”为模板时,核心诉求有且只有三个:零学习成本迁移、无运行时依赖侵入、对原始逻辑零改造。我们来逐一对比主流方案。
Handlebars 看似强大,但它强制要求你把所有数据包装成上下文对象(context),哪怕你原来是个简单的for (let i = 0; i < data.length; i++)循环,也得先const ctx = { items: data },再在模板里写{{#each items}}。这直接破坏了 Node 应用原有的控制流逻辑,等于让你重写业务代码。更致命的是,Handlebars 的沙箱机制会拦截require()、process.env等全局对象,而你的模板很可能需要读取环境变量来决定 CDN 地址或 API 基础路径——这时候你得写一堆 helper 函数去透传,工程量爆炸。
Pug(原 Jade)语法极度简洁,但代价是“非 JavaScript”。它的缩进语法、隐式标签、-开头的 JS 代码块,本质上是在创造一门新 DSL。当你需要在模板里调用一个复杂的 Node 内置模块方法,比如url.format({ protocol: 'https', hostname: env.HOST, port: env.PORT }),Pug 的语法会让你写得怀疑人生。而且 Pug 编译后的 JS 代码可读性极差,调试时看到__p += " <div class=\"header\">" + __j(__t(1)) + "</div>";这种东西,心态直接崩掉。
至于原生 JS 模板字面量(Template Literals),它看起来最“原生”,但恰恰是最危险的。\${data.title}`这种写法在简单场景下没问题,可一旦涉及循环、条件、嵌套、错误处理,你就得在字符串里疯狂拼接${},很快变成`${data.items.map(item => `
- ${item.name} ${item.price > 100 ? '🔥' : ''}
- ').join('')}`
这种难以维护的怪物。更重要的是,它完全不具备模板引擎的核心能力:**预编译缓存、局部作用域隔离、错误堆栈映射**。一个拼写错误的item.namme在模板字面量里只会报Cannot read property 'name' of undefined`,你根本不知道错在哪一行模板里。EJS 则完美踩中三个关键点。第一,它的语法就是 JavaScript:
<% if (user) { %>、<%= user.name %>、<%- include('header') %>,你不需要学新语法,只需要记住<% %>是执行代码,<%= %>是输出转义,<%- %>是输出不转义。第二,它默认允许访问全局作用域,process.env.NODE_ENV、require('fs')、甚至console.log都可以直接用,无需额外配置。第三,它支持.ejs文件的同步/异步预编译,你可以把模板编译成一个纯函数,然后像调用普通 JS 函数一样传参渲染,整个过程不引入任何运行时依赖,连node_modules都可以打包进最终产物。我实测过一个 500 行的 EJS 模板,在 Node 18 下编译耗时 12ms,渲染耗时 3ms;换成 Handlebars 同等复杂度,编译 47ms,渲染 8ms;Pug 编译 63ms,渲染 5ms。数字背后是真实体验:EJS 让你感觉不到“模板引擎”的存在,它只是你 Node 代码的自然延伸。这才是“将 Node 应用转化为模板”的本质——不是加一层抽象,而是去掉一层隔阂。
3. 核心细节解析:EJS 模板如何与 Node 应用逻辑无缝融合?
很多人以为 EJS 只是把 HTML 里的变量替换成值,这是巨大误解。真正的融合,发生在数据流、控制流、错误流三个维度。下面拆解四个最关键的融合点,每个都附带我在生产环境踩过的坑和解决方案。
3.1 数据注入:不只是传对象,而是构建可编程的数据上下文
EJS 默认接受一个
data对象作为上下文,但这只是起点。真正的威力在于,你可以在data对象里注入函数、类实例、甚至整个模块。比如,你的 Node 应用需要生成带时间戳的报告,传统做法是data.timestamp = new Date().toISOString(),但这样 timestamp 就是静态的。更好的方式是注入一个函数:const data = { now: () => new Date().toISOString(), formatDate: (date, format) => { // 自定义日期格式化逻辑 return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); }, config: require('./config.json'), // 直接注入配置文件 fs: require('fs').promises, // 注入 Promise 版 fs 模块 };在 EJS 模板里,你就可以这样用:
<h3>生成时间:<%= now() %></h3> <p>格式化时间:<%= formatDate(new Date(), 'YYYY-MM-DD') %></p> <% if (config.enableFeatureX) { %> <div class="feature-x">高级功能已启用</div> <% } %> <% const content = await fs.readFile('./content.md', 'utf8'); %> <%- content %>注意:注入
fs.promises时,必须确保模板渲染是async模式。EJS 提供ejs.renderFile(file, data, options, callback)和ejs.renderFile(file, data, options)(返回 Promise)两个接口,后者才是现代用法。如果忘记await,你会得到Promise { <pending> }字符串,而不是文件内容。3.2 控制流复用:把 Node 的 if/for/while,直接搬到模板里
这是 EJS 最被低估的能力。很多开发者习惯在 Node 层把数据“加工好”再传给模板,比如把一个扁平数组按分类分组,再传
groupedData给模板。这看似清晰,实则僵化。EJS 允许你在模板里直接操作原始数据:// Node 层只传原始数据 const rawData = [ { id: 1, category: 'tech', title: 'Node.js 性能优化' }, { id: 2, category: 'design', title: 'UI 设计原则' }, { id: 3, category: 'tech', title: 'EJS 深度解析' } ];<!-- EJS 模板里直接分组 --> <% const grouped = {}; %> <% rawData.forEach(item => { %> <% if (!grouped[item.category]) grouped[item.category] = []; %> <% grouped[item.category].push(item); %> <% }); %> <% Object.keys(grouped).forEach(category => { %> <h2><%= category %></h2> <ul> <% grouped[category].forEach(item => { %> <li><%= item.title %></li> <% }); %> </ul> <% }); %>这个例子展示了 EJS 如何成为 Node 逻辑的“延伸臂”。你不需要在 Node 层写一个
groupBy工具函数,模板自己就能完成。当然,性能敏感场景下,这种计算应该放在 Node 层,但对配置化、低频次的模板渲染,这种写法极大提升了灵活性。我曾用此技巧实现一个动态表单生成器:用户上传 JSON Schema,模板根据type字段动态渲染 input、select、textarea,并自动绑定required、minLength等属性,整个逻辑都在 EJS 里完成,Node 层只负责读取和传递 Schema。3.3 错误处理:让模板崩溃变得可预测、可捕获
模板出错最可怕的是“静默失败”。EJS 默认会在渲染错误时抛出异常,但堆栈信息指向
.ejs文件的某一行,而非原始 JS 代码。要解决这个问题,必须开启debug选项并配合compileDebug:const template = ejs.compile(ejsSource, { filename: 'report.ejs', debug: true, // 启用调试模式 compileDebug: true, // 生成带行号的 JS 代码 client: false // 服务端渲染,不生成浏览器版 }); try { const html = template(data); } catch (err) { console.error('模板渲染失败:', err.message); console.error('错误位置:', err.line, '行', err.column, '列'); // 这里可以记录详细日志,或返回友好的错误页面 }debug: true会让 EJS 在编译时生成类似// line 12的注释,compileDebug: true则确保这些注释被保留。当rawData是undefined导致<% rawData.forEach(...)%>报错时,err.line会精确指向模板里forEach所在的行号,而不是编译后的 JS 文件。这个配置是我所有 EJS 项目的标配,它让模板错误和普通 JS 错误一样可调试。3.4 模块化与继承:用
<%- include %>构建可复用的模板组件库EJS 的
include不是简单的文件拼接,而是作用域继承。被包含的文件共享父模板的全部上下文,同时可以接收额外参数。这让你能构建真正的组件库:<!-- layout.ejs --> <!DOCTYPE html> <html> <head> <title><%= title || '默认标题' %></title> </head> <body> <header><%- include('header', { user: user }) %></header> <main><%- body %></main> <footer><%- include('footer', { version: config.version }) %></footer> </body> </html><!-- index.ejs --> <%- include('layout', { title: '首页', user: { name: '张三', role: 'admin' }, body: '<h1>欢迎来到首页</h1>' }) %>注意
body: '<h1>欢迎来到首页</h1>'这个 trick:它把 HTML 字符串作为参数传入,<%- body %>会原样输出(不转义)。这相当于实现了“slot”机制。我用此模式构建了一个企业级文档模板库:layout.ejs定义整体结构,header.ejs处理导航和搜索,toc.ejs自动生成目录,code-block.ejs封装语法高亮逻辑。每个团队只需编写自己的content.ejs,通过include组合即可生成风格统一的文档,维护成本降低 70%。4. 实操过程:从零搭建一个可交付的 Node+EJS 模板应用
现在我们动手做一个真实可用的案例:一个多环境配置文件生成器。输入一个 YAML 配置模板和环境变量,输出对应环境的
config.js。它模拟了微服务部署中“一份配置,多套环境”的典型需求,也是我交付给客户的第一个 EJS 模板产品。4.1 项目初始化与依赖安装
创建项目目录,初始化
package.json:mkdir node-ejs-template && cd node-ejs-template npm init -y npm install ejs js-yaml这里只安装两个核心依赖:
ejs是模板引擎,js-yaml用于解析 YAML 配置。坚决不装express、koa等 Web 框架,因为我们要做的是 CLI 工具,不是 Web 服务。node的版本选择上,我推荐 Node 18 LTS(2022.10 发布),它对 ES Module 支持完善,且js-yaml的最新版已全面适配。如果你遇到node: /lib64/libstdc++.so.6: version 'cxxabi_1.3.11' not found这类错误(常见于 CentOS 7),不要慌,这不是 EJS 的问题,而是 Node 二进制包与系统 GLIBC 版本不兼容。解决方案有两个:一是用nvm安装一个兼容版本(如 Node 16),二是从 Node.js 官网 下载linux-x64包,解压后用./bin/node直接运行,绕过系统包管理器。nvm是管理多个 Node 版本的利器,安装后nvm install 16.20.2 && nvm use 16.20.2即可切换,比手动下载更省心。4.2 创建核心模板文件
config.ejs在项目根目录创建
templates/config.ejs:// config.js - 由 EJS 模板生成 module.exports = { // 基础配置 env: '<%= env %>', appName: '<%= appName %>', version: '<%= version %>', // 数据库配置 - 根据环境动态调整 database: { host: '<%= db.host || "localhost" %>', port: <%= db.port || 3306 %>, name: '<%= db.name %>', username: '<%= db.username %>', password: '<%= db.password %>', // 生产环境启用连接池 pool: { max: <%= env === 'production' ? 20 : 5 %>, min: <%= env === 'production' ? 5 : 1 %> } }, // API 配置 api: { baseUrl: '<%= api.baseUrl %>', timeout: <%= api.timeout || 5000 %>, // 开发环境启用 Mock mockEnabled: <%= env === 'development' ? 'true' : 'false' %> }, // 日志配置 logger: { level: '<%= logger.level || "info" %>', // 生产环境输出到文件,其他环境输出到控制台 output: '<%= env === "production" ? "file" : "console" %>' } };这个模板展示了 EJS 的核心能力:
<%= %>输出变量,<% %>执行逻辑,env === 'production'这样的判断直接嵌入。注意pool.max的赋值:<%= env === 'production' ? 20 : 5 %>,它不是一个字符串,而是一个 JS 表达式,EJS 会计算其值后输出。这比在 Node 层写if (env === 'production') config.pool.max = 20更直观。4.3 编写主程序
generate-config.js创建
generate-config.js:#!/usr/bin/env node const fs = require('fs').promises; const path = require('path'); const ejs = require('ejs'); const yaml = require('js-yaml'); // 1. 解析命令行参数 const args = process.argv.slice(2); if (args.length < 2) { console.error('用法: node generate-config.js <env> <config-yaml-file>'); console.error('示例: node generate-config.js production config.yaml'); process.exit(1); } const env = args[0]; const yamlFile = args[1]; // 2. 读取并解析 YAML 配置 let configData; try { const yamlContent = await fs.readFile(yamlFile, 'utf8'); configData = yaml.load(yamlContent); } catch (err) { console.error(`读取 YAML 文件失败: ${err.message}`); process.exit(1); } // 3. 合并环境变量(优先级最高) const envData = { ...configData, env, // 从 process.env 读取覆盖项,例如 NODE_ENV=production 时,DB_HOST 可覆盖 config.yaml 中的 host db: { ...configData.db, host: process.env.DB_HOST || configData.db?.host, port: process.env.DB_PORT || configData.db?.port, username: process.env.DB_USERNAME || configData.db?.username, password: process.env.DB_PASSWORD || configData.db?.password } }; // 4. 加载并编译 EJS 模板 const templatePath = path.join(__dirname, 'templates', 'config.ejs'); let template; try { const templateSource = await fs.readFile(templatePath, 'utf8'); template = ejs.compile(templateSource, { filename: templatePath, debug: true, compileDebug: true, client: false }); } catch (err) { console.error(`加载模板失败: ${err.message}`); process.exit(1); } // 5. 渲染模板 let result; try { result = template(envData); } catch (err) { console.error(`模板渲染失败: ${err.message}`); console.error(`错误位置: 第 ${err.line} 行, 第 ${err.column} 列`); process.exit(1); } // 6. 写入输出文件 const outputPath = path.join(__dirname, `config.${env}.js`); try { await fs.writeFile(outputPath, result, 'utf8'); console.log(`✅ 配置文件已生成: ${outputPath}`); } catch (err) { console.error(`写入文件失败: ${err.message}`); process.exit(1); }这个脚本严格遵循 Node CLI 工具的最佳实践:
#!/usr/bin/env node声明解释器,process.argv解析参数,try/catch全局错误处理,process.exit(1)明确错误码。关键点在于第 3 步的数据合并策略:configData是 YAML 解析出的基础数据,envData在此基础上用process.env覆盖,确保环境变量拥有最高优先级。这是 DevOps 场景的黄金法则。4.4 创建示例配置
config.yaml在项目根目录创建
config.yaml:appName: "MyApp" version: "1.0.0" db: host: "localhost" port: 3306 name: "myapp_dev" username: "dev_user" password: "dev_pass" api: baseUrl: "http://localhost:3000/api" timeout: 3000 logger: level: "debug"4.5 运行与验证
赋予脚本执行权限(Linux/macOS):
chmod +x generate-config.js生成开发环境配置:
node generate-config.js development config.yaml生成生产环境配置,并通过环境变量覆盖数据库地址:
DB_HOST=prod-db.example.com DB_PORT=3307 node generate-config.js production config.yaml检查生成的
config.production.js,你会发现database.host已被替换为prod-db.example.com,database.port为3307,而pool.max是20,mockEnabled是false。整个过程没有修改一行 Node 逻辑代码,所有差异化都由模板和输入数据驱动。实操心得:在 CI/CD 流水线中,我通常会把这个脚本封装成 npm script:
"scripts": { "gen:config": "node generate-config.js" },然后在 GitHub Actions 的deploy.yml里写npm run gen:config production config.yaml && scp config.production.js user@server:/app/。这样,部署脚本就变成了声明式的,而不是命令式的,可读性和可维护性大幅提升。5. 常见问题与排查技巧实录:那些官方文档不会告诉你的坑
EJS 上手容易,但深入使用后,总有一些“意料之外”的问题。以下是我在三年 EJS 实战中整理的高频问题速查表,每个问题都附带真实场景、根本原因和一招制敌的解决方案。
问题现象 根本原因 解决方案 我的实操记录 模板渲染后,HTML 标签被转义显示为 <div>而不是渲染为元素使用了 <%= %>而不是<%- %>。<%= %>会对输出进行 HTML 转义(<→<),<%- %>则原样输出。将 <%= rawHtml %>改为<%- rawHtml %>。如果rawHtml来自不可信源,务必先用DOMPurify.sanitize(rawHtml)过滤 XSS。客户要求在邮件模板中插入富文本编辑器生成的内容,我一开始用 <%= content %>,结果收到的邮件里全是<p>Hello</p>。改成<%- content %>后立刻解决。<%- include('partial') %>报错Error: Could not find include file: partialEJS 默认在当前工作目录查找 partial.ejs,而不是在templates/目录下。include的路径是相对于process.cwd(),不是相对于模板文件。在 ejs.compile()或ejs.renderFile()时,显式指定root选项:{ root: path.join(__dirname, 'templates') }。这样include('partial')就会去templates/partial.ejs查找。我第一次用 include时,把partial.ejs放在templates/下,主模板也在templates/下,但include('partial')就是找不到。加了root选项后秒解。模板里调用 require('fs').readFileSync()报错Error: ENOENTreadFileSync的路径是相对于process.cwd(),而process.cwd()在 CLI 工具中通常是启动目录,不是模板所在目录。使用 path.resolve(__dirname, '../data/file.txt')构造绝对路径,或者用fs.promises.readFile()配合path.join()。绝对路径永远可靠。一个模板需要读取同目录下的 logo.svg,我写fs.readFileSync('logo.svg'),在项目根目录运行node generate.js就报错。改成fs.readFileSync(path.join(__dirname, 'logo.svg'))后稳定。<% for (let i = 0; i < data.length; i++) { %>循环中,i的值在闭包里总是最后一个这是 JS 闭包的经典问题, var声明的变量在循环中共享同一个内存空间。EJS 的<% %>代码块里,默认使用var。在循环内用 let声明变量:<% for (let i = 0; i < data.length; i++) { let idx = i; %>,或者直接用data.forEach((item, idx) => { ... })。模板里生成一组按钮,每个按钮的 onclick要传入索引i,结果所有按钮都点了最后一个。加了let idx = i后修复。<%= user.name %>报错Cannot read property 'name' of undefined,但user对象明明存在user对象里某个中间属性为null或undefined,比如user.profile.name,而user.profile是null。EJS 不会做空值安全访问。使用可选链操作符(ES2020+):`<%= user?.profile?.name 除了这些具体问题,还有几个贯穿始终的经验技巧:
技巧一:模板预编译是性能和调试的双重保险
不要在每次渲染时都调用ejs.renderFile(),它内部会先读取文件、再编译、再执行。对于 CLI 工具或批处理任务,应该提前编译:const template = ejs.compile(source, options),然后反复调用template(data)。编译一次,渲染千次,既快又稳。我所有生产项目都采用此模式,渲染速度提升 3 倍以上。技巧二:用
client: true生成浏览器版模板(仅限必要场景)
当你的模板需要在浏览器端渲染(比如 SPA 的初始 HTML),设置client: true会让 EJS 生成一个function(data) { ... },你可以把它toString()后塞进<script>标签,或者用 Webpack 打包。但注意,浏览器版无法使用require、fs等 Node 模块,所以必须把所有依赖提前注入data。技巧三:
escape选项是 XSS 防御的最后防线
EJS 默认的escape函数是require('util').inspect,它并不防 XSS。你应该重写它:const ejs = require('ejs'); const escapeHtml = require('escape-html'); // npm install escape-html ejs.escape = function(str) { return typeof str === 'string' ? escapeHtml(str) : str; };这样
<%= userInput %>就会自动过滤<script>标签,而<%- userInput %>依然保持原样,给你精细控制权。最后分享一个小技巧:在模板顶部加一行
<!-- Generated by EJS on <%= new Date().toISOString() %> -->,这样生成的每个文件都自带时间戳和来源标识,当客户问“这个配置文件是谁生成的?什么时候生成的?”时,你不用翻 Git 记录,直接打开文件就能回答。这种细节,往往决定了客户对你专业度的评价。