gitbook源码全解析

873 阅读11分钟

之前因为搞一些个人文档和电子书用到了gitbook。由于gitbook项目不再维护还对比了一些替代产品,发现还是gitbook最好用。然而一些小瑕疵让人皱眉,于是改了改源码并以问题为线索对整个应用作了个初步分析。

已有产品的诸多问题

现在有很多线上的个人文档管理工具了,非常方便,但是有以下问题:

  • 数据安全问题 这个问题主要是针对国内厂商,以他们的毫无底线,必定需要手机号注册并且毫无疑问会在后台分析个人数据。但是国外厂商又有着翻墙问题,让人无奈,比如Notion,线上版gitbook。
  • 迁移成本大 很多个人文档的数据无法导出或不支持导入,如果某一天需要把个人的数据换一个商家保存,会非常困难。
  • 无差异对比 对于更改了的文档人们需要知道都更改了哪些东西,改错的内容怎么恢复,这几乎是一个刚性需求,但好多商家都没有实现,比如语雀;或者有的商家需要收费,比如我来。
  • 其他问题 在国内环境下,还有个问题日益严重,那就是对内容发布的审𨖚查𨖚。这是个极其蛋𨖚疼𨖚的事情,现在有巨量的不能提及的词语库,条目内容还不为人所知,汉字的复杂组合使得任何文章都可以被匹配到,作者想改还不知道改哪个地方!这对很多内容创作的人来说简直就是阉割一般。所以与其在平台上像扒光衣服一样被指指点点,不如直接全部自己保存!

如此一来,像gitbook这样的工具就非常满足中文创作者的需要了。首先两大功能都是通用标准,几乎任何平台都可以支持。这两大功能就是形成文章主体的Markdown标记,以及组织文章形成版本管理的git。然后也是因为git的分布式特性,可以在任何代码托管平台保存自己文章的原始内容,只要支持git就可以无缝迁移。只要自己手中有数据,内容发布的选择性很大,github, gitlab这样的平台都可一键集成gitbook;如果无法翻墙,现在自己搭一个小型的静态站点是非常容易的。关键所有的数据都为自己所有,不会因为意外所有数据都没了。

总之,即使gitbook这个工具本身问题多多,但这种工具的形态其实非常值得了解和学习。再去开发一个类似gitbook的工具也首先需要了解已有工具的特性和优势。

开发环境搭建

gitbook的运行测试环境搭起来非常方便,npm install -g gitbook-cli成功运行以后添加以下文件 $NODEJS/lib/node_modules/gitbook-cli/.vscode/launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "pwa-node",
            "request": "launch",
            "name": "Launch Program",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "program": "${workspaceFolder}/bin/gitbook.js",
            "args": ["serve", "/your/test/gitbook/directory"]
        }
    ]
}

然后用VSCode打开文件夹$NODEJS/lib/node_modules/gitbook-cli就能调试运行了,这比以往任何代码工程都简单!

整体阶段

还是以gitbook serve命令为例,在之前分析的基础之上,列出应用起始的大致调用序列如下(缩进表示函数内调用的函数):

manager.ensureAndLoad(bookRoot, program.gitbook)
  local.load('3.2.3');
    require('~/.gitbook/versions/3.2.3') => 3.2.3/lib/index.js
commands.exec(gitbook.commands)
  cmd.exec() => exec: function() // serve.js:136
    lrServer = tinylr({});
    serve.generateBook()
      var book = getBook(args, kwargs); Immutable.Record
        var book = Book.createForFS(fs);
        book.setLogLevel(logLevel);
      Parse.parseBook(book)
        resultBook = resultBook.set('config', config);
      Output.generate(Generator, resultBook, {root: outputFolder})
      server.start(outputFolder, port);
      watch(book.getRoot())

看起来异常简单。作为一项工程来说,能够达到简单其实非常不容易,对于初入工程的人来说能够“望文生义”;所谓“望文生义”就是各种函数变量的命名和实际作用是一致的,只要参与过大型工程的人想必是非常明白的。local.js中的ensureAndLoad显然主要是校验本地一些库,gitbook自己去下载了一些软件包并且对不同版本作了判断。起初这一点不太好理解,特别是对js不太熟悉的人来说,lib里的代码并不一定是全部逻辑,有可能在其它路径加载了运行代码,这种操作非常灵活,当然也非常隐蔽。不过这些都是外围的功能,不是整个工具的核心。

lrServer = tinylr({});一句是启动了一个liveReload的服务,用来监听本地文件更改以便触发一些动作,这里需要了解tinylr的作用。gitbook的livereload好像不能使用,不知是不是使用方式不对,但现在没有更深入这个问题。每个工程都牵扯了大量的外部库,区分哪些是调用代码,哪些是从0开始的流程代码,对于了解一个工程是非常必要的。

接着就是serve.js中最重要的方法generateBook,看调用流程,总共3个大步骤:

  1. 获取书籍信息getBook
  2. 解析书籍信息Parse.parseBook
  3. 输出书籍Output.generate
  4. 在指定目录启动静态网页服务

js语言中没有类,自定义类需要用到一些看起来很别扭的操作,这只是语言的特性,不必过于纠缠。另外表示书籍变量的命名,有时叫book,有时又叫gitbook,不容易对应变量的指代,对于习惯强类型编程语言的人来说是一种痛苦。getBook这个方法实际就是从参数与目录结构中的文件构建book对象,这个对象里包含的其实是书的信息不是书本身的内容,起名BookInfo更合适。启动静态网页服务这种功能比较通用,一般或者是框架的内置功能,或者借助已有类库,不用太仔细看。所以最关键的只有2个调用Parse.parseBookOutput.generate,对应两个模块解析输出

书本信息解析

直接列出解析过程的调用序列:

Parse.parseBook(book)
  parseIgnore
  parseConfig
  parseLanguages
  parseBookContent
    parseReadme
      parseStructureFile
        lookupStructureFile
        parseFile
          parser.parseReadme // parser.js:29
            parseReadme(html) // gitbook-html/readme.js:10
    parseSummary
    parseGlossary

从功能维度考虑过程是很清晰的,一个目录下会有各种文件,管理这个目录必然先要判断需要处理文件的类型,显然我们需要处理md文件,还有表示书籍信息的book.json文件,等等。而目录下本身还有表示版本和存储信息的git相关文件和目录,所以parseIgnore这个方法无论叫什么名字,都是明确处理对象的操作。所以紧接其后的Config,Languages,都是好理解的,这里的细节可以忽略。如果我们需要自己实现一个类似gitbook的工具,应该不是难事,需要什么添加什么,只是功能的堆加。最重要的自然是parseBookContent,从方法中调用的方法可以看出,主要是处理README, SUMMARY, GLOSSARY三个文件的。这三个文件将来本身也需要生成html文件,但特殊之处在于这三个文件还在一定程度上包含了书籍的结构信息。所以这个方法名称也是有点迷惑人,其实它是处理书本的结构信息,因而解析了一些文本,而不是处理书本的全部内容。深入方法调用最终调用到了3.2.3/lib/models/parser.js,以parseReadme为例,在这里又引用了gitbook-markdown库里的readme.js:

function parseReadme(html) {
    var $ = dom.parse(html);

    return {
        title: $('h1:first-child').text().trim(),
        description: $('div.paragraph,p').first().text().trim()
    };
}

也就是说,这里已经先将md文本转成了html文本,又从html文本中提取了标题信息和简介信息。这里有个疑问,把md文本转成html是否有必要,没有办法从markdown里直接解析出对应数据吗?似乎现在的markdown解析库都是直接转成html,不管哪种语言。$('h1:first-child')这种写法太js了,一般对应其它语言的Document.querySelector('h1:first-child')方法,应用css语法找到目标节点。

这里还有一个实现问题:为了提取书本信息提前解析了三个MD文件,是否可以利用现有html直接生成最终目标文件,后续输出最终文件岂不是冗余了?答案是否定的,输出最终文件应当属于Output模块,这不仅是工程中模块划分的需要,还涉及到上下文的问题,另外生成最终文件可能还需要加载其它模块,向外部通知,在这里做这些操作都是不合适的。

之前文章需要了解的解析GLOSSARY的过程也是类似:

function parseGlossary(html) {
    var $ = dom.parse(html);

    var entries = [];

    $('h2').each(function() {
        var $heading = $(this);
        var $next = $heading.next()
        var $p =  $next.is('p')? $next.first() : $next.find('p').first();

        var entry = {};

        entry.name = $heading.text();
        entry.description = $p.text();

        entries.push(entry);
    });

    return entries;
}

在GLOSSARY文件中找出所有的<h2>标签,对每一个h2,找到它毗邻的<p>标签,分别作为namedescription的属性值,保存在entries数组中,这个细节在前面的文章中描述过了。

获取到足够关于书本的信息就可以开始生成最终文件了。

书本内容生成

生成过程其实是相对复杂的,因为相当于把前面获取到的各种数据在这一阶段集中统一处理,整体的阶段可又分为:

  1. 准备阶段

  2. 初始化阶段

  3. 生成阶段

  4. 生成结束

对应调用序列如下:

Output.generate(Generator, resultBook, {root: outputFolder})
  generateBook() // generateBook.js:155
    processOutput()
      preparePlugins
      preparePages
      prepareAssets

      callHook.config
      callHook.init
      generator.onInit(output)

      generateAssets
      generatePages

      isMultilingual
      callHook.finish:before
      generator.onFinish
      callHook.finish

1. 准备阶段

这一阶段主要干三件事:preparePlugins,preparePages,prepareAssets。这是好理解的,系统处理的各种时机需要通知各个外部插件,所以先要收集好外部插件;书本除了文本还可能有图片,样式等各种数据,这些都需要集中处理一下;这里引入的Page从含义上看也不难理解,每一个md文件应当对应每一个Page。

  preparePages
    Parse.parsePagesList
      Parse.parseFilePage
        var page = Page.createForFile(file)
        parsePage(book, page)
          parsePageFromString(page, content)
    output.set('pages', pages)

可以看出单个文件中的内容被用于构建Page对象(3.2.3/lib/parse/parsePageFromString.js):

/**
 * Parse a page, its content and the YAMl header
 *
 * @param {Page} page
 * @return {Page}
 */
function parsePageFromString(page, content) {
    // Parse page YAML
    var parsed = fm(content);

    return page.merge({
        content:    parsed.body,
        attributes: Immutable.fromJS(parsed.attributes),
        dir:        direction(parsed.body)
    });
}

最终调用非常简单,也就是说一个Page对象持有了对应文件的全部内容,用于后续的输出操作。

2. 初始化阶段

这一阶段需要给外部插件一个通知时机,同时应用自身的生成器也需要做一些工作,具体的细节忽略。

callHook.config
callHook.init
generator.onInit(output)

3. 生成阶段

生成阶段包含两部分,生成资源与生成所有页面。gitbook会把非md的文件直接拷贝到生成目录里作为静态资源,这部分与之前的parseIgnore有些关联,但基本逻辑问题不大,所以忽略,除非有特定问题需要追踪。关键看如何生成单个页面的细节:

  generateAssets
  generatePages
    generatePage
      createTemplateEngine
      callPageHook('page:before', output, resultPage)
      parser.preparePage(currentPage.getContent())
        page.prepare(content)
          preparePage //gitbook-markdown/page.js
            annotate
              engine()=annotateEngine
      Templating.render(engine, );
      parser.parsePage(content)
      Templating.postRender(engine, output)
      callPageHook('page', output, currentPage
    generator.onPage(out, resultPage)
      Modifiers.modifyHTML
      Templating.renderFile( // render the theme

这里有一个preparePage的操作,看了下源码及方法的注释,原来是为了避免nunjucks这个库对code标签下的内容进行处理,因此用模板语言在code外围加了个套子{% raw %}{% endraw %}Add templating "raw" to code blocks to avoid nunjucks processing their content.(3.2.3/node_modules/gitbook-markdown/lib/page.js)。然后为了使用模板语言又引入了TemplateEngine这坨东西,最后又用Templating.render将模板去除。这委实有点难受,难道是因为2016年的这些老库还不够强大,所以搞了这坨弯弯绕吗?annotateEngine对应的方法在kramed/lib/annotate/engine.js,而kramed是一个markdown的解析和编译器,看了一下,已经6年没有更新了。

这一阶段的重点毫无疑问是parser.parsePage(output/generatePage.js:55),但其实却异常简单:

Parser.prototype.parsePage = function(content) {
    var page = this.get('page');
    return Promise(page(content));
};

/**
    Parse content of a page

    @param {String} html
    @return {Object}
*/
function parsePage(html) {
    return {
        content: html
    };
}

也就是说把解析后的html作为content返回给了外部。然后在这个时机做页面生成的通知操作。

接着到了这一阶段的末尾generator.onPage,显然生成后的内容不是立即写入磁盘,还有一些其他操作,这里遇到了之前文章提到的Modifiers.modifyHTML,也就是说对匹配词汇表是在生成html之后了。此外还有一个重要操作,对生成的网页文件应用主题:Templating.renderFile。主题(Theme)是另外一个比较庞杂的内容,暂且搁置,但这一部分不能算作是外围功能,因此涉及到交互和体验,而且考虑到为了扩展应用,支持其他人能够参与其中,主题的模板和开发其实是比较重要的。

4. 生成结束

结束阶段在理解上没有什么重点,先是针对多语言进行了判断,然后进行外部通知,主要看generator的工作(3.2.3/lib/output/website/onFinish.js)。

generator.onFinish()
  Templating.renderFile(
  writeFile
    fs.writeFile(filePath, content);

显然在onFinish阶段,真正地写入了文件。

一些结论

内存占用

generatePages那个方法里针对每一个page调用了generatePage(out, page),而这时的page对象已经持有了content, 而content赋值的时机早在准备阶段就执行了。 我们知道早在准备阶段page对象就已经加载了md文件的全部内容,parsePageFromString方法就是专门做这件事,然后在生成阶段的generatePages那个方法里针对每一个page进行了遍历。也就是说,在这时其实已经把书本中全部的markdown原始文本加载到了内存中。

一篇文章的内容可能是非常巨大的,加载原始内容可能会非常占用内存,是否有必要提前把所有内容加载呢? 目前看好像没有必要,在针对输出单个文件的方法generatePage中再去加载md内容似乎也可以。猜测可能是插件的原因,需要插件能够提前获得时机去修改内容,但是已经有一个callPageHook('page:before')时机了,可以在这时候进行修改的,这个问题没有再深入了。如果我们自己重新实现,显然可以有更好的方式。

对象组织关系

整体来看,gitbook中的Book对象其实是书的空壳,叫BookInfo更合适,但Page则是通过parsePage持有了具体内容信息。按照通常面向对象的方法论,Book应该持有多个Page对象,然而实际BookPage没有关系,而是都被Output对象持有:var book = output.getBook();还有output.set('pages', pages);

多语言

关于多语言我有2点想法。

代码组织

通过代码明显感到,多语言的支持带着明显的修补痕迹,随处可见的分支判断对主流程的理解带来困扰。多语言的支持归根结义其实是支持多本书籍的支持。同一个仓库下,可以有多本书,每本书的语言不同,内容不同,但目录结构和段落结构是相同的。因此其不管现状如何,如果要重新实现,则应当表示如下:

class BookManager {
  final books = <Book>[];
}

class Book {
  final String language;
}

这种表示结构其实是最适合多语言书籍的。

内容展示

多语言的多本书按现在gitbook的实现是相互分隔的,但实际的使用过程中会发现,将他们“合”起来展示会更方便更有价值。一般一种书有多个语言都是因为有不同语言的翻译版本,并不是再创作,因此普通人看到一句话可能想了解这句话的原文是什么,这时候如果我们的应用能方便的展示原文或原语言就十分必要。这一点flutter.cn给人以启发:点击一个中文段落,相应的英文段落就可以在中文段落下方展开显示出来,再次点击中文段落则英文段落收起,目前还不知道flutter.cn用的是哪种工具把同一段落的不同语言组织起来的。但是如果对于长段落的文章,这个小功能就不那么方便了,普通的电子书可能会有非常长的段落,如果只是在原段落下追加一个原语言的长段落,用户的体验效果并不会有提升。而且这个功能只是针对双语,如果有三种语言,应该如何展示呢?

一种比较好的体验就是行对行的方式,就像手里同时拿着多支笔书写,文字布局,折行逻辑都保持对应语言的方式不变,唯一变的只是单个语言折行后需要增大竖直方向的起始位置。一旦实现,这对翻译作者而言,将是非常方便的功能。当然,实现起来非常困难,这需要重新实现文本绘制的逻辑,甚至可能需要自定义编辑器。

如果有一种好的多语言展示方式,那一定会是个很棒的功能!

插件系统

gitbook比较强大的一点就是它的插件系统,插件系统不仅帮助应用扩展了功能,而且实现了一种开发生态。然而插件系统的实现与开发语言密切相关,因为要加载可执行代码,一般插件系统的实现都严重依赖开发语言的虚拟执行环境或runtime环境,js自不必说,java的插件系统也异常丰富。但在客户端领域插件系统实现起来很麻烦,安卓需要实现自己的DexClassLoader,加载的对象必须编译依赖app的开发环境,工程配置和代码实现都十分的烦琐;而iOS则根本禁止任何可执行代码的加载。C/C++, Rust, Go这种系统编程语言貌似可以通过动态链接库来实现插件,但是数据依赖是个问题,虽然可以引用头文件,但实现起来很麻烦,所以插件系统更适于一些脚本语言开发。

总之,不是所有系统和开发语言都能实现插件系统,比如最近比较火的Dart语言,语言本身也支持反射与代码动态加载,但基于这种语言的Flutter框架则无法使用这一特性。如果要重新实现gitbook,语言的选择不得不慎重考虑。