VillainHR

让微信文档维护也是件幸福的事

微信 文档 前端通信 2019-07-09

以前,微信开放文档是使用文档界的老大哥— gitbook。 用它搭建文档其实并不需要耗费多长时间,主要在于界面维护和自定义主题。Jquery 则是里面主要用来做前端 UI 界面的工具,前端er 应该都清楚 Jquery 有个代号:编程一时爽,重写火葬场。具体是什么意思呢?以前前端的思维模式是根据 事件驱动,导致,一整个项目下来差不多全部都是围绕着 addEventListener 写。而最新的前端框架提供的思维改变为 数据驱动,视图层分离,有点类似 Android/IOS 的组件开发模式 + 生命周期 Hook 类似。

所以,后面在提出文档改版需求时,内心就崩溃了,这怎么维护???因为微信这边,默认的是 code is document,然而,有些 code 你真的不知道什么意思,也希望其他同事写代码时,最起码把一些复杂函数的注释加上,不然,全程 debugger 看代码。

微信 以前的文档项目都是耦合 svrkit + kv 来做服务映射,导致前端文档没有比较靠谱的文档发布体系,导致发布流程非常的玄学,需要本地打包为 zip,运维再解包,svkit 在把指定资源存放在 kv,然后静态资源你上不了 CDN,HTML 也没办法做一些整体优化。后续,为了优化发布和服务映射这一块,直接使用 Node + 统一编译 来解放前端资源对 svrkit 的耦合关系。

tl;dr

  • 使用 vuepress 来做现代文档发布,并且针对大文档下 vuepress build 过慢做一些通用的分模块编译。
  • 通过 vuepress 的 md2html 直出模型,针对自定义 theme 做特殊的优化
  • 基于 markdown-it 插件原理,进行遗留插件的整体迁移和优化。
  • 使用统一编译来优化整体的 Node 静态资源的编译发布,慢慢朝着自动化 CI 前进。

现在文档架构体系

微信小程序开发者文档是直接使用 gitbook 来搞,不过,大家也都知道,gitbook 自己人都觉得没啥可搞的,也就不维护处于 Archive 状态。随着文档内容的增多和其他部门文档的接入,以前那套可能对于后面的业务量来说,有点 顶不住 了。

所以,后续考虑到可维护性和团队的技术栈,就直接采用现有开源的 vuepress 来做文档系统。

Vuepress 的技术背景直接引用一下官方的介绍:

VuePress is composed of two parts: a minimalistic static site generator with a Vue-powered theming system, and a default theme optimized for writing technical documentation. It was created to support the documentation needs of Vue’s own sub projects. Each page generated by VuePress has its own pre-rendered static HTML, providing great loading performance and is SEO-friendly. Once the page is loaded, however, Vue takes over the static content and turns it into a full Single-Page Application (SPA). Additional pages are fetched on demand as the user navigates around the site.

简单来说就是 利用现有的 Vue 的生态,实现了本地 Prerender static HTML 的流程。每个 markdown 都是单独的一个静态 html。

上面的描述,简直完美符合我的预期。不过,一切框架都只是设计很美好,如果社区建设和PR 跟不上也是很让人头疼的事情,vuepress 也有它的 阿喀琉斯之踵

vuepress 的 阿喀琉斯之踵

Vuepress 现在有一个 1.0 的 beta 版本,我一开始也是基于这上面做的。但是,后面出现了一个问题就是 性能。在 beta 版本里面跑多文档构建时,会发现在 renderPage 时,出现内存泄露(memory leak) 导致性能越来越差。(在文末后面会附上相关补充 —> #vuepress 内存泄露渲染过慢

后面切回到 0.11 版本之后,内存泄露的问题就解决了,不过,整体性能依旧是个坎。整体来说,有一下几个痛点:

  • 没有入口文件筛选的接口,vuepress 入口文件查找是利用 globby 做,但是没有暴露接口实现可配置,
  • 指定模块编译没有
  • .vuepress 的查找路径单一
  • 没有差量编译的解决方案

为了让大家更好的理解和学习上述解决方案,下面对 vuepress 的直出模型做一些简单的介绍和分析。

vuepress 的直出模型

Vuepress 和 gitbook 的最原始的出发点的是将 markdown 文件生成 static HTML 文件。最后的 HTML 一定是带上完整内容的 markdown 编译的结果。类比到 Vue 生态中的直出,就是数据 + template 得到对 SEO 友好的文件。

一般的直出方式有两种:SSR 和 prerender。两者本身没有任何区别,只是业务模型的选择。vuepress 使用 prerender 的方式来直出页面,不过,它并不是利用 puppeteer 的方式来做,其一,性能跟不上,其二,ppt 包太大不方便处理。

那 vuepress 的直出模型是怎样的一种方式?

Vuepress 通过在 routes.js 中注册 markdown 组件,通过定义一个动态组件 <Content/> 来生成 MD 内容。而,markdown 组件是通过 webpack-loader 从 md 编译为 Vue.component.

上面就是整个过程,可能看到了会有点懵,下面我们直接从代码里面看。

先在这里放一段代码,下面代码就是用 render 函数,来动态生成 组件。

# Content.js 的 Vue 组件
 render (h, { parent, props, data }) {
    return h(parent.$page.key, {
      class: [props.custom ? 'custom' : '', data.class, data.staticClass],
      style: data.style
    })
  }

# Page.vue 的组件
 <Content :custom="false"/>

上面的 parent.$page.key 是关键点,首先 render 函数中的 h 很重要,在 Vue 中代表的意思是 hyperscript 用来在当前 Vue 组件系统中,找到对应的组件 或者 HTML 标签来渲染。不过,之前你渲染的 component 必须已经注册才行。

那 Vuepress 是如何注册 markdown 组件呢?

Vue 就是 尤老板 写的,你觉得他会不知道?利用 Vue.component API 来注册即可。通过在 routes.js 中 beforeEnter hook 函数中,直接引入对应 markdown 文件。上面 parent.$page.key 对应的就是 Vue.component("v-7c33704c88532", comp.default) 中的 v-7c33704c88532

# routes.js
  {
    name: "v-7c33704c88532",
    path: "/minigame/analysis/",
    component: ThemeLayout,
    beforeEnter: (to, from, next) => {
      import("/Users/villainhr/Desktop/file/code/document-v2/src/minigame/analysis/README.md").then(comp => {
        Vue.component("v-7c33704c88532", comp.default)
        next()
      })
    }
  },

通过 render 的传入函数,来直接执行渲染的 组件 name,这里的 key 则已经在 routes.js 里面绑定好。在 mixin 中,将当前渲染的 $page 解析出来。到这一步,其实已经很好的解释,Vuepress 的直出模型。

在 Vue 层面的 core-render 逻辑就是这么多。在 import 之后,就交给 webpack 来处理了。

md2vue

整个流程是,启动 webpack 编译整个项目,由生成 routes.js 文件,编译对应的 markdown 文件,经由 markdown-loader => vue-loader 生成对应的 vue-component.

# markdown-loader 传递给 vue-loader 的编译 string
 const res = (
    `<template>\n` +
      `<div class="content">${html}</div>\n` +
    `</template>\n` +
    (hoistedTags || []).join('\n')
  )

到这里也就是整个 vuepress 编译的整个直出模型。

另外 vuepress 还提供在 md 中直接使用 vue 组件,这个直接通过 Vue.use 注册全局组件就可以做到。其它的点就不赘述了,不是本文的重点。

vuepress 编译性能优化

前文大致了解了 vp 是如何做到从 md => vue 的整个过程,也就是文档型架构的 prerender 模型。微信开放社区文档体系就是基于 vp 这个 prerender 模型来的,不过,在大型文档系统中,会遇到几个问题,vp 在多数量的 md files 情况下, renderPages 过慢,而本身没有提供一些解决办法。所以,下面主要针对上述问题提出的解决方案。

增量编译之分模块编译

Vuepress 编译规则是你在 cli 中指定编译路径,比如:

vuepress build src

它会寻找 src 下的所有 .md 文件,以及指定的 .vuepress 配置目录。

比如说,会寻找一下约定文件:

src
├── .vuepress
├── doc/**/*.md
├── minigame/**/*.md
└── miniprogram/**/*.md

不过,它的 cli 也就止步于此了。。。所以,为了做指定模块编译,需要修改一下 cli。我们最终要实现的 cli feature 为 vuepress build [base] [file1,file2,dir1,dir2]。举个例子就是:

# 编译单个文件夹
vuepress build src -a src/miniprogram

# 或者指定文件
vuepress build src -a src/miniprogram/README.md

# 或者通配符
vuepress build src -a src/miniprogram/**/README.md

# 设置多个目录
vuepress build src -a src/miniprogram,src/minigame

不过,由于 vuepress 是 SPA 的模式,所有的路由都会注册 routes.js 中,原始 vuepress 的 theme layout 是全局都是 router-link,这样造成的一个问题是,每次都必须编译所有的文件。所以,如果按照上面的编译模式会造成如果跨模块跳转时,会直接命中 404。

为了解决这个问题,微信开放文档中涉及到的对应跨模块 router-link 都需要改动为 a 标签。不过,这里对 微信开放文档 的编译是直接按照产品线模块划分。这里对 layout 的改动只要把 大模块的 list 中 router-link 修改为 a 标签即可。

CDN 资源路径处理

Vuepress 的编译系统是基于 webpack 来的。在 wp 中,我们一般是直接使用 url-loader 或者 file-loader 来设定全局路径的修改。但在 VP 里面,它其实并没有暴露这个接口。源码中,是直接利用 publicPath 属性值,但实际上这个 publicPath 本身的意思是 模块 映射的根路径。

他是直接设置对应的 base

  const options = {
    siteConfig,
    siteData,
    sourceDir,
    outDir,
    publicPath: base,
...
  }

所以,这里参考以前和运维定下来的规则,需要做对应的调整。

Vue 抽象组件

平常的 Vue 组件一般是直接写到业务中,引用对应的 .vue 文件,通过嵌套的 components 属性来传递。也就是 Vue 中介绍的 Single File Components

<template lang="jade">
div
  p {{ greeting }} World!
  OtherComponent

</template>
<script>
import OtherComponent from './OtherComponent.vue'

export default {
  components: {
    OtherComponent
  },
  data () {
    return {
      greeting: 'Hello'
    }
  }
}
</script>

<style lang="stylus" scoped>
p
  font-size 2em

  text-align center

</style>

SFC 通常用来写 UI 组件非常合适。但是,如果遇到一些非 UI 渲染场景的话,可能就不适合了。这就需要使用 Vue 中的 [Functional Components](https://vuejs.org/v2/guide/render-function.html#Functional-Components) ,它不涉及任何 state,它就是一个 render 函数。

比如这样:

Vue.component('my-component', {
  functional: true,
  // Props are optional
  props: {
    // ...
  },
  // To compensate for the lack of an instance,
  // we are now provided a 2nd context argument.
  render: function (createElement, context) {
    // ...
  }
})

这里,介绍一下 hyperscript 方法,同样也可以生成组件,但是并不涉及 UI 而是和 Vue 中的 lifeCycle hook 以及 props 有关。

用法和 SFC 类似,主要操作点在 render 函数,通过 render 中的 1,2 参数,得到一些 hook 和 state 属性,如下代码所示:

export default {
  functional: true,
  render (h, { parent, children }) {
    if (parent._isMounted) {
      return children
    } else {
      parent.$once('hook:mounted', () => {
        parent.$forceUpdate()
      })
    }
  }
}

render || hyperscript

render 中的 h 函数,主要是用来生成 Vue 中内置的 DOM 节点。其实带有语义的命名是 createElement.

render: function (createElement,context) {
  return createElement(App);
}

h 只是一个传统的缩写,因为 React 里面也有这样的,原意是 Hyperscript 。官方解释就是:

Hyperscript itself stands for “script that generates HTML structures”

h 函数的渲染格式可以参考官方文档

createElement(
  // componentName
  'div',
	// componentProps
  {
    // (see details in the next section below)
  },

 // childNodes
  [
    'Some text comes first.',
    createElement('h1', 'A headline'),
    createElement(MyComponent, {
      props: {
        someProp: 'foobar'
      }
    })
  ]
)

特别是第二点 props 属性里面有些还是比较重要的。

{
  class: {
    foo: true,
    bar: false
  },
  style: {
    color: 'red',
    fontSize: '14px'
  },
  on: {
    click: this.clickHandler
  },
  // Scoped slots in the form of
  // { name: props => VNode | Array<VNode> }
  scopedSlots: {
    default: props => createElement('span', props.text)
  },
  slot: 'name-of-slot',
  key: 'myKey',
  ref: 'myRef',
  refInFor: true
}

还有第二个参数 context,它里面包含当前 components 在当前 DOM 结构的所有信息,比如,parentDOM,childNodes,props 等,方便传给 h 函数。

不过,其实你也并不会用这么复杂,上面有些参数你知道就行,需要用的时候再查。ref: render 函数 context API 查询

API 简单了解之后,我们来具体看一下实际应用。

render 之实际应用

在微信社区中,会用到两个比较重要的组件,一个是 ClientOnly,一个是 Content

  • ClientOnly: 通过 hook 控制 slots 组件在 mounted 之后再渲染,将 clientRender 组件可以直接写在 SSR 中。
  • Content: 在 routes 里面注册 md 组件内容,通过 parent.key 找到对应节点并渲染。

上面可能就 ClientOnly 对大家比较有价值,这里会重点说一下。ClientOnly 的逻辑是通过判断 parent 的状态,当处于 mounted 之后,才会开始渲染 children 组件。

# ClientOnly
export default {
  functional: true,
  render (h, { parent, children }) {
    if (parent._isMounted) {
      return children
    } else {
      parent.$once('hook:mounted', () => {
        parent.$forceUpdate()
      })
    }
  }
}

具体使用 ClientOnly 组件的方式为:

 <ClientOnly>
            <Sidebar />
</ClientOnly>

这样 sidebar 组件就属于一个 ClientRender 组件,只会在 ClientRender 时触发,这样做的目的一是为了方便一些无法 SSR 的组件,另外一方面是为了减少 SSR 渲染损耗,加快渲染时间。

Content 我再附带介绍一下。为了方便 md2vue,这里需要有一个组件来承载 markdown 的内容。这里有一个特殊逻辑,在 render 该组件之前,会通过 router 中的 beforeEnter hook 注册一些全局 md 组件,这样的话,只要通过 parent.$page.key 就能直接找到通过 vue.component 注册的组件内容。

# Content.js 的 Vue 组件
 render (h, { parent, props, data }) {
    return h(parent.$page.key, {
      class: [props.custom ? 'custom' : '', data.class, data.staticClass],
      style: data.style
    })
  }

在 routes.js 中注册组件。

 {
    name: "v-d97f586d2342",
    path: "/doc/offiaccount/en/xxx.html",
    component: ThemeLayout,
    beforeEnter: (to, from, next) => {
      import("xxx.md").then(comp => {
        Vue.component("v-d97f586d2342", comp.default)
        next()
      })
    }
  },

markdownIt2html 插件化路程

微信文档的所有内容都是基于 markdown 来的,md2html 的工具使用了 markdown-it 这个 plugin 化的开源库。当然,还有比这个库,更多 star 的库比如 markup,但是,其提供的自定义功能太少,后面综合考虑就直接使用 markdown-it 来搞。

这里先简单介绍一下 markdown-it.

Markdown-it 里面的解析流 基本是从 core=> rule=>block => inline 这么走的,最终会由 renderer 方法渲染出最终结果。或者,你也可以通过 parse 方法得到最终生成的 token tree. parse 在构建 markdown-it 生态插件来说,其实不太重要,顶多是调试用,引用官方一段话就是:

You should not call this method directly, until you write custom renderer (for example, to produce AST).

根据上面的流程,MI (markdownIt) 的主要部分可以分为:

  • Ruler: 用来将纯字符串编译为 token tree 的过程
  • Renderer: 用来将 token tree 编译为 html 的模块
  • Parse: 用来得到当前 tokens 的方法

当然,如果你看了官方文档,上面所述的 模块概念可能不止上面这一点。不过,我这里推崇的是,先能快速理解核心,然后其他周边概念等你熟悉了之后,再去理解消化,应该就事半功倍了。

另外,还想吐槽一下,markdown-it 的文档。真的!首页 README.md 写这么多,感觉都很重要,结果看一遍之后,想找的没找到。后面找了半天,才找到 markdown-it 最值得看的文档,markdown-it 框架原理。(这是我认为最值得看的,可能大家不这么认为,这里就当我瞎逼逼就行)

如果你要写一个 markdown-it plugin, 中间肯定会涉及到 token 的查找和命名。这里有个必备的 debug 网站 markdown-it demo ,直接打开 debug 对照看就行。

为啥写 plugin?

在动手写一个 plugin 之前,我们需要明白一件事,我们做这件事的需求是啥? 其实看 markdown-it 中的官方文档推荐内容和源码,就可以了解到,上面所有的这些 rule,inline ruleXX 以及 render 都是一个一个 plugin.

# 官方给的架构图
core
    core.rule1 (normalize)
    ...
    core.ruleX

    block
        block.rule1 (blockquote)
        ...
        block.ruleX

    core.ruleX1 (intermediate rule that applies on block tokens, nothing yet)
    ...
    core.ruleXX

    inline (applied to each block token with "inline" type)
        inline.rule1 (text)
        ...
        inline.ruleX

    core.ruleYY (applies to all tokens)
    ... (abbreviation, footnote, typographer, linkifier)

只是有一些是常用的,markdown-it 就手动加进去了,比如下面代码。最终的结果就是搞出了初始化能用的 markdown-it。

var _rules = [
  [ 'normalize',      require('./rules_core/normalize')      ],
  [ 'block',          require('./rules_core/block')          ],
  [ 'inline',         require('./rules_core/inline')         ],
  [ 'linkify',        require('./rules_core/linkify')        ],
  [ 'replacements',   require('./rules_core/replacements')   ],
  [ 'smartquotes',    require('./rules_core/smartquotes')    ]
];

所以,假定你这边有一下需求,那么你可以考虑手写一个 plugin:

  • 修改 anchor 的 target 属性
  • 需要将已有的 link render 出来的 a 标签加上自定义化的结构
  • 有自己独特的解析规则,比如 {% fun(xx) %}

这里写一个 plugin 也是分难度的,从 覆盖更新 plugin 到 重写一个 plugin,难度是越来越大。

如果你只是想修改一个 anchor 的 target 属性,可以直接覆盖更新 link_open 的 rules 。

 md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
  //... dosth
	return defaultRender(tokens, idx, options, env, self)
  }

如果你要写一个完整的 plugin,那么你需要分析改语法是 inline 还是 block 的 rule,以及,在 render 时,需要渲染成什么样的结构等等。

到了这一步,你就需要了解 token 长啥样,怎么去操作 token,操作 src 的字符串内容。如果真的要写的话,推荐先了解一下 状态机原理,因为 markdown-it 里的 token rule 都是按照这个原则来写的。一段字符只对应一个状态,随着读取字符串的进度,其会从一个状态变为另外一个状态。

上面其实就是 编译原理 里面非常重要的一环,后面如果有时间,在开坑写吧。

具体例子,在 link 解析规则中,一个完整的解析过程,就是字符串内容的读取过程。

// 获得当前 rule 的上下文
 var attrs,
      code,
      label,
      labelEnd,
      labelStart,
      pos,
      res,
      ref,
      title,
      token,
      href = '',
      oldPos = state.pos,
      max = state.posMax,
      start = state.pos,
      parseReference = true;

// 开始读取字符
  if (state.src.charCodeAt(state.pos) !== 0x5B/* [ */) { return false; }

  labelStart = state.pos + 1;
  labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, true);

  // parser failed to find ']', so it's not a valid link
  if (labelEnd < 0) { return false; }

  pos = labelEnd + 1;

//... 其它读写规则


// 读写完毕后,将解析得到的 token 放到 state 中,并记录当前读取字符的位置
  if (!silent) {
    state.pos = labelStart;
    state.posMax = labelEnd;

    token        = state.push('link_open', 'a', 1);
    token.attrs  = attrs = [ [ 'href', href ] ];
    if (title) {
      attrs.push([ 'title', title ]);
    }

    state.md.inline.tokenize(state);

    token        = state.push('link_close', 'a', -1);
  }

  state.pos = pos;
  state.posMax = max;
  return true;

一般而言,当你需要添加一个单一的 state 规则外,还需要配套有对应的解析规则,也就是 ruler。比如说,上面你定义的 state 的 token type 是 link_close,那么你应该对应还需要具备一个 link_close 的 ruler 解析规则,而这个解析规则就是对应你的 token 字段解析来做,最终返回的是一个符合 HTML 规则的字符串。比如,下面的 code_inline 解析 ruler.

default_rules.code_inline = function (tokens, idx, options, env, slf) {
  var token = tokens[idx];

  return  '<code' + slf.renderAttrs(token) + '>' +
          escapeHtml(tokens[idx].content) +
          '</code>';
};

在迁移过程中,会遇到 markdown-it plugin 的迁移工作,主要是一些 template 的快捷操作,比如,smartlink, autolink 等。目的就是为了减少 js 注释代码的冗余。

附录

vuepress 内存泄露渲染过慢

通过在 vuepress beta 版本里面打点得到性能统计数据。

# analyse code
 try {
      console.time('start');
      html = await this.renderer.renderToString(context)
      console.timeEnd('start');
    } catch (e) {
      console.error(logger.error(chalk.red(`Error rendering ${pagePath}:`), false))
      throw e
    }

得到的结果是,渲染 HTML 文件时,会越来越慢,从原来一开始的 80ms 慢慢的上涨到 1000+ms。

plate-message/templateMessage.getTemplateLibraryList.htmlstart: 808.080ms
Rendering page: /miniprogram/dev/api-backend/open-api/template-message/templateMessage.send.htmlstart: 789.571ms
Rendering page: /miniprogram/dev/api-backend/open-api/template-message/templateMessage.getTemplateList.htmlstart: 807.422ms
Rendering page: /miniprogram/dev/api-backend/open-api/uniform-message/uniformMessage.send.htmlstart: 802.172ms
Rendering page: /miniprogram/dev/api-backend/open-api/updatable-message/updatableMessage.createActivityId.htmlstart: 812.487ms
Rendering page: /miniprogram/dev/api-backend/summary.part.htmlstart: 768.504ms
Rendering page: /miniprogram/dev/api-backend/open-api/updatable-message/updatableMessage.setUpdatableMsg.htmlstart: 834.227ms
Rendering page: /miniprogram/dev/api-backend/open-api/user-info/auth.getPaidUnionId.htmlstart: 848.840ms
Rendering page: /miniprogram/dev/api/ad/InterstitialAd.offError.htmlstart: 783.710ms

通过使用 headdump 的 nodejs 内存分析,主要是 vue app.js 里面有些逻辑没处理好,特别是像 Vue.mixin API,会在调用时,每次都生成一个实例,而不是引用拷贝。另外就是检查一些列 SSR 的生命周期的 hook 函数:

  • beforeEach
  • beforeRouteUpdate
  • beforeEnter
  • beforeRouteEnter
  • beforeResolve
  • afterEach
  • beforeRouteEnter

除了上面的一些案例,还有一些通常的 leak memory cases, 比如,window.bar 全局变量,闭包函数等。

原文链接: https://www.villianhr.com/2019/07/09/让微信文档维护也是件幸福的事