如何从 0 到 1 撸一个编译 markdown 文件的 webpack loader

896 阅读3分钟

Webpack 作为前端构建打包神器,已在如今的前端各大项目中必不可少,webpack 提供了一个核心,核心提供了很多开箱即用的功能,同时它可以用loader和plugin来扩展。webpack本身结构精巧,基于tapable的插件架构,扩展性强,众多的loader或者plugin让webpack显得很复杂。
webpack常用配置包括:devtool、entry、 output、module、resolve、plugins、externals等,本文将介绍如何只做一个自己的 loader

项目地址:github.com/zybingo/md-…

每一个 loader 其实都是一个 node 模块,提供一个函数,函数接收资源,进行一定的处理之后输出给下一个 loader。明白了这个基本原理,我们也可以编写一个自己的 loader。
熟悉 webpack 的同学对下面这张图一定不陌生

正如这张图所传达的信息一样,webpack 所做的就是将各种文件打包成浏览器可以识别的文件,比如常用的 babel-loader 可以将 es6 转成 es5,sass-loader、less-loader 负责预编译 sass less 为普通的 css,css-loader 解析 @import 和 url,style-loader 将 css 用 style 标签包裹,打包进 js,此外还有针对文件的 file-loader,raw-loader 等等。不同的 loader 执行不同的任务,对同一类文件配置的多种文件会从下往上以此执行。

项目开发中我们会用到一些 markdown 文件,有一些静态页面比如用户协议页面,我们会在项目中写一个 markdown,再用 js 去读取解析该文件,最后渲染出来。假如我们可以在 webpack 编译的过程中将 md 文件编译为 js 模块,在页面中直接引入,是不是会方便许多呢?

话不多说,开撸

webpack 提供了编写 loader 的文档提供参考

webpack.docschina.org/concepts/#l…

官方提供了一个 loader-utils 的库,我们充分利用loader-utils包。它提供了许多有用的工具,但最常用的一种工具是获取传递给 loader 的选项。schema-utils包配合loader-utils,用于保证 loader 选项,进行与 JSON Schema 结构一致的校验。这里有一个简单使用两者的例子:

import { getOptions } from 'loader-utils';
import validateOptions from 'schema-utils';

const schema = {
  type: 'object',
  properties: {
    test: {
      type: 'string'
    }
  }
};

export default function(source) {
  const options = getOptions(this);

  validateOptions(schema, options, 'Example Loader');

  // 对资源应用一些转换……

  return `export default ${ JSON.stringify(source) }`;
}

可以看到这个 loader 要做的就是资源转换,输出一个模版字符串,最终这个模版字符串就是一个普通的 js 模块,可以被 babel 直接处理,仿照这个写法,我们编写我们的 markdown loader。

  1. 首先提供我们主函数的入口
/** read and parser
 * @param {String} sourse
 */
function loader (source) {
  this.cacheable && this.cacheable()
  const callback = this.async()
  const options = Object.assign(defaultOptions, getOptions(this))
  let result = null

  // dosomething
  callback(null, result)
}

2. 一个普通的 markdown 文件,当在 webpack 中解析之后,输入到 loader 中的是一个字符串

## 这是标题 ## ### 这是副标题 ###

这里我们借助 commonmark 进行 markdown 文件的解析

/** read and parser
 * @param {String} sourse
 */
export function parser (source) {
  const fileContent = JSON.parse(JSON.stringify(source))
  const reader = new commonmark.Parser()
  const writer = new commonmark.HtmlRenderer()
  return writer.render(reader.parse(fileContent.trim()))
}

解析之后的内容为对应的富文本

<h1>这是标题</h1><h2>这是副标题</h2>

3. 最后我们构造一个普通的 react 组件的模版字符串进行输出

function loader (source) {
  this.cacheable && this.cacheable()
  const callback = this.async()
  const options = Object.assign(defaultOptions, getOptions(this))
  let result = null
  if (options.use_raw) {
    result = `
      const raw_content = '${source}'
      export default raw_content
    `
  } else {
    const html = parser(source).trim()
    result = `
      import React from 'react'
      const Component = props => (
        <div {...props}>${processHtml(html)}</div>
      )
      export default {
        html: '${html.replace(/\n/g, '')}',
        Component
      }
    `
  }
  callback(null, result)
}

调用 this.async() 方法将模版字符串输出,其实这就是一个 React Component 了,这里输出了一个组件和富文本,方便使用的时候也可以自定义渲染富文本。大家可能注意到了 processHtml 这个方法,这是因为 md 文件中可能有代码块, commonmark 解析代码块时会对 >< 字符进行转义,所以这里需要处理。同时 jsx 中 {} 内的内容会被当作 js 执行,因此以下代码会报错

const MyComponent = () => {
  return (
    <pre>
      <code>
        function calc () {
          let a = 3
          return a
        }
      </code>
    </pre>
  )
}

需要将 code 内的内容转为 模版字符串:

const MyComponent = () => {
  return (
    <pre>
      <code>
        {`
          ${function calc () {
            let a = 3
            return a
          }}
        `}
      </code>
    </pre>
  )
}

processHtml 主要做这个工作

/** process raw html
 * @param {String} html
 */
export function processHtml (html) {
  const codeReg = new RegExp('<code class=([\\s\\S]*)>([\\s\\S]*)</code>')
  const code = codeReg.exec(html)
  let newHtml = html
  if (code) {
    newHtml = newHtml.replace(code[2], '{`' + code[2] + '`}').replace(/&gt;/g, '>').replace(/&lt;/g, '<')
  }
  return newHtml
}

最终我们的 loader 发包,npm 上可搜索 md-react-loader

npm i md-react-loader

在我们项目的 webpack 中进行配置

{
  test: /\.(md)$/,
  use: [
    {
      loader: require.resolve('babel-loader'),
      options: {
        presets:[
          "@babel/preset-env",
          "@babel/preset-react"
        ]
      }
    },
    {
      loader: require.resolve('md-react-loader')
    }
  ]
}

注意这个 loader 产出的内容为 es6 语法的 React 组件,还需要 babel 去解析,重新编译之后我们就可以在项目中直接引入 markdown 了

import Md from './MyTest.md'
const MyComponent = () => {
  return (
    <Md.Component className="my_own_class" />
  )
}
export default MyComponent

或者

import Md from './MyTest.md'
const MyComponent = () => (
  <div
    dangerouslySetInnerHTML={{ __html: Md.html }}
  />
)
export default MyComponent

同时也可以自己添加 className 定义样式,当 md 中含有代码时,这个 loader 提供了高亮的基础 css,同时也需要你安装 prismjs 这个库

import Md from './MyTest.md'
import 'prismjs'
import 'md-react-loader/lib/index.css'

const MyComponent = () => <Md.Component className="my_own_class" />
export default MyComponent

怎么样,一个 webpack loader 是不是非常简单呢?

再打一个广告

github.com/zybingo/md-…

祝大家新年快乐~~