多页应用项目架构最终解决方案发布啦~css
【实战】webpack4 + ejs + egg 多页应用项目最终解决方案
egg
版本是在这一版本基础上所做的升级,服务端的健壮性和可扩展性都有所提升~ ,不过大部分细节这篇已经彻底介绍了,你们能够两篇结合着一块儿看。html
最近接了一个公司官网的项目,须要 SEO 友好,因此不能使用前端框架,前端框架自带的脚手架工具天然也帮不上啥忙。只好本身使用 webpack4 + ejs + express
,从头搭建一个多页应用的项目架构。搭建过程当中,遇到许多坑,然而网上的相关参考也是很是少,因此写个博客记录一下搭建过程以及注意事项。node
如下我会将重要的细节标红,给须要的朋友参考。jquery
这篇文章发表后,有朋友在评论区问为何不直接使用一些同构的框架,好比 nextjs
或者 nuxtjs
?这个问题可能也是咱们开发的时候比较纠结的问题之一,我说一下本身的想法。webpack
其实所谓的“同构”,也只是在加载网页第一屏的时候,使用了服务端渲染,由服务器解析 VDOM 生成真实 DOM 而后返回。等到网页第一屏代码加载完成、前端框架接管浏览器的时候,后续的整个流程,就已是客户端渲染,和服务端没有关系了。ios
优势git
缺点github
传统意义上的服务端渲染,则是在整个网页的生命周期内,都由服务端直接生成静态页面返回给客户端。web
优势
缺点
综合以上,当咱们在面对一个须要考虑 SEO
的项目,何时选择先后端同构,何时选择传统的服务端渲染?
我认为:若是你的项目是一个 toC
的产品、须要考虑 SEO
、涉及大量用户交互及频繁的需求变动,那么可能同构更适合你。它能以组件的方式构建你的项目,高度抽离和复用,而且可以支持一些在传统服务端渲染状况下根本无法作的功能,好比网页云音乐网页客户端,切换页面的时候还能保证歌曲不断,确定使用了同构。
而若是只是一个 toB
的中小型企业官网,考虑 SEO 可是不涉及大量的用户交互,一旦作完后期变更也不大,那么能够考虑选择传统的服务端渲染,也就是下面文章说起的方法。
在动手开发以前,咱们须要先明确这个项目的定位——公司官网,通常来讲,官网不会涉及大量的数据交互,比较偏向于数据展现。因此不用前端框架,jquery
便可知足需求。可是考虑到 SEO 因此须要用到服务端渲染,就要使用模板语言(ejs
),配合 node 来完成。
根据以上信息,咱们就能够肯定打包脚本的基本功能,先来简单列个清单:
webpack
来打包多页应用,且不须要每次新增一个视图文件都添加一个 HTMLWebpackPlugin
和重启 server ,能作到 webpack 配置和文件名解耦,尽可能的自动化。ejs
模板语言编写,可以插入变量和外部 includes
文件,最后运行 build 命令的时候能将通用模板文件(<meta>/<title>/<header>/<footer>
等)自动插入每一个视图文件对应位置。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% 的工做量,还有一些小细节须要注意,否则服务启动起来仍是会报错。
这个坑就埋在客户端的视图文件里,先来看看坑是什么:当咱们使用 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 中方便的写 <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,
...
}
复制代码
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
}))
复制代码
// 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,如你所愿,如今视图文件也支持热更新啦。😃😃
webpack-hot-middleware
默认继承了 overlay
,因此当热更新配置完成之后,overlay
报错功能也能正常使用了
最后来看一下 package.json
里的启动脚本,这边没啥难度,就直接上代码了
"scripts": {
"clear": "rimraf dist",
"server": "cross-env NODE_ENV=production node ./server/bin/server.js",
"dev": "cross-env NODE_ENV=development nodemon --watch server ./server/bin/server.js",
"build": "npm run clear && cross-env NODE_ENV=production webpack --env production --config ./build/webpack.prod.config.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
复制代码
当客户端代码变更时 webpack 会自动帮咱们编译重启,可是服务端的代码变更却不会实时刷新,这时须要用到 nodemon
,设置好监听目录之后,服务端的任何代码修改就能被 nodemon
监听,服务自动重启,很是方便。
这边也有一个小细节须要注意,nodemon --watch 最好指定监听服务端文件夹,由于毕竟只有服务端的代码修改才须要重启服务,否则默认监听整个根目录,写个样式都能重启服务,简直要把人烦死。
项目总体搭完后再回头看,仍是有很多须要注意和值得学习的地方。虽然踩了很多坑,但也对其中的一些原理有了更深刻的了解。
得益于前端脚手架工具,让咱们能在大部分项目中一键生成项目的基础配置,免去了不少工程搭建的烦恼,但这种方便在造福了开发者的同时,却也弱化了前端工程师的工程架构能力。现实中总有一些脚手架工具没办法的触及到的业务场景,这时就须要开发者主动寻求解决方案,甚至本身动手构建工程,以得到开发的最佳灵活性。
完整项目地址能够查看个人 GitHub ,喜欢的话给个 Star⭐️ ,多谢多谢~😃😃