NutUI 是一款很是优秀的移动端组件库, GitHub 上已得到 1.8k 的 star,NPM 下载量超过 13k。公司内部已赋能支持 40+ 个项目,外部接入使用项目达到 20+ 个。使用者将会得到以下收益:css
NutUI 的历史已经有 2 年了,2017 年的版本是 v1.0 ,那是一个造轮子和摸索的过程。最开始的组件大都是来源于业务,项目中通过抽离封装作成的组件。html
那仍是一我的人均可以提交组件的年代,当时最朴素的一个想法就是复用,先把业务中组件数量积累下来。好比一个地址配送组件一个同窗开发花了2-3人日,在另外一个购物场景项目中也会有,若是每人都去开发一个必然浪费。最先组件库仅仅是在公司内部使用,那时候的 NutUi是下面这个样子:前端
早期的首页:vue
早期的文档页:node
经过上面两张图咱们能够看到,当初的网站有不少不足乃至成为痛点,具体表现如下几点:webpack
针对这些痛点 2.0 做出以下改变:git
对比 1.0 咱们的组件库有了全面的提高,代码规范简约,可维护性强,组件使用场景考虑充分,功能更加完善。那么这么多的组件,它的官方网站是怎么构建的呢?本文将为你揭秘。github
经过这篇文章后你会了解 NutUI 组件库官方网站的开发流程。同时文章里面咱们详细分析了为何选用 Webpack 插件的这种形式去开发,以及 .md
转 .vue
这种方式的好处。web
在开发这个插件功能中,咱们还用到了一些 Node 操做,和 NPM 依赖包,它和咱们平时的前端开发有很多区别,不一样于页面的交互逻辑,我感受这更有意思,那么我们就一探究竟吧。npm
在文章开始以前,咱们先介绍下这个插件的使用方法,这有助于你理解咱们实现这个功能的思路。
总的来讲,它是一个 Webpack 插件,因此在使用上只需在 Webpack 中配置,具体以下:
{ [ ...new mdtohtml({ entry: "./docs", output: "./sites/doc/page/", template: "./doc-site/template.html", nav: "left", needCode: false, isProduction: isDev }) ]; }
属性说明
参数 | 值 | 说明 |
---|---|---|
entry | string | 须要处理文件的文件夹路径 |
output | string | 处理完成后输出的文件路径 |
template | string | 转换成 .vue 以后须要配置的 HTML 模版路径 |
nav | string | 生成网站导航的位置,目前只支持左边 |
needCode | boolean | 是否须要代码显示工具 |
isProduction | boolean | 是开发环境仍是编译环境 |
咱们 NutUI 的官方网站的需求是什么呢?
了解了具体需求,下面就能够开发功能了,其中最重要的就是选择一条对的路。
这里我选择的是经过 .md
转换成 .vue
,为何呢?
.vue
模版开发正好能够把 .md
转换过来的 HTML 直接嵌套到 template 中。咱们的 Style 不多使用 Class ,而是基于标签选择去作样式处理:
h1,h2,p { color: #333333; } h1 { font-size: 30px; font-weight: 700; margin: 10px 0 20px; }
这样的好处就是咱们在编写文档时不用去关心这些,只须要记住简单的几个 MD 语法就能够写出一篇相对完整的文档。
基本书写格式以下:
首先感谢 AST 让咱们能够实现这个功能,下面咱们先看下它是如何进行转换的,咱们不只仅会开车,还要学会修车。同时它也是目前市面上各类代码转换工具的基础。话很少说,先开车了。
首先咱们举个简单的例子,下面是一段 MD 格式的片断:
## marked 转换 ### marked 转换 \`\`\`js var a = 0; var b = 2; \`\`\`
经过 AST 处理结果,以下:
它处理的结果是一个大的对象,每一个节点都有特定的 Type ,咱们根据这些内容就能够进行处理,从新生成一份咱们想要的格式。
经过上面的图片咱们能够看到: ## 的 Type 为 heading , depth:2
经过这个能够理解为这是一个 h2 标签,而 ``` 的 Type 为 Code , 咱们写在 ```里面的内容都放在 Code 这个对象里面。
它的结构就像一颗大树,有不一样的枝干,我想这也是为何 AST 被称为抽象语法树了。经过处理生成的 AST 对象大致结构你们能够参考下图:
接下来咱们看下详细的转换,例如咱们这个项目里面是须要把它转换为 HTML。
咱们就是经过递归的方式去处理这个对象的结构,把它们转换成想要的文本。 在 NPM 包里面也有不少工具包能够帮我去作处理这个对象,例如:
经过 estraverse 这个插件库去遍历 AST 对象的全部节点:
const res = estraverse.traverse(ast, { enter: function (node, parent) { if (node.type == "heading") return ""; }, leave: function (node, parent) { if (node.type == "code") console.log(node.id.name); } });
说明: 经过 estraverse.traverse
这个方法去遍历整个 AST 对象。 在遍历的过程当中它接受一个 option
,其中有两个属性分别是 enter
和 leave
。它们分别表明监听遍历的进入阶段和离开阶段。一般咱们只须要定义 enter
里面的方法就好,例如上面的例子,当条件知足的时候咱们去执行某些咱们想要的处理方式。
上面仅仅是对代码转换过程的一个简单的模拟,而实际开发过程当中咱们能够借助封装好的工具去完成上面的事情。看到这你们是否是也跃跃欲试尝试着本身去转换一番代码。其实随着你们对 AST 这个方向去研究,就会发现 Vue React Babel ESlint Webpack 中的 Loader、代码对比工具中都有 AST 的影子。AST 这种对文件分析的方式其实就在咱们身边。
在写这个插件之初,我在 NPM 库中找了不少的成熟的包,这里我列举两种实现方案,仅供你们参考。
const parser = require("@babel/parser"); const remark = require("remark"); const guide = require("remark-preset-lint-md-style-guide"); const html = require("remark-html"); getAst = (path) => { // 读取入口文件 const content = fs.readFileSync(path, "utf-8"); remark() .use(guide) .use(html) .process(content, function (err, file) { console.log(String(file)); }); }; getAst("./src/test.md");
转换结果以下:
<h2> mdtoVue 代码转换测试 </h2> <pre> <code class="language-js"> var a = 0; var b = 2; </code> </pre>
使用插件 marked
。
下载
npm i marked -D
使用
const fs = require("fs"); const marked = require("marked"); test = (path) => { const content = fs.readFileSync(path, "utf-8"); const html = marked(content); console.log(html); }; test("./src/test.md");
输出结果:
<h2 id="mdtovue-代码转换测试">mdtoVue 代码转换测试</h2> <pre><code class="language-js">var a = 0; var b = 2;</code></pre>
最终我选择的是方案二 ,由于只须要 marked(content)
就完成了,至于内部是怎么处理的,咱们不用去理会。
等等还没结束,咱们的插件莫非就这么简单?固然不是了,你们喝口水慢慢看下去哈~
选定了转换工具咱们还须要去定制化其中的一些内容,例如咱们须要在里面加个二维码,加个书签目录,通常的网站都会有这类的需求,那么具体如何作到的呢?各位观众请往下看~
这里咱们拿网站中二维码展现这个功能举例:marked
暴露出了一个叫 rendererMd
的属性,咱们经过这个属性就能够处理 marked
转换以后代码的结果。
_that.rendererMd.heading = function (text, level) { const headcode = `<i class="qrcode"><a :href="demourl"> <span>请使用手机扫码体验</span> <img :src="codeurl" alt=""></a> </i>`; const codeHead = `<h1>` + text + headcode + `</h1>`; if (_that.options.hasMarkList && _that.options.needCode) { if (level == 1) { return codeHead; } else if (level == 2) { return maskIdHead; } else { return normal; } } };
从上面的代码中咱们能够了解 rendererMd
是一个对象,其中就是 AST中的 Type ,例如:heading
、code
等等。能够经过一个 fn
,它接受两个参数 text
内容和 level
就是 depth
你们能够看看文章前面的 AST 处理结果。经过改变 marked
转换的内容,咱们能够把每一个组件文档开头二维码的 HTML 结构插入到转换结果中去 ,在把上面的转换的结果在拼接成一个 .vue
文件 就像下面这样:
write(param){ const _that = this; return new Promise((resolve, reject) => { const outPath = path.join(param.outsrc, param.name); const contexts = `<template> <div @click="dsCode"> <div v-if="content" class="layer"> <pre><span class="close-box" @click="closelayer"></span><div v-html="content"></div></pre> </div>` + param.html + (_that.options.hasMarkList ? '<ul class="markList">' + _that.Articlehead + "</ul>" : "") + `<nut-backtop :right="50" :bottom="50"></nut-backtop> </div> </template><script>import root from '../root.js'; export default { mixins:[root] }</script>`; _that.Articlehead = ""; _that.Articleheadcount = 0; fs.writeFile(outPath, contexts, "utf8", (err, res) => {}); }); }
上面的整个过程咱们作了 3 件事:
mark
把 .md
文件 转换成了 HTML 语言,并插入了咱们想要定制化的代码结构。fs.writeFile
生成一个新的 .vue
文件 。这就完成了咱们 .md 转 .vue
转换的第一个功能,把 MD 语言转换成 Vue语言。
下面的内容比较枯燥无味,不过它倒是这个插件中不可或缺的部分,没有它整个转换过程将会变得奇慢无比。
有了上面的基础,接下来咱们就须要借助 Node 去进行文件的读写了,其实做为一个前端开发人员,我对于这块的掌握开始是 0 ,不过凭借着看过代码无数,心中天然有数的看片定律,经过 Node 官方文档的学习,我把 get 到的知识分享给你们,接下来献丑了。
老样子,开车以前先找路,理清思路,事半功倍!
.md
的文件。要求就是无论这个 .md
文件放在什么地方,咱们都须要它找出并解析出来。并且这个速度要快,毕竟时间就是生命。我当时首先考虑的就是一次性抓取路径并存储,再次执行的时候经过 hash 对比添加。具体思路咱们看下面的流程:
这样的好处就是只有当文件有变更的时候才会再次执行转换,若是文件没有变更咱们就没有必要去一遍遍的执行了。代码以下:
const { hashElement } = require("folder-hash"); hashElement(_that.options.entry, { folders: { exclude: [".*", "node_modules", "test_coverage"] }, files: { include: ["*.md"] }, matchBasename: true }).then((res) => {});
它的返回 res
结构以下 :
{ name: ".", hash: "YZOrKDx9LCLd8X39PoFTflXGpRU=", children: [ { name: "examples", hash: "aG8wg8np5SGddTnw1ex74PC9EnM=", children: [ { name: "readme-example1.js", hash: "Xlw8S2iomJWbxOJmmDBnKcauyQ8=" }, { name: "readme-with-callbacks.js", hash: "ybvTHLCQBvWHeKZtGYZK7+6VPUw=" }, { name: "readme-with-promises.js", hash: "43i9tE0kSFyJYd9J2O0nkKC+tmI=" }, { name: "sample.js", hash: "PRTD9nsZw3l73O/w5B2FH2qniFk=" } ] }, { name: "index.js", hash: "kQQWXdgKuGfBf7ND3rxjThTLVNA=" }, { name: "package.json", hash: "w7F0S11l6VefDknvmIy8jmKx+Ng=" }, { name: "test", hash: "H5x0JDoV7dEGxI65e8IsencDZ1A=,", children: [ { name: "parameters.js", hash: "3gCEobqzHGzQiHmCDe5yX8weq7M=" }, { name: "test.js", hash: "kg7p8lbaVf1CPtWLAIvkHkdu1oo=" } ] } ] };
咱们只须要一个递归把整个结构处理成一个文件路径映射就完成了 hash 的提取工做
const fileHash = {}; const disfile = (res, outpath) => { if (res.children) { disfile(res.children, res.name); } fileHash[res.name + outpath] = res.hash; }; disfile(obj, "");
而最终咱们获得的是一个有完整的路径和对应 hash 的对象:
{ "./src/test.md": "3gCEobqzHGzQiHmCDe5yX8weq7M", "./src/tes2.md": "3gCEobqzHGzQiHmCDe5yX8weq7M", "./src/test/tes2.md": "3gCEobqzHGzQiHmCDe5yX8weq7M" }
咱们把这个对象经过 Node 的 fs 去保存到一个 cache 文件中。可使用 fs.writeFile
把文件写进去。
这里的路径主要是方便使用 fs.readFile
去获取文件的内容进行转换。
从流程图中咱们看到有了这一步以后,再次执行的时候,咱们只要对比下文件的 hash 和历史 hash 有没有变化就好了,若是没有变化就能够跳过剩下的过程,这就节约了不少时间,提升了转换效率。
对比 hash 的代码咱们把它放到了一个新的 js 文件。
咱们在写文档的过程当中每每习惯边看边写,这里就须要有实时编译的功能。这个功能看起来很难实际上实现起来却不难:
filelisten() { const _that = this; const watcher = Chokidar.watch(path, { persistent: true, usePolling: true }); const log = console.dir.bind(console); const watchAction = function ({ event, eventPath }) { // 这里进行文件更改后的操做 if (/\.md$/.test(eventPath)) { _that.vueDesWrite(eventPath); } }; watcher .on("change", (path) => watchAction({ event: "change", eventPath: path }) ) .on("unlink", (path) => watchAction({ event: "remove", eventPath: path }) ); }
核心方法就是 Chokidar.watch
当咱们检测到有文件变更了就经过我定义的转换器把文件转换一次。
可是在写这篇文章的时候我脑洞大开,有了一个新的方案:
首先,Chokidar.watch
监听的文件越多,越会影响性能,其次,每次改变一个字符,整个文件就会从新编译一次。若是咱们可以明确 path 只监听当前编辑的文件,那么性能无疑会提高不少。
其次,就是编译转换,这里应该要使用热更新原理,相似于 Vnode 的实现方案只去更新变更的节点。目前我没有发现市面上存在现成的工具包,期待有志之士来实现这样的工具了
全部的功能都实现以后咱们须要把咱们的代码和 Webpack 融合,也就是写成 Webpack 插件的形式。那么 Webpack 的插件开发有什么要注意的呢?
其实插件的开发很是简单,只须要注意要定义一个 Apply 去用来监听 Webpack 的各类事件。
MyPlugin.prototype.apply = function(compiler) {}
这个功能主要经过 Compiler
来实现的, Compiler
就是 Webpack 编译器的引用。经过 Compiler
能够实现对 Webpack 的监听:
Webpack 开始编译时候
apply(compiler) { compiler.plugin("compile", function (params) { console.log("The compiler is starting to compile..."); }); }
Webpack 编译生成最终资源的时候
apply(compiler) { compiler.plugin("emit", function(compilation, callback) { } }
其实 Webpack 在编译的过程当中还会有不少节点,咱们均可以经过这个方法去监听 Webpack 。在调用这个方法的时候还能够经过 Compilation
去对编译的对象引用监听。看到这里,很多人会搞晕 Compiler
和 Compilation
,其实它们很好区分:
Compiler
表明编译器实体,主要就是编译器上的回调事件。Compilation
表明编译过程也就是咱们在编译器中定义的进程例如:
// compilation('编译器'对'编译ing'这个事件的监听) compiler.plugin("compilation", function(compilation) { console.log("The compiler is starting a new compilation..."); // 在compilation事件监听中,咱们能够访问compilation引用,它是一个表明编译过程的对象引用 // 咱们必定要区分compiler和compilation,一个表明编译器实体,另外一个表明编译过程 // optimize('编译过程'对'优化文件'这个事件的监听) compilation.plugin("optimize", function() { console.log("The compilation is starting to optimize files..."); }); });
咱们在下面会有详细介绍,最终在文件的结尾咱们经过
module.export = MyPlugin;
把整个函数导出就能够了。
简单的了解完 Webpack 的插件开发,咱们还须要知道 Webpack 的处理流程,由于咱们的这个插件须要一个合适的时机进入。这里就是在 Webpack 开始执行就去处理,由于咱们转换的产物不是最终的 HTML 而是 Vue 它还须要 Webpack 去处理。
咱们但愿能够整个过程能够按照下面的流程去实现:
这样作的目的就是但愿性能更好,用起来更方便!
因此咱们须要简单的了解下 Webpack 的插件机制,这对咱们整个功能的开发有着重要的意义,当插件出现问题咱们可可以快速的定位。
经过上面这张图咱们看到, MD 转 Vue 必定要是同步执行,这里是一个关键,只有当咱们把全部的 .md
转换成 .vue
才能在让 Webpack 进行下面的工做。
而 Webpack 本质上是一种串行事件流的机制,它的工做流程就是将各个插件串联起来
实现这一切的核心就是 Tapable。
Tapable
是一个相似于 nodejs
的 EventEmitter
的库, 主要是控制钩子函数的发布与订阅。固然,Tapable
提供的 hook
机制比较全面,分为同步和异步两个大类(异步中又区分异步并行和异步串行),而根据事件执行的终止条件的不一样,由衍生出了 Bail/Waterfall/Loop 类型。
Webpack 中许多对象扩展自 Tapable
类。Tapable
类暴露了 tap、tapAsync 和 tapPromise 方法,能够根据钩子的同步/异步方式来选择一个函数注入逻辑。
compiler
对象表明了完整的 Webpack 环境配置。这个对象在启动 Webpack 时被一次性创建,并配置好全部可操做的设置,包括 options
,loader
和 plugin
。当在 Webpack 环境中应用一个插件时,插件将收到此 compiler
对象的引用。可使用 compiler
来访问 Webpack 的主环境。它内部实现大致上以下:
class Compiler extends Tapable { constructor(context) { super(); this.hooks = { /** @type {SyncBailHook<Compilation>} */ shouldEmit: new SyncBailHook(["compilation"]), /** @type {AsyncSeriesHook<Stats>} */ done: new AsyncSeriesHook(["stats"]), /** @type {AsyncSeriesHook<>} */ additionalPass: new AsyncSeriesHook([]), /** @type {AsyncSeriesHook<Compiler>} */ ...... ...... some code }; ...... ...... some code }
能够看到, Compier
继承了 Tapable
, 而且在实例上绑定了一个 hook
对象, 使得 Compier
的实例 compier
能够像这样使用
compiler.hooks.compile.tapAsync( "afterCompile", (compilation, callback) => { console.log("This is an example plugin!"); console.log( "Here’s the `compilation` object which represents a single build of assets:", compilation ); // 使用 webpack 提供的 plugin API 操做构建结果 compilation.addModule(/* ... */); callback(); } );
compiler
对象是 Webpack 的编译器对象,Webpack 的核心就是编译器。
compilation
对象表明了一次资源版本构建。当运行 Webpack 开发环境中间件时,每当检测到一个文件变化,就会建立一个新的 compilation
,从而生成一组新的编译资源。一个 compilation
对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation
对象也提供了不少关键时机的回调,以供插件作自定义处理时选择使用。它内部实现大致上以下:
class Compilation extends Tapable { /** * Creates an instance of Compilation. * @param {Compiler} compiler the compiler which created the compilation */ constructor(compiler) { super(); this.hooks = { /** @type {SyncHook<Module>} */ buildModule: new SyncHook(["module"]), /** @type {SyncHook<Module>} */ rebuildModule: new SyncHook(["module"]), /** @type {SyncHook<Module, Error>} */ failedModule: new SyncHook(["module", "error"]), /** @type {SyncHook<Module>} */ succeedModule: new SyncHook(["module"]), /** @type {SyncHook<Dependency, string>} */ addEntry: new SyncHook(["entry", "name"]), /** @type {SyncHook<Dependency, string, Error>} */ } } }
简单来讲就是 compilation
对象负责生成编译资源。
下面咱们在介绍下 Webpack 中 compiler
和 compilation
一些比较重要的事件钩子。
Compiler:
事件钩子 | 触发时机 | 参数 | 类型 |
---|---|---|---|
entry-option | 初始化 option | - | SyncBailHook |
run | 开始编译 | compiler | AsyncSeriesHook |
compile | 真正开始的编译,在建立 compilation 对象以前 | compilation | SyncHook |
compilation | 生成好了 compilation 对象,能够操做这个对象啦 | compilation | SyncHook |
make | 从 entry 开始递归分析依赖,准备对每一个模块进行 build | compilation | AsyncParallelHook |
after-compile | 编译 build 过程结束 | compilation | AsyncSeriesHook |
emit | 在将内存中 assets 内容写到磁盘文件夹以前 | compilation | AsyncSeriesHook |
after-emit | 在将内存中 assets 内容写到磁盘文件夹以后 | compilation | AsyncSeriesHook |
done | 完成全部的编译过程 | stats | AsyncSeriesHook |
failed | 编译失败的时候 | error | SyncHook |
Compilation:
事件钩子 | 触发时机 | 参数 | 类型 |
---|---|---|---|
normal-module-loader | 普通模块 loader,真正(一个接一个地)加载模块图(graph)中全部模块的函数。 | loaderContext module | SyncHook |
seal | 编译(compilation)中止接收新模块时触发。 | - | SyncHook |
optimize | 优化阶段开始时触发。 | - | SyncHook |
optimize-modules | 模块的优化 | modules | SyncBailHook |
optimize-chunks | 优化 chunk | chunks | SyncBailHook |
additional-assets | 为编译(compilation)建立附加资源(asset)。 | - | AsyncSeriesHook |
optimize-chunk-assets | 优化全部 chunk 资源(asset)。 | chunks | AsyncSeriesHook |
optimize-assets | 优化存储在 compilation.assets 中的全部资源(asset) | assets | AsyncSeriesHook |
能够看到其实就是在 apply 中传入一个 Compiler 实例而后基于该实例注册事件, Compilation 同理, 最后 Webpack 会在各流程执行 call 方法。
其语法是
compileer.hooks.阶段.tap函数('插件名称', (阶段回调参数) => { });
例如:
compiler.hooks.run.tap(pluginName, compilation=>{ console.log('webpack 构建过程开始'); });
咱们在 Node 中运行 Webpack 以后就能够看到:
$webpack ..config webpack .dev.js webpack 构建开始 hash:f12203213123123123 ..... Done in 4.1s~~~~
咱们也能够监听一些 Webpack 定义好的事件,以下。
compiler.plugin('complie', params => { console.log('我是同步钩子') });
总结下上面的内容 Webpack 有不少事件节点,而咱们的插件经过在 apply
中就能够监听 Webpack 的过程。在适当的时机插入进去执行想要的事情。
一路走来,咱们终于把 .md
转换成了 .vue
这种组件的形式,接下来主要是 Vue 方面的开发了,终于到了前端该作的事情了。
咱们的单页面应用离不开路由,那么咱们是怎么管理的呢?
一张分布图,带你了解 NutUI 的结构
上面是咱们的主要目录,经过 MD 转 Vue 把 .md 转换的 .vue
文件所有放到
view 这个文件下。把咱们 引言等 .md
转换放到 page
里面去。其实这么作主要是为了管理员对它们的区分。
那么咱们的 router 怎么管理呢,首先咱们的项目在建立时候就会有一个 json
文件里面主要记录组件的一些信息
"sorts": [ "数据展现", "数据录入", "操做反馈", "导航组件", "布局组件", "基础组件", "业务组件" ], "packages": [ { "name": "Cell", "version": "1.0.0", "sort": "4", "chnName": "列表项", "type": "component", "showDemo": true, "desc": "列表项,可组合成列表", "author": "Frans" } ]
接下来只要把它和咱们定好的目录结合起来就好了。
const routes = []; list.map((item) => { if (item.showDemo === false) return; const pkgName = item.name.toLowerCase(); // 随着转换咱们的路径已经能够肯定了 routes.push({ path: "/" + item.name, components: { default: Index, main: () => import("./view/" + pkgName + ".vue") }, name: item.name }); }); const router = new VueRouter({ routes }); Vue.use(vueg, router, options); export default router;
咱们的网站还有全屏和复制功能,这些对于一个 Vue 项目来讲就在简单不过了,我就不在具体的描述了,只有把每一个组件的说明文档经过 mixins
把它们写在一个 js 文件中而后混入就好了。
文章到这就结束了,本文主要介绍了 MD 格式转 Vue 的实现,最终一键生成官网网页。而咱们对技术领域的探索并无结束,经过总结规划,寻找更快的解决方案,是咱们每个开发者对本身领域的执着。NutUI 在将来也会随着使用者的反馈去修改自身的不足,争取让其用户体验更加的优秀,在前端组件库丰富的时代走出一条本身的道路。前路漫漫,咱们你们一块儿去探究吧!