HTML Templates

HTML模板元素提供了一种声明式的方式来定义可复用的HTML片段。本章将介绍template元素和slot元素的使用方法。

template元素

基本用法

<template id="card-template">
    <style>
        .card {
            border: 1px solid #ddd;
            border-radius: 8px;
            padding: 16px;
            margin: 16px 0;
        }
        .card-title {
            font-size: 18px;
            font-weight: bold;
            margin-bottom: 8px;
        }
        .card-content {
            color: #666;
        }
    </style>
    <div class="card">
        <div class="card-title"></div>
        <div class="card-content"></div>
    </div>
</template>

<script>
    function createCard(title, content) {
        const template = document.getElementById('card-template');
        const clone = template.content.cloneNode(true);
        
        clone.querySelector('.card-title').textContent = title;
        clone.querySelector('.card-content').textContent = content;
        
        return clone;
    }
    
    document.body.appendChild(createCard('标题', '内容'));
</script>

模板特性

const templateFeatures = {
    inert: '模板内容在克隆前不会被渲染',
    hidden: '模板内容默认隐藏',
    script: '模板内的脚本不会执行',
    style: '模板内的样式不会应用',
    images: '模板内的图片不会加载',
    content: '通过template.content访问DocumentFragment'
};

动态模板

class TemplateManager {
    constructor() {
        this.templates = new Map();
    }
    
    register(name, template) {
        if (typeof template === 'string') {
            const element = document.createElement('template');
            element.innerHTML = template;
            this.templates.set(name, element);
        } else {
            this.templates.set(name, template);
        }
    }
    
    get(name) {
        return this.templates.get(name);
    }
    
    instantiate(name, data = {}) {
        const template = this.templates.get(name);
        if (!template) return null;
        
        const clone = template.content.cloneNode(true);
        this.bindData(clone, data);
        
        return clone;
    }
    
    bindData(fragment, data) {
        const elements = fragment.querySelectorAll('[data-bind]');
        
        elements.forEach(element => {
            const key = element.getAttribute('data-bind');
            const value = this.getNestedValue(data, key);
            
            if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
                element.value = value;
            } else {
                element.textContent = value;
            }
        });
        
        const attrElements = fragment.querySelectorAll('[data-bind-attr]');
        attrElements.forEach(element => {
            const config = JSON.parse(element.getAttribute('data-bind-attr'));
            Object.entries(config).forEach(([attr, key]) => {
                element.setAttribute(attr, this.getNestedValue(data, key));
            });
        });
    }
    
    getNestedValue(obj, path) {
        return path.split('.').reduce((acc, part) => acc?.[part], obj);
    }
}

const templateManager = new TemplateManager();

templateManager.register('user-card', `
    <div class="user-card">
        <img data-bind-attr='{"src": "avatar", "alt": "name"}'>
        <h3 data-bind="name"></h3>
        <p data-bind="email"></p>
    </div>
`);

const userCard = templateManager.instantiate('user-card', {
    name: '张三',
    email: 'zhangsan@example.com',
    avatar: '/avatar.jpg'
});

slot元素

基本插槽

<template id="panel-template">
    <style>
        .panel {
            border: 1px solid #ccc;
            border-radius: 8px;
            overflow: hidden;
        }
        .panel-header {
            background: #f5f5f5;
            padding: 12px;
            border-bottom: 1px solid #ccc;
        }
        .panel-body {
            padding: 16px;
        }
        .panel-footer {
            background: #f5f5f5;
            padding: 12px;
            border-top: 1px solid #ccc;
        }
    </style>
    <div class="panel">
        <div class="panel-header">
            <slot name="header">默认标题</slot>
        </div>
        <div class="panel-body">
            <slot>默认内容</slot>
        </div>
        <div class="panel-footer">
            <slot name="footer">默认底部</slot>
        </div>
    </div>
</template>

插槽内容访问

class SlotComponent extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        const template = document.getElementById('panel-template');
        this.shadowRoot.appendChild(template.content.cloneNode(true));
        
        this.shadowRoot.querySelectorAll('slot').forEach(slot => {
            slot.addEventListener('slotchange', (e) => {
                console.log(`插槽 ${slot.name || 'default'} 内容变化`);
                console.log('分配的节点:', slot.assignedNodes());
                console.log('分配的元素:', slot.assignedElements());
            });
        });
    }
    
    getSlotContent(slotName) {
        const slot = this.shadowRoot.querySelector(
            slotName ? `slot[name="${slotName}"]` : 'slot:not([name])'
        );
        return slot ? slot.assignedNodes() : [];
    }
    
    getSlotElements(slotName) {
        const slot = this.shadowRoot.querySelector(
            slotName ? `slot[name="${slotName}"]` : 'slot:not([name])'
        );
        return slot ? slot.assignedElements() : [];
    }
}

条件插槽

class ConditionalSlot extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
    }
    
    connectedCallback() {
        this.render();
    }
    
    render() {
        const hasHeader = this.querySelector('[slot="header"]');
        const hasFooter = this.querySelector('[slot="footer"]');
        
        this.shadowRoot.innerHTML = `
            <style>
                .container { border: 1px solid #ccc; }
                .header, .footer { background: #f5f5f5; padding: 8px; }
                .content { padding: 16px; }
            </style>
            <div class="container">
                ${hasHeader ? '<div class="header"><slot name="header"></slot></div>' : ''}
                <div class="content"><slot></slot></div>
                ${hasFooter ? '<div class="footer"><slot name="footer"></slot></div>' : ''}
            </div>
        `;
    }
}

模板继承

class TemplateInheritance {
    constructor() {
        this.baseTemplates = new Map();
        this.derivedTemplates = new Map();
    }
    
    defineBase(name, template) {
        this.baseTemplates.set(name, template);
    }
    
    define(name, baseName, extensions) {
        const baseTemplate = this.baseTemplates.get(baseName);
        if (!baseTemplate) throw new Error(`基础模板 ${baseName} 不存在`);
        
        const derived = document.createElement('template');
        derived.innerHTML = baseTemplate.innerHTML;
        
        Object.entries(extensions).forEach(([selector, content]) => {
            const element = derived.content.querySelector(selector);
            if (element) {
                if (typeof content === 'string') {
                    element.innerHTML = content;
                } else if (content.append) {
                    element.appendChild(content.cloneNode(true));
                }
            }
        });
        
        this.derivedTemplates.set(name, derived);
    }
    
    get(name) {
        return this.derivedTemplates.get(name) || this.baseTemplates.get(name);
    }
}

const inheritance = new TemplateInheritance();

inheritance.defineBase('base-card', `
    <div class="card">
        <div class="header"><slot name="header"></slot></div>
        <div class="body"><slot></slot></div>
        <div class="footer"><slot name="footer"></slot></div>
    </div>
`);

inheritance.define('image-card', 'base-card', {
    '.header': '<slot name="header"></slot><slot name="image"></slot>'
});

模板编译

class TemplateCompiler {
    constructor() {
        this.compiled = new Map();
    }
    
    compile(template) {
        const html = typeof template === 'string' 
            ? template 
            : template.innerHTML;
        
        const tokens = this.tokenize(html);
        const render = this.generateRender(tokens);
        
        return render;
    }
    
    tokenize(html) {
        const tokens = [];
        const regex = /\{\{([^}]+)\}\}|\{%\s*(\w+)\s+([^%]+)\s*%\}|\{%\s*end(\w+)\s*%\}/g;
        
        let lastIndex = 0;
        let match;
        
        while ((match = regex.exec(html)) !== null) {
            if (match.index > lastIndex) {
                tokens.push({ type: 'text', value: html.slice(lastIndex, match.index) });
            }
            
            if (match[1]) {
                tokens.push({ type: 'variable', value: match[1].trim() });
            } else if (match[2]) {
                tokens.push({ type: 'block', name: match[2], value: match[3].trim() });
            } else if (match[4]) {
                tokens.push({ type: 'endblock', name: match[4] });
            }
            
            lastIndex = regex.lastIndex;
        }
        
        if (lastIndex < html.length) {
            tokens.push({ type: 'text', value: html.slice(lastIndex) });
        }
        
        return tokens;
    }
    
    generateRender(tokens) {
        return (data) => {
            let result = '';
            let i = 0;
            
            while (i < tokens.length) {
                const token = tokens[i];
                
                switch (token.type) {
                    case 'text':
                        result += token.value;
                        break;
                        
                    case 'variable':
                        result += this.getValue(data, token.value);
                        break;
                        
                    case 'block':
                        if (token.name === 'for') {
                            const [item, list] = token.value.split(' in ').map(s => s.trim());
                            const endToken = this.findEndBlock(tokens, i, 'for');
                            const blockTokens = tokens.slice(i + 1, endToken);
                            
                            const items = this.getValue(data, list) || [];
                            items.forEach(itemData => {
                                result += this.generateRender(blockTokens)({ ...data, [item]: itemData });
                            });
                            
                            i = endToken;
                        } else if (token.name === 'if') {
                            const condition = this.getValue(data, token.value);
                            const endToken = this.findEndBlock(tokens, i, 'if');
                            const blockTokens = tokens.slice(i + 1, endToken);
                            
                            if (condition) {
                                result += this.generateRender(blockTokens)(data);
                            }
                            
                            i = endToken;
                        }
                        break;
                }
                
                i++;
            }
            
            return result;
        };
    }
    
    findEndBlock(tokens, start, name) {
        let depth = 1;
        for (let i = start + 1; i < tokens.length; i++) {
            if (tokens[i].type === 'block' && tokens[i].name === name) {
                depth++;
            } else if (tokens[i].type === 'endblock' && tokens[i].name === name) {
                depth--;
                if (depth === 0) return i;
            }
        }
        return tokens.length;
    }
    
    getValue(data, path) {
        return path.split('.').reduce((obj, key) => obj?.[key], data);
    }
}

const compiler = new TemplateCompiler();

const render = compiler.compile(`
    <ul>
        {% for item in items %}
        <li>{{item.name}}: {{item.price}}</li>
        {% endfor %}
    </ul>
`);

const html = render({
    items: [
        { name: '商品A', price: 100 },
        { name: '商品B', price: 200 }
    ]
});

东巴文小贴士

📋 模板使用建议

  1. 复用性:将通用结构定义为模板
  2. 性能:模板只解析一次,多次克隆使用
  3. 维护性:集中管理模板,便于修改
  4. 类型安全:结合TypeScript提供数据类型检查

🔄 模板与组件

  • template适合静态结构
  • Web Components适合交互组件
  • 可以结合使用:组件内部使用模板

下一步

下一章将探讨 模块化开发,学习前端模块化开发的最佳实践。