純手擼簡單實用的webpack-html-include-loader(附開發詳解)


背景介紹

在單頁應用盛行的今天,不少人彷佛已經把簡單的切圖不當作一種技術活了。對於切頁面,寫靜態網站都快要嗤之以鼻了。其實並不是如此,寫靜態頁面是前端入門的基本工做,是基本功紮實的體現。並且在工做中,咱們也少不了要開發一些靜態的官網類網站。咱們要作的是想想如何更好的開發靜態頁面。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 包含如下核心功能:

  • 支持 include html 文件
  • 支持嵌套 include
  • 支持傳入參數 & 變量解析
  • 支持自定義語法標記

本文依次介紹這 4 個核心功能,並講解相關實現。讀完本文,你會收穫如何使用這一 loader,而且獲悉一點 webpack loader 的開發經驗,若有問題還請不吝賜教。

1、實現基礎的包含功能

爲了可以更靈活的組織靜態頁面,咱們必不可少的功能就是 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 包含功能。可是,咱們顯然不知足於此,最起來嵌套包含仍是要支持的吧?下面咱們一塊兒來看看如何實現嵌套包含。

2、提升包含的靈活度:嵌套包含

上面,咱們已經實現了基礎的包含功能,再去實現嵌套包含其實就很簡單了。遞歸地處理一下就行了。因爲要遞歸調用,因此咱們將 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的包含是支持傳入參數的,能夠替換包含模板中的一些內容。咱們能夠稱之爲變量。

3、傳入參數 & 變量解析

一樣,先設定一個默認的傳入參數的語法標記,以下:<%- 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 所應該具有的全部主要功能。爲了讓使用者更加駕輕就熟,咱們再擴展實現一下自定義語法標記的功能。

4、自定義語法標記

經過指定 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 開發時頗有用武之地。

5、其餘一些邏輯

除了上面的核心功能,還有比較細的邏輯,好比藉助schema-utils對自定義選項進行驗證,自定義的一些通用函數,這裏就不一一介紹了。感興趣的同窗能夠在翻看翻看源碼。連接以下:github.com/verymuch/we…,歡迎批評指正 + star。

總結

本文介紹了webpack-html-include-loader的主要功能以及開發思路,但願讀完本文你可以有所收穫,對於 webpack loader 的開發有一個簡單的瞭解。


若是你喜歡,歡迎掃碼關注個人公衆號,我會按期陪讀,並分享一些其餘的前端知識喲。

相關文章
相關標籤/搜索