跳到主要内容

模板字符串解析

问题

手写实现模板字符串解析函数,将模板中的变量占位符替换为实际值。

答案

模板字符串解析是前端常见需求,需要将 {{name}}${name} 等占位符替换为对应数据。


基础实现

双大括号模板 {{variable}}

function render(template: string, data: Record<string, unknown>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return data[key] !== undefined ? String(data[key]) : match;
});
}

// 测试
const template = 'Hello, {{name}}! You are {{age}} years old.';
const data = { name: 'Alice', age: 25 };
console.log(render(template, data));
// 'Hello, Alice! You are 25 years old.'

支持嵌套属性 {{user.name}}

function renderNested(template: string, data: Record<string, unknown>): string {
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? String(value) : match;
});
}

function getValueByPath(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
return current && typeof current === 'object'
? (current as Record<string, unknown>)[key]
: undefined;
}, obj);
}

// 测试
const template2 = '{{user.name}} works at {{user.company.name}}';
const data2 = {
user: {
name: 'Bob',
company: { name: 'Tech Corp' },
},
};
console.log(renderNested(template2, data2));
// 'Bob works at Tech Corp'

支持默认值 {{name | default}}

function renderWithDefault(
template: string,
data: Record<string, unknown>
): string {
return template.replace(
/\{\{([\w.]+)(?:\s*\|\s*([^}]+))?\}\}/g,
(match, path, defaultValue) => {
const value = getValueByPath(data, path.trim());
if (value !== undefined && value !== null) {
return String(value);
}
return defaultValue !== undefined ? defaultValue.trim() : match;
}
);
}

// 测试
const template3 = '{{name | Guest}}, welcome to {{city | Unknown City}}!';
const data3 = { name: 'Charlie' };
console.log(renderWithDefault(template3, data3));
// 'Charlie, welcome to Unknown City!'

ES6 模板字符串风格 ${variable}

基础实现(使用 Function)

function templateLiteral(template: string, data: Record<string, unknown>): string {
const keys = Object.keys(data);
const values = Object.values(data);

// 使用 Function 构造器创建动态函数
const fn = new Function(...keys, `return \`${template}\`;`);
return fn(...values);
}

// 测试
const template4 = 'Hello, ${name}! You are ${age} years old.';
const data4 = { name: 'David', age: 30 };
console.log(templateLiteral(template4, data4));
// 'Hello, David! You are 30 years old.'

安全实现(正则替换)

function templateSafe(template: string, data: Record<string, unknown>): string {
return template.replace(/\$\{([\w.]+)\}/g, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? String(value) : match;
});
}

高级功能

支持表达式

function templateWithExpression(
template: string,
data: Record<string, unknown>
): string {
// 警告:使用 eval 有安全风险,仅用于可信模板
return template.replace(/\{\{(.+?)\}\}/g, (match, expression) => {
try {
// 创建上下文
const context = { ...data };
const keys = Object.keys(context);
const values = Object.values(context);

const fn = new Function(...keys, `return ${expression};`);
const result = fn(...values);
return result !== undefined ? String(result) : '';
} catch {
return match;
}
});
}

// 测试
const template5 = '{{name.toUpperCase()}} is {{age >= 18 ? "adult" : "minor"}}';
const data5 = { name: 'Eve', age: 20 };
console.log(templateWithExpression(template5, data5));
// 'EVE is adult'

支持过滤器/管道

type FilterFn = (value: unknown, ...args: string[]) => unknown;

const filters: Record<string, FilterFn> = {
uppercase: (v) => String(v).toUpperCase(),
lowercase: (v) => String(v).toLowerCase(),
capitalize: (v) => {
const s = String(v);
return s.charAt(0).toUpperCase() + s.slice(1);
},
currency: (v, symbol = '$') => `${symbol}${Number(v).toFixed(2)}`,
date: (v, format = 'YYYY-MM-DD') => {
const d = new Date(v as string | number | Date);
return format
.replace('YYYY', String(d.getFullYear()))
.replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
.replace('DD', String(d.getDate()).padStart(2, '0'));
},
truncate: (v, length = '20') => {
const s = String(v);
const len = parseInt(length, 10);
return s.length > len ? s.slice(0, len) + '...' : s;
},
};

function renderWithFilters(
template: string,
data: Record<string, unknown>
): string {
return template.replace(
/\{\{([\w.]+)((?:\s*\|\s*\w+(?::\S+)?)*)\}\}/g,
(match, path, filterStr) => {
let value = getValueByPath(data, path);

if (value === undefined) return match;

// 解析过滤器
const filterMatches = filterStr.matchAll(/\|\s*(\w+)(?::(\S+))?/g);
for (const [, filterName, args] of filterMatches) {
const filter = filters[filterName];
if (filter) {
const filterArgs = args ? args.split(',') : [];
value = filter(value, ...filterArgs);
}
}

return String(value);
}
);
}

// 测试
const template6 = `
Name: {{name | uppercase}}
Price: {{price | currency:¥}}
Description: {{desc | truncate:10}}
Created: {{date | date:YYYY/MM/DD}}
`;

const data6 = {
name: 'Product',
price: 99.9,
desc: 'This is a very long description',
date: new Date(),
};

console.log(renderWithFilters(template6, data6));

支持条件渲染

function renderWithCondition(
template: string,
data: Record<string, unknown>
): string {
// 处理 if 条件
let result = template.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(match, condition, content) => {
const value = getValueByPath(data, condition);
return value ? content : '';
}
);

// 处理 if-else
result = result.replace(
/\{\{#if\s+([\w.]+)\}\}([\s\S]*?)\{\{#else\}\}([\s\S]*?)\{\{\/if\}\}/g,
(match, condition, ifContent, elseContent) => {
const value = getValueByPath(data, condition);
return value ? ifContent : elseContent;
}
);

// 处理变量
return renderNested(result, data);
}

// 测试
const template7 = `
{{#if isLoggedIn}}
Welcome, {{username}}!
{{#else}}
Please login.
{{/if}}
`;

console.log(renderWithCondition(template7, { isLoggedIn: true, username: 'Frank' }));
// ' Welcome, Frank!'

console.log(renderWithCondition(template7, { isLoggedIn: false }));
// ' Please login.'

支持循环

function renderWithLoop(
template: string,
data: Record<string, unknown>
): string {
// 处理 each 循环
let result = template.replace(
/\{\{#each\s+([\w.]+)\s+as\s+(\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g,
(match, arrayPath, itemName, itemTemplate) => {
const array = getValueByPath(data, arrayPath);
if (!Array.isArray(array)) return '';

return array
.map((item, index) => {
const itemData = {
...data,
[itemName]: item,
[`${itemName}Index`]: index,
};
return renderNested(itemTemplate, itemData);
})
.join('');
}
);

return renderNested(result, data);
}

// 测试
const template8 = `
<ul>
{{#each items as item}}
<li>{{itemIndex}}. {{item.name}}: {{item.price}}</li>
{{/each}}
</ul>
`;

const data8 = {
items: [
{ name: 'Apple', price: 1.5 },
{ name: 'Banana', price: 0.5 },
{ name: 'Cherry', price: 3.0 },
],
};

console.log(renderWithLoop(template8, data8));

完整模板引擎

interface TemplateOptions {
delimiters?: [string, string];
filters?: Record<string, FilterFn>;
}

class TemplateEngine {
private filters: Record<string, FilterFn>;
private openTag: string;
private closeTag: string;

constructor(options: TemplateOptions = {}) {
const [open, close] = options.delimiters || ['{{', '}}'];
this.openTag = this.escapeRegex(open);
this.closeTag = this.escapeRegex(close);
this.filters = { ...filters, ...options.filters };
}

private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

render(template: string, data: Record<string, unknown>): string {
let result = template;

// 处理循环
result = this.processLoops(result, data);

// 处理条件
result = this.processConditions(result, data);

// 处理变量和过滤器
result = this.processVariables(result, data);

return result;
}

private processLoops(template: string, data: Record<string, unknown>): string {
const loopRegex = new RegExp(
`${this.openTag}#each\\s+([\\w.]+)\\s+as\\s+(\\w+)${this.closeTag}([\\s\\S]*?)${this.openTag}/each${this.closeTag}`,
'g'
);

return template.replace(loopRegex, (_, arrayPath, itemName, content) => {
const array = getValueByPath(data, arrayPath);
if (!Array.isArray(array)) return '';

return array
.map((item, index) => {
const itemData = { ...data, [itemName]: item, index };
return this.render(content, itemData);
})
.join('');
});
}

private processConditions(template: string, data: Record<string, unknown>): string {
const ifElseRegex = new RegExp(
`${this.openTag}#if\\s+([\\w.]+)${this.closeTag}([\\s\\S]*?)${this.openTag}#else${this.closeTag}([\\s\\S]*?)${this.openTag}/if${this.closeTag}`,
'g'
);

let result = template.replace(ifElseRegex, (_, condition, ifContent, elseContent) => {
const value = getValueByPath(data, condition);
return value ? ifContent : elseContent;
});

const ifRegex = new RegExp(
`${this.openTag}#if\\s+([\\w.]+)${this.closeTag}([\\s\\S]*?)${this.openTag}/if${this.closeTag}`,
'g'
);

return result.replace(ifRegex, (_, condition, content) => {
const value = getValueByPath(data, condition);
return value ? content : '';
});
}

private processVariables(template: string, data: Record<string, unknown>): string {
const varRegex = new RegExp(
`${this.openTag}([\\w.]+)((?:\\s*\\|\\s*\\w+(?::[^|${this.closeTag}]+)?)*)${this.closeTag}`,
'g'
);

return template.replace(varRegex, (match, path, filterStr) => {
let value = getValueByPath(data, path);
if (value === undefined) return match;

// 应用过滤器
const filterMatches = filterStr.matchAll(/\|\s*(\w+)(?::([^|]+))?/g);
for (const [, filterName, args] of filterMatches) {
const filter = this.filters[filterName];
if (filter) {
const filterArgs = args ? args.split(',').map((s) => s.trim()) : [];
value = filter(value, ...filterArgs);
}
}

return String(value);
});
}
}

// 使用
const engine = new TemplateEngine({
filters: {
reverse: (v) => String(v).split('').reverse().join(''),
},
});

console.log(engine.render('Hello, {{name | uppercase | reverse}}!', { name: 'world' }));
// 'Hello, DLROW!'

常见面试问题

Q1: 如何避免 XSS 攻击?

答案

function escapeHtml(str: string): string {
const escapeMap: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return str.replace(/[&<>"']/g, (char) => escapeMap[char]);
}

function renderSafe(template: string, data: Record<string, unknown>): string {
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? escapeHtml(String(value)) : match;
});
}

// 使用 {{{ }}} 表示不转义
function renderWithRaw(template: string, data: Record<string, unknown>): string {
// 先处理不转义
let result = template.replace(/\{\{\{([\w.]+)\}\}\}/g, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? String(value) : match;
});

// 再处理转义
return result.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
const value = getValueByPath(data, path);
return value !== undefined ? escapeHtml(String(value)) : match;
});
}

Q2: 使用 new Function 有什么风险?

答案

风险说明
XSS执行恶意代码
性能每次解析都创建新函数
CSP可能被 Content-Security-Policy 阻止

安全替代方案:使用纯正则解析,避免执行任意代码。

Q3: 如何实现模板预编译?

答案

function compile(template: string): (data: Record<string, unknown>) => string {
// 提取所有变量路径
const matches = template.matchAll(/\{\{([\w.]+)\}\}/g);
const paths = [...matches].map((m) => m[1]);

return (data: Record<string, unknown>) => {
let result = template;
for (const path of paths) {
const value = getValueByPath(data, path);
result = result.replace(
new RegExp(`\\{\\{${path}\\}\\}`, 'g'),
value !== undefined ? String(value) : ''
);
}
return result;
};
}

// 预编译一次,多次使用
const compiled = compile('Hello, {{name}}!');
console.log(compiled({ name: 'Alice' })); // 'Hello, Alice!'
console.log(compiled({ name: 'Bob' })); // 'Hello, Bob!'

相关链接