这个博客是我的蓝鸟博客的第二部分。请参阅第 1 部分
对于没有依赖项的 Twitter 克隆,我决定以返回变量和模板名称的方式设计我的路由处理程序。这允许轻松测试,因为我的测试只需对模板名称和变量进行断言,而不是检查 HTML 文档。
// request { method: "GET", path: "/profile/1234", cookies: { "user-id": 54 }, } // response { status: 200, template: "public-profile-show", variables: { user: { id: 54, name: "John Doe", }, posts: [ { id: 55412, message: "Have you seen the new iThing?", createdAt: 1699788972 } ] } }
在这篇博客中,我将实现这个模板语言。
我需要的模板语言,需要输出仅包含一组变量作为输入的 HTML 文档。我想要将模板编译为 JS 函数。例如,我想要 Hello 编译成这样的东西:
({ name }) => `Hello ${escapeHtml(name)}`;
我会选择经典的<%= %>语法,因为它非常常见且众所周知。大多数看到此语法的开发人员都会直观地知道您可以在其中编写常规代码,并且代码的输出将添加到输出中。
它必须支持变量和自动转义 HTML 实体。还必须支持循环、if/else 语句以及包括其他模板。如果我们可以调用任意函数并做一些基本的数学运算,那就太好了;
所以基本上,我希望它能够执行任意 javascript。
我想我刚刚开始编写代码,看看我最终会得到什么。首先,测试一下。
it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); });
我能想到的最简单的实现是使用正则表达式。 之外的所有内容只会添加到输出中,其间的内容将作为 JS 执行。
使用的正则表达式为 /(.*?)/sg。此正则表达式捕获任何文本,直到使用 (.*?)使用 (.*?)%>。 s 修饰符允许 . (点)匹配换行符。 g 修饰符允许多个匹配。
Javascript 在字符串上的替换函数允许为每个匹配执行代码,同时还返回替换值,在我的代码中为“”。因为每个匹配项都被替换为空字符串,所以只有最后一个 %> 之后的文本才被替换。由替换函数返回,我称之为尾部。
我使用 JSON.stringify 创建字符串文字。
const Template = { parse(template) { let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(${code.substr(1)});`); return ""; }); body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); return new Function('vars', body.join("\n")) } };
对于测试中的模板,该函数返回如下函数:
function(vars) { eval(`var { ${Object.keys(vars).join(', ')} } = vars;`); out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); }
这段代码的另一个值得注意的部分是 eval 语句。为了允许模板引用 vars 中的任何变量(本例中的名称),我需要使 vars 中的属性可用作函数中的局部变量。
编译时没有简单的方法来确定可能的变量,因此我在运行时生成它们。我知道在运行时分配任意局部变量的唯一方法是使用 eval。
// request { method: "GET", path: "/profile/1234", cookies: { "user-id": 54 }, } // response { status: 200, template: "public-profile-show", variables: { user: { id: 54, name: "John Doe", }, posts: [ { id: 55412, message: "Have you seen the new iThing?", createdAt: 1699788972 } ] } }
另一种方法是使用with语句,但不鼓励使用。不管怎样,我们来试试吧。
({ name }) => `Hello ${escapeHtml(name)}`;
生成的函数完美运行。不幸的是,该功能被不鼓励、遗留或弃用,具体取决于你问的是谁。到目前为止,我的选择是邪恶的评估或弃用。理想情况下,我想确定在编译时使用的变量,但这需要编译 Javascript 代码来确定使用的变量。
没有简单的方法可以使用普通 NodeJS 获取某段 Javascript 的抽象语法树。
现在要转义 HTML 实体,支持 if/else 语句并添加一些小修复。
it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); });
我还添加了一些更多测试。
const Template = { parse(template) { let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(${code.substr(1)});`); return ""; }); body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); return new Function('vars', body.join("\n")) } };
为了允许包含,我将添加一个函数来解析目录中的所有模板文件。该函数将保存一个字典,其中模板名称作为键,其解析的模板函数作为值。
function(vars) { eval(`var { ${Object.keys(vars).join(', ')} } = vars;`); out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); }
var { foo } = { foo: 1 }; // foo = 1 eval('var { bar } = { bar: 2 }'); // bar = 2
function(vars) { with (vars) { out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); } }
现在将此模板引擎集成到 main.mjs 文件中,以使用 .ejs 模板渲染模板。
// Template.parse let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { if (content) body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(escapeHtml(${code.substr(1)}));`); else if (code.startsWith("-")) body.push(`out.push(${code.substr(1)});`); else body.push(code); return ""; }); if (tail.length > 0) body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); body = body.join("\n"); const fn = new Function('vars', body); return (vars) => fn({ ...vars, ...Template.locals }); // Template.locals locals: { escapeHtml: (str) => `${str}`.replace(/[<>&"']/g, s => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[s]) }
describe("Template.parse", () => { it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); }); it("math template", () => { const fn = Template.parse("1 + 1 = <%= 1 + 1 %>"); assert.equal(fn({}), "1 + 1 = 2"); }); it("function template", () => { const fn = Template.parse("Hello <%= foo() %>"); assert.equal(fn({ foo: () => "world" }), "Hello world"); }); it("if-else template", () => { const fn = Template.parse(`Answer: <% if (answer) { %>Yes<% } else { %>No<% } %>`); assert.deepEqual( [fn({ answer: true }), fn({ answer: false })], ["Answer: Yes", "Answer: No"]); }); it("multiline template", () => { const fn = Template.parse(` Answer: <% if (answer) { %> Yes <% } else { %> No <% } %> `); assert.deepEqual( [delws(fn({ answer: true })), delws(fn({ answer: false }))], ["Answer: Yes", "Answer: No"]); }); it("escape html", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "<script> & \" '" }), "Hello, <script> & " '"); }); }); function delws(str) { return str.replace(/^\s+|\s+$/g, "").replace(/\s+/g, " "); }
export const Template = { /** * * @param {string} path Directory containing one or more template files * @returns {Promise<(template: string, vars: Record<string, any>) => string>} */ async parseDirectory(path) { /** @type {Map<string, function>} */ const templates = new Map(); const include = (templateName, vars) => { const template = templates.get(templateName); if (!template) throw new Error(`Template ${path}/${templateName} does not exist, ` + `templates found: ${Array.from(templates.keys()).join(", ")}`); return template({ ...vars, include }); }; const readDir = async (prefix) => { const innerPath = join(path, prefix); const fileNames = await promises.readdir(join(innerPath)); for (const fileName of fileNames) { const templateName = join(prefix, fileName); const filePath = join(innerPath, fileName); if (fileName.endsWith(".ejs")) { const body = await promises.readFile(filePath, { encoding: "utf-8" }); templates.set(templateName, Template.parse(body, { filePath })); } else if ((await promises.stat(filePath)).isDirectory) { await readDir(join(prefix, fileName)); } } }; await readDir(""); return include; } }
现在我们准备开始编写我们的应用程序,这将在下一篇博客中继续
以上是模板语言无依赖性的详细内容。更多信息请关注PHP中文网其他相关文章!