基于Webpack4与EJS的传统多页面应用组件化重构方案

作者:清欢时间:2026-10-23 06:58:38浏览:221

本文详解如何利用Webpack4结合EJS模板引擎,解决传统MVC模式下前后端耦合严重的问题。通过配置Babel、Sass、PostCSS及各类优化插件,实现资源打包与代码压缩。核心方案采用Node.js动态生成临时EJS模板,结合html-webpack-plugin实现页面组件化与热更新,最终达成前后端彻底分离的高效开发流程。

项目背景与技术方案

近期有一个项目仍沿用传统的MVC模式进行开发,完全依赖jQuery,并使用了基于Java的Velocity模板引擎。由于页面中嵌入了大量Java语法,导致前后端分离不够彻底,工程打包和上线过程十分繁琐。为了实现后端服务化,前端也必须从后端中完全剥离出来。

采用的技术方案为Webpack4配合EJS模板引擎。

关于Webpack的配置与使用。

  • 负责打包所有静态资源。
  • 负责打包所有脚本文件。
  • 负责打包所有图片资源。
  • 负责打包所有样式文件。
  • 负责打包表格相关资源。

关于EJS模板引擎的介绍。

EJS是一个高效的JavaScript模板引擎,旨在替代Velocity,提升前端开发效率。

Webpack配置与插件

Webpack的具体配置细节。

基础插件的配置列表。

  • 包括@babel/core、@babel/preset-env和babel-loader。

用于将ES6语法转译为兼容环境的代码。

  • 包括css-loader和style-loader。

用于编译并打包CSS样式。

  • 包括node-sass和sass-loader。

用于解析Sass语法。

  • 包括postcss-loader和autoprefixer。

自动为CSS样式添加浏览器厂商前缀。

  • mini-css-extract-plugin插件。

将CSS从JS中抽离出来,生成独立的CSS文件。

  • optimize-css-assets-webpack-plugin插件。

用于压缩CSS代码。

  • uglifyjs-webpack-plugin插件。

用于压缩JavaScript代码。

  • ejs-loader插件。

用于解析EJS模板文件。

  • html-webpack-plugin插件。

用于自动生成HTML文件。

  • rimraf工具。

用于删除文件或文件夹。

  • watch工具。

用于监听文件变化。

上述为所需插件列表,具体用法此处不再赘述。

多入口与出口配置

入口文件的配置。

入口文件结构如下(支持单一入口或多入口模式):


// 多入口
entry: {
 pageA: './src/pageA/index.js',
 pageB: './src/pageB/index.js',
 'pageC/login': './src/pageC/login/login.js'
}

出口文件的配置如下:


output: {
 filename: '[name].js',
 path: path.resolve(__dirname, '../dist'),
}

filename值中的[name]对应入口文件的key值,并通过/分割文件夹层级。

最终会在dist文件夹下生成如下文件结构:

  • dist/pageA/index.js
  • dist/pageB/index.js
  • dist/pageC/login/login.js

既然是多页面开发,就需要配置多个入口,每个页面都应有对应的JS入口。我们只需遍历HTML文件,找到对应的JS,并将其处理为entry对象即可。


const path = require('path')
const glob = require('glob')

const pages = (entries => {
 let entry = {}, htmlArr = []
 // 格式化生成入口
 entries.forEach((file) => {
  // ...../webpack-mvc/src/page/pageA/index.html
  const fileSplit = file.split('/')
  const length = fileSplit.length

  // 页面入口 pageA/index.html
  const filePath = fileSplit.slice(length - 2, length).join('/') 

  // 根据html路径找到对应的js路径,js可以和html放在同一文件夹,也可单独放在一个文件夹内,只要能找到 
  const jsPath = path.resolve(__dirname, `../src/page/${filePath.split('.')[0]}.js`) 

  // _main.ejs 页面主题框架,html组件化
  pageHtml = path.resolve(__dirname, '../src/_main.ejs') 

  if (!fs.existsSync(jsPath)) {
   return;
  }
  entry['js/' + filePath.split('.')[0]] = jsPath // 加 js/ 即表示将打包后的js单独放在一个文件夹内
 })
 return entry
})(glob(path.resolve(__dirname, '../src/page/*/*.html'), {sync: true}))

以上仅为示例目录结构,可根据实际目录结构调整路径,核心目的是建立‘JS打包生成路径’与‘入口JS’的映射关系。

EJS组件化与模板处理

HTML(EJS)组件化实现。

页面框架搭建。

1、主体框架src/_main.ejs。


<!DOCTYPE html>
<html lang="en">

<head>
 <meta charset="utf-8">
 <meta http-equiv="X-UA-Compatible" content="IE=edge">
 <meta name="viewport" content="width=device-width,initial-scale=1.0">
 <title><%= htmlWebpackPlugin.options.title %></title>
</head>

<body>
 <div class="main-head">
  <%= require('@/common/components/header/header.ejs')() %>
 </div>

 <div class="main-content">
  <%= htmlWebpackPlugin.options.content %>
 </div>

 <div class="main-foot">
  <%= require('@/common/components/footer/footer.ejs')() %>
 </div>
</body>

</html>

2、公共页面组件。

header和footer每个页面都包含,因此放入主体框架页面内。

3、页面各自部分。

各个页面只需编写自身页面的HTML内容,并可引入公共EJS组件。


// pageA/index.html
<div>
 <h1>pageA index</h1>
</div>

// pageA/login.html
<div>
 <%= require('@/common/components/form.ejs')() %>
 <h1>pageA login</h1>
</div>

查阅大量资料后,未找到能直接实现上述步骤的方法,多数方案要求在每个页面的JS中编写EJS语法,无法做到仅关注页面本身内容。

替换_main.ejs,生成临时模板。

我的解决方案是通过Node.js读取页面HTML文件,然后替换_main.ejs中的content部分,生成一个临时EJS模板文件,再通过html-webpack-plugin插件生成最终的页面HTML文件。


function createTemplate(file, jsPath, entry) {
 let obj = {
  title: '',
  template: '',
  filename: '',
  chunks: [jsPath]
 }
 // _main.ejs 页面主题框架,html组件化
 let mainHtml = path.resolve(__dirname, '../src/_main.ejs')
 let fileSplit = file.split('/')
 // html 生成路径
 let filename = fileSplit.slice(fileSplit.length - 2).join('/').split('.')[0];

 let strContent = fs.readFileSync(file, 'utf-8')
 let strMain = fs.readFileSync(mainHtml, 'utf-8')
 let template = fileSplit.slice(fileSplit.length - 2).join('_').split('.')[0];
 strMain = strMain.replace(/<%= htmlWebpackPlugin.options.content %>/, strContent)
 fs.writeFileSync(path.resolve(__dirname, `../src/template/template_${template}.ejs`), strMain)

 obj.template = path.resolve(__dirname, `../src/template/template_${template}.ejs`)
 obj.filename = filename
 return obj
}

基于上述思路,我们可以在各自页面中进行更多操作。

页面title的处理。


// pageA/index.html

<%=title 页面A %>
<div>
 <h1>pageA index</h1>
</div>

页面直接引入JS,仅压缩不打包。


// pageA/index.html

<%=title 页面A %>

<div>
 <h1>pageA index</h1>
</div>

<script src="js/common/util.js"></script>
<script src="js/common/server.api.js"></script>

这里引入的JS路径是最终文件压缩生成的位置(dist目录下)。由于开发模式和生产环境路径不同,后续代码中需区分不同环境以替换相应的路径。

页面引入EJS组件。


// pageA/index.html

<%=title 页面A %>

<div>
 <%= require('@/common/components/form.ejs')() %>
 <h1>pageA index</h1>
</div>

<script src="js/common/util.js"></script>
<script src="js/common/server.api.js"></script>

page.config.js配置。


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

if (process.env.NODE_ENV === 'development') {
 const rimraf = require('rimraf')
 rimraf.sync(path.resolve(__dirname, '../src/template/*'), fs, function cb() {
  console.log('template目录已清空')
 })
}

const pages = (entries => {
 let entry = {}, htmlArr = []
 // 格式化生成入口
 entries.forEach((file) => {
  // ...../webpack-mvc/src/page/pageA/index.html
  let fileSplit = file.split('/')
  let length = fileSplit.length

  // 页面入口 page/pageA/index.html
  let filePath = fileSplit.slice(length - 3, length).join('/')

  // 根据html路径找到对应的js路径,js可以和html放在同一文件夹,也可单独放在一个文件夹内,只要能找到
  let jsFile = path.resolve(__dirname, `../src/${filePath.split('.')[0]}.js`)
  if (!fs.existsSync(jsFile)) {
   return;
  }
  let jsPath = 'js/' + filePath.split('.')[0]
  entry['js/' + filePath.split('.')[0]] = jsFile
  htmlArr.push(createTemplate(file, jsPath, entry))
 })
 return {entry, htmlArr}
})(glob(path.resolve(__dirname, '../src/page/*/*.html'), {sync: true}))

function scriptLinkEntry(entry, file) {
 // file: /js/common/js/util.js
 let fileNew = './src/' + file.split('/').slice(2).join('/')
 let fileSplit = fileNew.split('/')
 entry['js/common/' + fileSplit.slice(fileSplit.length - 1).join('/').replace('.js', '')] = fileNew
}

function replaceScript(content, entry) {
 let scriptLink = content.match(/<script.*src=["|'](.*)["|']><\/script>/g)
 if (scriptLink) {
  scriptLink.forEach(item => {
   // src: /js/common/js/util.js
   let src = item.match(/src=["|'](.*)["|']/)[1];
   scriptLinkEntry(entry, src)
   let scriptlinNew = src
   // 生产环境根据页面路径找到js的相对路径,开发环境 /js/ 指向 dist 目录下 js 文件夹
   if (process.env.NODE_ENV === 'production') {
    let srcSplit = src.split('/')
    srcSplit.splice(3, 1) // ['', 'js', 'common', 'util.js']
    scriptLinkNew = `..${srcSplit.join('/')}` // ../js/common/util.js
   }
   content = content.replace(src, scriptLinkNew) 
  })
 }
 return content;
}

function createTemplate(file, jsPath, entry) {
 let obj = {
  title: '',
  template: '',
  filename: '',
  chunks: [jsPath]
 }
 // _main.ejs 页面主题框架,html组件化
 let mainHtml = path.resolve(__dirname, '../src/_main.ejs')
 let fileSplit = file.split('/')
 // html 生成路径
 let filename = fileSplit.slice(fileSplit.length - 2).join('/').split('.')[0];

 let strContent = fs.readFileSync(file, 'utf-8')
 let strMain = fs.readFileSync(mainHtml, 'utf-8')
 let template = fileSplit.slice(fileSplit.length - 2).join('_').split('.')[0]

 // 提取页面title
 let titleMatch = strContent.match(/<%=title(.*)%>/)
 let title = ''
 if (titleMatch) {
  title = titleMatch[1]
  strContent = strContent.replace(/<%=title(.*)%>/, '')
 }

 // 提取页面与主体框架中引入的静态js文件,将其放入入口文件中经行压缩,并适应开发与生产路径
 strMain = replaceScript(strMain, entry)
 strContent = replaceScript(strContent, entry)

 strMain = strMain.replace(/<%= htmlWebpackPlugin.options.content %>/, strContent)
 fs.writeFileSync(path.resolve(__dirname, `../src/template/template_${template}.ejs`), strMain)

 obj.title = title
 obj.template = path.resolve(__dirname, `../src/template/template_${template}.ejs`)
 obj.filename = filename
 return obj
}

module.exports = pages;

热更新与国际化支持

热刷新机制。

此时热刷新只能监听到JS和CSS的改变,因为模板是动态生成的,更改页面内容时模板并未改变,所以无法触发devServer的热刷新,手动刷新也无变化,因为临时模板文件未变。借助watch插件监听HTML文件变化,然后重写模板文件即可解决问题。


const fs = require('fs')
const path = require('path')
const watch = require('watch')
const { replaceScript } = require('./page.config.js')

watch.watchTree(path.resolve(__dirname, '../src/page'), (f, curr, prev) => {
 if (typeof f == 'object' && prev === null && curr === null) {
  // Finished walking the tree
 } else if (prev === null) {
  // f is a new file
  createTemplate(f)
 } else if (curr.link === 0) {
  // f was removed
 } else {
  createTemplate(f)
 }
})

function createTemplate(file) {
 if (file.indexOf('.html') === -1) {
  return
 }
 console.log('file', file)
 let mainHtml = path.resolve(__dirname, '../src/_main.ejs')
 let strContent = fs.readFileSync(file, 'utf-8')
 let strMain = fs.readFileSync(mainHtml, 'utf-8')
 let template = file.split('\\').slice(file.split('\\').length - 2).join('_').split('.')[0]
 // 提取页面与主体框架中引入的静态js文件,将其放入入口文件中经行压缩,并适应开发与生产路径
 // 这里不再处理 title 和 静态js 入口压缩
 strMain = replaceScript(strMain, {}, true)
 strContent = replaceScript(strContent, {}, true)
 strContent = strContent.replace(/<%=(.*)%>/, '')
 strMain = strMain.replace(/<%= htmlWebpackPlugin.options.content %>/, strContent)
 fs.writeFileSync(path.resolve(__dirname, `../src/template/template_${template}.ejs`), strMain)
}

这里不再处理title和静态JS入口压缩,更改这些内容后只能重新执行npm run dev。

国际化支持。


const languageProperty = require('../properties/language.properties.js')

function getLanText(val) {
 let lan = 'zh' // $.cookie('lan')
 let str = languageProperty[val] && languageProperty[val][lan] || val
 let defaultOpt = languageProperty[val] && languageProperty[val]['default']
 let opts = defaultOpt && $.extend(true, [], defaultOpt)
 opts ? opts.unshift('') : false
 let args = opts && arguments.length === 1 ? opts : arguments
 if (args.length > 1) {
  let params = Array.property.slice.call(args, 1)
  return str.replace(/{(\d+)}/g, function(curr, index) {
   return params[index]
  })
 } else {
  return str
 }
}

function translateAll() {
 let num = $('html').find('[lang]').length
 let count = 0
 if (num === 0) {
  $('body').show()
 }
 $('html').find('[lang]').each(function() {
  count += 1;
  let lang = $(this).attr('lang')
  if (lang === '') {
   return;
  }
  let nodeName = $(this)[0].nodeName
  let text = getLanText(lang)
  // 简单处理,复杂的可再这里更改
  if (nodeName === 'INPUT') {
   $(this).attr('placeholder', text)
  } else {
   $(this).html(text)
  }
  if (count === num) {
   $('body').show()
  }
 })
}

module.exports = { getLanText, translateAll }

在header.js里调用一次即可。

总结与展望

结语。

至此,传统多页面组件化开发流程基本完成,可以完全脱离后台,愉快地进行前端开发,抛弃Eclipse,拥抱VSCode。

本文仅构建了基本框架,中间还有许多优化点,如打包速度、公共代码提取等尚未深入探讨。随着页面和代码量的增加,这些将是必须研究的课题,前路漫漫,任重道远。

GitHub可直接运行npm run dev和npm run build,顺便求个Star 😄

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章