读 VuePress(四)插件系统的设计

前言

从 9 月份开始,vuepress 源码进行了重新设计和拆分。先是开了个 next 分支,后来又合并到 master 分支,为即将发布的 1.x 版本做准备。

最主要的变化是:大部分的全局功能都被拆分成了插件的形式,以可插拔的方式来支撑 vuepress 的运作,这一点很像 webpack。

具体架构如下:


架构

从图中我们可以看出,vuepress 被划分成了两个部分:前端部分和服务端(Node.js)部分。

  1. 前端部分
  1. 服务端部分
  • 2.1 构建流程,这部分暴露出了 webpackwebpack-dev-servermarkdown-it动态模块的配置。
  • 2.2 用户文件,包括配置文件和 markdown 文件(文档),这些文件相当于站点的元数据。
  • 2.3 主题,这部分被划分为配置文件和布局组件。vuepress 提供了一份默认的主题。

在这个架构中,主题即插件。也就是说使用(开发)一个主题和使用(开发)一个插件的方式几乎一致。

  • 2.4 插件 API,这是今天我们重点介绍的部分,特别是插件机制的核心实现。

根据这个架构,vuepress 的插件便可以做很多事情了。具体用法可以参考文档

内部插件和官方插件

让我们先来了解一下 vuepress 的内部插件和官方插件都有些什么,借助插件机制做了哪些事情。

内部插件

  1. 全局增强:默认用来实现全局应用增强的逻辑。
    它使用 enhanceAppFiles 指定增强全局应用和主题的文件路径。凭着这个,vuepress 就能准确地找到你全局增强或是主题的文件所在地。

  2. 布局组件:默认提供的布局组件。
    它使用 clientDynamicModules 来实现动态引入布局相关的组件。

  3. 页面组件:默认提供的页面组件(布局组件的子组件)。
    它使用 clientDynamicModules 来实现动态引入页面相关的组件。

  4. 根组件混入:默认往根组件混入的逻辑。
    它使用 clientDynamicModules 来实现动态混入元信息。包括根组件的标题、语言等。

  5. 路由:默认的生成路由逻辑。
    它使用 clientDynamicModules 来实现动态注册路由。我们的 markdown 文件在转换成 vue 组件后就是通过它自动注册到 vue-router 的。

  6. 站点数据:默认的生成站点数据逻辑。
    它使用 clientDynamicModules 来实现生成全局站点数据。我们在页面里拿到的全局计算属性 $site 就是这样来的。

  7. 模块化转化:将 cmd 代码转成 esm 代码的逻辑。
    还是用 clientDynamicModules 来实现将 cmd 代码转成 esm 代码。主要是因为 ClientComputedMixin 这个类前后端代码都要使用。

  8. 样式增强
    全局样式增强。使用 enhanceAppFiles 和 ready 钩子来实现(主题样式+用户样式+父主题样式)。

  9. 样式覆盖
    全局样式覆盖,使用 ready 钩子来实现,覆盖 config.styl 和父主题的 palette。

  10. dataBlock数据注入
    解析 blockType=data 的数据,使用 chainWebpack 和 enhanceAppFiles 来实现,对 blockType=data 类型的数据注入到 markdown 生成的 vue 组件里去,每个组件可以访问自己的 $dataBlock 属性拿到。

官方插件

  1. 活动的标题链接
    它会在用户滚动页面时自动转变侧边栏的高亮标题。
    它使用了 clientRootMixin 和 define 往根组件混入了滚动逻辑:监听 onScroll 事件,获取所有锚点元素并根据滚动距离计算出高亮的锚点。

  2. 回到顶部
    使用了 enhanceAppFiles 和 globalUIComponents 注册了一个全局组件:点击后可以滚动到页面顶部。

  3. 博客

    • 3.1 使用 extendPageData 创建标签页和目录页
    • 3.2 使用 ready、clientDynamicModules、enhanceAppFile 创建页面元数据。
  4. ga
    谷歌分析站点的库。使用了 define 和 enhanceAppFiles 初始化了 ga。

  5. 国际化(废弃)
    可以让你的站点拥有切换语言的能力。使用了 enhanceAppFiles 和 additionalPages 注册了个 I18n 布局组件。

  6. 文档的最近更新时间
    可以让每个文档页下面显示最近的 git 提交时间。使用 extendPageData 拓展了 $page 的 lastUpdated 属性。

  7. 图片预览
    集成了 medium-zoom。使用了 define、clientRootMixin 往根组件里混入了 zoom 的初始化和更新逻辑。

  8. 分页
    让共享侧边菜单栏的文档拥有分页切换的能力。使用了 enhanceAppFiles 定义了所有页面的索引和顺序。ready 定义了分页的规则如排序规则等、clientDynamicModules 生成动态模块给前端代码使用。

  9. pwa
    集成 service-worker 功能
    - 9.1. 使用 ready 开启 serviceWorker 选项
    - 9.2. 使用 alias 实现用 vue 当事件通道
    - 9.3. 使用 define、globalUIComponents 注册更新 PWA 应用按钮组件
    - 9.4. 使用 enhanceAppFiles 注入 register-service-worker 的初始化和更新逻辑
    - 9.5. 使用 generated 通过 workbox-build 完成 sw 功能

  10. 注册全局 Vue 组件
    使用 enhanceAppFiles 把一个文件夹中的 vue 组件文件都注册好。

  11. 搜索框
    使用 alias 和 define 让搜索框可以动态引入。

  12. 进度条
    使用 clientRootMixin 和 enhanceAppFiles 集成 nprogress。

lerna

项目管理上,插件机制也使得原来的一个大项目拆成了 1 + N 的形式,package.json 也变得多了起来,为了管理这种项目,vuepress 引入了 lerna。

关于 lerna 的知识,有兴趣的读者可以参考:lerna管理前端packages的最佳实践

核心实现


当一系列插件要使用时,需要通过 PluginAPI 和组成它的各种 Option 来实现。

整体流程大致如下:


这里我划分成了两个阶段,用虚线分隔,一个是调用前阶段,一个是调用后阶段。插件们被调用前,是会被载入以及注册的,之后化整为零,映射成若干个 Option 实例。

源码

  1. PluginAPI 类,这部分代码包含了插件机制中的注册调用实现。
    • 构造(constructor):初始化选项、插件上下文、插件队列(可注册插件列表)、日志插件、初始化标志位、插件解析器属性,然后把选项们都装载进来(initializeOptions)。这里会把一个插件映射成若干个 Option 实例。
      例如,一个插件只有 ready、chainWebpack、additionalPages 三个选项,则会得到三个 Option 实例。


    • 使用(use),需要 _initialized 标志为 false 才能调用,用于确认哪些插件是可以被注册的:
      • 对于非对象类型的插件,会调用 normalizePlugin 方法将之转成对象
        • 期间会调用 _pluginResolver(ModuleResolver 实例) 来解析模块
          • 用于解析模块的 ModuleResolver 类,工作原理类似 webpack 的模块解析。源码
          • 这里值得一提的是 resolve 方法,它支持从非字符串包、npm 包、绝对路径、相对路径中解析模块。


          • 相对路径的模块先使用 node 的原生 path.resolve 方法解析得到绝对路径,然后交给解析绝对路径模块的方法处理。
          • 绝对路径、非字符串包和 npm 包会用通用模块 CommonModule 表示。




          • 通用模块有四个属性:entry、shortcut、name、fromDep。
        • 还会调用 flattenPlugin 拍平插件,主要是获取配置。
          • 如果传入配置是函数,则返回调用后的结果,入参为插件选项、插件上下文、PluginAPI 实例。


          • 传入的配置是对象,则返回一个拷贝后的对象。


      • 非 multiple 的插件,会根据插件名字去重。


      • 标准化后的插件,会加入到插件队列中去。
      • 最后,存在插件中使用插件的情况时,会调用 useByPluginsConfig 来实现。


        • 这里面的 normalizePluginsConfig 会将配置格式化成[[p1]、[p2]的形式]。
    • 初始化(initialize):先将 _initialized 标志位置为 true,然后注册所有可用的插件。
      • 在初始化之前,内部插件的使用,会先于用户的插件。


      • 注册(applyPlugin):到这里,插件已经被拆分成细化的选项,按照信息类(pluginName、shortcut)、钩子类(ready、compiled 等)、其他类(chainWebpack、chainMarkdown、enhanceAppFiles 等)按顺序链式注册(registerOption)。




        此时,一个 Option 实例中已经承载了若干个插件的逻辑了。

    • enabledPlugins 和 disabledPlugins 两个只读属性可以取启用(可注册)或禁用(不可注册)的插件列表。
    • getOption 可以取具体的一个选项实例,applyAsyncOption 和 applySyncOption 分别应用异步选项和同步选项中的逻辑(回调函数)。

选项和异步选项,插件的本体

  1. Option 类
    - 每个实例初始化 key(选项标识) 和 items(这个选项所对应的函数们) 属性。

    • 重要方法:syncApply(也叫 apply),对之前保存在实例中的 items 遍历调用 add 方法,如果 item 中的值是函数,则执行之取其返回值。
    • 在插件应用选项时如果匹配成功,会调用 add 方法将选项映射成 1-n 个对象推入 items 属性里。


    • 除了 add 还有 delete 和 clear 方法,不做赘述。(增删清)
    • 另外有 values、entries 和 appliedValues 三个只读属性,用于获取值、实体、已应用的值。
    • 管道方法(pipeline),它将实例的 values 属性柯里化成一个组合函数,依次执行。


  2. AsyncOption 类

    • asyncApply 异步版syncApply,调用函数的时候使用了 await。
    • parallelApply 如果说 pipeline 是串行,它就是并行:使用了 Promise.all


    • pipeline 同理,调用函数的时候使用了 await。

特殊选项

  1. EnhanceAppFilesOption、ClientDynamicModulesOption、GlobalUIComponentsOption、DefineOption、AliasOption 类
    • AliasOption
      • 在创建 webpack 配置的时候调用
      • 重写 apply 方法:先调用 syncApply,然后将 appliedValues 取出,设置为 webpack 的 alias
    • ClientDynamicModulesOption
      • 在 prepare 阶段调用
      • 重写 apply 方法:从 appliedItems 取出应用的插件信息,遍历写入文件以待使用
    • DefineOption
      • 类似 AliasOption,只不过是设置 webpack 的全局变量
      • 最后在 injections 插件(DefinePlugin)触发时收集选项将 define 注入进去
    • EnhanceAppFilesOption
      • 在 prepare 阶段调用
      • 重写 apply 方法:从 appliedItems 取出插件信息,生成引入模块或者注册组件的代码文件
    • GlobalUIComponentsOption
      • 类似 ClientDynamicModulesOption,写全局 ui 组件文件

调用函数型 Option 时机

  1. extendCli
    创建 cli 命令时
  2. chainMarkdown 和 extendMarkdown
    创建 MarkdownIt 实例时
  3. additionalPages
    解析完所有页面后
    3、extendPageData
    additionalPages 执行完之后,依赖 additionalPages 执行完的结果
  4. ready
    紧跟 additionalPages 之后
  5. clientDynamicModules、enhanceAppFiles、globalUIComponents
    紧跟 ready 之后
  6. define、alias
    创建公共 webpack 配置后
  7. chainWebpack
    创建 dev webpack 配置后、创建 build webpack 配置后
  8. beforeDevServer
    webpack-dev-server 的 before 选项执行后
  9. afterDevServer
    webpack-dev-server 的 after 选项执行后
  10. generated
    build 完成后
  11. updated
    文件更新后
  12. clientRootMixin
    clientDynamicModules 选项执行时

编写一个 vuepress 插件

我也写了一个小插件,它可以将你的 vuepress 站点下载成一个 pdf 文件:vuepress-plugin-export-site

源码

  1. 使用 ready 选项
  2. 借助 puppeteer 和 easy-pdf-merge 实现:从上下文中拿到路由信息,然后使用 puppeteer 遍历访问并下载,最后合并成一个大 PDF。
    • 因为需要下载 chromium,所以国内网络受限。我们换成了 puppeteer-cn。
    • easy-pdf-merge 如果在 windows 下运行需要指定 jar 环境变量。

后记

我们熟悉的 webpack、vue 也有插件系统,它们都有两个共同的特点:

  1. 提供一个功能扩展点,让插件能够去扩展它。
  2. 提供一个功能注册功能,让插件注册进来。

其实插件机制也可以看做设计模式的一种体现:抽离出变化的部分,保留不变的部分。这些变化的部分,便可以称之为插件。

在我们造轮子的时候,如果轮子的功能越来越多,代码越来越臃肿的话,引入插件机制会让后续的开发更加灵活。

最后,帮插件机制的开发者真山同学宣传一下,届时会有更加精彩的 vuepress 分享:


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,254评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,875评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,682评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,896评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,015评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,152评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,208评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,962评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,388评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,700评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,867评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,551评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,186评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,901评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,689评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,757评论 2 351

推荐阅读更多精彩内容

  • Vue 实例 属性和方法 每个 Vue 实例都会代理其 data 对象里所有的属性:var data = { a:...
    云之外阅读 2,204评论 0 6
  • 第一部分 HTML&CSS整理答案 1. 什么是HTML5? 答:HTML5是最新的HTML标准。 注意:讲述HT...
    kismetajun阅读 27,456评论 1 45
  • GitChat技术杂谈 前言 本文较长,为了节省你的阅读时间,在文前列写作思路如下: 什么是 webpack,它要...
    萧玄辞阅读 12,682评论 7 110
  • 因新工作主要负责微信小程序这一块,最近的重心就移到这一块,该博客是对微信小程序整体的整理归纳以及标明一些细节点,初...
    majun00阅读 7,332评论 0 9
  • 今日任务1500,未完成。 比学习:学习别人的长处,弥补自己的短处。 比付出:拨打回访。 比改变:不要纠结一些小事...
    姜博士眼镜任亚茹1885331阅读 166评论 0 0