在单页应用盛行的今天,不少人彷佛已经把简单的切图不当作一种技术活了。对于切页面,写静态网站都快要嗤之以鼻了。其实并不是如此,写静态页面是前端入门的基本工做,是基本功扎实的体现。并且在工做中,咱们也少不了要开发一些静态的官网类网站。咱们要作的是想想如何更好的开发静态页面。javascript
歪马最近因工做缘由,须要对一个托管于内容管理系统的官网类网站进行迁移。既然要从新弄,那工程化天然少不了,webpack、css 预编译等全上了。这样才能向更好的开发体验靠齐。css
因为是静态官网,在使用 webpack 的时候,须要指定多入口,而且为不一样的入口指定不一样的 template 模板。借助html-webpack-plugin
能够为不一样的入口指定模板,以下所示:html
// ...
entrys.map(entryName => {
htmlWebpackPlugins.push(
new HtmlWebpackPlugin({
template: `${entryName}.html`,
filename: `${entryName}.html`,
chunks: ['vendor', 'common', entryName],
}),
)
})
复制代码
经过对入口列表进行遍历,咱们能够为不一样的入口指定不一样的模板。前端
在使用 Vue/React 等框架时,咱们早已习惯在开发的过程当中进行组件的抽取与复用。那么在这类纯静态的网站开发中,咱们也必定想要尽量的复用页面内的公共部分,如 header、footer、copyright 等内容。java
这些在服务端渲染的开发模式下早就已经很成熟了,借助模板引擎能够轻松地完成,如nunjucks
/pug
/ejs
等。webpack
webpack-html-plugin
中的template
默认使用的就是ejs
。既然官方使用的就是ejs
,那么咱们也先从这个方向找找方案。git
通过歪马的尝试,发现ejs
并不能很好的实现如下功能:github
支持 include,可是传参的格式不够优雅,用法以下:web
index.ejs:正则表达式
<h1><%= require('./header.ejs')({ title: '页面名称' }) %></h1>
复制代码
header.ejs:
<title><%= title %></title>
复制代码
不支持对文件内的图片 src 进行处理
没法对图片进行处理,这就没得玩了。歪马只能另寻他法,最后找到的方案都不理想。就本身动手实现了一个功能简单,方便易用的 HTML 包含 loader —— webpack-html-include-loader。
webpack-html-include-loader 包含如下核心功能:
本文依次介绍这 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对自定义选项进行验证,自定义的一些通用函数,这里就不一一介绍了。感兴趣的同窗能够在翻看翻看源码。连接以下:github.com/verymuch/we…,欢迎批评指正 + star。
本文介绍了webpack-html-include-loader
的主要功能以及开发思路,但愿读完本文你可以有所收获,对于 webpack loader 的开发有一个简单的了解。
若是你喜欢,欢迎扫码关注个人公众号,我会按期陪读,并分享一些其余的前端知识哟。