由于 Hexo 中使用了 bluebird
这个 Promise 库,会导致代码较难理解
本文会省略一些和 issue 无关的代码
最近看到了 Hexo 的 issue #4976,其中提到了大文件的 CSS 在生成的过程中可能会丢失部分代码。本人感觉这个问题非常有意思,于是自己也尝试了一下大文件的 CSS,没想到也成功复现了这个问题。
先说结论:问题应该出现在 post.js
中的 escapeAllSwigTags()
函数。由于压缩过的 CSS 中可能出现诸如{#main 这样的语句,而这样的语句会在这个函数中被当成 swig 模板进行处理,导致了代码的丢失。
解决方法:在 _config.yml
的 skip_render
中添加 CSS 的相对路径
以下为排查的过程和部分源码的分析:
# hexo-cli
hexo 中所输入的命令实际运行的是 hexo/bin/hexo
文件:
#!/usr/bin/env node | |
require('hexo-cli')(); |
hexo 文件中直接导入 hexo-cli
模块,查看 hexo-cli
入口点:
{ | |
"main": "lib/hexo" | |
} |
即入口点为 hexo/node_modules/hexo-cli/lib/hexo.js
,其模块导出:
module.exports = entry; |
查看 entry()
函数:
其输入两个参数 cwd
和 args
。在该函数中调用了 loadModule()
函数,并将其返回结果赋值给 hexo
,接着调用其 init()
函数
function entry(cwd = process.cwd(), args) { | |
//... | |
// 此时的 hexo 变量不是真正的 Hexo 对象 | |
let hexo = new Context(cwd, args); | |
//... | |
return findPkg(cwd, args).then(path => { | |
if (!path) return; | |
hexo.base_dir = path; | |
return loadModule(path, args).catch(err => { | |
//... | |
}); | |
}).then(mod => { | |
// 将 loadModule 返回的 Hexo 对象赋值给 hexo | |
if (mod) hexo = mod; | |
// 引入 console 模块,其中包含部分命令如 init,help 和 version | |
require('./console')(hexo); | |
// 调用其 init () 函数 | |
return hexo.init(); | |
}).then(() => { | |
//... | |
}).catch(handleError); | |
} |
查看 loadModule()
函数,在该函数中创建 Hexo 对象并返回:
function loadModule(path, args) { | |
return Promise.try(() => { | |
const modulePath = resolve.sync('hexo', { basedir: path }); | |
const Hexo = require(modulePath); | |
// 创建 Hexo 对象,args 代表命令参数 | |
return new Hexo(path, args); | |
}); | |
} |
总结:
hexo-cli 中创建 Hexo 对象,并调用其 init () 函数
# hexo 初始化
查看 hexo
入口点:
{ | |
"main": "lib/hexo" | |
} |
即入口点为 hexo/lib/hexo/index.js
,其模块导出:
module.exports = Hexo; |
先查看 Hexo 类的构造函数,在该函数中主要为属性赋值,初始化配置文件;同时初始化数据库,绑定查询方法
constructor(base = process.cwd(), args = {}) { | |
super(); | |
// 初始化各种路径变量 | |
this.base_dir = base + sep; | |
this.public_dir = join(base, 'public') + sep; | |
this.source_dir = join(base, 'source') + sep; | |
this.plugin_dir = join(base, 'node_modules') + sep; | |
this.script_dir = join(base, 'scripts') + sep; | |
this.scaffold_dir = join(base, 'scaffolds') + sep; | |
this.theme_dir = join(base, 'themes', defaultConfig.theme) + sep; | |
this.theme_script_dir = join(this.theme_dir, 'scripts') + sep; | |
// 初始化环境变量 | |
this.env = { | |
args, | |
debug: Boolean(args.debug), | |
safe: Boolean(args.safe), | |
silent: Boolean(args.silent), | |
env: process.env.NODE_ENV || 'development', | |
version, | |
cmd: args._ ? args._[0] : '', | |
init: false | |
}; | |
// 初始化各类 extend 模块 | |
this.extend = { | |
console: new Console(), | |
deployer: new Deployer(), | |
filter: new Filter(), | |
generator: new Generator(), | |
helper: new Helper(), | |
injector: new Injector(), | |
migrator: new Migrator(), | |
processor: new Processor(), | |
renderer: new Renderer(), | |
tag: new Tag() | |
}; | |
// 其余的初始化 | |
this.config = { ...defaultConfig }; | |
this.log = logger(this.env); | |
this.render = new Render(this); | |
this.route = new Router(); | |
this.post = new Post(this); | |
this.scaffold = new Scaffold(this); | |
this._dbLoaded = false; | |
this._isGenerating = false; | |
// If `output` is provided, use that as the | |
// root for saving the db. Otherwise default to `base`. | |
const dbPath = args.output || base; | |
//... | |
// 初始化数据库,用于临时存储需要生成和处理的原始文件 | |
this.database = new Database({ | |
version: dbVersion, | |
path: join(dbPath, 'db.json') | |
}); | |
// 初始化配置文件 | |
const mcp = multiConfigPath(this); | |
this.config_path = args.config ? mcp(base, args.config, args.output) | |
: join(base, '_config.yml'); | |
// 注册数据库中的模型(相当于表),模型中具体的 schema 定义可以查阅 hexo/lib/models 中对应的模块 | |
registerModels(this); | |
this.source = new Source(this); | |
this.theme = new Theme(this); | |
this.locals = new Locals(this); | |
// 绑定 local 的查询方法 | |
this._bindLocals(); | |
} |
由于在 hexo-cli
调用了 Hexo 类中的 init()
函数,查看该函数:
init() { | |
//... | |
// 加载外部 plugins | |
require('../plugins/console')(this); // 控制台插件,用于处理输入的指令 | |
require('../plugins/filter')(this); // 过滤器插件 | |
require('../plugins/generator')(this);// 生成器插件,用于生成转换后的文件 | |
require('../plugins/helper')(this); | |
require('../plugins/injector')(this); | |
require('../plugins/processor')(this);// 处理器插件,用于文件生成前的预处理 | |
require('../plugins/renderer')(this); | |
require('../plugins/tag')(this); | |
// 加载配置 | |
return Promise.each([ | |
'update_package', // Update package.json | |
'load_config', // Load config | |
'load_theme_config', // Load alternate theme config | |
'load_plugins' // Load external plugins & scripts | |
], name => require(`./${name}`)(this)).then(() => this.execFilter('after_init', null, { context: this })).then(() => { | |
// Ready to go! | |
this.emit('ready'); | |
}); | |
} |
至此,Hexo 初始化完成,可以开始执行用户输入的指令
# generate
hexo/lib/plugins/console
用于处理用户输入的指令hexo/lib/plugins/console/index.js
是该模块的入口,该模块用于向对应的 extend 中注册模块,以下以 generate 命令为例:
module.exports = function(ctx) { | |
const { console } = ctx.extend; | |
//... | |
// 注册 generate 相关处理模块,require ('./generate') | |
console.register('generate', 'Generate static files.', { | |
options: [ | |
{name: '-d, --deploy', desc: 'Deploy after generated'}, | |
{name: '-f, --force', desc: 'Force regenerate'}, | |
{name: '-w, --watch', desc: 'Watch file changes'}, | |
{name: '-b, --bail', desc: 'Raise an error if any unhandled exception is thrown during generation'}, | |
{name: '-c, --concurrency', desc: 'Maximum number of files to be generated in parallel. Default is infinity'} | |
] | |
}, require('./generate')); | |
//... | |
}; |
查看同目录下的 generate.js
模块:
其创建了 Generater 对象,并调用了 this.load()
函数,由于 this 就是 Hexo 对象,所以相当于调用了 Hexo 对象中的 load()
函数
function generateConsole(args = {}) { | |
const generator = new Generater(this, args); | |
// 如果指令中存在 -w 或 --watch 则会执行以下代码 | |
if (generator.watch) { | |
return generator.execWatch(); | |
} | |
// 先调用 load () 函数加载需要生成的文件,之后才进行生成操作 | |
return this.load().then(() => generator.firstGenerate()).then(() => { | |
// 如果指令中存在 -d 或 --deploy,则会执行以下代码 | |
if (generator.deploy) { | |
return generator.execDeploy(); | |
} | |
}); | |
} | |
module.exports = generateConsole; |
查看 Hexo 类中的 load()
函数:
该函数首先调用 load_database.js
中的 loadDatabase
模块,先检查是否在根目录下存在 db.json
数据库文件,如果有则进行读取,否则直接返回
由于 hexo 将需要处理的文件分成了 source
(\source 目录下的文件)和 theme
(\themes 目录下的文件),所以分别需要对这两个部分执行 process()
函数进行预处理
在异步调用结束后,需要生成的文件已经被存入了 hexo 对象中的 database
属性中,等待被生成。此时执行 mergeCtxThemeConfig()
函数进行配置的融合,并调用 _generate()
函数用于执行生成前和生成后的过滤器(filter)
由于 CSS 文件位于 \source
目录下,所以 CSS 文件会在 this.source.process()
中被处理
load(callback) { | |
// 检查是否在根目录下存在 `db.json` 数据库文件,如果有则进行读取,否则直接返回 | |
return loadDatabase(this).then(() => { | |
this.log.info('Start processing'); | |
// 进行预处理,将文件读入数据库中 | |
return Promise.all([ | |
this.source.process(), | |
this.theme.process() | |
]); | |
}).then(() => { | |
// 融合配置文件 | |
mergeCtxThemeConfig(this); | |
// 执行生成前和生成后的过滤器(filter) | |
return this._generate({ cache: false }); | |
}).asCallback(callback); | |
} |
由于 issue 中所提到的 CSS 文件属于 soruce,所以只需要研究 this.source.process()
# processor
查看 hexo/lib/box/index.js
中的 process()
函数:
重点在于最后的 return 语句,通过 _readDir()
函数读取文件到数据库中,再使用过滤器处理被删除的文件。而 issue 中提到的问题正是在将文件读取到数据库中发生的
process(callback) { | |
const { base, Cache, context: ctx } = this; | |
return stat(base).then(stats => { | |
//... | |
// Handle deleted files | |
return this._readDir(base) | |
.then(files => cacheFiles.filter(path => !files.includes(path))) | |
.map(path => this._processFile(File.TYPE_DELETE, path)); | |
}).catch(err => { | |
//... | |
}).asCallback(callback); | |
} |
查看同文件下 _readDir()
函数:
函数比较简单,即递归读取特定目录下所有文件,检查其状态并使用 _processFile()
函数进行处理
读取文件本身不存在问题,问题出在对读取出来数据的处理上
_readDir(base, prefix = '') { | |
const results = []; | |
return readDirWalker(base, results, this.ignore, prefix) | |
.return(results) | |
.map(path => this._checkFileStatus(path)) | |
.map(file => this._processFile(file.type, file.path).return(file.path)); | |
} |
查看同文件下 _processFile
函数:bluebird
的使用使得代码较难理解,大意就是对于每个 path
,判断其是否匹配 processor
中的 pattern
。如果匹配,则执行 processor
中的 process()
函数,并将结果返回
_processFile(type, path) { | |
//... | |
// 对 this.processor 中的每个 processor 都进行如下操作 | |
return Promise.reduce(this.processors, (count, processor) => { | |
// 判断 path 是否匹配 processor 中的 pattern | |
const params = processor.pattern.match(path); | |
// 如果不匹配,则直接返回 | |
if (!params) return count; | |
const file = new File({ | |
source: join(base, path), | |
path, | |
params, | |
type | |
}); | |
// 否则,执行 processor 中的 process 方法,并将结果返回 | |
return Reflect.apply(Promise.method(processor.process), ctx, [file]) | |
}, 0).catch(err => { | |
ctx.log.error({ err }, 'Process failed: %s', magenta(path)); | |
}).finally(() => { | |
this._processingFiles[path] = false; | |
}).thenReturn(path); | |
} |
注意,这里的 this
是 hexo 对象中的 source
而非 theme
,所以查看 hexo/lib/hexo/source.js
:
class Source extends Box { | |
constructor(ctx) { | |
super(ctx, ctx.source_dir); | |
this.processors = ctx.extend.processor.list(); | |
} | |
} |
继续查看 hexo/lib/extend/processor.js
:
可以知道最终 processors 中的处理器就是那些初始化时被注册的处理器(和 console 一样)
class Processor { | |
constructor() { | |
this.store = []; | |
} | |
list() { | |
return this.store; | |
} | |
register(pattern, fn) { | |
//... | |
this.store.push({ | |
pattern: new Pattern(pattern), | |
process: fn | |
}); | |
} | |
} |
继续查看 hexo/lib/plugins/processor/index.js
:
可以知道 asset
、 data
和 post
三个处理器被成功注册,而 CSS 文件是归属于 asset
进行处理的
module.exports = ctx => { | |
const { processor } = ctx.extend; | |
function register(name) { | |
const obj = require(`./${name}`)(ctx); | |
processor.register(obj.pattern, obj.process); | |
} | |
register('asset'); | |
register('data'); | |
register('post'); | |
}; |
继续查看 hexo/lib/plugins/processor/asset.js
:
CSS 文件的 renderable
是 true
,所以会进入 processPage()
函数中
module.exports = ctx => { | |
return { | |
pattern: new Pattern(path => { | |
if (isExcludedFile(path, ctx.config)) return; | |
return { | |
// 如果这里_config.yml 中设置了 skip_render,则这里的 renderable 会变为 false,也就不会参与之后的转义了 | |
renderable: ctx.render.isRenderable(path) && !isMatch(path, ctx.config.skip_render) | |
}; | |
}), | |
process: function assetProcessor(file) { | |
if (file.params.renderable) { | |
return processPage(ctx, file); | |
} | |
return processAsset(ctx, file); | |
} | |
}; | |
}; |
继续查看 processPage()
函数:
在该函数中,主要是读取对应文件,进行一定的处理后将结果存入数据库的 Page
模型中
function processPage(ctx, file) { | |
const Page = ctx.model('Page'); | |
const { path } = file; | |
const doc = Page.findOne({source: path}); | |
const { config } = ctx; | |
const { timezone: timezoneCfg } = config; | |
//... | |
return Promise.all([ | |
file.stat(), | |
// 读取文件 | |
file.read() | |
]).spread((stats, content) => { | |
const data = yfm(content); | |
const output = ctx.render.getOutput(path); | |
data.source = path; | |
//raw 是读取的原始数据 | |
data.raw = content; | |
data.date = toDate(data.date); | |
// 一系列处理 | |
//... | |
// 存入数据库中 | |
return Page.insert(data); | |
}); | |
} |
# filter
process()
函数已经完成,此时回到 load()
函数中:
开始执行 _generate()
函数
load(callback) { | |
// 检查是否在根目录下存在 `db.json` 数据库文件,如果有则进行读取,否则直接返回 | |
return loadDatabase(this).then(() => { | |
this.log.info('Start processing'); | |
// 进行预处理,将文件读入数据库中 | |
return Promise.all([ | |
this.source.process(), | |
this.theme.process() | |
]); | |
}).then(() => { | |
// 融合配置文件 | |
mergeCtxThemeConfig(this); | |
// 执行生成前和生成后的过滤器(filter) | |
return this._generate({ cache: false }); | |
}).asCallback(callback); | |
} |
查看同目录下的 _generate()
函数:
基本上就是先运行 before_generate
过滤器,接着运行 _runGenerators
调用生成器进行生成,最后运行 after_generate
过滤器
_generate(options = {}) { | |
//... | |
this.emit('generateBefore'); | |
// 运行 before_generate 过滤器 | |
return this.execFilter('before_generate', this.locals.get('data'), { context: this }) | |
// 运行_runGenerators 调用生成器进行生成 | |
.then(() => this._routerReflesh(this._runGenerators(), useCache)).then(() => { | |
this.emit('generateAfter'); | |
// 运行 after_generate 过滤器 | |
return this.execFilter('after_generate', null, { context: this }); | |
}).finally(() => { | |
this._isGenerating = false; | |
}); | |
} |
问题出现在 before_generate
过滤器之中,查看 hexo/lib/plugins/filter/before_generate/render_post.js
:
在该过滤器中,对于 Post
模型和 Page
模型分别调用 render()
函数对 post 进行处理 (如转义)
function renderPostFilter(data) { | |
const renderPosts = model => { | |
// 获得所有 content 为空的 post | |
const posts = model.toArray().filter(post => post.content == null); | |
return Promise.map(posts, post => { | |
// 先赋值为_content | |
post.content = post._content; | |
post.site = {data}; | |
// 调用 render () 函数对 post 进行处理 (如转义) | |
return this.post.render(post.full_source, post).then(() => | |
// 保存回数据库 | |
post.save()); | |
}); | |
}; | |
return Promise.all([ | |
renderPosts(this.model('Post')), | |
renderPosts(this.model('Page')) | |
]); | |
} |
查看 hexo/lib/hexo/post.js
中的 render()
函数:
在该函数中,首先运行 before_post_render
过滤器,接着在对文件进行转义操作后使用渲染器对 markdown 等进行渲染,最后运行 after_post_render
过滤器
问题就出在 escapeAllSwigTags()
函数中。由于压缩过的 CSS 中可能出现诸如{#main这样的语句,而这样的语句会在这个函数中被当成 swig 模板进行处理,导致文件丢失部分代码
render(source, data = {}, callback) { | |
const ctx = this.context; | |
const { config } = ctx; | |
const { tag } = ctx.extend; | |
const ext = data.engine || (source ? extname(source) : ''); | |
let promise; | |
//... | |
return promise.then(content => { | |
data.content = content; | |
// 运行 before_post_render 过滤器 | |
return ctx.execFilter('before_post_render', data, { context: ctx }); | |
}).then(() => { | |
data.content = cacheObj.escapeCodeBlocks(data.content); | |
// Escape all Nunjucks/Swig tags | |
if (disableNunjucks === false) { | |
// 问题出在这句话 | |
//CSS 不需要转义 swig 模板!!! | |
data.content = cacheObj.escapeAllSwigTags(data.content); | |
} | |
// 使用渲染器对 markdown 等进行渲染 | |
//... | |
}).then(content => { | |
data.content = cacheObj.restoreCodeBlocks(content); | |
// 运行 after_post_render 过滤器 | |
return ctx.execFilter('after_post_render', data, { context: ctx }); | |
}).asCallback(callback); | |
} |