什么是真正的静态网站生成器?

Avatar of Brian Rinaldi
Brian Rinaldi

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

我经常谈论静态网站生成器,但总是谈论使用静态网站生成器。在大多数情况下,它可能看起来像一个黑盒子。我创建了一个模板和一些 Markdown,然后就会生成一个完整的 HTML 页面。神奇!

但究竟什么是静态网站生成器?那个黑盒子里面发生了什么?这是怎样的巫术?

在这篇文章中,我想探索构成静态网站生成器的所有部分。首先,我们将以通用的方式讨论这些部分,然后我们将深入了解 HarpJS 的内部代码,从而更深入地了解一些实际代码。所以,带上你的探险家帽子,让我们开始探索吧。

为什么选择 Harp? 两个原因。首先,HarpJS 的设计非常简单,是一个非常简单的静态网站生成器。它没有很多可能会让我们在探索功能更全面的静态网站生成器(例如 Jekyll)时迷路的特性。第二个原因,更实际的原因是,我会 JavaScript,对 Ruby 并不熟悉。

静态网站生成器的基本原理

事实是,静态网站生成器是一个相当简单的概念。静态网站生成器的关键要素通常是

  • 用于创建页面/帖子模板的模板语言
  • 用于创作内容的轻量级标记语言(通常是 Markdown)
  • 用于提供配置和元数据的结构和标记(通常是 YAML)(例如,“前置 matter“)
  • 用于组织和命名要导出/编译的文件、不要导出的文件以及如何处理这些文件的规则或结构(例如,经常以下划线开头文件或文件夹意味着它不会导出到最终的站点文件或所有帖子都位于 posts 文件夹中)
  • 将模板和标记编译成 HTML 的方法(通常也包括对 CSS 或 JavaScript 预处理器的支持)
  • 用于测试的本地服务器。

就是这样。如果你在想,“嘿……我可以构建它!”,你可能是正确的。但是,当你开始扩展功能时,事情就开始变得复杂了,就像大多数静态网站生成器所做的那样。

因此,让我们看看 Harp 如何处理这个问题。

深入了解 Harp

让我们看看 Harp 如何处理上面描述的关键要素的基本原理。Harp 提供了更多功能,但为了我们的检查,我们将坚持使用那些要素。

首先,让我们讨论 Harp 的基本原理。

Harp 基本原理

Harp 支持 JadeEJS(用于模板)以及 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 共同撰写的那本书叫做 使用静态站点,并且可以作为早期版本使用,但很快就会以印刷形式出版。