From 73896eabe13581d8e0a0a8788a3d730a8cee7636 Mon Sep 17 00:00:00 2001 From: Mimi <1119186082@qq.com> Date: Sat, 8 Aug 2026 22:35:18 +0800 Subject: [PATCH 1/2] perf(nunjucks): reuse compiled view templates --- lib/plugins/renderer/nunjucks.ts | 32 ++++++++++---- test/scripts/renderers/nunjucks.ts | 69 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/lib/plugins/renderer/nunjucks.ts b/lib/plugins/renderer/nunjucks.ts index 660026999..dcc0fc598 100644 --- a/lib/plugins/renderer/nunjucks.ts +++ b/lib/plugins/renderer/nunjucks.ts @@ -3,6 +3,15 @@ import { readFileSync } from 'hexo-fs'; import { dirname } from 'path'; import type { StoreFunctionData } from '../../extend/renderer'; +interface CacheableEnvironment extends Environment { + invalidateCache(): void; +} + +interface CompiledTemplate { + env: CacheableEnvironment; + template: nunjucks.Template; +} + function toArray(value) { if (Array.isArray(value)) { // Return if given value is an Array @@ -42,27 +51,34 @@ const nunjucksAddFilter = (env: Environment): void => { env.addFilter('safedump', safeJsonStringify); }; -function njkCompile(data: StoreFunctionData): nunjucks.Template { - let env: Environment; +function njkCompile(data: StoreFunctionData): CompiledTemplate { + let env: CacheableEnvironment; if (data.path) { - env = nunjucks.configure(dirname(data.path), nunjucksCfg); + env = nunjucks.configure(dirname(data.path), nunjucksCfg) as CacheableEnvironment; } else { - env = nunjucks.configure(nunjucksCfg); + env = nunjucks.configure(nunjucksCfg) as CacheableEnvironment; } nunjucksAddFilter(env); const text = 'text' in data ? data.text : readFileSync(data.path); - return nunjucks.compile(text, env, data.path); + return { + env, + template: nunjucks.compile(text, env, data.path) + }; } function njkRenderer(data: StoreFunctionData, locals?: any): string { - return njkCompile(data).render(locals); + return njkCompile(data).template.render(locals); } njkRenderer.compile = (data: StoreFunctionData): (locals: any) => string => { - // Need a closure to keep the compiled template. - return locals => njkCompile(data).render(locals); + const { env, template } = njkCompile(data); + + return locals => { + env.invalidateCache(); + return template.render(locals); + }; }; export = njkRenderer; diff --git a/test/scripts/renderers/nunjucks.ts b/test/scripts/renderers/nunjucks.ts index b8ab1179f..e73e70871 100644 --- a/test/scripts/renderers/nunjucks.ts +++ b/test/scripts/renderers/nunjucks.ts @@ -1,6 +1,10 @@ import r from '../../../lib/plugins/renderer/nunjucks'; +import nunjucks from 'nunjucks'; import { dirname, join } from 'path'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; import chai from 'chai'; +import { spy } from 'sinon'; const _should = chai.should(); @@ -47,6 +51,71 @@ describe('nunjucks', () => { }).should.eql('Hello world!\n'); }); + it('compile template only once', () => { + const compile = spy(nunjucks, 'compile'); + + try { + const render = r.compile({ + text: 'Hello {{ name }}!' + }); + + render({ name: 'world' }).should.eql('Hello world!'); + render({ name: 'Hexo' }).should.eql('Hello Hexo!'); + compile.calledOnce.should.be.true; + } finally { + compile.restore(); + } + }); + + const dependencyCases = [ + { + name: 'include', + source: '{%- include \'dependency.njk\' -%}', + firstDependency: 'one', + secondDependency: 'two', + firstResult: 'one', + secondResult: 'two' + }, + { + name: 'extends', + source: '{% extends \'dependency.njk\' %}{% block body %}body{% endblock %}', + firstDependency: 'one:{% block body %}{% endblock %}', + secondDependency: 'two:{% block body %}{% endblock %}', + firstResult: 'one:body', + secondResult: 'two:body' + }, + { + name: 'import', + source: '{%- import \'dependency.njk\' as dependency -%}{{ dependency.value() }}', + firstDependency: '{% macro value() %}one{% endmacro %}', + secondDependency: '{% macro value() %}two{% endmacro %}', + firstResult: 'one', + secondResult: 'two' + } + ]; + + for (const dependencyCase of dependencyCases) { + it(`invalidate ${dependencyCase.name} cache before rendering`, () => { + const fixtureDir = mkdtempSync(join(tmpdir(), 'hexo-nunjucks-')); + const templatePath = join(fixtureDir, 'template.njk'); + const dependencyPath = join(fixtureDir, 'dependency.njk'); + + try { + writeFileSync(dependencyPath, dependencyCase.firstDependency); + const render = r.compile({ + path: templatePath, + text: dependencyCase.source + }); + + render({}).should.eql(dependencyCase.firstResult); + writeFileSync(dependencyPath, dependencyCase.secondDependency); + render({}).should.eql(dependencyCase.secondResult); + } finally { + rmSync(fixtureDir, { recursive: true }); + } + }); + } + describe('nunjucks filters', () => { const forLoop = [ '{% for x in arr | toarray %}', From 05485825d6be301d5c5fa55ef93813edc99e3cfb Mon Sep 17 00:00:00 2001 From: Mimi <1119186082@qq.com> Date: Sat, 8 Aug 2026 22:57:31 +0800 Subject: [PATCH 2/2] docs(nunjucks): explain dependency cache trade-off --- lib/plugins/renderer/nunjucks.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/plugins/renderer/nunjucks.ts b/lib/plugins/renderer/nunjucks.ts index dcc0fc598..71a2555e5 100644 --- a/lib/plugins/renderer/nunjucks.ts +++ b/lib/plugins/renderer/nunjucks.ts @@ -76,6 +76,13 @@ njkRenderer.compile = (data: StoreFunctionData): (locals: any) => string => { const { env, template } = njkCompile(data); return locals => { + // The top-level template is compiled directly and is not stored in the loader cache, + // so invalidating the cache here does not recompile it. This intentionally reloads + // extends/include/import dependencies before every render to preserve `hexo server` + // hot updates because the renderer has no signal indicating that a dependency changed. + // The trade-off is that unchanged dependencies are also recompiled on every render. + // If a reliable theme-template change signal becomes available, invalidate the cache + // in response to that signal instead of removing this call outright. env.invalidateCache(); return template.render(locals); };