实现模板引擎

手写 Mustache 风格的模板引擎

问题

实现一个 Mustache 风格的模板引擎,支持以下语法:

  • {{name}} - 变量替换
  • {{a.b}} - 嵌套属性访问
  • {{#list}}...{{/list}} - 循环/条件渲染
  • {{^show}}...{{/show}} - 反向条件(falsy 时渲染)

解答

基础版本:变量替换

function render(template, data) {
  // 匹配 {{xxx}} 或 {{a.b.c}}
  return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, key) => {
    return getNestedValue(data, key) ?? '';
  });
}

// 获取嵌套属性值:'a.b.c' -> data.a.b.c
function getNestedValue(obj, path) {
  return path.split('.').reduce((acc, key) => acc?.[key], obj);
}

// 测试
const template = '你好,{{name}}!你的邮箱是 {{user.email}}';
const data = { name: '张三', user: { email: 'test@example.com' } };
console.log(render(template, data));
// 输出:你好,张三!你的邮箱是 test@example.com

完整版本:支持循环和条件

function render(template, data) {
  let result = template;

  // 1. 处理循环/条件块 {{#key}}...{{/key}}
  result = result.replace(
    /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g,
    (match, key, content) => {
      const value = getNestedValue(data, key);

      // 数组:循环渲染
      if (Array.isArray(value)) {
        return value.map(item => render(content, { ...data, ...item, '.': item })).join('');
      }

      // truthy:渲染内容
      if (value) {
        return render(content, typeof value === 'object' ? { ...data, ...value } : data);
      }

      // falsy:不渲染
      return '';
    }
  );

  // 2. 处理反向条件 {{^key}}...{{/key}}
  result = result.replace(
    /\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g,
    (match, key, content) => {
      const value = getNestedValue(data, key);
      // falsy 或空数组时渲染
      if (!value || (Array.isArray(value) && value.length === 0)) {
        return render(content, data);
      }
      return '';
    }
  );

  // 3. 处理变量 {{key}} 或 {{.}}
  result = result.replace(/\{\{(\.|[\w.]+)\}\}/g, (match, key) => {
    if (key === '.') return escapeHtml(data['.'] ?? '');
    const value = getNestedValue(data, key);
    return escapeHtml(value ?? '');
  });

  return result;
}

// 获取嵌套属性
function getNestedValue(obj, path) {
  return path.split('.').reduce((acc, key) => acc?.[key], obj);
}

// HTML 转义,防止 XSS
function escapeHtml(str) {
  const escapeMap = {
    '&': '&',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#39;'
  };
  return String(str).replace(/[&<>"']/g, char => escapeMap[char]);
}

使用示例

const template = `
<h1>{{title}}</h1>

{{#showIntro}}
<p>欢迎来到 {{siteName}}</p>
{{/showIntro}}

<ul>
{{#users}}
  <li>{{name}} - {{email}}</li>
{{/users}}
</ul>

{{^users}}
  <p>暂无用户</p>
{{/users}}
`;

const data = {
  title: '用户列表',
  siteName: 'MyApp',
  showIntro: true,
  users: [
    { name: '张三', email: 'zhang@test.com' },
    { name: '李四', email: 'li@test.com' }
  ]
};

console.log(render(template, data));

输出:

<h1>用户列表</h1>

<p>欢迎来到 MyApp</p>

<ul>
  <li>张三 - zhang@test.com</li>
  <li>李四 - li@test.com</li>
</ul>

基于词法分析的实现(更健壮)

class MustacheEngine {
  constructor(template) {
    this.tokens = this.tokenize(template);
  }

  // 词法分析:将模板拆分为 token
  tokenize(template) {
    const tokens = [];
    const regex = /\{\{([#^/])?(\.|[\w.]+)\}\}/g;
    let lastIndex = 0;
    let match;

    while ((match = regex.exec(template)) !== null) {
      // 添加文本节点
      if (match.index > lastIndex) {
        tokens.push({ type: 'text', value: template.slice(lastIndex, match.index) });
      }

      const [, modifier, key] = match;
      if (modifier === '#') {
        tokens.push({ type: 'section', key });
      } else if (modifier === '^') {
        tokens.push({ type: 'inverted', key });
      } else if (modifier === '/') {
        tokens.push({ type: 'end', key });
      } else {
        tokens.push({ type: 'variable', key });
      }

      lastIndex = regex.lastIndex;
    }

    // 添加剩余文本
    if (lastIndex < template.length) {
      tokens.push({ type: 'text', value: template.slice(lastIndex) });
    }

    return this.buildTree(tokens);
  }

  // 构建 AST
  buildTree(tokens) {
    const root = { type: 'root', children: [] };
    const stack = [root];

    for (const token of tokens) {
      const parent = stack[stack.length - 1];

      if (token.type === 'section' || token.type === 'inverted') {
        const node = { ...token, children: [] };
        parent.children.push(node);
        stack.push(node);
      } else if (token.type === 'end') {
        stack.pop();
      } else {
        parent.children.push(token);
      }
    }

    return root;
  }

  // 渲染
  render(data) {
    return this.renderNode(this.tokens, data);
  }

  renderNode(node, data) {
    if (node.type === 'text') {
      return node.value;
    }

    if (node.type === 'variable') {
      const value = this.getValue(data, node.key);
      return this.escapeHtml(value ?? '');
    }

    if (node.type === 'section') {
      const value = this.getValue(data, node.key);
      if (Array.isArray(value)) {
        return value.map(item => 
          this.renderChildren(node.children, { ...data, ...item, '.': item })
        ).join('');
      }
      if (value) {
        return this.renderChildren(node.children, 
          typeof value === 'object' ? { ...data, ...value } : data
        );
      }
      return '';
    }

    if (node.type === 'inverted') {
      const value = this.getValue(data, node.key);
      if (!value || (Array.isArray(value) && value.length === 0)) {
        return this.renderChildren(node.children, data);
      }
      return '';
    }

    if (node.type === 'root') {
      return this.renderChildren(node.children, data);
    }

    return '';
  }

  renderChildren(children, data) {
    return children.map(child => this.renderNode(child, data)).join('');
  }

  getValue(obj, path) {
    if (path === '.') return obj['.'];
    return path.split('.').reduce((acc, key) => acc?.[key], obj);
  }

  escapeHtml(str) {
    const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
    return String(str).replace(/[&<>"']/g, c => map[c]);
  }
}

// 使用
const engine = new MustacheEngine(template);
console.log(engine.render(data));

关键点

  • 正则匹配:使用 /\{\{(\w+(?:\.\w+)*)\}\}/g 匹配变量,[\s\S]*? 非贪婪匹配块内容
  • 嵌套属性:通过 split('.') + reduce 实现 a.b.c 路径访问
  • 递归渲染:循环和条件块需要递归调用 render,处理嵌套模板
  • XSS 防护:输出变量时进行 HTML 转义
  • 词法分析:复杂场景下,先 tokenize 再构建 AST,比纯正则更健壮