這個博客是我的藍鳥博客的第二部分。請參閱第 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中文網其他相關文章!