简单实用的webpack-html-include-loader(附开发详解)
- 2020 年 3 月 31 日
- 筆記
背景介绍
在单页应用盛行的今天,很多人似乎已经把简单的切图不当做一种技术活了。对于切页面,写静态网站都快要嗤之以鼻了。其实并非如此,写静态页面是前端入门的基本工作,是基本功扎实的体现。而且在工作中,我们也少不了要开发一些静态的官网类网站。我们要做的是想一想如何更好的开发静态页面。
歪马最近因工作原因,需要对一个托管于内容管理系统的官网类网站进行迁移。既然要重新弄,那工程化自然少不了,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[1]。
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[2]提供的,context
表示当前文件的所在目录。借助这一属性,我们能够获取被包含文件的具体路径,进而获取文件内容进行替换。
此外,你可能还注意到了代码中还调用了this.addDependency(filePath)
,这一方法可以将文件添加到了依赖中,这样就可以监听到文件的变化了。
其余逻辑比较简单,如果你对字符串replace
不是很熟悉,推荐看下阮一峰老师的这篇正则相关的基础文档[3]。
好了,到现在我们实现了最基础的 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[4]提供的方法,它额外还提供了了很多工具,在进行 loader 开发时很有用武之地。
五、其他一些逻辑
除了上面的核心功能,还有比较细小的逻辑,比如借助schema-utils[5]对自定义选项进行验证,自定义的一些通用函数,这里就不一一介绍了。感兴趣的同学可以在翻看翻看源码。链接如下:https://github.com/verymuch/webpack-html-include-loader[6],欢迎批评指正 + star。
总结
本文介绍了webpack-html-include-loader
的主要功能以及开发思路,希望读完本文你能够有所收获,对于 webpack loader 的开发有一个简单的了解。
参考资料
[1]
webpack-html-include-loader: https://github.com/verymuch/webpack-html-include-loader
[2]
loader API: http://webpack.docschina.org/api/loaders#this-context
[3]
阮一峰老师的这篇正则相关的基础文档: https://javascript.ruanyifeng.com/stdlib/regexp.html#toc8
[4]
loader-utils: https://github.com/webpack/loader-utils
[5]
schema-utils: https://github.com/webpack-contrib/schema-utils
[6]
https://github.com/verymuch/webpack-html-include-loader: https://github.com/verymuch/webpack-html-include-loader
最后
- 欢迎加我微信(winty230),拉你进技术群,长期交流学习…
- 欢迎关注「前端Q」,认真学前端,做个专业的技术人…