Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单实用的webpack-html-include-loader(附开发详解) #13

Open
verymuch opened this issue Mar 23, 2020 · 4 comments
Open

简单实用的webpack-html-include-loader(附开发详解) #13

verymuch opened this issue Mar 23, 2020 · 4 comments

Comments

@verymuch
Copy link
Owner

背景介绍

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

歪马最近因工作原因,需要对一个托管于内容管理系统的官网类网站进行迁移。既然要重新弄,那工程化自然少不了,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 的开发有一个简单的了解。

@songxingguo
Copy link

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

这段代码有点问题,会将代码注入到页面中,而不是渲染页面,需要改成下面这段代码

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

@verymuch
Copy link
Owner Author

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

这段代码有点问题,会将代码注入到页面中,而不是渲染页面,需要改成下面这段代码

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

最上面的那段是ejs里的语法,用的是ejs-loader<%=应该是没问题吧

@songxingguo
Copy link

songxingguo commented Jun 18, 2020

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

这段代码有点问题,会将代码注入到页面中,而不是渲染页面,需要改成下面这段代码

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

最上面的那段是ejs里的语法,用的是ejs-loader<%=应该是没问题吧

  plugins: [new HtmlWebpackPlugin({
    template: 'src/index.ejs',
    inject: true,
    minify: {
      collapseWhitespace: true
    }
  }),

我这边是直接使用的是 HtmlWebpackPlugin ,没有使用 ejs-loader,<%= 就会输出代码, 而 <%- 则能正常渲染

看官网解释是 <%= 是会转义的,而 <%- 是非转义的

@verymuch
Copy link
Owner Author

如果没有设置loader的话,默认会使用 ejs-loader,参见链接1

ejs中使用include,参见链接2

不过,ejs的官方文档中确实是你给的这种用法,参见链接3的include部分。

如果确实如你上述所说的话,应该也没问题,可能我当时为了测试并没有写标签进去,所以不涉及到转义的问题,现在不太好浮现当时的场景了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants