纯手撸简单实用的webpack-html-include-loader(附开发详解)

背景介绍

在单页应用盛行的今天,很多人似乎已经把简单的切图不当做一种技术活了。对于切页面,写静态网站都快要嗤之以鼻了。其实并非如此,写静态页面是前端入门的基本工作,是基本功扎实的体现。而且在工作中,我们也少不了要开发一些静态的官网类网站。我们要做的是想一想如何更好的开发静态页面。

歪马最近因工作原因,需要对一个托管于内容管理系统的官网类网站进行迁移。既然要重新弄,那工程化自然少不了,webpack、css预编译等全上了。这样才能向更好的开发体验靠齐。

由于是静态官网,在使用webpack的时候,需要指定多入口,并且为不同的入口指定不同的template模板。借助html-webpack-plugin可以为不同的入口指定模板,如下所示:

// ...
entrys.map(entryName => {
  htmlWebpackPlugins.push(
    new HtmlWebpackPlugin({
      template: `${entryName}.html`,
      filename: `${entryName}.html`,
      chunks: ['vendor', 'common', entryName],
    }),
  )
})

通过对入口列表进行遍历,我们可以为不同的入口指定不同的模板。

在使用Vue/React等框架时,我们早已习惯在开发的过程中进行组件的抽取与复用。那么在这类纯静态的网站开发中,我们也一定想要尽可能的复用页面内的公共部分,如header、footer、copyright等内容。

这些在服务端渲染的开发模式下早就已经很成熟了,借助模板引擎可以轻松地完成,如nunjucks/pug/ejs等。

webpack-html-plugin中的template默认使用的就是ejs。既然官方使用的就是ejs,那么我们也先从这个方向找找方案。

经过歪马的尝试,发现ejs并不能很好的实现以下功能:

  • 支持include,但是传参的格式不够优雅,用法如下:

    index.ejs

    <h1><%= require('./header.ejs')({ title: '页面名称' }) %></h1>
    

    header.ejs

    <title><%= title %></title>
    
  • 不支持对文件内的图片src进行处理

无法对图片进行处理,这就没得玩了。歪马只能另寻他法,最后找到的方案都不理想。就自己动手实现了一个功能简单,方便易用的HTML包含loader —— webpack-html-include-loader

webpack-html-include-loader包含以下核心功能:

  • 支持include html文件
  • 支持嵌套include
  • 支持传入参数 & 变量解析
  • 支持自定义语法标记

本文依次介绍这4个核心功能,并讲解相关实现。读完本文,你会收获如何使用这一loader,并且获悉一点webpack loader的开发经验,如有问题还请不吝赐教。

一、实现基础的包含功能

为了能够更灵活的组织静态页面,我们必不可少的功能就是include包含功能。我们先来看看如何实现包含功能。

假设,默认情况下,我们使用以下语法标记进行include:

<%- include("./header/main.html") %>

想要实现这一功能,其实比较简单。webpack的loader接受的参数可以是原始模块的内容或者上一个loader处理后的结果,这里我们的loader直接对原始模块的内容进行处理,也就是内容字符串。

所以,想要实现包含功能,只需要通过正则匹配到包含语法,然后全局替换为对应的文件内容即可。整体代码如下

// index.js
const path = require('path')
const fs = require('fs')

module.exports = function (content) {
  const defaultOptions = {
    includeStartTag: '<%-',
    includeEndTag: '%>',
 }

  const options = Object.assign({}, defaultOptions)

  const {
    includeStartTag, includeEndTag
  } = options

  const pathRelative = this.context

  const pathnameREStr = '[-_.a-zA-Z0-9/]+'
  // 包含块匹配正则
  const includeRE = new RegExp(
    `${includeStartTag}\\s*include\\((['|"])(${pathnameREStr})\\1\\)\\s*${includeEndTag}`,
    'g',
  )

  return content.replace(includeRE, (match, quotationStart, filePathStr,) => {
    const filePath = path.resolve(pathRelative, filePathStr)
    const fileContent = fs.readFileSync(filePath, {encoding: 'utf-8'})
    // 将文件添加到依赖中,从而实现热更新
    this.addDependency(filePath)
    return fileContent
  })
}

其中,const pathRelative = this.context,是webpack loader API提供的,context表示当前文件的所在目录。借助这一属性,我们能够获取被包含文件的具体路径,进而获取文件内容进行替换。

此外,你可能还注意到了代码中还调用了this.addDependency(filePath),这一方法可以将文件添加到了依赖中,这样就可以监听到文件的变化了。

其余逻辑比较简单,如果你对字符串replace不是很熟悉,推荐看下阮一峰老师的这篇正则相关的基础文档

好了,到现在我们实现了最基础的HTML包含功能。但是,我们显然不满足于此,最起来嵌套包含还是要支持的吧?下面我们一起来看看如何实现嵌套包含。

二、提高包含的灵活度:嵌套包含

上面,我们已经实现了基础的包含功能,再去实现嵌套包含其实就很简单了。递归地处理一下就好了。由于要递归调用,所以我们将include语法标记的替换逻辑提取为一个函数replaceIncludeRecursive

下面上代码:

const path = require('path')
const fs = require('fs')

+ // 递归替换include
+ function replaceIncludeRecursive({
+  apiContext, content, includeRE, pathRelative, maxIncludes,
+ }) {
+   return content.replace(includeRE, (match, quotationStart, filePathStr) => {
+     const filePath = path.resolve(pathRelative, filePathStr)
+     const fileContent = fs.readFileSync(filePath, {encoding: 'utf-8'})
+ 
+     apiContext.addDependency(filePath)
+ 
+     if(--maxIncludes > 0 && includeRE.test(fileContent)) {
+       return replaceIncludeRecursive({
+         apiContext, content: fileContent, includeRE, pathRelative: path.dirname(filePath), maxIncludes,
+       })
+     }
+     return fileContent
+   })
+ }

module.exports = function (content) {
  const defaultOptions = {
    includeStartTag: '<%-',
    includeEndTag: '%>',
+   maxIncludes: 5,
  }

  const options = Object.assign({}, defaultOptions)

  const {
    includeStartTag, includeEndTag, maxIncludes
  } = options

  const pathRelative = this.context

  const pathnameREStr = '[-_.a-zA-Z0-9/]+'
  const includeRE = new RegExp(
    `${includeStartTag}\\s*include\\((['|"])(${pathnameREStr})\\1\\)\\s*${includeEndTag}`,
    'g',
  )

-   return content.replace(includeRE, (match, quotationStart, filePathStr,) => {
-     const filePath = path.resolve(pathRelative, filePathStr)
-     const fileContent = fs.readFileSync(filePath, {encoding: 'utf-8'})
-     // 将文件添加到依赖中,从而实现热更新
-     this.addDependency(filePath)
-     return fileContent
-   })
+   const source = replaceIncludeRecursive({
+     apiContext: this, content, includeRE, pathRelative, maxIncludes,
+   })
+   return source
}

逻辑很简单,把原本的替换逻辑放到了replaceIncludeRecursive函数内,在主逻辑中调用更该方法即可。另外,webpack-html-include-loader默认设置了最大嵌套层数的限制为5层,超过则不再替换。

至此,我们实现了比较灵活的include包含功能,不知道你还记不记得最开始ejs的包含是支持传入参数的,可以替换包含模板中的一些内容。我们可以称之为变量。

三、传入参数 & 变量解析

同样,先设定一个默认的传入参数的语法标记,如下:<%- include("./header/main.html", {"title": "首页"}) %>

在包含文件时,通过JSON序列化串的格式传入参数。

为什么是JSON序列化串,因为loader最终处理的是字符串,我们需要将字符串参数转为参数对象,需要借助JSON.parse方法来解析。

然后在被包含的文件中使用<%= title %>进行变量插入。

那么想要实现变量解析,我们需要先实现传入参数的解析,然后再替换到对应的变量标记中。

代码如下:

const path = require('path')
const fs = require('fs')

// 递归替换include
function replaceIncludeRecursive({
- apiContext, content, includeRE, pathRelative, maxIncludes,
+ apiContext, content, includeRE, variableRE, pathRelative, maxIncludes,
}) {
- return content.replace(includeRE, (match, quotationStart, filePathStr) => {
+ return content.replace(includeRE, (match, quotationStart, filePathStr, argsStr) => {
+   // 解析传入的参数
+   let args = {}
+   try {
+     if(argsStr) {
+       args = JSON.parse(argsStr)
+     }
+   } catch (e) {
+     apiContext.emitError(new Error('传入参数格式错误,无法进行JSON解析成'))
+   }

    const filePath = path.resolve(pathRelative, filePathStr)
    const fileContent = fs.readFileSync(filePath, {encoding: 'utf-8'})

    apiContext.addDependency(filePath)

+   // 先替换当前文件内的变量
+   const fileContentReplacedVars = fileContent.replace(variableRE, (matchedVar, variable) => {
+     return args[variable] || ''
+   })

-   if(--maxIncludes > 0 && includeRE.test(fileContent)) {
+   if(--maxIncludes > 0 && includeRE.test(fileContentReplacedVars)) {
      return replaceIncludeRecursive({
-       apiContext, content: fileContent, includeRE, pathRelative: path.dirname(filePath), maxIncludes,
+       apiContext, content: fileContentReplacedVars, includeRE, pathRelative: path.dirname(filePath), maxIncludes,
      })
    }
-   return fileContentReplacedVars
+   return fileContentReplacedVars
  })
}

module.exports = function (content) {
  const defaultOptions = {
    includeStartTag: '<%-',
    includeEndTag: '%>',
+   variableStartTag: '<%=',
+   variableEndTag: '%>',
    maxIncludes: 5,
  }

  const options = Object.assign({}, defaultOptions)

  const {
-   includeStartTag, includeEndTag, maxIncludes
+   includeStartTag, includeEndTag, maxIncludes, variableStartTag, variableEndTag,
  } = options

  const pathRelative = this.context

  const pathnameREStr = '[-_.a-zA-Z0-9/]+'
+ const argsREStr = '{(\\S+?\\s*:\\s*\\S+?)(,\\s*(\\S+?\\s*:\\s*\\S+?)+?)*}'
  const includeRE = new RegExp(
-   `${includeStartTag}\\s*include\\((['|"])(${pathnameREStr})\\1\\)\\s*${includeEndTag}`,
+   `${includeStartTag}\\s*include\\((['|"])(${pathnameREStr})\\1\\s*(?:,\\s*(${argsREStr}))?\\s*\\)\\s*${includeEndTag}`,
    'g',
  )

+ const variableNameRE = '\\S+'
+ const variableRE = new RegExp(
+   `${variableStartTag}\\s*(${variableNameRE})\\s*${variableEndTag}`,
+   'g',
+ )

  const source = replaceIncludeRecursive({
-   apiContext: this, content, includeRE, pathRelative, maxIncludes,
+   apiContext: this, content, includeRE, variableRE, pathRelative, maxIncludes,
  })

  return source
}

其中,当loader处理过程中遇到错误时,可以借助oader API的emitError来对外输出错误信息。

至此,我们实现了webpack-html-include-loader所应该具备的所有主要功能。为了让使用者更加得心应手,我们再扩展实现一下自定义语法标记的功能。

四、自定义语法标记

通过指定loader的options,或者内嵌query的形式,我们可以传入自定义选项。本文是从webpack-html-plugin说起,我们就以此为例。我们将文章开头的webpack-html-plugin相关的代码做如下修改,将include的起始标记改为<#-

entrys.map(entryName => {
  htmlWebpackPlugins.push(
    new HtmlWebpackPlugin({
-     template: `${entryName}.html`,
+     template: `html-loader!webpack-html-include-loader?includeStartTag=<#-!${entryName}.html`,
      filename: `${entryName}.html`,
      chunks: ['vendor', 'common', entryName],
    }),
  )
})

其中,webpack-html-include-loader解决了文件包含的问题,html-loader解决了图片等资源的处理。如果你也有类似需求,可以作参考。

想要实现自定义的语法标记也很简单,将自定义的标记动态传入正则即可。只有一点需要注意,那就是要对传入的值进行转义

正则表达式中,需要反斜杠转义的,一共有12个字符:^.[$()|*+?{\\如果使用RegExp方法生成正则对象,转义需要使用两个斜杠,因为字符串内部会先转义一次

代码逻辑如下:

module.exports = function (content) {
  const defaultOptions = {
    includeStartTag: '<%-',
    includeEndTag: '%>',
    variableStartTag: '<%=',
    variableEndTag: '%>',
    maxIncludes: 5,
  }
+ const customOptions = getOptions(this)

+ if(!isEmpty(customOptions)) {
+   // 对自定义选项中需要正则转义的内容进行转义
+   Object.keys(customOptions).filter(key => key.endsWith('Tag')).forEach((tagKey) => {
+     customOptions[tagKey] = escapeForRegExp(customOptions[tagKey])
+   })
+ }

- const options = Object.assign({}, defaultOptions)
+ const options = Object.assign({}, defaultOptions, customOptions)
  // ...
}

escapeForRegExp的逻辑如下,其中$&为正则匹配的字符串:

// 转义正则中的特殊字符
function escapeForRegExp(str) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

其中,getOptions方法是loader-utils提供的方法,它额外还提供了了很多工具,在进行loader开发时很有用武之地。

五、其他一些逻辑

除了上面的核心功能,还有比较细的逻辑,比如借助schema-utils对自定义选项进行验证,自定义的一些通用函数,这里就不一一介绍了。感兴趣的同学可以在翻看翻看源码。链接如下:https://github.com/verymuch/webpack-html-include-loader,欢迎批评指正 + star。

总结

本文介绍了webpack-html-include-loader的主要功能以及开发思路,希望读完本文你能够有所收获,对于webpack loader的开发有一个简单的了解。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,816评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,729评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,300评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,780评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,890评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,084评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,151评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,912评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,355评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,666评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,809评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,504评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,150评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,121评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,628评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,724评论 2 351

推荐阅读更多精彩内容