我经常谈论静态网站生成器,但总是谈论使用静态网站生成器。在大多数情况下,它可能看起来像一个黑盒子。我创建了一个模板和一些 Markdown,然后就会生成一个完整的 HTML 页面。神奇!
但究竟什么是静态网站生成器?那个黑盒子里面发生了什么?这是怎样的巫术?
在这篇文章中,我想探索构成静态网站生成器的所有部分。首先,我们将以通用的方式讨论这些部分,然后我们将深入了解 HarpJS 的内部代码,从而更深入地了解一些实际代码。所以,带上你的探险家帽子,让我们开始探索吧。
为什么选择 Harp? 两个原因。首先,HarpJS 的设计非常简单,是一个非常简单的静态网站生成器。它没有很多可能会让我们在探索功能更全面的静态网站生成器(例如 Jekyll)时迷路的特性。第二个原因,更实际的原因是,我会 JavaScript,对 Ruby 并不熟悉。
静态网站生成器的基本原理
事实是,静态网站生成器是一个相当简单的概念。静态网站生成器的关键要素通常是
- 用于创建页面/帖子模板的模板语言
- 用于创作内容的轻量级标记语言(通常是 Markdown)
- 用于提供配置和元数据的结构和标记(通常是 YAML)(例如,“前置 matter“)
- 用于组织和命名要导出/编译的文件、不要导出的文件以及如何处理这些文件的规则或结构(例如,经常以下划线开头文件或文件夹意味着它不会导出到最终的站点文件或所有帖子都位于 posts 文件夹中)
- 将模板和标记编译成 HTML 的方法(通常也包括对 CSS 或 JavaScript 预处理器的支持)
- 用于测试的本地服务器。
就是这样。如果你在想,“嘿……我可以构建它!”,你可能是正确的。但是,当你开始扩展功能时,事情就开始变得复杂了,就像大多数静态网站生成器所做的那样。
因此,让我们看看 Harp 如何处理这个问题。
深入了解 Harp
让我们看看 Harp 如何处理上面描述的关键要素的基本原理。Harp 提供了更多功能,但为了我们的检查,我们将坚持使用那些要素。
首先,让我们讨论 Harp 的基本原理。
Harp 基本原理
Harp 支持 Jade 和 EJS(用于模板)以及 Markdown 作为其轻量级标记语言(用于内容)。请注意,尽管 Jade 现在称为 Pug,但 Harp 在其文档或代码中尚未正式过渡,因此我们将在这里坚持使用 Jade。Harp 还提供对其他预处理的支持,例如 Less、Sass 和 Stylus 用于 CSS,以及 CoffeeScript 用于 JavaScript。
默认情况下,Harp 不需要很多配置或元数据。它倾向于支持 惯例优于配置。但是,它允许使用 JSON 进行特定的元数据和配置。它与许多其他静态网站生成器的不同之处在于,文件元数据包含在实际文件之外的 `_data.json` 文件中。
虽然 Harp 在一定程度上是可配置的,但它对如何构建文件有一些既定的准则。例如,在典型的应用程序中,要提供的文件位于 `public` 目录中。此外,任何以下划线开头的文件或文件夹都不会提供。
最后,Harp 提供了一个基本的本地 Web 服务器用于测试,其中包含一些可配置的选项。当然,它还会编译要部署的完成的 HTML、CSS 和 JavaScript 文件。
让我们看看 Harp 的实际源代码
由于静态网站生成器的很多功能都是规则和约定,因此代码主要集中在实际的提供和编译(在大多数情况下)上。让我们深入研究一下。
服务器函数
在 Harp 中,通常通过从命令行执行 `harp server` 来提供项目。让我们看看 该函数的代码
exports.server = function(dirPath, options, callback){
var app = connect()
app.use(middleware.regProjectFinder(dirPath))
app.use(middleware.setup)
app.use(middleware.basicAuth)
app.use(middleware.underscore)
app.use(middleware.mwl)
app.use(middleware.static)
app.use(middleware.poly)
app.use(middleware.process)
app.use(middleware.fallback)
return app.listen(options.port || 9966, options.ip, function(){
app.projectPath = dirPath
callback.apply(app, arguments)
})
}
虽然该函数看起来很简单,但显然在 中间件 中还有很多没有说明的内容。
该函数的其余部分使用您指定的选项(如果有)打开一个服务器。这些选项包括端口、要绑定的 IP 和目录。默认情况下,端口为 9000(不是像代码中您可能猜到的那样为 9966),目录为当前目录(即 Harp 运行的目录),IP 为 `0.0.0.0`。
编译器函数
在 index.js 中,接下来让我们看看 `compile` 函数。
exports.compile = function(projectPath, outputPath, callback){
/**
* Both projectPath and outputPath are optional
*/
if(!callback){
callback = outputPath
outputPath = "www"
}
if(!outputPath){
outputPath = "www"
}
/**
* Setup all the paths and collect all the data
*/
try{
outputPath = path.resolve(projectPath, outputPath)
var setup = helpers.setup(projectPath, "production")
var terra = terraform.root(setup.publicPath, setup.config.globals)
}catch(err){
return callback(err)
}
/**
* Protect the user (as much as possible) from compiling up the tree
* resulting in the project deleting its own source code.
*/
if(!helpers.willAllow(projectPath, outputPath)){
return callback({
type: "Invalid Output Path",
message: "Output path cannot be greater then one level up from project path and must be in directory starting with `_` (underscore).",
projectPath: projectPath,
outputPath: outputPath
})
}
/**
* Compile and save file
*/
var compileFile = function(file, done){
process.nextTick(function () {
terra.render(file, function(error, body){
if(error){
done(error)
}else{
if(body){
var dest = path.resolve(outputPath, terraform.helpers.outputPath(file))
fs.mkdirp(path.dirname(dest), function(err){
fs.writeFile(dest, body, done)
})
}else{
done()
}
}
})
})
}
/**
* Copy File
*
* TODO: reference ignore extensions from a terraform helper.
*/
var copyFile = function(file, done){
var ext = path.extname(file)
if(!terraform.helpers.shouldIgnore(file) && [".jade", ".ejs", ".md", ".styl", ".less", ".scss", ".sass", ".coffee"].indexOf(ext) === -1){
var localPath = path.resolve(outputPath, file)
fs.mkdirp(path.dirname(localPath), function(err){
fs.copy(path.resolve(setup.publicPath, file), localPath, done)
})
}else{
done()
}
}
/**
* Scan dir, Compile Less and Jade, Copy the others
*/
helpers.prime(outputPath, { ignore: projectPath }, function(err){
if(err) console.log(err)
helpers.ls(setup.publicPath, function(err, results){
async.each(results, compileFile, function(err){
if(err){
callback(err)
}else{
async.each(results, copyFile, function(err){
setup.config['harp_version'] = pkg.version
delete setup.config.globals
callback(null, setup.config)
})
}
})
})
})
}
第一部分通过命令行调用 `harp compile` 来定义输出路径(此处为源代码)。如您所见,默认值为 `www`。回调函数是命令行实用程序传递的回调函数,它不可配置。
下一部分首先调用 helpers 模块 中的 `setup` 函数。为了简洁起见,我们不会深入讨论该函数的具体代码(您可以随意自己查看),但本质上它是读取站点配置(即 `harp.json`)。
您可能还会注意到对名为 `terraform` 的内容的调用。这将在本函数中再次出现。 Terraform 实际上是 Harp 所需的另一个独立项目,它是其 资产管道 的基础。资产管道是完成编译和构建完成站点的工作的地方(我们将在稍后查看 Terraform 代码)。
代码的下一部分,正如它所述,试图阻止您指定一个输出目录,该目录会无意中覆盖您的源代码(这将很糟糕,因为您将丢失上次提交后的所有工作)。
`compileFile` 和 `copyFile` 函数非常直观。`compileFile` 函数依赖于 Terraform 来完成实际的编译。这两个函数都驱动 `prime` 函数,该函数使用辅助函数(`fs`)来遍历目录,在此过程中根据需要编译或复制文件。
Terraform
正如我所讨论的,Terraform 负责将 Jade、Markdown、Sass 和 CoffeeScript 编译成 HTML、CSS 和 JavaScript 的繁重工作(并根据 Harp 的定义组装这些部分)。Terraform 由许多文件组成,这些文件定义了其 JavaScript、CSS/样式表和模板(在本例中包括 Markdown)的处理器。

在每个这些文件夹中,都有一个 `processors` 文件夹,其中包含 Terraform(即 Harp)支持的每个特定处理器的代码。例如,在 templates 文件夹中,有一些文件构成了编译 EJS、Jade 和 Markdown 文件的基础。

我不会深入探讨每个的代码,但大多数情况下,它们依赖于处理支持的处理器的外部 npm 模块。例如,对于 Markdown 支持,它依赖于 Marked。
Terraform 的核心逻辑包含在其 render
函数中。
/**
* Render
*
* This is the main method to to render a view. This function is
* responsible to for figuring out the layout to use and sets the
* `current` object.
*
*/
render: function(filePath, locals, callback){
// get rid of leading slash (windows)
filePath = filePath.replace(/^\\/g, '')
// locals are optional
if(!callback){
callback = locals
locals = {}
}
/**
* We ignore files that start with underscore
*/
if(helpers.shouldIgnore(filePath)) return callback(null, null)
/**
* If template file we need to set current and other locals
*/
if(helpers.isTemplate(filePath)) {
/**
* Current
*/
locals._ = lodash
locals.current = helpers.getCurrent(filePath)
/**
* Layout Priority:
*
* 1. passed into partial() function.
* 2. in `_data.json` file.
* 3. default layout.
* 4. no layout
*/
// 1. check for layout passed in
if(!locals.hasOwnProperty('layout')){
// 2. _data.json layout
// TODO: Change this lookup relative to path.
var templateLocals = helpers.walkData(locals.current.path, data)
if(templateLocals && templateLocals.hasOwnProperty('layout')){
if(templateLocals['layout'] === false){
locals['layout'] = null
} else if(templateLocals['layout'] !== true){
// relative path
var dirname = path.dirname(filePath)
var layoutPriorityList = helpers.buildPriorityList(path.join(dirname, templateLocals['layout'] || ""))
// absolute path (fallback)
layoutPriorityList.push(templateLocals['layout'])
// return first existing file
// TODO: Throw error if null
locals['layout'] = helpers.findFirstFile(root, layoutPriorityList)
}
}
// 3. default _layout file
if(!locals.hasOwnProperty('layout')){
locals['layout'] = helpers.findDefaultLayout(root, filePath)
}
// 4. no layout (do nothing)
}
/**
* TODO: understand again why we are doing this.
*/
try{
var error = null
var output = template(root, templateObject).partial(filePath, locals)
}catch(e){
var error = e
var output = null
}finally{
callback(error, output)
}
}else if(helpers.isStylesheet(filePath)){
stylesheet(root, filePath, callback)
}else if(helpers.isJavaScript(filePath)){
javascript(root, filePath, callback)
}else{
callback(null, null)
}
}
(如果你仔细阅读了所有代码,你可能会注意到 TODO、拼写错误,甚至一个有趣的“再次理解我们为什么要这样做”的注释。这是真实的生活编码!)
render
函数中的大多数代码都与处理模板有关。像 CoffeeScript 和 Sass 这样的东西从根本上来说是一对一地渲染。例如,style.scss
将渲染为 style.css
。即使它包含 include,也会由渲染器处理。render
函数的最后部分处理这些类型的文件。
Harp 中的布局 另一方面,以多种方式相互嵌套,甚至可能取决于配置。例如,about.md
可能会在默认的 _layout.jade
中渲染(确切位置由该布局中 != yield
的使用决定)。但是,_layout.jade
可能会通过 Harp 中的 部分支持 在自身内包含多个其他布局。
部分是将模板拆分为多个文件的一种方式。它们对于代码重用特别有用。例如,我可能会将站点标题放在部分中。部分对于使静态站点生成器中的布局可维护很重要,但它们也为编译模板的逻辑增加了相当大的复杂性。这种复杂性由 模板处理器 的 partial
函数处理。
最后,你可以通过在 _data.json
配置文件中为特定文件指定特定布局或根本不指定布局来覆盖默认布局。所有这些场景都在 render
函数的逻辑中处理(甚至编号)。
这并不复杂,是吗?
为了使这易于理解,我省略了大量其他细节。从本质上讲,我使用过的每个静态站点生成器(我使用过 一堆)的功能都类似:一组规则、约定和配置,这些规则、约定和配置通过各种支持的标记的编译器运行。也许这就是为什么有 大量 静态站点生成器存在的原因。
话虽如此,我不想自己构建一个!
我的报告和书籍
如果你有兴趣学习如何使用静态站点生成器构建网站,我为 O'Reilly 撰写了一份报告,并与 Raymond Camden 共同撰写了一本书,你可能对此感兴趣。我的报告,简单地命名为 静态站点生成器 是免费的,试图建立静态站点生成器背后的历史、现状和基础知识。

我和 Raymond Camden 共同撰写的那本书叫做 使用静态站点,并且可以作为早期版本使用,但很快就会以印刷形式出版。
如果你把网站拿掉,它基本上与报告生成器没有太大区别。它在事件发生时运行(可能定期运行,因为这是一个非常基本的事件);它从数据源中提取数据并呈现某些内容。
我目前非常感兴趣的是(尽管我还没有找到愿意和我一起进行此旅程的客户)将静态前端与动态后端的原则应用于系统。轻量级选项是从动态服务器站点抓取静态版本,我已经做过很多次,但真正强大的地方在于能够降低每个访问者的成本,甚至通过在服务器上减少对非业务关键任务的思考来降低服务器成本。
很好的阅读;也许在某些细节上有点具体。
我不了解各位,但对我来说,所有这些生成器都像是鲁布·戈德堡机器。你必须安装一堆插件、库、编译这些、设置路径、学习和使用新的标记语言,最后你得到一个简单的网站,你可以在没有这些工具的情况下在同一时间甚至更快地编写。我知道我知道,这是“更难、更好、更快、更强”,但我怀念那些只需要代码编辑器、HTML CSS 知识以及一些 js 或 php 就可以编写网站的时代,而无需所有这些增强功能。
无论如何,好文章。谢谢
嘿,Mark。作为一个网络开发领域的新手(大约 18 个月),我查看过许多生成器、框架等,但我仍然无法看到它们的好处。对我来说,这一切似乎都是“又一件事要学习”。我仍然没有找到适合我的生成器/框架。我似乎对“仅”HTML、CSS、js 感觉很好。希望我能找到一个有同样想法的雇主。
感谢 Brian 深入研究 Harp 的核心功能,这篇文章现在可以在法语中获得 (https://jamstatic.fr/2017/02/09/y-a-quoi-dans-un-generateur-de-site-statique/),如果你想链接到翻译。
@Mark @Jared 如今你需要能够持续交付。
结合 Git 版本控制和自动测试构建(由静态站点生成器完成)可以安全地持续交付。这可能不适用于每个项目。
Netlify 的首席执行官 Matthias Billman 谈论了当前的 Web 前端堆栈以及它为何会演变:https://vimeo.com/163522126