我是如何閱讀 md-loader 源碼并優(yōu)化它的?
背景
相信很多同學(xué)在學(xué)習(xí) webpack 的時(shí)候,對(duì) loader 的概念應(yīng)該有所了解,它用于模塊源碼的轉(zhuǎn)換,描述了 webpack 如何處理非 JavaScript 模塊,常見(jiàn)的有 css-loader、babel-loader、url-loader、vue-loader 等。
大部分 loader 已經(jīng)滿(mǎn)足我們的日常開(kāi)發(fā)需求,不過(guò)有些時(shí)候我們?nèi)匀恍枰远x loader。為了讓你了解如何開(kāi)發(fā)一個(gè) webpack loader,我決定從 ElementUI 組件庫(kù)的 md-loader 入手,帶你去了解其中的實(shí)現(xiàn)原理,以及在它的基礎(chǔ)上,如何做進(jìn)一步的優(yōu)化。
文檔的設(shè)計(jì)
對(duì)于一個(gè)組件的文檔,首先我們要考慮的是如何更好地展現(xiàn)組件的功能,其次要考慮的是如何更方便地做文檔維護(hù)。
想要編寫(xiě)好一個(gè)組件的文檔,需要做好以下幾點(diǎn):
1.功能描述
對(duì)組件功能、使用場(chǎng)景做詳細(xì)的描述。
2.demo 演示
直觀地讓用戶(hù)感受到組件的功能,并且能展示 demo 對(duì)應(yīng)的代碼。
3.接口說(shuō)明
寫(xiě)清楚組件支持的屬性、方法、事件等。
那么,如何方便地維護(hù)文檔呢?
ElementUI 組件庫(kù)的文檔也是一個(gè) Vue 項(xiàng)目,組件的文檔頁(yè)面是單獨(dú)的路由視圖,而文檔是用 markdown 文件來(lái)描述的,在文檔內(nèi)部,不僅包含了對(duì)組件的功能以及接口的描述,還可以通過(guò)編寫(xiě) vue 組件的方式直接編寫(xiě)組件的 demo,這種方式對(duì)于組件文檔的維護(hù)還是比較方便的。
以 ElementUI 組件庫(kù) Alter 組件為例:
##?Alert?警告
用于頁(yè)面中展示重要的提示信息。
###?基本用法
頁(yè)面中的非浮層元素,不會(huì)自動(dòng)消失。
:::demo?Alert?組件提供四種主題,由`type`屬性指定,默認(rèn)值為`info`。
```html
??????title="成功提示的文案"
????type="success">
??
??????title="消息提示的文案"
????type="info">
??
??????title="警告提示的文案"
????type="warning">
??
??????title="錯(cuò)誤提示的文案"
????type="error">
??
```
:::
最終它在頁(yè)面上的展示效果如下:

可以看到,組件的路由視圖對(duì)應(yīng)的是一個(gè) markdown 文件,而在我們通常的認(rèn)知中,Vue 的路由視圖應(yīng)該對(duì)應(yīng)的是一個(gè) Vue 組件。
在 ElementUI 內(nèi)部,是通過(guò) require.ensure 的方式去加載一個(gè) .md 文件,它的返回值會(huì)作為路由視圖對(duì)應(yīng)的異步組件。
const?LOAD_DOCS_MAP?=?{
??'zh-CN':?path?=>?{
????return?r?=>?require.ensure([],?()?=>
??????r(require(`./docs/zh-CN${path}.md`)),
????'zh-CN');
??},
??//?...
}??
因此內(nèi)部就必須要把 markdown 文件轉(zhuǎn)換一個(gè) Vue 組件,我們可以借助 webpack loader 來(lái)實(shí)現(xiàn)這一需求。
自定義 md-loader
首先,在 webpack 的配置規(guī)則中,需要指定 .md 文件應(yīng)用的 loader:
{
??test:?/\.md$/,
??use:?[
????{
??????loader:?'vue-loader',
??????options:?{
????????compilerOptions:?{
??????????preserveWhitespace:?false
????????}
??????}
????},
????{
??????loader:?path.resolve(__dirname,?'./md-loader/index.js')
????}
??]
}
接下來(lái),我們就來(lái)分析 md-loader 的源碼實(shí)現(xiàn):
const?{
??stripScript,
??stripTemplate,
??genInlineComponentText
}?=?require('./util');
const?md?=?require('./config');
module.exports?=?function(source)?{
??const?content?=?md.render(source);
??const?startTag?=?'';
??const?endTagLen?=?endTag.length;
??let?componenetsString?=?'';
??let?id?=?0;?//?demo?的?id
??let?output?=?[];?//?輸出的內(nèi)容
??let?start?=?0;?//?字符串開(kāi)始位置
??let?commentStart?=?content.indexOf(startTag);
??let?commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??while?(commentStart?!==?-1?&&?commentEnd?!==?-1)?{
????output.push(content.slice(start,?commentStart));
????const?commentContent?=?content.slice(commentStart?+?startTagLen,?commentEnd);
????const?html?=?stripTemplate(commentContent);
????const?script?=?stripScript(commentContent);
????let?demoComponentContent?=?genInlineComponentText(html,?script);
????const?demoComponentName?=?`element-demo${id}`;
????output.push(`<${demoComponentName}?/>`);
????componenetsString?+=?`${JSON.stringify(demoComponentName)}:?${demoComponentContent},`;
????//?重新計(jì)算下一次的位置
????id++;
????start?=?commentEnd?+?endTagLen;
????commentStart?=?content.indexOf(startTag,?start);
????commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??}
??//?僅允許在?demo?不存在時(shí),才可以在?Markdown?中寫(xiě)?script?標(biāo)簽
??let?pageScript?=?'';
??if?(componenetsString)?{
????pageScript?=?``;
??}?else?if?(content.indexOf('')?+?''.length;
????pageScript?=?content.slice(0,?start);
??}
??output.push(content.slice(start));
??return?`
????
??????
????????${output.join('')}
??????
????
????${pageScript}
??`;
};
md-loader 要做的事情,就是把 markdown 語(yǔ)法的字符串,轉(zhuǎn)成 Vue 組件字符串。轉(zhuǎn)換的過(guò)程可以拆成三個(gè)步驟:markdown 渲染,demo 子組件的處理,構(gòu)造完整的組件。接下來(lái)我們就來(lái)依次分析這三個(gè)步驟。
markdown 渲染
markdown 文件內(nèi)容會(huì)渲染生成對(duì)應(yīng)的 HTML,它是通過(guò)下面這段代碼完成的:
const?md?=?require('./config');
module.exports?=?function(source)?{
??const?content?=?md.render(source);
}
而 md 對(duì)象的來(lái)源如下:
const?Config?=?require('markdown-it-chain');
const?anchorPlugin?=?require('markdown-it-anchor');
const?slugify?=?require('transliteration').slugify;
const?containers?=?require('./containers');
const?overWriteFenceRule?=?require('./fence');
const?config?=?new?Config();
config.options.html(true).end()
??.plugin('anchor').use(anchorPlugin,?[
????{
??????level:?2,
??????slugify:?slugify,
??????permalink:?true,
??????permalinkBefore:?true
????}
??]).end()
??.plugin('containers').use(containers).end();
const?md?=?config.toMd();
overWriteFenceRule(md);
module.exports?=?md;
首先實(shí)例化了 config 對(duì)象,它依賴(lài)于 markdown-it-chain,通過(guò) webpack chain 的鏈?zhǔn)?API,配置了 markdown-it 的插件。而 md 對(duì)象指向的就是 markdown-it 的實(shí)例。
markdown-it 的實(shí)例提供了很多 API,具體可以參考它的官網(wǎng)文檔。其中 md.render 就是把 markdown 字符串渲染生成 HTML。
不過(guò)我們注意到,組件文檔使用了一些非標(biāo)準(zhǔn)的 markdown 語(yǔ)法,比如:
:::demo
:::
它實(shí)際上是一個(gè) markdown 的自定義容器,借助于 markdown-it-container 插件,就可以解析這個(gè)自定義容器:
const?mdContainer?=?require('markdown-it-container');
module.exports?=?md?=>?{
??md.use(mdContainer,?'demo',?{
????validate(params)?{
??????return?params.trim().match(/^demo\s*(.*)$/);
????},
????render(tokens,?idx)?{
??????const?m?=?tokens[idx].info.trim().match(/^demo\s*(.*)$/);
??????if?(tokens[idx].nesting?===?1)?{
????????const?description?=?m?&&?m.length?>?1???m[1]?:?'';
????????const?content?=?tokens[idx?+?1].type?===?'fence'???tokens[idx?+?1].content?:?'';
????????return?`
????????${description???`${md.render(description)}`?:?''}
????????
????????` ;
??????}
??????return?'';
????}
??});
??md.use(mdContainer,?'tip');
??md.use(mdContainer,?'warning');
};
可以看到,對(duì)于 demo 這個(gè)自定義容器,它會(huì)解析 demo 后面緊接著的描述字符串以及 code fence,并生成新的 HTML 字符串。
此外,code fence 也定義了新的渲染策略:
//?覆蓋默認(rèn)的?fence?渲染策略
module.exports?=?md?=>?{
??const?defaultRender?=?md.renderer.rules.fence;
??md.renderer.rules.fence?=?(tokens,?idx,?options,?env,?self)?=>?{
????const?token?=?tokens[idx];
????//?判斷該?fence?是否在?:::demo?內(nèi)
????const?prevToken?=?tokens[idx?-?1];
????const?isInDemoContainer?=?prevToken?&&?prevToken.nesting?===?1?&&?prevToken.info.trim().match(/^demo\s*(.*)$/);
????if?(token.info?===?'html'?&&?isInDemoContainer)?{
??????return?`${md.utils.escapeHtml(token.content)}
`;
????}
????return?defaultRender(tokens,?idx,?options,?env,?self);
??};
};
對(duì)于在 demo 容器內(nèi)且?guī)в?html 標(biāo)記的 code fence,會(huì)做一層特殊處理。
對(duì)于我們前面的示例:
:::demo?Alert?組件提供四種主題,由`type`屬性指定,默認(rèn)值為`info`。
```html
??????title="成功提示的文案"
????type="success">
??
??????title="消息提示的文案"
????type="info">
??
??????title="警告提示的文案"
????type="warning">
??
??????title="錯(cuò)誤提示的文案"
????type="error">
??
```
:::
經(jīng)過(guò)解析后,生成的 HTML 大致如下:
<demo-block>
??<div><p>Alert?組件提供四種主題,由<code>typecode>屬性指定,默認(rèn)值為<code>infocode>。p>
??div>
??
??<template?slot="highlight"><pre?v-pre><code?class="html"><template>
??<el-alert
????title="成功提示的文案"
????type="success">
??</el-alert>
??<el-alert
????title="消息提示的文案"
????type="info">
??</el-alert>
??<el-alert
????title="警告提示的文案"
????type="warning">
??</el-alert>
??<el-alert
????title="錯(cuò)誤提示的文案"
????type="error">
??</el-alert>
??</template>
??code>pre>template>
demo-block>
demo 子組件的處理
目前我們了解到,每一個(gè) demo 容器對(duì)應(yīng)一個(gè)示例,它會(huì)解析生成對(duì)應(yīng)的 HTML,最終會(huì)通過(guò) demo-block 組件渲染,這個(gè)組件是預(yù)先定義好的 Vue 組件:
<template>
??<div
????class="demo-block"
????:class="[blockClass,?{?'hover':?hovering?}]"
????@mouseenter="hovering?=?true"
????@mouseleave="hovering?=?false">
????<div?class="source">
??????<slot?name="source">slot>
????div>
????<div?class="meta"?ref="meta">
??????<div?class="description"?v-if="$slots.default">
????????<slot>slot>
??????div>
??????<div?class="highlight">
????????<slot?name="highlight">slot>
??????div>
????div>
????<div
??????class="demo-block-control"
??????ref="control"
??????:class="{?'is-fixed':?fixedControl?}"
??????@click="isExpanded?=?!isExpanded">
??????<transition?name="arrow-slide">
????????<i?:class="[iconClass,?{?'hovering':?hovering?}]">i>
??????transition>
??????<transition?name="text-slide">
????????<span?v-show="hovering">{{?controlText?}}span>
??????transition>
??????<el-tooltip?effect="dark"?:content="langConfig['tooltip-text']"?placement="right">
????????<transition?name="text-slide">
??????????<el-button
????????????v-show="hovering?||?isExpanded"
????????????size="small"
????????????type="text"
????????????class="control-button"
????????????@click.stop="goCodepen">
????????????{{?langConfig['button-text']?}}
??????????el-button>
????????transition>
??????el-tooltip>
????div>
??div>
template>
demo-block 支持了多個(gè)插槽,其中默認(rèn)插槽對(duì)應(yīng)了組件的描述部分;highlight 插槽對(duì)應(yīng)組件高亮的代碼部分;source 插槽對(duì)應(yīng) demo 實(shí)現(xiàn)的部分。
因此,目前我們生成的 HTML 字符串還不能夠直接被 demo-block 組件使用,需要進(jìn)一步的處理:
module.exports?=?function(source)?{
??const?content?=?md.render(source);
??const?startTag?=?'';
??const?endTagLen?=?endTag.length;
??let?componenetsString?=?'';
??let?id?=?0;?//?demo?的?id
??let?output?=?[];?//?輸出的內(nèi)容
??let?start?=?0;?//?字符串開(kāi)始位置
??let?commentStart?=?content.indexOf(startTag);
??let?commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??while?(commentStart?!==?-1?&&?commentEnd?!==?-1)?{
????output.push(content.slice(start,?commentStart));
????const?commentContent?=?content.slice(commentStart?+?startTagLen,?commentEnd);
????const?html?=?stripTemplate(commentContent);
????const?script?=?stripScript(commentContent);
????let?demoComponentContent?=?genInlineComponentText(html,?script);
????const?demoComponentName?=?`element-demo${id}`;
????output.push(`<${demoComponentName}?/>`);
????componenetsString?+=?`${JSON.stringify(demoComponentName)}:?${demoComponentContent},`;
????//?重新計(jì)算下一次的位置
????id++;
????start?=?commentEnd?+?endTagLen;
????commentStart?=?content.indexOf(startTag,?start);
????commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??}
??
??//?處理?script
??//?...
??
??output.push(content.slice(start))
};
其中 output 表示要輸出的模板內(nèi)容,componenetsString 表示要輸出的腳本內(nèi)容。這段代碼要做的事情就是填充 demo-block 組件內(nèi)部的 source 插槽,并且插槽的內(nèi)容是一個(gè) demo 子組件。
由于前面生成的 HTML 中包含了 注釋字符串,因此就可以找到注釋字符串的位置,通過(guò)字符串截取的方式來(lái)獲取注釋內(nèi)外的內(nèi)容。
對(duì)于注釋內(nèi)的內(nèi)容,會(huì)提取其中的模板部分和 JS 部分,然后構(gòu)造出一個(gè)內(nèi)聯(lián)的組件字符串。
前面的示例經(jīng)過(guò)處理,output 對(duì)應(yīng)的內(nèi)容如下:
[
??`
?????Alert 組件提供四種主題,由type屬性指定,默認(rèn)值為info。
` ,
??` `,?
??`<template>
?????<el-alert
???????title="成功提示的文案"
???????type="success">
?????</el-alert>
?????<el-alert
???????title="消息提示的文案"
???????type="info">
?????</el-alert>
?????<el-alert
???????title="警告提示的文案"
???????type="warning">
?????</el-alert>
?????<el-alert
???????title="錯(cuò)誤提示的文案"
???????type="error">
?????</el-alert>
?????</template>
?????
?????`
]
處理后的 demo-block 就變成一個(gè)標(biāo)準(zhǔn)的 Vue 組件的應(yīng)用了。
componenetsString 對(duì)應(yīng)的內(nèi)容如下:
`"element-demo0":?(function()?{
??var?render?=?function()?{
????var?_vm?=?this
????var?_h?=?_vm.$createElement
????var?_c?=?_vm._self._c?||?_h
????return?_c(
??????"div",
??????[
????????[
??????????_c("el-alert",?{?attrs:?{?title:?"成功提示的文案",?type:?"success"?}?}),
??????????_vm._v("?"),
??????????_c("el-alert",?{?attrs:?{?title:?"消息提示的文案",?type:?"info"?}?}),
??????????_vm._v("?"),
??????????_c("el-alert",?{?attrs:?{?title:?"警告提示的文案",?type:?"warning"?}?}),
??????????_vm._v("?"),
??????????_c("el-alert",?{?attrs:?{?title:?"錯(cuò)誤提示的文案",?type:?"error"?}?})
????????]
??????],
??????2
????)??
??}??
??var?staticRenderFns?=?[]
??render._withStripped?=?true
??const?democomponentExport?=?{}
??return?{
????render,
????staticRenderFns,
????...democomponentExport
??}
})(),`
通過(guò)內(nèi)聯(lián)的方式定義了 element-demo0 子組件的實(shí)現(xiàn)。
示例只是處理了單個(gè) demo 子組件,如果有多個(gè) demo 容器,就可以通過(guò)循環(huán)查找注釋字符串 element-demo:,處理所有的 demo-block。
構(gòu)造完整的組件
module.exports?=?function(source)?{
??const?content?=?md.render(source);
??let?componenetsString?=?'';
??let?output?=?[];
??let?start?=?0;
??//?循環(huán)處理?demo?子組件
??//?...
??
??let?pageScript?=?'';
??if?(componenetsString)?{
????pageScript?=?``;
??}?else?if?(content.indexOf('')?+?''.length;
????pageScript?=?content.slice(0,?start);
??}
??output.push(content.slice(start));
??return?`
????
??????
????????${output.join('')}
??????
????
????${pageScript}
??`;
};
可以看到,output 負(fù)責(zé)組件的模板定義,pageScript 負(fù)責(zé)組件的腳本定義,最終會(huì)通過(guò)字符串拼接的方式,返回完整的組件定義。
對(duì)于最開(kāi)始完整的示例而言,經(jīng)過(guò) md-loader 處理的結(jié)果如下:
<template>
??<section?class="content?element-doc">
????<h2?id="alert-jing-gao"><a?class="header-anchor"?href="#alert-jing-gao"?aria-hidden="true">?a>?Alert?警告h2>
????<p>用于頁(yè)面中展示重要的提示信息。p>
????<h3?id="ji-ben-yong-fa"><a?class="header-anchor"?href="#ji-ben-yong-fa"?aria-hidden="true">?a>?基本用法h3>
????<p>頁(yè)面中的非浮層元素,不會(huì)自動(dòng)消失。p>
????<demo-block>
??????<div><p>Alert?組件提供四種主題,由<code>typecode>屬性指定,默認(rèn)值為<code>infocode>。p>
??????div>
??????<template?slot="source">
????????<element-demo0/>
??????template>
??????<template?slot="highlight"><pre?v-pre><code?class="html"><template>
??????????<el-alert
????????????title="成功提示的文案"
????????????type="success">
??????????</el-alert>
??????????<el-alert
????????????title="消息提示的文案"
????????????type="info">
??????????</el-alert>
??????????<el-alert
????????????title="警告提示的文案"
????????????type="warning">
??????????</el-alert>
??????????<el-alert
????????????title="錯(cuò)誤提示的文案"
????????????type="error">
??????????</el-alert>
????????</template>
????????code>pre>
??????template>
????demo-block>
??section>
template>
<script>
??export?default?{
????name:?'component-doc',
????components:?{
??????"element-demo0":?(function()?{
????????var?render?=?function()?{
??????????var?_vm?=?this
??????????var?_h?=?_vm.$createElement
??????????var?_c?=?_vm._self._c?||?_h
??????????return?_c(
????????????"div",
????????????[
??????????????[
????????????????_c("el-alert",?{?attrs:?{?title:?"成功提示的文案",?type:?"success"?}?}),
????????????????_vm._v("?"),
????????????????_c("el-alert",?{?attrs:?{?title:?"消息提示的文案",?type:?"info"?}?}),
????????????????_vm._v("?"),
????????????????_c("el-alert",?{?attrs:?{?title:?"警告提示的文案",?type:?"warning"?}?}),
????????????????_vm._v("?"),
????????????????_c("el-alert",?{?attrs:?{?title:?"錯(cuò)誤提示的文案",?type:?"error"?}?})
??????????????]
????????????],
????????????2
??????????)
????????}
????????var?staticRenderFns?=?[]
????????render._withStripped?=?true
????????const?democomponentExport?=?{}
????????return?{
??????????render,
??????????staticRenderFns,
??????????...democomponentExport
????????}
??????})(),
????}
??}
script>
顯然,經(jīng)過(guò) md-loader 處理后原來(lái) markdown 語(yǔ)法的字符串變成了一個(gè) Vue 組件定義的字符串,就可以交給 vue-loader 繼續(xù)處理了。
文檔的優(yōu)化
ElementUI 文檔的設(shè)計(jì)確實(shí)巧妙,由于我們研發(fā)的 ZoomUI 是 fork 自 ElementUI 的,很長(zhǎng)一段時(shí)間,我們也沿用了 ElementUI 文檔的編寫(xiě)方式。
但是隨著我們自研的組件越來(lái)越多,組件使用的場(chǎng)景也越來(lái)越豐富,我們對(duì)于文檔編寫(xiě)和維護(hù)的需求也越來(lái)越多。
我發(fā)現(xiàn)在現(xiàn)有模式下寫(xiě)文檔有幾個(gè)不爽的點(diǎn):
1.在 .md 中寫(xiě) Vue 組件不方便,沒(méi)法格式化代碼,IDE 的智能提示不夠友好。
2.在 demo 中寫(xiě) style 是無(wú)效的,需要在外部的 css 文件另外定義樣式。
3.中英文文檔需要分別寫(xiě) demo,修改一處沒(méi)法自動(dòng)同步到另一處。
我認(rèn)為理想中編寫(xiě)一個(gè)組件的文檔的方式是這樣的:

##?Select?選擇器
當(dāng)選項(xiàng)過(guò)多時(shí),使用下拉菜單展示并選擇內(nèi)容。
###?基礎(chǔ)用法
適用廣泛的基礎(chǔ)單選。
:::demo?`v-model`?的值為當(dāng)前被選中的?`zm-option`?的?`value`?屬性值。
```html
```
:::
###?有禁用選項(xiàng)
:::demo?在?`zm-option`?中,設(shè)定?`disabled`?值為?`true`,即可禁用該選項(xiàng)。
```html
```
:::
所有組件的 demo 拆成一個(gè)個(gè) Vue 組件,然后在 markdown 文檔中引入這些同名的組件。通過(guò)這種方式,前面提到的三個(gè)痛點(diǎn)就解決了。
那么,想達(dá)到這種效果,我們需要對(duì) md-loader 做哪些修改呢?
來(lái)看一下修改后的 md-loader 的實(shí)現(xiàn):
const?md?=?require('./config');
module.exports?=?function(source)?{
??const?content?=?md.render(source,?{
????resourcePath:?this.resourcePath
??});
??const?startTag?=?'';
??const?endTagLen?=?endTag.length;
??const?tagReg?=?/\s*<([\w-_]+)\s*\/>\s*/;
??let?componenetsString?=?'';
??let?output?=?[];?//?輸出的內(nèi)容
??let?start?=?0;?//?字符串開(kāi)始位置
??let?commentStart?=?content.indexOf(startTag);
??let?commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??while?(commentStart?!==?-1?&&?commentEnd?!==?-1)?{
????output.push(content.slice(start,?commentStart));
????const?commentContent?=?content.slice(commentStart?+?startTagLen,?commentEnd);
????const?matches?=?commentContent.match(tagReg);
????if?(matches)?{
??????const?demoComponentName?=?matches[1];
??????output.push(`<${demoComponentName}?/>`);
??????const?imports?=?`()=>import('../demos/${demoComponentName}.vue')`;
??????componenetsString?+=?`${JSON.stringify(demoComponentName)}:?${imports},`;
????}
????start?=?commentEnd?+?endTagLen;
????commentStart?=?content.indexOf(startTag,?start);
????commentEnd?=?content.indexOf(endTag,?commentStart?+?startTagLen);
??}
??let?pageScript?=?'';
??if?(componenetsString)?{
????pageScript?=?``;
??}?else?if?(content.indexOf('')?+?''.length;
????pageScript?=?content.slice(0,?start);
??}
??output.push(content.slice(start));
??return?`
????
??????
????????${output.join('')}
??????
????
????${pageScript}
??`;
};
思路很簡(jiǎn)單,解析出每個(gè) demo 容器中的組件名稱(chēng),通過(guò)動(dòng)態(tài) import 的方式加載組件,然后在 source 插槽中直接用這個(gè)組件。
這樣就把組件的 markdown 文檔和 demo 直接關(guān)聯(lián)起來(lái)。但這樣還不夠,我們還需要解決組件 demo 下面的代碼展示問(wèn)題,需要對(duì) code fence 渲染策略做一定的修改:
const?path?=?require('path');
const?fs?=?require('fs');
const?tagReg?=?/\s*<([\w-_]+)\s*\/>\s*/;
//?覆蓋默認(rèn)的?fence?渲染策略
module.exports?=?md?=>?{
??const?defaultRender?=?md.renderer.rules.fence;
??md.renderer.rules.fence?=?(tokens,?idx,?options,?env,?self)?=>?{
????const?token?=?tokens[idx];
????//?判斷該?fence?是否在?:::demo?內(nèi)
????const?prevToken?=?tokens[idx?-?1];
????const?isInDemoContainer?=?prevToken?&&?prevToken.nesting?===?1?&&?prevToken.info.trim().match(/^demo\s*(.*)$/);
????if?(token.info?===?'html'?&&?isInDemoContainer)?{
??????const?matches?=?token.content.match(tagReg);
??????if?(matches)?{
????????const?componentName?=?matches[1];
????????const?componentPath?=?path.resolve(env.resourcePath,?`../../demos/${componentName}.vue`);
????????const?content?=?fs.readFileSync(componentPath,?'utf-8');
????????return?`${md.utils.escapeHtml(content)}
`;
??????}
??????return?'';
????}
????return?defaultRender(tokens,?idx,?options,?env,?self);
??};
};
由于組件 demo 的代碼已經(jīng)不在 markdown 文檔中維護(hù)了,因此只能從組件文件中讀取了。
但是我們?nèi)绾沃缿?yīng)該從哪個(gè)路徑讀取對(duì)應(yīng)的 demo 組件呢?
在 webpack loader 中,我們可以通過(guò) this.resourcePath 獲取到當(dāng)前處理文件的路徑,那么在執(zhí)行 markdown 渲染的過(guò)程中就可以把路徑當(dāng)做環(huán)境變量傳入:
const?content?=?md.render(source,?{
??resourcePath:?this.resourcePath
})
這樣在 markdown 處理器的內(nèi)部我們就可以通過(guò) env.resourcePath 拿到處理的 markdown 文件路徑,從而通過(guò)相對(duì)路徑計(jì)算出要讀取組件的路徑,然后讀取它們的內(nèi)容:
const?componentPath?=?path.resolve(env.resourcePath,?`../../demos/${componentName}.vue`);
const?content?=?fs.readFileSync(componentPath,?'utf-8');
有了組件文檔的重構(gòu)方案,接下來(lái)的工作就是依次重構(gòu)組件的文檔。當(dāng)然在這個(gè)階段,新老文檔編寫(xiě)的方式都需要支持。
因此需要對(duì) webpack 的配置做一些修改:
{
??test:?/examples(\/|\\)docs(\/|\\).*\.md$/,
??use:?[
????{
??????loader:?'vue-loader',
??????options:?{
????????compilerOptions:?{
??????????preserveWhitespace:?false
????????}
??????}
????},
????{
??????loader:?path.resolve(__dirname,?'./md-loader/index.js')
????}
??]
},?{
??test:?/(examples(\/|\\)docs-next(\/|\\).*|changelog\.[\w-_]+)\.md$/i,
??use:?[
????{
??????loader:?'vue-loader',
??????options:?{
????????compilerOptions:?{
??????????preserveWhitespace:?false
????????}
??????}
????},
????{
??????loader:?path.resolve(__dirname,?'./md-loader-next/index.js')
????}
??]
}
對(duì)于重構(gòu)的文檔,使用新的 markdown loader。當(dāng)然加載組件視圖的邏輯也需要做一定的修改,對(duì)于重構(gòu)的文檔,指向新的文檔地址。
總結(jié)
ElementUI 通過(guò) markdown 編寫(xiě)組件文檔的思路還是非常棒的,主要利用了自定義 md-loader 對(duì) markdown 文件內(nèi)容做了一層處理,解析成 Vue 組件字符串,再交給 vue-loader 處理。
在寫(xiě)這篇文章之前,我就在粉絲群里分享了重構(gòu)文檔的方案。有同學(xué)告訴我,Element-plus 已經(jīng)用 vitepress 重寫(xiě),看了一下文檔的組織方式,和我重構(gòu)的方式非常類(lèi)似,這就是傳說(shuō)中的英雄所見(jiàn)略同嗎?
我在之前的文章中強(qiáng)調(diào)過(guò),要善于發(fā)現(xiàn)工作中的痛點(diǎn),并通過(guò)技術(shù)的方式解決,這是優(yōu)秀的工程師重要的能力之一,希望這篇文章能夠帶給你這方面的一些思考。
參考資料
[1] markdown-it-chain:??https://github.com/ulivz/markdown-it-chain
[2] markdown-it:?https://markdown-it.github.io/markdown-it/
