テンプレートエンジン
テンプレートエンジンは、Webアプリケーション開発における重要な基盤技術です。HTMLやCSSの生成を効率化し、コードの再利用性と保守性を高めることができます。本ハンドブックでは、主要なテンプレートエンジンの特徴と実践的な使用方法について解説します。
HTMLテンプレートエンジン
[編集]Pug(旧Jade)
[編集]Pugは、インデントベースの文法を採用し、HTMLの冗長性を排除することで、より読みやすく保守しやすいコードの記述を実現します。特にNode.js環境において広く採用されています。
基本的な構文例を見てみましょう:
doctype html html(lang="ja") head meta(charset="UTF-8") title マイページ link(rel="stylesheet" href="/styles/main.css") body header.main-header nav ul li: a(href="/") ホーム li: a(href="/about") 概要 li: a(href="/contact") お問い合わせ main.content h1 ようこそ#{username}さん if messages.length .message-list each message in messages article.message h2= message.title p= message.content time(datetime=message.created)= message.formattedDate else p メッセージはありません
Pugの強力な機能の一つが継承機能です。以下のように基本レイアウトを定義し、各ページで拡張して使用できます:
- layout.pug
doctype html html head block head title デフォルトタイトル body block header include includes/header block content block footer include includes/footer block scripts
- page.pug
extends layout block head title マイページ - サイト名 block content main h1 マイページ p ここにコンテンツが入ります block scripts script(src="/js/main.js")
また、再利用可能なコンポーネントをミックスインとして定義できます:
mixin userCard(user) .user-card img.avatar(src=user.avatar alt=user.name) .user-info h3= user.name p.title= user.title if user.isOnline span.status.online オンライン else span.status.offline オフライン // 使用例 +userCard({ name: '山田太郎', title: 'シニアエンジニア', avatar: '/images/yamada.jpg', isOnline: true })
EJS
[編集]EJSは、JavaScriptの式を直接HTMLに埋め込めるテンプレートエンジンです。学習曲線が緩やかで、既存のJavaScript知識をそのまま活用できる点が特徴です。
基本的な使用例を見てみましょう:
<!DOCTYPE html> <html> <head> <title><%= title %></title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <header> <% if (user) { %> <nav> <a href="/dashboard">ダッシュボード</a> <a href="/profile">プロフィール</a> <a href="/logout">ログアウト</a> </nav> <% } else { %> <nav> <a href="/login">ログイン</a> <a href="/register">新規登録</a> </nav> <% } %> </header> <main> <h1><%= pageTitle %></h1> <%- include('partials/messageList', { messages: messages }) %> </main> <%- include('partials/footer') %> </body> </html>
部分テンプレート(パーシャル)の例:
<!-- partials/messageList.ejs --> <% if (messages && messages.length) { %> <div class="message-list"> <% messages.forEach(function(message) { %> <article class="message <%= message.type %>"> <header> <h2><%= message.title %></h2> <time datetime="<%= message.created %>"> <%= message.formattedDate %> </time> </header> <div class="content"> <%= message.content %> </div> <% if (message.attachments && message.attachments.length) { %> <footer> <h3>添付ファイル</h3> <ul class="attachments"> <% message.attachments.forEach(function(file) { %> <li> <a href="<%= file.url %>"> <%= file.name %> (<%= file.size %>) </a> </li> <% }); %> </ul> </footer> <% } %> </article> <% }); %> </div> <% } else { %> <p class="no-messages">メッセージはありません</p> <% } %>
Handlebars (HBS)
[編集]Handlebarsは、ロジックレスなテンプレートエンジンとして知られています。テンプレート内のロジックを最小限に抑え、プレゼンテーション層とビジネスロジックの分離を促進します。
基本的な使用例:
カスタムヘルパーの定義と使用:
// ヘルパーの登録 Handlebars.registerHelper('formatDate', function(date) { return new Date(date).toLocaleDateString('ja-JP'); }); Handlebars.registerHelper('truncate', function(text, length) { if (text.length > length) { return text.substring(0, length) + '...'; } return text; });
- postCard.hbs
CSSプリプロセッサ
[編集]Sass/SCSS
[編集]Sassは最も成熟したCSSプリプロセッサの一つです。SCSSシンタックスは、標準のCSSと完全な互換性を持ちながら、強力な機能を提供します。
変数とネスティングを活用した基本的な例:
// variables.scss $primary-color: #007bff; $secondary-color: #6c757d; $spacing-unit: 8px; $border-radius: 4px; $transition-base: all 0.2s ease-in-out; // コンポーネントの定義 .card { background: white; border-radius: $border-radius; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: $transition-base; &:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); } .card-header { padding: $spacing-unit * 2; border-bottom: 1px solid rgba(0, 0, 0, 0.1); h2 { margin: 0; color: $primary-color; } } .card-body { padding: $spacing-unit * 3; } }
ミックスインとエクステンドの活用例:
// mixins.scss @mixin flex-container($direction: row, $justify: center, $align: center) { display: flex; flex-direction: $direction; justify-content: $justify; align-items: $align; } @mixin responsive-text($min-size, $max-size, $min-width: 320px, $max-width: 1200px) { font-size: clamp(#{$min-size}, #{($max-size - $min-size) / ($max-width - $min-width)} * 100vw, #{$max-size}); } // プレースホルダーセレクタ %button-base { padding: $spacing-unit $spacing-unit * 2; border: none; border-radius: $border-radius; cursor: pointer; transition: $transition-base; } // 実装例 .container { @include flex-container(column, flex-start, stretch); max-width: 1200px; margin: 0 auto; } .heading { @include responsive-text(24px, 48px); color: $primary-color; } .button { @extend %button-base; &.primary { background-color: $primary-color; color: white; &:hover { background-color: darken($primary-color, 10%); } } &.secondary { background-color: $secondary-color; color: white; &:hover { background-color: darken($secondary-color, 10%); } } }
Stylus
[編集]Stylusは、より簡潔な文法を提供し、オプションの括弧やセミコロンを特徴とします。
基本的な使用例:
// variables primary-color = #007bff secondary-color = #6c757d spacing = 8px // mixins flex-center() display flex align-items center justify-content center button(bg-color) background-color bg-color color white padding spacing * 2 border none border-radius 4px cursor pointer transition all 0.2s ease &:hover background-color darken(bg-color, 10%) // implementation .header flex-center() height 60px background-color #f8f9fa .nav-item margin 0 spacing a color primary-color text-decoration none &:hover color darken(primary-color, 20%) .button &.primary button(primary-color) &.secondary button(secondary-color)
Less
[編集]Lessは、CSSに近い文法を持ちながら、変数やミックスインなどの機能を提供します。
// Variables @primary-color: #007bff; @secondary-color: #6c757d; @spacing: 8px; @border-radius: 4px; // Mixins .gradient-background(@start-color, @end-color) { background: @start-color; background: linear-gradient(180deg, @start-color 0%, @end-color 100%); } .transition(@property: all, @duration: 0.2s, @timing: ease) { transition: @arguments; } // Components .navbar { .gradient-background(#ffffff, #f8f9fa); padding: @spacing * 2; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); &-brand { color: @primary-color; font-size: 1.5em; .transition(color); &:hover { color: darken(@primary-color, 10%); } } &-nav { display: flex; gap: @spacing * 2; .nav-item { a { color: @secondary-color; text-decoration: none; .transition(color); &:hover { color: darken(@secondary-color, 15%); } } } } }
モダンなテンプレートエンジン
[編集]Nunjucks
[編集]Nunjucksは、Mozillaが開発したPythonのJinja2に影響を受けたテンプレートエンジンです。
- layout.njk
{# layout.njk #} <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>{{ title }} | サイト名</title> {% block styles %} <link rel="stylesheet" href="/css/main.css"> {% endblock %} </head> <body> {% include "partials/header.njk" %} <main> {% block content %}{% endblock %} </main> {% include "partials/footer.njk" %} {% block scripts %} <script src="/js/main.js"></script> {% endblock %} </body> </html>
- index.njk
{# index.njk #} {% extends "layout.njk" %} {% block content %} <div class="container"> <h1>{{ pageTitle }}</h1> {% if posts.length %} <div class="post-grid"> {% for post in posts %} {% include "partials/post-card.njk" %} {% endfor %} </div> {% else %} <p>投稿がありません。</p> {% endif %} {% if pagination.pages > 1 %} <nav class="pagination"> {% for pageNum in range(1, pagination.pages + 1) %} <a href="?page={{ pageNum }}" class="page-link {{ 'active' if pageNum == pagination.current }}"> {{ pageNum }} </a> {% endfor %} </nav> {% endif %} </div> {% endblock %}
マクロの使用例:
- macros/forms.njk
{# macros/forms.njk #} {% macro input(name, label, type="text", value="", required=false) %} <div class="form-group"> <label for="{{ name }}">{{ label }}</label> <input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value }}" {% if required %}required{% endif %} class="form-control"> </div> {% endmacro %} {% macro select(name, label, options, selected="") %} <div class="form-group"> <label for="{{ name }}">{{ label }}</label> <select id="{{ name }}" name="{{ name }}" class="form-select"> {% for option in options %} <option value="{{ option.value }}" {{ 'selected' if option.value == selected }}> {{ option.label }} </option> {% endfor %} </select> </div> {% endmacro %} {# usage #} {% import "macros/forms.njk" as forms %} <form method="post" action="/register"> {{ forms.input('username', 'ユーザー名', required=true) }} {{ forms.input('email', 'メールアドレス', type='email', required=true) }} {{ forms.input('password', 'パスワード', type='password', required=true) }} {{ forms.select('role', '権限', [ {value: 'user', label: '一般ユーザー'}, {value: 'admin', label: '管理者'} ]) }} <button type="submit">登録</button> </form>
Twig
[編集]Twigは、PHPのテンプレートエンジンとして広く使用されており、特にSymfonyフレームワークのデフォルトテンプレートエンジンとして知られています。
基本的な構文例:
- layout.twig
{# layout.twig #} <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>{% block title %}デフォルトタイトル{% endblock %}</title> {% block stylesheets %} <link href="{{ asset('css/main.css') }}" rel="stylesheet"> {% endblock %} </head> <body> {% include 'partials/header.twig' with {'menu_items': main_menu} %} <div class="container"> {% block content %}{% endblock %} </div> {{ include('partials/footer.twig') }} {% block javascripts %} <script src="{{ asset('js/main.js') }}"></script> {% endblock %} </body> </html>
マクロとコンポーネントの実装例:
- components/forms.twig
{# components/forms.twig #} {% macro form_row(label, name, type = 'text', value = '', attributes = {}) %} <div class="form-group"> <label for="{{ name }}">{{ label }}</label> <input type="{{ type }}" id="{{ name }}" name="{{ name }}" value="{{ value }}" {% for attr, val in attributes %} {{ attr }}="{{ val }}" {% endfor %} class="form-control"> </div> {% endmacro %} {% macro alert(message, type = 'info') %} <div class="alert alert-{{ type }}" role="alert"> {% if type == 'error' %} <i class="fas fa-exclamation-triangle"></i> {% endif %} {{ message }} {% if type != 'error' %} <button type="button" class="close" data-dismiss="alert"> <span>×</span> </button> {% endif %} </div> {% endmacro %} {# usage.twig #} {% import "components/forms.twig" as forms %} <form action="/register" method="post"> {{ forms.form_row('ユーザー名', 'username', 'text', '', { 'required': 'required', 'minlength': '3', 'maxlength': '20' }) }} {{ forms.form_row('メールアドレス', 'email', 'email', '', { 'required': 'required' }) }} {% if errors %} {{ forms.alert(errors.join('\n'), 'error') }} {% endif %} </form>
Liquid
[編集]Liquidは、Shopifyで開発され、Jekyllなどの静的サイトジェネレータでも使用される安全なテンプレート言語です。
- layout.liquid
{% comment %}layout.liquid{% endcomment %} <!DOCTYPE html> <html lang="{{ page.language | default: 'en' }}"> <head> <meta charset="UTF-8"> <title> {{ page.title | default: site.title }} | {{ site.name }} </title> {% if page.description %} <meta name="description" content="{{ page.description | escape }}"> {% endif %} <link rel="stylesheet" href="{{ 'css/main.css' | asset_url }}"> </head> <body class="{{ page.layout }}"> {% include 'header' %} <main> {{ content }} </main> {% include 'footer' %} {% if page.custom_js %} {% for js in page.custom_js %} <script src="{{ js | asset_url }}"></script> {% endfor %} {% endif %} </body> </html>
製品リスト表示の実装例:
- products.liquid
{% comment %}products.liquid{% endcomment %} {% assign sorted_products = collection.products | sort: 'price' %} <div class="product-grid"> {% paginate sorted_products by 12 %} {% for product in sorted_products %} <div class="product-card"> {% if product.featured_image %} <img src="{{ product.featured_image | img_url: '300x300', crop: 'center' }}" alt="{{ product.title | escape }}" loading="lazy"> {% endif %} <h3>{{ product.title }}</h3> <div class="price"> {% if product.compare_at_price > product.price %} <span class="sale-price"> {{ product.price | money }} </span> <s class="compare-price"> {{ product.compare_at_price | money }} </s> {% else %} <span class="regular-price"> {{ product.price | money }} </span> {% endif %} </div> {% if product.available %} <form method="post" action="/cart/add"> <input type="hidden" name="id" value="{{ product.variants.first.id }}"> <button type="submit" class="add-to-cart"> カートに追加 </button> </form> {% else %} <button disabled class="sold-out"> 売り切れ </button> {% endif %} </div> {% endfor %} {% if paginate.pages > 1 %} <nav class="pagination"> {{ paginate | default_pagination }} </nav> {% endif %} {% endpaginate %} </div>
パフォーマンス最適化
[編集]テンプレートエンジンを効率的に使用するためのベストプラクティス:
- キャッシュの活用
// Express + EJSでのキャッシュ設定 app.set('view cache', true); // カスタムキャッシュの実装 const cache = new Map(); function renderWithCache(template, data, cacheKey) { if (cache.has(cacheKey)) { return cache.get(cacheKey); } const rendered = ejs.render(template, data); cache.set(cacheKey, rendered); return rendered; }
- 部分的なレンダリング
// パーシャルの効率的な使用 const headerHtml = await renderWithCache('header', headerData, 'header'); const footerHtml = await renderWithCache('footer', footerData, 'footer'); const contentHtml = await render('content', pageData); const fullPage = ` ${headerHtml} ${contentHtml} ${footerHtml} `;
- 非同期レンダリング
// [[Node.js]]での非同期レンダリング async function renderTemplate(template, data) { return new Promise((resolve, reject) => { ejs.renderFile(template, data, {}, (err, str) => { if (err) reject(err); else resolve(str); }); }); } // 複数テンプレートの並列レンダリング const [header, content, footer] = await Promise.all([ renderTemplate('header.ejs', headerData), renderTemplate('content.ejs', contentData), renderTemplate('footer.ejs', footerData) ]);
テンプレートエンジンの選定基準
[編集]テンプレートエンジンを選択する際の主要な考慮点:
- プロジェクトの要件
-
- 静的/動的コンテンツの比率
- リアルタイム更新の必要性
- SEO要件
- パフォーマンス要件
// 動的コンテンツが多い場合の例(React + EJS) function DynamicTemplate({ initialData }) { const [data, setData] = useState(initialData); useEffect(() => { const socket = new WebSocket('ws://api.example.com'); socket.onmessage = (event) => { setData(JSON.parse(event.data)); }; return () => socket.close(); }, []); return <div dangerouslySetInnerHTML={{ __html: ejs.render(template, data) }} />; }
- 開発チームのスキルセット
-
- 既存の技術スタックとの整合性
- 学習曲線
- ドキュメントの充実度
- メンテナンス性
-
- コードの可読性
- デバッグのしやすさ
- エラーハンドリング
// デバッグしやすい実装例 const debugTemplate = (template, data) => { try { return ejs.render(template, data); } catch (error) { console.error('Template rendering failed:', { template: template.slice(0, 100) + '...', data: JSON.stringify(data, null, 2), error: error.message }); throw error; } };
セキュリティ対策
[編集]テンプレートエンジンを使用する際は、適切なセキュリティ対策が不可欠です。
XSS対策
[編集]テンプレートエンジンでの一般的なXSS対策実装例:
// EJSでのエスケープ処理 const escapeHtml = str => { const htmlEscapes = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }; return str.replace(/[&<>"']/g, match => htmlEscapes[match]); }; // カスタムフィルターの実装 app.locals.safeHtml = (unsafe) => { // 許可するタグのホワイトリスト const allowedTags = { 'b': [], 'i': [], 'em': [], 'strong': [], 'a': ['href', 'title'], 'p': [] }; // DOMPurifyなどのライブラリを使用した安全な HTML サニタイズ return DOMPurify.sanitize(unsafe, { ALLOWED_TAGS: Object.keys(allowedTags), ALLOWED_ATTR: ['href', 'title'] }); }; // テンプレートでの使用例 app.get('/article/:id', async (req, res) => { const article = await getArticle(req.params.id); res.render('article', { title: escapeHtml(article.title), content: req.app.locals.safeHtml(article.content) }); });
CSRF対策
[編集]CSRFトークンの実装と検証:
// CSRFトークンの生成 const generateCsrfToken = () => { return crypto.randomBytes(32).toString('hex'); }; // CSRFミドルウェア const csrfProtection = (req, res, next) => { if (req.method === 'GET') { // GETリクエストの場合、新しいトークンを生成 const token = generateCsrfToken(); res.locals.csrfToken = token; res.cookie('_csrf', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production' }); } else { // POST/PUT/DELETEリクエストの場合、トークンを検証 const cookieToken = req.cookies._csrf; const bodyToken = req.body._csrf; if (!cookieToken || !bodyToken || cookieToken !== bodyToken) { return res.status(403).json({ error: 'CSRF token validation failed' }); } } next(); }; // テンプレートでの使用例
<form method="post" action="/submit"> <input type="hidden" name="_csrf" value="{{ csrfToken }}"> <!-- フォームフィールド --> </form>
入力検証
[編集]テンプレートエンジンでの入力検証の実装:
// バリデーションルールの定義 const validationRules = { username: { required: true, minLength: 3, maxLength: 20, pattern: /^[a-zA-Z0-9_]+$/ }, email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }, age: { required: true, min: 18, max: 120 } }; // バリデーション関数 const validateInput = (data, rules) => { const errors = {}; for (const [field, rule] of Object.entries(rules)) { const value = data[field]; if (rule.required && !value) { errors[field] = `${field} is required`; continue; } if (value) { if (rule.minLength && value.length < rule.minLength) { errors[field] = `${field} must be at least ${rule.minLength} characters`; } if (rule.maxLength && value.length > rule.maxLength) { errors[field] = `${field} must be no more than ${rule.maxLength} characters`; } if (rule.pattern && !rule.pattern.test(value)) { errors[field] = `${field} format is invalid`; } if (rule.min && Number(value) < rule.min) { errors[field] = `${field} must be at least ${rule.min}`; } if (rule.max && Number(value) > rule.max) { errors[field] = `${field} must be no more than ${rule.max}`; } } } return errors; };
デバッグとトラブルシューティング
[編集]デバッグモードの実装
[編集]const debugMiddleware = (req, res, next) => { if (process.env.NODE_ENV === 'development') { // デバッグ情報の収集 res.locals.debug = { startTime: Date.now(), queries: [], templates: [], logs: [] }; // SQLクエリのログ const logQuery = (query, params, duration) => { res.locals.debug.queries.push({ query, params, duration, timestamp: Date.now() }); }; // テンプレートのログ const logTemplate = (template, data) => { res.locals.debug.templates.push({ template, data: JSON.stringify(data, null, 2), timestamp: Date.now() }); }; // デバッグ情報をレスポンスヘッダーに追加 res.on('finish', () => { const duration = Date.now() - res.locals.debug.startTime; console.log(`Request completed in ${duration}ms`); console.log('Queries:', res.locals.debug.queries.length); console.log('Templates:', res.locals.debug.templates.length); }); } next(); };
エラーハンドリング
[編集]// カスタムエラーハンドラー class TemplateError extends Error { constructor(message, template, data) { super(message); this.name = 'TemplateError'; this.template = template; this.data = data; Error.captureStackTrace(this, TemplateError); } } // エラーハンドリングミドルウェア const errorHandler = (err, req, res, next) => { if (err instanceof TemplateError) { console.error('Template Error:', { message: err.message, template: err.template, data: err.data, stack: err.stack }); if (process.env.NODE_ENV === 'development') { res.status(500).render('error', { error: err, stack: err.stack, template: err.template, data: err.data }); } else { res.status(500).render('error', { message: 'An error occurred while processing your request.' }); } } else { next(err); } };
パフォーマンスモニタリング
[編集]const performanceMiddleware = (req, res, next) => { const metrics = { startTime: process.hrtime(), templateRenderTime: 0, queryTime: 0, totalQueries: 0 }; // テンプレートレンダリング時間の計測 const originalRender = res.render; res.render = function(view, options, callback) { const renderStart = process.hrtime(); const renderCallback = (err, html) => { if (!err) { const renderDuration = process.hrtime(renderStart); metrics.templateRenderTime = renderDuration[0] * 1e3 + renderDuration[1] / 1e6; } if (callback) callback(err, html); }; return originalRender.call(this, view, options, renderCallback); }; // レスポンス完了時のメトリクス記録 res.on('finish', () => { const duration = process.hrtime(metrics.startTime); const totalTime = duration[0] * 1e3 + duration[1] / 1e6; console.log({ path: req.path, method: req.method, totalTime: `${totalTime.toFixed(2)}ms`, templateRenderTime: `${metrics.templateRenderTime.toFixed(2)}ms`, queryTime: `${metrics.queryTime.toFixed(2)}ms`, totalQueries: metrics.totalQueries }); }); next(); };
テンプレートエンジンの将来展望
[編集]新しいトレンド
[編集]- Hybrid Rendering
-
- サーバーサイドとクライアントサイドのレンダリングを組み合わせた手法
- 初期表示の高速化とインタラクティブ性の両立
- TypeScriptサポート
-
- 型安全なテンプレートエンジン
- コンパイル時のエラー検出
- マイクロフロントエンド対応
-
- 複数のテンプレートエンジンの共存
- モジュール化されたテンプレート管理
これらのトレンドに対応した実装例:
// TypeScriptを活用したテンプレートエンジンの型定義 interface TemplateContext { user?: { id: string; name: string; email: string; }; content: { title: string; body: string; tags: string[]; }; settings: { theme: 'light' | 'dark'; language: string; }; } // ハイブリッドレンダリングの実装例 class HybridTemplate { private static readonly clientPrefix = 'data-client-render'; static async render(template: string, context: TemplateContext): Promise<string> { // サーバーサイドレンダリング const ssrContent = await this.serverRender(template, context); // クライアントサイド用のデータ埋め込み return ` ${ssrContent} <script> window.__INITIAL_STATE__ = ${JSON.stringify(context)}; </script> <script src="/js/client-hydration.js"></script> `; } private static async serverRender(template: string, context: TemplateContext): Promise<string> { // テンプレートのサーバーサイドレンダリング return ''; // 実装省略 } }
これらの新しいアプローチにより、テンプレートエンジンはより強力で柔軟なツールとして進化を続けています。開発者は、プロジェクトの要件に応じて適切なアプローチを選択し、最新のトレンドを取り入れることで、より効率的な開発を実現できます。