百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

webpack 常见loader原理剖析,动手实现一个md2html的loader

myzbx 2025-03-28 19:09 4 浏览

本文会带你简单的认识一下webpack的loader,动手实现一个利用md转成抽象语法树,再转成html字符串的loader。顺便简单的了解一下几个style-loader,vue-loader,babel-loader的源码以及工作流程。

loader简介

webpack允许我们使用loader来处理文件,loader是一个导出为function的node模块。可以将匹配到的文件进行一次转换,同时loader可以链式传递。 loader文件处理器是一个CommonJs风格的函数,该函数接收一个 String/Buffer 类型的入参,并返回一个 String/Buffer 类型的返回值。

loader 的配置的两种形式

方案1:

// webpack.config.js
module.exports = {
  ...
  module: {
    rules: [{
      test: /.vue$/,
      loader: 'vue-loader'
    }, {
      test: /.scss$/,
      // 先经过 sass-loader,然后将结果传入 css-loader,最后再进入 style-loader。
      use: [
        'style-loader',//从JS字符串创建样式节点
        'css-loader',// 把  CSS 翻译成 CommonJS
        {
          loader: 'sass-loader',
          options: {
            data: '$color: red;'// 把 Sass 编译成 CSS
          }
        }
      ]
    }]
  }
  ...
}

方法2(右到左地被调用)

// module
import Styles from 'style-loader!css-loader?modules!./styles.css';

当链式调用多个 loader 的时候,请记住它们会以相反的顺序执行。取决于数组写法格式,从右向左或者从下向上执行。像流水线一样,挨个处理每个loader,前一个loader的结果会传递给下一个loader,最后的 Loader 将处理后的结果以 String 或 Buffer 的形式返回给 compiler。

使用 loader-utils 能够编译 loader 的配置,还可以通过 schema-utils 进行验证

import { getOptions } from 'loader-utils'; 
import { validateOptions } from 'schema-utils';  
const schema = {
  // ...
}
export default function(content) {
  // 获取 options
  const options = getOptions(this);
  // 检验loader的options是否合法
  validateOptions(schema, options, 'Demo Loader');

  // 在这里写转换 loader 的逻辑
  // ...
   return content;   
};
  • content: 表示源文件字符串或者buffer
  • map: 表示sourcemap对象
  • meta: 表示元数据,辅助对象

同步loader

同步 loader,我们可以通过return和this.callback返回输出的内容

module.exports = function(content, map, meta) {
  //一些同步操作
  outputContent=someSyncOperation(content)
  return outputContent;
}

如果返回结果只有一个,也可以直接使用 return 返回结果。但是,如果有些情况下还需要返回其他内容,如sourceMap或是AST语法树,这个时候可以借助webpack提供的api this.callback

module.exports = function(content, map, meta) {
  this.callback(
    err: Error | null,
    content: string | Buffer,
    sourceMap?: SourceMap,
    meta?: any
  );
  return;
}

第一个参数必须是 Error 或者 null 第二个参数是一个 string 或者 Buffer。 可选的:第三个参数必须是一个可以被这个模块解析的 source map。 可选的:第四个选项,会被 webpack 忽略,可以是任何东西【可以将抽象语法树(abstract syntax tree - AST)(例如 ESTree)作为第四个参数(meta),如果你想在多个 loader 之间共享通用的 AST,这样做有助于加速编译时间。】。

异步loader

异步loader,使用 this.async 来获取 callback 函数。

// 让 Loader 缓存
module.exports = function(source) {
    var callback = this.async();
    // 做异步的事
    doSomeAsyncOperation(content, function(err, result) {
        if(err) return callback(err);
        callback(null, result);
    });
};

详情请参考官网API

开发一个简单的md-loader

const marked = require("marked");

const loaderUtils = require("loader-utils");
module.exports = function (content) {
   this.cacheable && this.cacheable();
   const options = loaderUtils.getOptions(this);
   try {
       marked.setOptions(options);
       return marked(content)
   } catch (err) {
       this.emitError(err);
       return null
   }
    
};

上述的例子是通过现成的插件把markdown文件里的content转成html字符串,但是如果没有这个插件,该怎么做呢?这个情况下,我们可以考虑另外一种解法,借助 AST 语法树,来协助我们更加便捷地操作转换。

利用 AST 作源码转换

markdown-ast是将markdown文件里的content转成数组形式的抽象语法树节点,操作 AST 语法树远比操作字符串要简单、方便得多:

const md = require('markdown-ast');//通过正则的方法把字符串处理成直观的AST语法树
module.exports = function(content) {
    this.cacheable && this.cacheable();
    const options = loaderUtils.getOptions(this);
    try {
      console.log(md(content))
      const parser = new MdParser(content);
      return parser.data
    } catch (err) {
      console.log(err)
      return null
    }
};

md通过正则切割的方法转成抽象语树

const md = require('markdown-ast');//md通过正则匹配的方法把buffer转抽象语法树
const hljs = require('highlight.js');//代码高亮插件
// 利用 AST 作源码转换
class MdParser {
 constructor(content) {
    this.data = md(content);
    console.log(this.data)
  this.parse()
 }
 parse() {
  this.data = this.traverse(this.data);
 }
 traverse(ast) {
    console.log("md转抽象语法树操作",ast)
     let body = '';
    ast.map(item => {
      switch (item.type) {
        case "bold":
        case "break":
        case "codeBlock":
          const highlightedCode = hljs.highlight(item.syntax, item.code).value
          body += highlightedCode
          break;
        case "codeSpan":
        case "image":
        case "italic":
        case "link":
        case "list":
          item.type = (item.bullet === '-') ? 'ul' : 'ol'
          if (item.type !== '-') {
            item.startatt = (` start=${item.indent.length}`)
          } else {
            item.startatt = ''
          }
          body += '<' item.type item.startatt>\n' + this.traverse(item.block) + '\n'
          break;
        case "quote":
          let quoteString = this.traverse(item.block)
          body += '
\n' + quoteString + '
\n'; break; case "strike": case "text": case "title": body += `${item.text}` break; default: throw Error("error", `No corresponding treatment when item.type equal${item.type}`); } }) return body } }

完整的代码参考这里

ast抽象语法数转成html字符串

loader的一些开发技巧

  1. 尽量保证一个loader去做一件事情,然后可以用不同的loader组合不同的场景需求
  2. 开发的时候不应该在 loader 中保留状态。loader必须是一个无任何副作用的纯函数,loader支持异步,因此是可以在 loader 中有 I/O 操作的。
  3. 模块化:保证 loader 是模块化的。loader 生成模块需要遵循和普通模块一样的设计原则。
  4. 合理的使用缓存 合理的缓存能够降低重复编译带来的成本。loader 执行时默认是开启缓存的,这样一来, webpack 在编译过程中执行到判断是否需要重编译 loader 实例的时候,会直接跳过 rebuild 环节,节省不必要重建带来的开销。 但是当且仅当有你的 loader 有其他不稳定的外部依赖(如 I/O 接口依赖)时,可以关闭缓存:
this.cacheable&&this.cacheable(false);
  1. loader-runner 是一个非常实用的工具,用来开发、调试loader,它允许你不依靠 webpack 单独运行 loader npm install loader-runner --save-dev
// 创建 run-loader.js
const fs = require("fs");
const path = require("path");
const { runLoaders } = require("loader-runner");

runLoaders(
  {
    resource: "./readme.md",
    loaders: [path.resolve(__dirname, "./loaders/md-loader")],
    readResource: fs.readFile.bind(fs),
  },
  (err, result) => 
    (err ? console.error(err) : console.log(result))
);

执行 node run-loader

认识更多的loader

style-loader源码简析

作用:把样式插入到DOM中,方法是在head中插入一个style标签,并把样式写入到这个标签的 innerHTML 里 看下源码。

先去掉option处理代码,这样就比较清晰明了了

返回一段js代码,通过require来获取css内容,再通过addStyle的方法把css插入到dom里 自己实现一个简陋的style-loader.js

module.exports.pitch = function (request) {
  const {stringifyRequest}=loaderUtils
  var result = [
    //1. 获取css内容。2.// 调用addStyle把CSS内容插入到DOM中(locals为true,默认导出css)
    'var content=require(' + stringifyRequest(this, '!!' + request) + ')’, 
    'require(' + stringifyRequest(this, '!' + path.join(__dirname, "addstyle.js")) + ')(content)’, 
    'if(content.locals) module.exports = content.locals’ 
  ]
  return result.join(';')
}

需要说明的是,正常我们都会用default的方法,这里用到pitch方法。pitch 方法有一个官方的解释在这里 pitching loader。简单的解释一下就是,默认的loader都是从右向左执行,用 pitching loader 是从左到右执行的。

{
  test: /\.css$/,
  use: [
    { loader: "style-loader" },
    { loader: "css-loader" }
  ]
}

为什么要先执行style-loader呢,因为我们要把css-loader拿到的内容最终输出成CSS样式中可以用的代码而不是字符串。

addstyle.js

module.exports = function (content) {
  let style = document.createElement("style")
  style.innerHTML = content
  document.head.appendChild(style)
}
babel-loader源码简析

首先看下跳过loader的配置处理,看下babel-loader输出

上图我们可以看到是输出transpile(source, options)的code和map 再来看下transpile方法做了啥

babel-loader是通过babel.transform来实现对代码的编译的, 这么看来,所以我们只需要几行代码就可以实现一个简单的babel-loader

const babel = require("babel-core")
module.exports = function (source) {
  const babelOptions = {
    presets: ['env']
  }
  return babel.transform(source, babelOptions).code
}
vue-loader源码简析

vue单文件组件(简称sfc)


<script>
export default {
  data () {
    return {
      a: "vue demo"
    };
  }
};
</script>

webpack配置

const VueloaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
  ...
  module: {
    rules: [
      ...
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  }

  plugins: [
    new VueloaderPlugin()
  ]
  ...
}

VueLoaderPlugin 作用:将在webpack.config定义过的其它规则复制并应用到 .vue 文件里相应语言的块中。

plugin-webpack4.js

 const vueLoaderUse = vueUse[vueLoaderUseIndex]
    vueLoaderUse.ident = 'vue-loader-options'
    vueLoaderUse.options = vueLoaderUse.options || {}
    // cloneRule会修改原始rule的resource和resourceQuery配置,
    // 携带特殊query的文件路径将被应用对应rule
    const clonedRules = rules
      .filter(r => r !== vueRule)
      .map(cloneRule)

    // global pitcher (responsible for injecting template compiler loader & CSS
    // post loader)
    const pitcher = {
      loader: require.resolve('./loaders/pitcher'),
      resourceQuery: query => {
        const parsed = qs.parse(query.slice(1))
        return parsed.vue != null
      },
      options: {
        cacheDirectory: vueLoaderUse.options.cacheDirectory,
        cacheIdentifier: vueLoaderUse.options.cacheIdentifier
      }
    }

    // 更新webpack的rules配置,这样vue单文件中的各个标签可以应用clonedRules相关的配置
    compiler.options.module.rules = [
      pitcher,
      ...clonedRules,
      ...rules
    ]

获取webpack.config.js的rules项,然后复制rules,为携带了?vue&lang=xx...query参数的文件依赖配置xx后缀文件同样的loader 为Vue文件配置一个公共的loader:pitcher 将[pitchLoder, ...clonedRules, ...rules]作为webapck新的rules。

再看一下vue-loader结果的输出

当引入一个vue文件后,vue-loader是将vue单文件组件进行parse,获取每个 block 的相关内容,将不同类型的 block 组件的 Vue SFC 转化成 js module 字符串。

// vue-loader使用`@vue/component-compiler-utils`将SFC源码解析成SFC描述符,,根据不同 module path 的类型(query 参数上的 type 字段)来抽离 SFC 当中不同类型的 block。
const { parse } = require('@vue/component-compiler-utils')
// 将单个*.vue文件内容解析成一个descriptor对象,也称为SFC(Single-File Components)对象
// descriptor包含template、script、style等标签的属性和内容,方便为每种标签做对应处理
const descriptor = parse({
  source,
  compiler: options.compiler || loadTemplateCompiler(loaderContext),
  filename,
  sourceRoot,
  needMap: sourceMap
})

// 为单文件组件生成唯一哈希id
const id = hash(
  isProduction
  ? (shortFilePath + '\n' + source)
  : shortFilePath
)
// 如果某个style标签包含scoped属性,则需要进行CSS Scoped处理
const hasScoped = descriptor.styles.some(s => s.scoped)

然后下一步将新生成的 js module 加入到 webpack 的编译环节,即对这个 js module 进行 AST 的解析以及相关依赖的收集过程。

来看下源码是怎么操作不同type类型(template/script/style)的,selectBlock 方法内部主要就是根据不同的 type 类型,来获取 descriptor 上对应类型的 content 内容并传入到下一个 loader 处理

这三段代码可以把不同type解析成一个import的字符串

import { render, staticRenderFns } from "./App.vue?vue&type=template&id=7ba5bd90&"
import script from "./App.vue?vue&type=script&lang=js&"
export * from "./App.vue?vue&type=script&lang=js&"
import style0 from "./App.vue?vue&type=style&index=0&lang=scss&scope=true&"

总结一下vue-loader的工作流程

  1. 注册VueLoaderPlugin 在插件中,会复制当前项目webpack配置中的rules项,当资源路径包含query.lang时通过resourceQuery匹配相同的rules并执行对应loader时 插入一个公共的loader,并在pitch阶段根据query.type插入对应的自定义loader
  2. 加载*.vue时会调用vue-loader,.vue文件被解析成一个descriptor对象,包含template、script、styles等属性对应各个标签, 对于每个标签,会根据标签属性拼接src?vue&query引用代码,其中src为单页面组件路径,query为一些特性的参数,比较重要的有lang、type和scoped 如果包含lang属性,会匹配与该后缀相同的rules并应用对应的loaders 根据type执行对应的自定义loader,template将执行templateLoader、style将执行stylePostLoader
  3. 在templateLoader中,会通过vue-template-compiler将template转换为render函数,在此过程中, 会将传入的scopeId追加到每个标签的上,最后作为vnode的配置属性传递给createElemenet方法, 在render函数调用并渲染页面时,会将scopeId属性作为原始属性渲染到页面上
  4. 在stylePostLoader中,通过PostCSS解析style标签内容


文中涉及的demo源码

点击github仓库

参考文献

  1. webpack官网loader api
  2. 手把手教你写webpack yaml-loader
  3. 言川-webpack 源码解析系列
  4. 从vue-loader源码分析CSS Scoped的实现

相关推荐

攀升战境S5电竞主机评测:NVIDIA RTX 3060实力助阵,光追游戏走起

此次笔者将为玩家们推荐一款游戏主机——攀升战境S5。该主机是攀升电脑今年力推的游戏装备,主机采用一线品牌配件,特别是在显卡选用上严苛把关,精选GeForceRTX30系列显卡,玩家们大可以放心选购...

慎买-神牛闪光灯兼容性问题:神牛V350&amp;松下S5M2

神牛V350和松下S5M2的兼容性问题。大家好,我是向往闪光灯人像的Fish。国庆期间,我购买了神牛V350闪光灯和神牛X2T引闪器,但这成为了我的噩梦。我原以为客服和松友们说这款闪光灯在松下S5M2...

Acer蜂鸟持续办公一整天(acer 蜂鸟s5)

移动办公在工作节奏日益加快的今天越来越普遍,目前大部分工作无法在手持设备上完成,笔记本依然是移动办公最明智的选择。为了实现移动办公,很多笔记本越做越轻薄,性能也越来越强,而续航却一直没有很大提升。笔者...

职业车手明年会骑什么?2021赛季各大世巡赛车队使用器材一览

新年的钟声即将敲响,意味着充满魔幻色彩的2020年即将过去。受新冠肺炎的影响,2020年的赛季非常不同寻常。因这一原因不得不延迟举行的各种比赛导致许多车队的赞助商无法得到足够曝光,这也间接导致了许多车...

三星部分手机系统升级路线图流出(三星系统在哪升级)

三星包括Note3和S5在内的手机在升级到4.4.2系统之后一直没有什么系统升级的消息,而最近流出的一张三星的系统升级路线图中出现了一共13台手机升级KTU84P(也就是Android4.4.4)...

索尼Xperia Z3配置大曝光:升级并不大

IT之家(www.ithome.com):索尼XperiaZ3配置大曝光:升级并不大索尼明天就会在IFA2014大会上发布其下代旗舰XperiaZ3智能手机,目前网上曝光了其原型机,并且机身背后...

不进反退 三星Exynos 5433只能运行32位模式?

三星GalaxyNote4将带有两个版本,除了国行使用的骁龙805以外,还有三星自家的Exynos5433版本。而这颗SoC的详细信息三星并没有公布,据外媒Anandtech称,他们从源码中确认...

尼康Z6III测评:对比EOS R6 II、A7M4、S5IIX

摄影器材测评网站DPReview刚刚发布了尼康Z6III的完整图文测评,该机获得金奖评级,得分达到91%。以下是该文章的摘录——尼康Z6III核心规格:2400万像素“部分堆栈式”传感器RAW连拍:机...

赛默飞Ion S5首批数据公布,玩爆前任PGMTM系列

北美时间9月1日,赛默飞发布了两款最新的NGS系统IonS5和IonS5XL,旨在提供更加简捷的靶向测序流程。10月29日IonS5测序仪的首批实验数据产生于阜外医院。阜外医院研究人员选用了主...

Excel技巧:快速制作批量文件夹,省时省力,加强工作效率

大家好,如果公司领导要求按人员姓名制作文件夹,以一人一档的形式呈现人员档案,办公人员一个一个制作费时费力,而且效力低下,今天为大家介绍快捷制作批量文件夹的方法下面我们用图片来进行演示操作打开表格,选...

国行、港版、美版Apple watch各版本售价一览

今天凌晨,苹果牌手表正式发布,苹果开始正式进入可穿戴设备领域,除了功能和外观,我相信大家更关心的是价格问题了,小编就将国行、港版、美版的Applewatch售价做一总结,以供参考。国行:美版:港版:...

松下全画幅微单S5和S1到底哪里不一样?

Hello,我是ET,欢迎大家来到我的“相机笔记”。————9月2日晚,松下正式发布了第4款全画幅微单LUMIXS5。这一篇,我们主要来说松下LUMIXS5和LUMIXS1到底有哪些区别...

融会贯通之典范 神舟S7-2021S5评测

便携、性能、续航,这简简单单的六个字道出了这么些年来笔记本电脑的设计方向,可是由于底层技术、模具设计等等原因,这三点并不能很好的融合在一起。虽说闻道有先后,术业有专攻,但能够有一台融会贯通的产品,不是...

三国志战略版:S5赛季装X指南,开荒不是一成不变,需要因地制宜

大家好我是零氪玩家花席,S5赛季已经开始,因为S5赛季的野地阵容和S4赛季没有区别,所以S5赛季开荒相对不难。你在S4有经验,并且多了很多武将和战法,还能用150赛季功勋兑换7500战法点。S5赛季新...

聊聊松下S5M2和S5M2X的区别(松下s5k和s5c有什么区别)

先简单说下哪里不同:12bitRAWHDMI外录支持直接将视频录制到USB-SSD上多了All-Intra和ProRes编码支持有线/无线IP推流,USB网络连接黑化的机身不过要特别强调一下,S5...