前言
Github 完整项目地址
好久都没有写过文章了,之前写过一篇 《【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构》,发布后我发现大家对于 “简化多页应用开发流程” 这块需求强烈,并且,随着我将上一篇文章中介绍的多页开发模式运用到实际项目中后,发现还是存在一些缺陷的。其中痛点之一就是,使用 express
作为后台开发框架。
express
固然不错,但是现在开发讲究效率,所谓伸手就来开箱即用,这么一对比,express
还是偏向底层,作为服务端框架,很多东西还是要自己费心费神找插件 install
看文档。于是,这一次我准备使用更上层的 egg
作为替代框架。
虽然是上一版的进化版,但是很多主要的实现思路是没有变的,想要详细了解的朋友推荐先看下上一篇 《【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构》,这篇只做关键步骤分析,详细代码可见 Github 完整项目地址
项目结构
目录乍一看似乎有点多,没关系都是唬人的,最重要的几个目录我已在截图上标出,我们可以展开看下主要目录的详细目录结构:
egg 层
项目结构介绍完,下面就要开始改造之前的代码了,可是这么多代码从哪里动手呢?我们这次主要目的就是将 express
换成 egg
,那当然是从 egg
开始着手改造。
改造之前,我们还需要明白最重要的两个问题,这两个问题一旦被解决,可以说整个项目的改造也完成的差不多了。哪两个问题呢?
- 作为一个服务端框架
egg
要怎样与webpack
结合? - 使用
ejs
作为模板引擎,要怎样在dev
环境和prod
环境正确将ejs
渲染成html
并显示在页面上?
egg + webpack
在动手处理 egg
层之前,我们需要先去官方文档上了解一下这个框架。 由于是阿里旗下产品,所以框架本身的稳定性、生态建设程度和文档的友好性肯定是有保证的。
egg
是一款基于 koa
开发的 “企业级应用框架”,简单理解就是在 koa
上又封装了一层,把什么 request
、response
以及相关的一切操作方法都简化封装了,让普通开发者能更容易的使用,将更多的精力放在 996 啊不是,是业务开发上,就是所谓的伸手就来。
egg
奉行 “约定优于配置” 的原则,这一点在和 express
一对比就立马体现出来。express
就约束程度而言和 jquery
差不多,随便写。心之所向,哪里都是 router
,至于 middleware
、service
和 controller
,那是什么东西??
对于 egg
来说就不是这样,它牺牲了自由性,取而代之的是更加统一的写法:业务代码写到 controller
里,中间件写到 middleware
里,sql
写到 service
,其余的插件和配置也有统一的入口,不然它就跑不起来。加之又有强大插件生态加持,灵活性也是不弱的。
egg-webpack
作为 egg
生态支持的 webpack
插件,直接就可以 npm install
一把梭。梭的时候注意,这个东西是 devDependencies
,不要梭到 dependencies
里面。
开启插件
安装完成以后,需要写入 /config/plugin.js
的插件配置里,设置为 true
开启插件:
/** @type Egg.EggPlugin */
module.exports = {
webpack: { // 开发环境,开启 egg-webpack 插件
enable: process.env.NODE_ENV === 'development',
package: 'egg-webpack',
},
ejs: {
enable: true,
package: 'egg-view-ejs',
},
static: {
enable: true,
package: 'egg-static',
},
};
至于其他两个 egg-view-ejs
和 egg-static
你也看到了,一个是 ejs
的模板引擎插件,一个是静态资源插件,都梭过来。
配置插件所需的 webpack 配置文件
上面一步将插件安装并开启后,下面需要告诉 egg-webpack
去哪里找到原生 webpack
配置文件。
打开 /config/config.local.js
写入如下代码:
/* eslint valid-jsdoc: "off" */
'use strict';
const path = require('path');
/**
* @param {Egg.EggAppInfo} appInfo app info
*/
module.exports = appInfo => {
/**
* built-in config
* @type {{}}
**/
const config = exports = {};
// add your middleware config here
config.middleware = [];
// 开发环境下需要开启 webpack 编译
config.webpack = {
// port: 9000, // port: {Number}, default 9000. webpack dev server 的默认端口,默认为 9000,开启热更新时 websocket 的自动请求端口
webpackConfigList: [ require('../build/webpack.dev.config') ],
};
// 开发环境下,将 egg-static 静态资源转发目录由默认的 /app/public 改为 /src/static (具体的转发地址可以自行定义)
config.static = {
prefix: '/public/',
dir: path.join(appInfo.baseDir, 'src/static'),
};
// add your user config here
const userConfig = {
// myAppName: 'egg',
};
return {
...config,
...userConfig,
};
};
注意:
egg-webpack
只有在开发环境下才需要开启,生产环境直接在package.json
里配置build
脚本就好
"build": "cross-env NODE_ENV=production webpack --config ./build/webpack.prod.config.js",
egg
会自动根据 package.json
的脚本命令找到合适的配置文件,例如,开发模式下会找到 /config/config.default.js
和 /config/config.local.js
文件进行合并;生产环境下会找到 /config/config.default.js
和 /config/config.prod.js
文件进行合并。
至于 /build
里的 webpack
配置信息,前一篇文章已经详细说明,这里就不过多赘述了。
上述代码中,还有一块比较重要的配置:
egg-static
的配置,config.static
的配置将前缀为/public/
的请求标记为静态资源请求,全部转发至/src/static
目录下。
ejs 模板的获取和渲染
其实,如何在开发环境下获取到 ejs
模板并且将数据合成上去渲染成浏览器能够识别的 html
然后返回,才是真正的灵魂步骤。
开启 ejs 配置
这个在 egg-webpack
那块的 /config/plugin.js
就说过了。
配置 ejs 视图引擎
打开 /config/config.default.js
写入如下代码:
/* eslint valid-jsdoc: "off" */
'use strict';
const path = require('path');
/**
* @param {Egg.EggAppInfo} appInfo app info
*/
module.exports = appInfo => {
/**
* built-in config
* @type {Egg.EggAppConfig}
**/
const config = exports = {};
// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + '_1599807210902_4670';
// add your middleware config here
config.middleware = [];
config.view = {
mapping: {
'.ejs': 'ejs',
},
defaultViewEngine: 'ejs',
};
// add your user config here
const userConfig = {
// myAppName: 'egg',
};
return {
...config,
...userConfig,
};
};
其中 config.view
用于配置模板文件后缀和默认模板引擎。
作为开发环境和生产环境都需要的代码片段,因此要写入
/config/config.default.js
配置文件中。
egg 服务端代码编写
在开启了 egg-view-ejs
相关配置后,我们要开始进行 egg
的业务代码编写。
首先配置 /app/router.js
文件:
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/welcome', controller.welcome.index);
};
好了,现在 /
和/welcome
两个请求将被 egg
转发到对应的 controller
层进行处理,控制器经过数据的请求组装和处理,最后会给页面返回出一个能够渲染的 html
,下面我们看看控制器做了什么。
由于此项目是个模板框架,后端代码并不会涉及到数据库和中间件,因此不需要
middleware
和service
,不过如果你想以此为起点进行二次项目开发,这两个几乎是必不可少的。
由于
/app
中并不会直接存放原始的前端代码,所有的 es6、样式和模板文件最后都会被 webpack 编译成静态资源塞入其中,因此/app/public
和/app/view
在初始状态下应该是空的。类似与下图
[图片上传失败...(image-83f039-1603243193542)]
控制器文件以 /controller/home.js
为例分析:
const Controller = require('egg').Controller;
const { render } = require('../utils/utils.js');
class HomeController extends Controller {
async index() {
const { ctx } = this;
await render(ctx, 'home.ejs', { title: '首页' });
// ctx.render('home.ejs', { title: '首页' });
}
}
module.exports = HomeController;
可以看到本身代码非常简单,页面渲染的重点在于 render
方法,我们看看/app/utils/utils.js
文件
神奇的 render
const axios = require('axios');
// const ejs = require('ejs');
const CONFIG = require('../../build/config.dev');
const isDev = process.env.NODE_ENV === 'development';
function getTemplateString(filename) {
return new Promise((resolve, reject) => {
axios.get(`http://localhost:${CONFIG.PORT}${CONFIG.PATH.PUBLIC_PATH}${CONFIG.DIR.VIEW}/${filename}`).then(res => {
resolve(res.data);
}).catch(reject);
});
}
/**
* render 方法
* @param ctx egg 的 ctx 对象
* @param filename 需要渲染的文件名
* @param data ejs 渲染时需要用到的附加对象
* @return {Promise<*|undefined>}
*/
async function render(ctx, filename, data = {}) {
// 文件后缀
const ext = '.ejs';
filename = filename.indexOf(ext) > -1 ? filename.split(ext)[0] : filename;
try {
if (isDev) {
const template = await getTemplateString(`${filename}${ext}`);
ctx.body = await ctx.renderString(template, data);
} else {
await ctx.render(`${filename}${ext}`, data);
}
} catch (e) {
return Promise.reject(e);
}
}
module.exports = {
getTemplateString,
render,
};
可以看到 render
函数的内部实现逻辑:
- 如果是生产环境,那就非常简单,只需要使用数据,然后用
egg
提供的ctx.render
渲染出指定的模板文件就可以了 - 如果是开发环境,则需要先请求自身
http://localhost:7001/public/view/*.ejs
获取到ejs
的源文件字符串,然后使用egg
提供的ctx.renderString
将其渲染到页面上。
关于如何获取模板这一问题,我也看过很多老师的方法,其中一种就是调用
webpack
相关 API 直接一把揪出底层的memory
内存文件,然后手动调用 js 编译一顿操作猛如虎,最后把它渲染出来,龟龟~ 反正我是看了半天没有学会,而且看代码量感觉工作量不菲且要对webpack
的编译原理研究颇深,方可有所建树。如果大家有兴趣也可以探究探究。
注意:这里有一处非常有意思的地方。大家仔细想一下就会发现不对:我们在 egg-static
中配置的静态资源映射路径是前缀为 /public/
的资源请求全都转发到 /src/static
下,但是这个 /public/view/*.ejs
文件的原资源路径是在 /src/view
里的,这是怎么映射过去的??
其实除了
egg-static
配置的静态资源映射,webpack
自己也有一层资源映射,而我此处webpack.output.publicPath
写的刚好也是/public/
,就是说,webpack
编译并将文件生成到内存中的时候,内存的访问地址前缀也需要加上/public/
;而这个/public/view/*.ejs
文件访问到的正是webpack
内存中的资源文件。
个人感觉开发环境中的静态资源的访问模式是:到 egg-static
和 webpack
配置的不同地址下去找,找到哪个就返回哪个。
当然,生产环境下的静态资源访问由于不会有 webpack
直接参与,就不会存在这个问题了,你可以使用 egg-static
配置在同项目下,也可以使用 nginx
跨项目进行静态资源转发配置。
隐藏的细节彩蛋
写到这里,基本项目已经几乎完成了。剩下还有一些细节需要注意,我写在这里,提醒大家也提醒自己:
图片资源路径
我们如果在 ejs
中写入图片等静态资源,有两种方式:
-
/public/
前缀这种绝对路径的手法,这种方法,需要注意的是:egg
的local
配置文件/config/config.local.js
中改写了egg-static
的静态资源指向为/src/static/
。所以在 dev 环境图片资源是能够正常访问到的。但是由于生产环境下的egg-static
的静态资源指向默认是/app/public
,并且绝对路径的图片引用形式不会被被webpack
识别处理,所以一定要保证生产环境下/app/public
文件夹下有该图片资源,否则就是 404 资源请求。如果使用这种图片引用方式,推荐使用copyWebpack
之类的插件做生产环境的静态资源的拷贝处理。 - 写成
../
的相对路径形式,相对路径的请求形式,是能够正常被 webpack 识别处理和复制的,所以并不需要开发者做额外处理。只是,由于ejs
是由includes
功能的,有时候我们可能会引入一些公用的ejs
代码块,而这些代码块中很有可能是有图片等引用资源的。这个时候要注意,由于这块是includes
的文件,最后includes
文件会被拼接到主文件中,然后再丢给html-loader
解析,所以这块的图片路径需要写主文件下的相对路径,不然就找不到图片。
[图片上传失败...(image-88b181-1603243193542)]
如上图,两种写法获得的图片明显是不一样的,上面一种未经过 webpack
打包,下面的明显被 webpack
处理过了。
热更新
关于热更新,这一版和上一版不太一样,所以有些地方需要修改一下:
首先是 /build/webpack.base.config.js
文件:
module.exports = {
// ...
entry: (filepathList => {
const entry = {};
filepathList.forEach(filepath => {
const list = filepath.split(/(\/|\/\/|\\|\\\\)/g);
const key = list[list.length - 1].replace(/\.js/g, '');
// 如果是开发环境,才需要引入 hot module
entry[key] = isDev ?
[
filepath,
// 这边注意端口号,之间安装的 egg-webpack,会启动 dev-server,默认端口号为 9000
`webpack-hot-middleware/client?path=http://127.0.0.1:9000/__webpack_hmr&noInfo=false&reload=true&quiet=false`,
] : filepath;
});
return entry;
})(glob.sync(resolve(__dirname, '../src/js/*.js'))),
// ...
};
这边的 entry
入口除了 filepath
,还需要把 webpack-hot-middleware
加上,并把相关配置以 queryString
的方式拼接,最重要的配置就是 path=http://127.0.0.1:9000/__webpack_hmr
,这句是指定了热更新的 websocket
的地址的,由于 egg
本身启动的服务和 webpack-dev-server
启动的服务并不一样,这里不配置的话,默认热更新会去请求 7001
端口,也就是开发端口,那肯定是拿不到东西的。
不知道大家有没有注意到之前 /config/config.local.js
中的 webpack
配置,里面有一项可以设置 webpack-dev-server
的端口号:
// 开发环境下需要开启 webpack 编译
config.webpack = {
// port: 9000, // port: {Number}, default 9000. webpack dev server 的默认端口,默认为 9000,开启热更新时 websocket 的自动请求端口
webpackConfigList: [ require('../build/webpack.dev.config') ],
};
如果不想用默认的 9000 ,更改这个 port
也是可以的,只不过改了默认端口也要记得把 webpack
热更新配置里的默认端口也同时改掉。
最后,webpack-hot-module
原生是不支持模板文件的热更新的,这点在上一篇中也说明了。所以每个前端页面的 js 入口文件中需要加上:
if (process.env.NODE_ENV === 'development') {
// 在开发环境下,使用 raw-loader 引入 ejs 模板文件,强制 webpack 将其视为需要热更新的一部分 bundle
require('raw-loader!../view/home.ejs');
if (module.hot) {
module.hot.accept();
/**
* 监听 hot module 完成事件,重新从服务端获取模板,替换掉原来的 document
* 这种热更新方式需要注意:
* 1. 如果你在元素上之前绑定了事件,那么热更新之后,这些事件可能会失效
* 2. 如果事件在模块卸载之前未销毁,可能会导致内存泄漏
* 上述两个问题的解决方式,可以在 document.body 内容替换之前,将事件手动解绑。
*/
module.hot.dispose(() => {
const href = window.location.href;
axios.get(href).then(res => {
document.body.innerHTML = res.data;
}).catch(e => {
console.error(e);
});
});
}
}
注意:上面这一段热更新代码是不能拆成函数去引入使用的,没有用,我试过,只能在每个页面的入口文件中
ctrlCV
,当然如果你觉得麻烦,完全可以不这么做,顶多就是模板文件更改不会热更新而已,自己刷新一下也不麻烦,效果一样。
总结
记得我在从业时的第一家公司的第一份工作,就是改写官网。那个官网是用前人写的 gulp
编译脚本打包的,而 gulp
对于高阶的 ES6+
语法的支持简直就是一塌糊涂;更糟的是由于纯前端代码没有 node
层支持,只能靠 ajax
来获取数据。在那个前后端分离还没有完全推行的时代,在那个 angularjs
脏检查疯狂遍历的年代,前端写代码还要开 eclipse
,等后端兄弟的服务起来才能动手。
我从那时便想,如果有一天,前端开发多页应用能像拉屎一样简单。
那该多好。
完整项目地址可以查看我的 Github 完整项目地址 ,喜欢的话给个 Star⭐️ ,多谢~ 你的点赞,将是我持续输出的动力❤️