From 24f534608489fccc155f30bbaf37397c46278da6 Mon Sep 17 00:00:00 2001 From: harttle Date: Sun, 27 Jun 2021 02:33:49 +0800 Subject: [PATCH] perf: improve performance by 4x by simplified parseFile - previously deprecated `getTemplate()` and `getTemplateSync()` not no longer supported - `opts` no longer support dynamic set in `parseFile()`, `renderFile()` arguments --- benchmark/engines/liquid.js | 1 + .../tutorials/contribution-guidelines.md | 4 +- .../tutorials/contribution-guidelines.md | 2 + package.json | 7 +- src/builtin/tags/include.ts | 2 +- src/builtin/tags/layout.ts | 2 +- src/builtin/tags/render.ts | 4 +- src/liquid.ts | 114 +++++++++--------- test/integration/builtin/tags/render.ts | 7 +- test/integration/liquid/cache.ts | 56 --------- test/integration/liquid/liquid.ts | 4 +- test/integration/liquid/strict.ts | 40 +++--- test/stub/render.ts | 7 +- test/unit/util/strftime.ts | 4 +- 14 files changed, 103 insertions(+), 151 deletions(-) diff --git a/benchmark/engines/liquid.js b/benchmark/engines/liquid.js index 0ec8d11e5..4a1a9e6be 100644 --- a/benchmark/engines/liquid.js +++ b/benchmark/engines/liquid.js @@ -4,6 +4,7 @@ const { join } = require('path') const liquid = new Liquid({ root: join(__dirname, '../templates'), + cache: true, extname: '.liquid' }) diff --git a/docs/source/tutorials/contribution-guidelines.md b/docs/source/tutorials/contribution-guidelines.md index 722037ccb..4fe95accd 100644 --- a/docs/source/tutorials/contribution-guidelines.md +++ b/docs/source/tutorials/contribution-guidelines.md @@ -4,7 +4,7 @@ title: Contribution Guideline ## Star on Github 👉 [![harttle/liquidjs](https://img.shields.io/github/stars/harttle/liquidjs?style=flat-square)][liquidjs] -Staring us is the most important and easiest way to support us: boost its rank and expose it to more people, which in turn makes it better. +Staring LiquidJS is the most important and easiest way to support us: boost its rank and expose it to more people, which in turn makes it better. ## Show Me Your Code @@ -22,6 +22,8 @@ npm test **Commit Message**: Please align to [the Angular Commit Message Guidelines](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits), especially note the [type identifier](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#type), on which semantic-release bot depends. +**Backward-Compatibility**: please be backward-compatible. LiquidJS is used by multiple layers of softwares, including underlying libraries, compilers, site generators and Web servers. It's not easy to do a major upgrade for most of them. + ## Financial Support LiquidJS is Open Source and Free and **without** capitalists support and **without** any ADs. To help it live and thrive, consider contribute on [Open Collective][oc] or [Patreon][pt]. To acknowledge your contribution, your name and avatar will be listed here and on [Github README][liquidjs]. diff --git a/docs/source/zh-cn/tutorials/contribution-guidelines.md b/docs/source/zh-cn/tutorials/contribution-guidelines.md index 71dc81c2f..474e506ab 100644 --- a/docs/source/zh-cn/tutorials/contribution-guidelines.md +++ b/docs/source/zh-cn/tutorials/contribution-guidelines.md @@ -22,6 +22,8 @@ npm test **提交消息**:请遵守 [Angular 提交消息规范](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits),尤其注意 [type 标识](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#type),semantic-release 机器人依赖这个标识自动发布。 +**向后兼容**:请考虑向后(之前的旧的版本)兼容。LiquidJS 被用于很多层的软件,包括底层库、编译器、站点生成器、 Web 服务器。对多数最终用户来说,驱动或请求整个系统做一次主版本升级是很难办到的。 + ## 成为赞助者! LiquidJS 是开源的、免费的,并且 **没有** 商业支持,也 **没有** 任何广告。如果你喜欢 LiquidJS 或你的公司在使用 LiquidJS,请考虑通过 [Open Collective][oc] 或 [Patreon][pt] 赞助,作为感谢你的名字和头像(或 Logo)会展示在这里和 [Github README][liquidjs]。 diff --git a/package.json b/package.json index e9228cdf2..413f64ced 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,8 @@ "scripts": { "lint": "eslint \"**/*.ts\" .", "check": "npm test && npm run lint", - "unit": "mocha \"test/unit/**/*.ts\"", - "integration": "mocha \"test/integration/**/*.ts\"", - "e2e": "mocha \"test/e2e/**/*.ts\"", "test": "nyc mocha \"test/**/*.ts\"", - "benchmark:prepare": "cd benchmark && npm ci", - "benchmark": "cd benchmark && npm start", - "benchmark:engines": "cd benchmark && npm run engines", + "benchmark": "cd benchmark && npm ci && npm start", "build": "npm run build:dist && npm run build:docs", "build:dist": "rm -rf dist && rollup -c rollup.config.ts && ls -lh dist", "build:docs": "bin/build-docs.sh" diff --git a/src/builtin/tags/include.ts b/src/builtin/tags/include.ts index 23575856b..6f7d5be3c 100644 --- a/src/builtin/tags/include.ts +++ b/src/builtin/tags/include.ts @@ -37,7 +37,7 @@ export default { ctx.setRegister('blockMode', BlockMode.OUTPUT) const scope = yield hash.render(ctx) if (withVar) scope[filepath] = evalToken(withVar, ctx) - const templates = yield liquid._parseFile(filepath, ctx.opts, ctx.sync) + const templates = yield liquid.parseFileImpl(filepath, ctx.sync) ctx.push(scope) yield renderer.renderTemplates(templates, ctx, emitter) ctx.pop() diff --git a/src/builtin/tags/layout.ts b/src/builtin/tags/layout.ts index 23f09f475..3b12ec679 100644 --- a/src/builtin/tags/layout.ts +++ b/src/builtin/tags/layout.ts @@ -26,7 +26,7 @@ export default { : evalToken(this.file, ctx)) : file.getText() assert(filepath, () => `file "${file.getText()}"("${filepath}") not available`) - const templates = yield liquid._parseFile(filepath, ctx.opts, ctx.sync) + const templates = yield liquid.parseFileImpl(filepath, ctx.sync) // render remaining contents and store rendered results ctx.setRegister('blockMode', BlockMode.STORE) diff --git a/src/builtin/tags/render.ts b/src/builtin/tags/render.ts index 79c1fce93..26bb142bf 100644 --- a/src/builtin/tags/render.ts +++ b/src/builtin/tags/render.ts @@ -64,12 +64,12 @@ export default { scope['forloop'] = new ForloopDrop(collection.length) for (const item of collection) { scope[alias] = item - const templates = yield liquid._parseFile(filepath, childCtx.opts, childCtx.sync) + const templates = yield liquid.parseFileImpl(filepath, childCtx.sync) yield renderer.renderTemplates(templates, childCtx, emitter) scope.forloop.next() } } else { - const templates = yield liquid._parseFile(filepath, childCtx.opts, childCtx.sync) + const templates = yield liquid.parseFileImpl(filepath, childCtx.sync) yield renderer.renderTemplates(templates, childCtx, emitter) } } diff --git a/src/liquid.ts b/src/liquid.ts index 5b8191332..af5b1e43f 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -24,6 +24,7 @@ export class Liquid { public parser: Parser public filters: FilterMap public tags: TagMap + private parseFileImpl: (file: string, sync?: boolean) => Iterator public constructor (opts: LiquidOptions = {}) { this.options = applyDefault(normalize(opts)) @@ -31,6 +32,7 @@ export class Liquid { this.renderer = new Render() this.filters = new FilterMap(this.options.strictFilters, this) this.tags = new TagMap() + this.parseFileImpl = this.options.cache ? this._parseFileCached : this._parseFile forOwn(builtinTags, (conf: TagImplOptions, name: string) => this.registerTag(snakeCase(name), conf)) forOwn(builtinFilters, (handler: FilterImplOptions, name: string) => this.registerFilter(snakeCase(name), handler)) @@ -41,64 +43,61 @@ export class Liquid { return this.parser.parse(tokens) } - public _render (tpl: Template[], scope?: object, opts?: LiquidOptions, sync?: boolean): IterableIterator { - const options = { ...this.options, ...normalize(opts) } - const ctx = new Context(scope, options, sync) - const emitter = new Emitter(options.keepOutputType) + public _render (tpl: Template[], scope?: object, sync?: boolean): IterableIterator { + const ctx = new Context(scope, this.options, sync) + const emitter = new Emitter(this.options.keepOutputType) return this.renderer.renderTemplates(tpl, ctx, emitter) } - public async render (tpl: Template[], scope?: object, opts?: LiquidOptions): Promise { - return toPromise(this._render(tpl, scope, opts, false)) + public async render (tpl: Template[], scope?: object): Promise { + return toPromise(this._render(tpl, scope, false)) } - public renderSync (tpl: Template[], scope?: object, opts?: LiquidOptions): any { - return toValue(this._render(tpl, scope, opts, true)) + public renderSync (tpl: Template[], scope?: object): any { + return toValue(this._render(tpl, scope, true)) } - public _parseAndRender (html: string, scope?: object, opts?: LiquidOptions, sync?: boolean): IterableIterator { + public _parseAndRender (html: string, scope?: object, sync?: boolean): IterableIterator { const tpl = this.parse(html) - return this._render(tpl, scope, opts, sync) + return this._render(tpl, scope, sync) } - public async parseAndRender (html: string, scope?: object, opts?: LiquidOptions): Promise { - return toPromise(this._parseAndRender(html, scope, opts, false)) + public async parseAndRender (html: string, scope?: object): Promise { + return toPromise(this._parseAndRender(html, scope, false)) } - public parseAndRenderSync (html: string, scope?: object, opts?: LiquidOptions): any { - return toValue(this._parseAndRender(html, scope, opts, true)) + public parseAndRenderSync (html: string, scope?: object): any { + return toValue(this._parseAndRender(html, scope, true)) } - public * _parseFile (file: string, opts?: LiquidOptions, sync?: boolean) { - const options = { ...this.options, ...normalize(opts) } - const paths = options.root.map(root => options.fs.resolve(root, file, options.extname)) - if (options.fs.fallback !== undefined) { - const filepath = options.fs.fallback(file) - if (filepath !== undefined) paths.push(filepath) - } + private * _parseFileCached (file: string, sync?: boolean) { + const cache = this.options.cache! + let tpls = yield cache.read(file) + if (tpls) return tpls - for (const filepath of paths) { - const { cache } = options - if (cache) { - const tpls = yield cache.read(filepath) - if (tpls) return tpls - } - if (!(sync ? options.fs.existsSync(filepath) : yield options.fs.exists(filepath))) continue - const tpl = this.parse(sync ? options.fs.readFileSync(filepath) : yield options.fs.readFile(filepath), filepath) - if (cache) cache.write(filepath, tpl) + tpls = yield this._parseFile(file, sync) + cache.write(file, tpls) + return tpls + } + private * _parseFile (file: string, sync?: boolean) { + const { fs, root } = this.options + + for (const filepath of this.lookupFiles(file, this.options)) { + if (!(sync ? fs.existsSync(filepath) : yield fs.exists(filepath))) continue + const tpl = this.parse(sync ? fs.readFileSync(filepath) : yield fs.readFile(filepath), filepath) return tpl } - throw this.lookupError(file, options.root) + throw this.lookupError(file, root) } - public async parseFile (file: string, opts?: LiquidOptions): Promise { - return toPromise(this._parseFile(file, opts, false)) + public async parseFile (file: string): Promise { + return toPromise(this.parseFileImpl(file, false)) } - public parseFileSync (file: string, opts?: LiquidOptions): Template[] { - return toValue(this._parseFile(file, opts, true)) + public parseFileSync (file: string): Template[] { + return toValue(this.parseFileImpl(file, true)) } - public async renderFile (file: string, ctx?: object, opts?: LiquidOptions) { - const templates = await this.parseFile(file, opts) - return this.render(templates, ctx, opts) + public async renderFile (file: string, ctx?: object) { + const templates = await this.parseFile(file) + return this.render(templates, ctx) } - public renderFileSync (file: string, ctx?: object, opts?: LiquidOptions) { - const templates = this.parseFileSync(file, opts) - return this.renderSync(templates, ctx, opts) + public renderFileSync (file: string, ctx?: object) { + const templates = this.parseFileSync(file) + return this.renderSync(templates, ctx) } public _evalValue (str: string, ctx: Context): IterableIterator { @@ -123,9 +122,25 @@ export class Liquid { } public express () { const self = this // eslint-disable-line + let firstCall = true + return function (this: any, filePath: string, ctx: object, callback: (err: Error | null, rendered: string) => void) { - const opts = { root: [...normalizeStringArray(this.root), ...self.options.root] } - self.renderFile(filePath, ctx, opts).then(html => callback(null, html) as any, callback as any) + if (firstCall) { + firstCall = false + self.options.root.unshift(...normalizeStringArray(this.root)) + } + self.renderFile(filePath, ctx).then(html => callback(null, html) as any, callback as any) + } + } + + private * lookupFiles (file: string, options: NormalizedFullOptions) { + const { root, fs, extname } = options + for (const dir of root) { + yield fs.resolve(dir, file, extname) + } + if (fs.fallback !== undefined) { + const filepath = fs.fallback(file) + if (filepath !== undefined) yield filepath } } @@ -135,17 +150,4 @@ export class Liquid { err.code = 'ENOENT' return err } - - /** - * @deprecated use parseFile instead - */ - public async getTemplate (file: string, opts?: LiquidOptions): Promise { - return this.parseFile(file, opts) - } - /** - * @deprecated use parseFileSync instead - */ - public getTemplateSync (file: string, opts?: LiquidOptions): Template[] { - return this.parseFileSync(file, opts) - } } diff --git a/test/integration/builtin/tags/render.ts b/test/integration/builtin/tags/render.ts index 61a19ce50..fa2a5a3c7 100644 --- a/test/integration/builtin/tags/render.ts +++ b/test/integration/builtin/tags/render.ts @@ -76,15 +76,12 @@ describe('tags/render', function () { }) it('should be able to access globals', async function () { + liquid = new Liquid({ root: '/', extname: '.html', globals: { name: 'Harttle' } }) mock({ '/hash.html': 'InParent: {{name}} {% render "user.html" %}', '/user.html': 'InChild: {{name}}' }) - const html = await liquid.renderFile('hash.html', { - name: 'harttle' - }, { - globals: { name: 'Harttle' } - }) + const html = await liquid.renderFile('hash', { name: 'harttle' }) expect(html).to.equal('InParent: harttle InChild: Harttle') }) diff --git a/test/integration/liquid/cache.ts b/test/integration/liquid/cache.ts index 4a3a6fcf6..789b577b3 100644 --- a/test/integration/liquid/cache.ts +++ b/test/integration/liquid/cache.ts @@ -132,34 +132,6 @@ describe('LiquidOptions#cache', function () { const y = await engine.renderFile('foo') expect(y).to.equal('foo') }) - it('should respect passed in cache=false option', async function () { - const engine = new Liquid({ - root: '/root/', - extname: '.html', - cache: true - }) - mock({ '/root/files/foo.html': 'foo' }) - const x = await engine.renderFile('files/foo') - expect(x).to.equal('foo') - mock({ '/root/files/foo.html': 'bar' }) - const y = await engine.renderFile('files/foo') - expect(y).to.equal('foo') - const z = await engine.renderFile('files/foo', undefined, { cache: false }) - expect(z).to.equal('bar') - }) - it('should use cache when passing in other options', async function () { - const engine = new Liquid({ - root: '/root/', - extname: '.html', - cache: true - }) - mock({ '/root/files/foo.html': 'foo' }) - const x = await engine.renderFile('files/foo') - expect(x).to.equal('foo') - mock({ '/root/files/foo.html': 'bar' }) - const y = await engine.renderFile('files/foo', undefined, { greedy: true }) - expect(y).to.equal('foo') - }) }) describe('#renderFileSync', function () { @@ -202,33 +174,5 @@ describe('LiquidOptions#cache', function () { const y = await engine.renderFile('foo') expect(y).to.equal('foo') }) - it('should respect passed in cache=false option', async function () { - const engine = new Liquid({ - root: '/root/', - extname: '.html', - cache: true - }) - mock({ '/root/files/foo.html': 'foo' }) - const x = engine.renderFileSync('files/foo') - expect(x).to.equal('foo') - mock({ '/root/files/foo.html': 'bar' }) - const y = engine.renderFileSync('files/foo') - expect(y).to.equal('foo') - const z = engine.renderFileSync('files/foo', undefined, { cache: false }) - expect(z).to.equal('bar') - }) - it('should use cache when passing in other options', async function () { - const engine = new Liquid({ - root: '/root/', - extname: '.html', - cache: true - }) - mock({ '/root/files/foo.html': 'foo' }) - const x = engine.renderFileSync('files/foo') - expect(x).to.equal('foo') - mock({ '/root/files/foo.html': 'bar' }) - const y = engine.renderFileSync('files/foo', undefined, { greedy: true }) - expect(y).to.equal('foo') - }) }) }) diff --git a/test/integration/liquid/liquid.ts b/test/integration/liquid/liquid.ts index abcc69d81..c48f49ba2 100644 --- a/test/integration/liquid/liquid.ts +++ b/test/integration/liquid/liquid.ts @@ -91,7 +91,7 @@ describe('Liquid', function () { root: ['/root/'], extname: '.html' }) - const tpls = await engine.getTemplate('mocha') + const tpls = await engine.parseFileSync('mocha') expect(tpls.length).to.gte(1) expect(tpls[0].token.getText()).to.contain('module.exports') }) @@ -126,7 +126,7 @@ describe('Liquid', function () { root: ['/boo', '/root/'], extname: '.html' }) - return expect(() => engine.getTemplateSync('/not/exist.html')) + return expect(() => engine.parseFileSync('/not/exist.html')) .to.throw(/Failed to lookup "\/not\/exist.html" in "\/boo,\/root\/"/) }) }) diff --git a/test/integration/liquid/strict.ts b/test/integration/liquid/strict.ts index a693a1117..ed1f26703 100644 --- a/test/integration/liquid/strict.ts +++ b/test/integration/liquid/strict.ts @@ -20,53 +20,61 @@ describe('LiquidOptions#strict*', function () { }) it('should throw when strictVariables true', function () { const tpl = engine.parse('before{{notdefined}}after') - const opts = { + engine = new Liquid({ + root: '/root/', + extname: '.html', strictVariables: true - } - return expect(engine.render(tpl, ctx, opts)).to + }) + return expect(engine.render(tpl, ctx)).to .be.rejectedWith(/undefined variable: notdefined/) }) it('should pass strictVariables to render by parseAndRender', function () { const html = 'before{{notdefined}}after' - const opts = { + engine = new Liquid({ + root: '/root/', + extname: '.html', strictVariables: true - } - return expect(engine.parseAndRender(html, ctx, opts)).to + }) + return expect(engine.parseAndRender(html, ctx)).to .be.rejectedWith(/undefined variable: notdefined/) }) describe('with strictVariables and lenientIf', function () { - const strictLenientOpts = { - strictVariables: true, - lenientIf: true - } + beforeEach(() => { + engine = new Liquid({ + root: '/root/', + extname: '.html', + strictVariables: true, + lenientIf: true + }) + }) it('should not throw in `if` with a single variable', async function () { const tpl = engine.parse('before{% if notdefined %}{{notdefined}}{% endif %}after') - const html = await engine.render(tpl, ctx, strictLenientOpts) + const html = await engine.render(tpl, ctx) return expect(html).to.equal('beforeafter') }) it('should support elsif with undefined variables', async function () { const tpl = engine.parse('{% if notdefined1 %}a{% elsif notdefined2 %}b{% elsif defined3 %}{{defined3}}{% else %}d{% endif %}') - const html = await engine.render(tpl, { 'defined3': 'bla' }, strictLenientOpts) + const html = await engine.render(tpl, { 'defined3': 'bla' }) return expect(html).to.equal('bla') }) it('should not throw in `unless` with a single variable', async function () { const tpl = engine.parse('before{% unless notdefined %}X{% else %}{{notdefined}}{% endunless %}after') - const html = await engine.render(tpl, ctx, strictLenientOpts) + const html = await engine.render(tpl, ctx) return expect(html).to.equal('beforeXafter') }) it('should still throw with an undefined variable in a compound `if` expression', function () { const tpl = engine.parse('{% if notdefined == 15 %}a{% endif %}') - const fhtml = engine.render(tpl, ctx, strictLenientOpts) + const fhtml = engine.render(tpl, ctx) return expect(fhtml).to.be.rejectedWith(/undefined variable: notdefined/) }) it('should allow an undefined variable when before the `default` filter', async function () { const tpl = engine.parse('{{notdefined | default: "a" | tolower}}') - const html = await engine.render(tpl, ctx, strictLenientOpts) + const html = await engine.render(tpl, ctx) return expect(html).to.equal('a') }) it('should not allow undefined variable even if `lenientIf` set', async function () { const tpl = engine.parse('{{notdefined | tolower}}') - return expect(() => engine.renderSync(tpl, ctx, strictLenientOpts)).to.throw('undefined variable: notdefined') + return expect(() => engine.renderSync(tpl, ctx)).to.throw('undefined variable: notdefined') }) }) }) diff --git a/test/stub/render.ts b/test/stub/render.ts index a8bdb46ba..dbbb05b0b 100644 --- a/test/stub/render.ts +++ b/test/stub/render.ts @@ -4,8 +4,8 @@ import { expect } from 'chai' export const liquid = new Liquid() -export function render (src: string, ctx?: object, opts?: LiquidOptions) { - return liquid.parseAndRender(src, ctx, opts) +export function render (src: string, ctx?: object) { + return liquid.parseAndRender(src, ctx) } export async function test (src: string, ctx: object | string, dst?: string, opts?: LiquidOptions) { @@ -13,5 +13,6 @@ export async function test (src: string, ctx: object | string, dst?: string, opt dst = ctx as string ctx = {} } - return expect(await render(src, ctx as object, opts)).to.equal(dst) + const engine = opts ? new Liquid(opts) : liquid + return expect(await engine.parseAndRender(src, ctx as object)).to.equal(dst) } diff --git a/test/unit/util/strftime.ts b/test/unit/util/strftime.ts index 614efff07..45d5e7905 100644 --- a/test/unit/util/strftime.ts +++ b/test/unit/util/strftime.ts @@ -210,8 +210,8 @@ describe('util/strftime', function () { expect(t(now, '%#P')).to.equal('PM') }) it('should support : flag', () => { - const date = new Date('2016-01-04T13:15:23.000Z') - date.getTimezoneOffset = () => -480 // suppose we're in +8:00 + const date = new Date('2016-01-04T13:15:23.000Z'); + (timezoneOffset as any) = -480 // suppose we're in +8:00 expect(t(date, '%:z')).to.equal('+08:00') expect(t(date, '%z')).to.equal('+0800') })