【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构
前言
最近接了一个公司官网的项目,需要 SEO 友好,所以不能使用前端框架,前端框架自带的脚手架工具自然也帮不上啥忙。只好自己使用 webpack4 + ejs + express
,从头搭建一个多页应用的项目架构。搭建过程中,遇到许多坑,然而网上的相关参考也是非常少,所以写个博客记录一下搭建过程以及注意事项。
项目地址
明确需求
在动手开发之前,我们需要先明确这个项目的定位——公司官网,一般来说,官网不会涉及大量的数据交互,比较偏向于数据展示。所以不用前端框架,jquery
即可满足需求。但是考虑到 SEO 所以需要用到服务端渲染,就要使用模板语言(ejs
),配合 node 来完成。
根据以上信息,我们就可以确定打包脚本的基本功能,先来简单列个清单:
- 需要
webpack
来打包多页应用,且不需要每次新增一个视图文件都添加一个HTMLWebpackPlugin
和重启 server ,能做到 webpack 配置和文件名解耦,尽量的自动化。 - 需要使用
ejs
模板语言编写,能够插入变量和外部includes
文件,最后运行 build 命令的时候能将通用模板文件(<meta>/<title>/<header>/<footer>
等)自动插入每个视图文件对应位置。 - 需要用到服务端渲染,所以开发环境要脱离 webpack 集成的
webpack-dev-server
,能使用自己编写的 node 代码启动服务。 - 拥有完善的
overlay
功能,可以像webpack-dev-server
那样集成漂亮的 overlay 屏幕报错。 - 能监听文件变化,自动打包和重启服务,最好能做到热更新
开始构建
先建立一个空项目,由于需要自己编写服务端代码,所以我们需要多建一个 /server
文件夹,用来存放 express
的代码,搭建完成后,我们的项目结构看起来是这样。
除此以外,我们需要初始化一些通用配置文件,包括:
.babelrc
babel 配置文件.gitignore
git 忽略文件.editorConfig
编辑器配置文件.eslintrc.js
eslint 配置文件README.md
文件package.json
文件
大的框架出来以后,我们开始编写工程代码。
打包脚本
首先是编写打包脚本,在/build
文件夹里新建几个文件
webpack.base.config.js
,用来存放生产环境和开发环境通用的 webpack 配置webpack.dev.config.js
用来存放开发环境的打包配置webpack.prod.config.js
用来存放生产环境的打包配置config.json
用来存放一些配置常量,例如端口名,路径名之类。
一般来说,webpack.base.config
文件里,放一些开发生产环境通用的配置,例如 output
、entry
以及一些 loader
, 例如编译ES6语法的 babel-loader
、打包文件的 file-loader
等。常用的 loader 的使用方式我们可以查看文档 webpack loaders,需要注意的是,这边有个非常重要的 loader ———— ejs-html-loader
一般来说,我们使用 html-loader
来对.html
结尾的视图文件做处理,然后扔给 html-webpack-plugin
生成对应的文件,但是 html-loader
无法处理 ejs 模板语法中的 <% include ... %>
语法,会报错。然而在多页应用里,这个 include 的功能是必须的,不然每个视图文件里都要手动去写一份 header/footer
是什么感觉。。。所以我们需要再多配置一份 ejs-html-loader:
// webpack.base.config.js 部分代码 module: { rules: [ ... { test: /\.ejs$/, use: [ { loader: 'html-loader', // 使用 html-loader 处理图片资源的引用 options: { attrs: ['img:src', 'img:data-src'] } }, { loader: 'ejs-html-loader', // 使用 ejs-html-loader 处理 .ejs 文件的 includes 语法 options: { production: process.env.ENV === 'production' } } ] } ... ] }
第一个坑绕过之后,第二个:entry 入口要怎么写?
记得之前公司的一个老项目,五十几个页面,五十几个 entry
和 new HTMLwebpackPlugin()
一个文件展开来可以绕地球一圈。。。这边为了避免这种惨状,写一个方法,返回一个 entry 数组。
可以使用 glob 来处理这些文件,获取文件名,当然同样也可以使用原生 node 来实现。只要保证 JavaScript
文件名和视图文件名相同即可,比如,首页的视图文件名是 home.ejs
,那么对应的脚本文件名就要用同样的名字 home.js
来命名,webpack 打包的时候会找到脚本文件入口,通过映射关系生成对应视图文件:
// webpack.base.config.js 部分代码 const Webpack = require('Webpack') const glob = require('glob') const { resolve } = require('path') // webpack 入口文件 const entry = ((filepathList) => { let entry = {} filepathList.forEach(filepath => { const list = filepath.split(/[\/|\/\/|\\|\\\\]/g) // 斜杠分割文件目录 const key = list[list.length - 1].replace(/\.js/g, '') // 拿到文件的 filename // 如果是开发环境,才需要引入 hot module entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client?reload=true'] : filepath }) return entry })(glob.sync(resolve(__dirname, '../src/js/*.js'))) module.exports = { entry, ... }
HTMLWebpackPlugin 的配置也同理:
// webpack.base.config.js 部分代码 ... plugins: [ // 打包文件 ...glob.sync(resolve(__dirname, '../src/tpls/*.ejs')).map((filepath, i) => { const tempList = filepath.split(/[\/|\/\/|\\|\\\\]/g) // 斜杠分割文件目录 const filename = `views/${tempList[tempList.length - 1]}` // 拿到文件的 filename const template = filepath // 指定模板地址为对应的 ejs 视图文件路径 const fileChunk = filename.split('.')[0].split(/[\/|\/\/|\\|\\\\]/g).pop() // 获取到对应视图文件的 chunkname const chunks = ['manifest', 'vendors', fileChunk] // 组装 chunks 数组 return new HtmlWebpackPlugin({ filename, template, chunks }) // 返回 HtmlWebpackPlugin 实例 }) ] ...
编写好 webpack.base.config.js
文件,根据自己项目需求编写好 webpack.dev.config.js
和 webpack.prod.config.js
,使用 webpack-merge 将基础配置和对应环境下的配置合并。
webpack 其他的一些细节配置大家可以参考 webpack 中文网址
服务端
打包脚本编写完成,我们开始编写服务,我们使用 express
来搭建服务。(由于是工程架构演示,所以这个服务暂不涉及任何的数据库的增删改查,只是包含基本的路由跳转)
server
简单的结构如下:
服务端启动文件
bin/server.js
启动文件,作为服务的入口,需要同时启动本地服务和 webpack 的开发时编译。一般项目 webpack-dev-server
是写在 package.json
里的,当你运行 npm run dev
的时候,就在使用 webpack-dev-server
启动开发服务,这个 webpack-dev-server 功能十分强大,不仅能一键启动本地服务,还可以监听模块,实时编译。这边我们使用 express
+ webpack-dev-middleware 也可以达到同样的功能。
webpack-dev-middleware 可以理解为一个抽离出来的 webpack-dev-server,只是没有启动本地服务的功能,以及使用方式上略有改变。它相比于 webpack-dev-server 的灵活性在于,它以一个中间件的形式存在,允许开发者编写自己的服务来使用它。
其实 webpack-dev-server 的内部实现机制也是借助于 webpack-dev-middleware 和 express 有兴趣的朋友可以去看一下。
以下是服务入口文件的部分代码
// server/bin/server.js 文件代码 const path = require('path') const express = require('express') const webpack = require('webpack') const webpackDevMiddleware = require('webpack-dev-middleware') const webpackHotMiddleware = require('webpack-hot-middleware') const { routerFactory } = require('../routes') const isDev = process.env.NODE_ENV === 'development' let app = express() let webpackConfig = require('../../build/webpack.dev.config') let compiler = webpack(webpackConfig) // 开发环境下才需要启用实时编译和热更新 if (isDev) { // 用 webpack-dev-middleware 启动 webpack 编译 app.use(webpackDevMiddleware(compiler, { publicPath: webpackConfig.output.publicPath, overlay: true, hot: true })) // 使用 webpack-hot-middleware 支持热更新 app.use(webpackHotMiddleware(compiler, { publicPath: webpackConfig.output.publicPath, noInfo: true })) } // 添加静态资源拦截转发 app.use(webpackConfig.output.publicPath, express.static(path.resolve(__dirname, isDev ? '../../src' : '../../dist'))) // 构造路由 routerFactory(app) // 错误处理 app.use((err, req, res, next) => { res.status(err.status || 500) res.send(err.stack || 'Service Error') }) app.listen(port, () => console.log(`development is listening on port 8888`))
服务端路由
路由的跳转方式,属于整个工程中非常重要的一步。不知道阅读文章的朋友有没有疑问,本地的视图文件是 .ejs 后缀结尾的文件,浏览器只能识别 .html 后缀文件,这块视图数据的渲染是怎么做的? webpack-dev-middleware 打包出来的资源都是存在内存中的,存储在内存中的资源文件,服务端要怎么获取?
先来看具体的路由代码,此处以首页路由作为演示
// server/routs/home.js 文件 const ejs = require('ejs') const { getTemplate } = require('../common/utils') const homeRoute = function (app) { app.get('/', async (req, res, next) => { try { const template = await getTemplate('index.ejs') // 获取 ejs 模板文件 let html = ejs.render(template, { title: '首页' }) res.send(html) } catch (e) { next(e) } }) app.get('/home', async (req, res, next) => { try { const template = await getTemplate('index.ejs') // 获取 ejs 模板文件 let html = ejs.render(template, { title: '首页' }) res.send(html) } catch (e) { next(e) } }) } module.exports = homeRoute
可以看到关键点就在 getTemplate 这个方法,我们看看这个 getTemplate
做了咩
// server/common/utils.js 文件 const axios = require('axios') const CONFIG = require('../../build/config') function getTemplate (filename) { return new Promise((resolve, reject) => { axios.get(`http://localhost:8888/public/views/${filename}`) // 注意这个 'public' 公共资源前缀非常重要 .then(res => { resolve(res.data) }) .catch(reject) }) } module.exports = { getTemplate }
从上面代码可以看到,路由中的做的非常重要的事情,就是直接用对应视图的 ejs 文件名,去请求自身服务,从而获取到存在 webpack 缓存中的资源和数据。
通过这种方式拿到模板字符串后,ejs 引擎会用数据渲染对应变量,最终以 html 字符串的形式返回到浏览器进行渲染。
本地服务会以一个 publicPath 路径前缀来标记静态资源请求,如果服务接受到的请求是带有 publicPath 前缀,就会被 /bin/server.js
中的静态资源中间件拦截到,映射到对应资源目录,返回静态资源,而这个 publicPath 就是 webpack 配置中的 output.publicPath
关于 webpack 的打包时缓存,我之前翻了很多地方都没有找到很好的文档和操作工具,这边给大家推荐两个链接
- Webpack Custom File Systems (webpack 自定义文件系统官方说明)
- memory-fs(获取 webpack 编译到内存中的数据)
客户端
完成了服务端渲染、webpack 构建配置后,算是搞定了 80% 的工作量,还有一些小细节需要注意,不然服务启动起来还是会报错。
webpack 编译时的坑
这个坑就埋在客户端的视图文件里,先来看看坑是什么:当我们使用 ejs 语法(<%= title %>
)这种语法的时候,webpack 编译就会报错,说是 title is undefined
要解决这个问题,需要首先明白 webpack 编译时的运行机制,它做了什么。我们知道,webpack 内部模板机制就是基于的 ejs,所以在我们服务端渲染之前,也就是 webpack 的编译阶段,已经执行过了一次 ejs.render 了,这个时候,在 webpack 的配置文件里,我们是没有传递过 title 这个变量的,所以编译会报错。那么要怎么写才能识别呢?答案就在 ejs 的官方文档
从官网的介绍上可以看出,当我们使用 <%%
打头的时候,会被转义成 <%
字符串,类似于 html 标签的转义,这样才能避免 webpack 中自带的 ejs 的错误识别,生成正确的 ejs 文件。所以以变量为例,在代码中我们需要这样写: <%%= title %>
这样,webpack 才能顺利编译完成,将 compiler 继续传递到 ejs-html-loader 这里
使用 html-loader 识别图片资源
如果了解 html-loader
的朋友就知道,在项目中,我们之所以能够在 html 中方便的写 <img src="../static/imgs/XXX.png">
这种图片格式,还能被 webpack 正确识别,离不开 html-loader 里的 attrs
配置项,
但是在 ejs-html-loader 里,没有提供这种方便的功能,所以我们依旧要使用 html-loader
来对 html 中的图片引用做处理,这边需要注意 loader 的配置顺序
// webpack.base.config.js 部分代码 module: { rules: [ ... { test: /\.ejs$/, use: [ { loader: 'html-loader', // 使用 html-loader 处理图片资源的引用 options: { attrs: ['img:src', 'img:data-src'] } }, { loader: 'ejs-html-loader', // 使用 ejs-html-loader 处理 .ejs 文件的 includes 语法 options: { production: process.env.ENV === 'production' } } ] } ... ] }
配置热更新
接下来是配置热更新,使用 webpack-dev-middleware
时的热更新配置方式和 webpack-dev-server
略有不同,但是 webpack-dev-middleware
稍微简单一点。webpack 打包多页应用配置热更新,一共四步:
- 在
entry
入口里多写一个webpack-hot-middleware/client?reload=true
的入口文件
// webpack.base.config.js 部分代码 // webpack 入口文件 const entry = ((filepathList) => { let entry = {} filepathList.forEach(filepath => { ... // 如果是开发环境,才需要引入 hot module entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client?reload=true'] : filepath ... }) return entry })(...) module.exports = { entry, ... }
在 webpack 的
plugins
里多写三个 plugin:// webpack.dev.config.js 文件部分代码 plugins: [ ... // OccurrenceOrderPlugin is needed for webpack 1.x only new Webpack.optimize.OccurrenceOrderPlugin(), new Webpack.HotModuleReplacementPlugin(), // Use NoErrorsPlugin for webpack 1.x new Webpack.NoEmitOnErrorsPlugin() ... ]
在
bin/server.js
服务入口中引入webpack-hot-middleware
, 并将webpack-dev-server
打包完成的compiler
用webpack-hot-middleware
包装起来:// server/bin/server.js 文件 let compiler = webpack(webpackConfig) // 用 webpack-dev-middleware 启动 webpack 编译 app.use(webpackDevMiddleware(compiler, { publicPath: webpackConfig.output.publicPath, overlay: true, hot: true })) // 使用 webpack-hot-middleware 支持热更新 app.use(webpackHotMiddleware(compiler, { publicPath: webpackConfig.output.publicPath, reload: true, noInfo: true }))
在视图对应的 js 文件里加一段代码:
// src/js/index.js 文件 if (module.hot) { module.hot.accept() }
关于 webpack-hot-middleware 的更多配置细节,请看文档
这边需要注意的是:
1. 光是这么写的话,webpack hot module 只能支持 JS 部分的修改,如果需要支持样式文件( css / less / sass ... )的 hot reload ,就不能使用 extract-text-webpack-plugin 将样式文件剥离出去,否则无法监听修改、实时刷新。
2. webpack hot module 原生是不支持 html 的热替换的,但是很多开发者对于这块的需求比较大,于是我找了一个相对比较简单的方法,来支持视图文件的热更新
需要在原有的代码做一点修改,先来看代码:
// src/js/index.js 文件 import axios from 'axios' // styles import 'less/index.less' const isDev = process.env.NODE_ENV === 'development' // 在开发环境下,使用 raw-loader 引入 ejs 模板文件,强制 webpack 将其视为需要热更新的一部分 bundle if (isDev) { require('raw-loader!../tpls/index.ejs') } ... if (module.hot) { module.hot.accept() /** * 监听 hot module 完成事件,重新从服务端获取模板,替换掉原来的 document * 这种热更新方式需要注意: * 1. 如果你在元素上之前绑定了事件,那么热更新之后,这些事件可能会失效 * 2. 如果事件在模块卸载之前未销毁,可能会导致内存泄漏 */ module.hot.dispose(() => { const href = window.location.href axios.get(href).then(res => { const template = res.data document.body.innerHTML = template }).catch(e => { console.error(e) }) }) }
// webpack.dev.config.js plugins: [ ... new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('development') }) ... ]
// webpack.prod.config.js plugins: [ ... new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }) ... ]
OK,如你所愿,现在视图文件也支持热更新啦。