【实战】webpack4 + ejs + egg 多页应用项目最终解决方案

2,725 阅读14分钟

前言

Github 完整项目地址

好久都没有写过文章了,之前写过一篇 《【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构》,发布后我发现大家对于 “简化多页应用开发流程” 这块需求强烈,并且,随着我将上一篇文章中介绍的多页开发模式运用到实际项目中后,发现还是存在一些缺陷的。其中痛点之一就是,使用 express 作为后台开发框架。

express 固然不错,但是现在开发讲究效率,所谓伸手就来开箱即用,这么一对比,express 还是偏向底层,作为服务端框架,很多东西还是要自己费心费神找插件 install 看文档。于是,这一次我准备使用更上层的 egg 作为替代框架。

虽然是上一版的进化版,但是很多主要的实现思路是没有变的,想要详细了解的朋友推荐先看下上一篇 《【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构》,这篇只做关键步骤分析,详细代码可见 Github 完整项目地址

项目结构

目录乍一看似乎有点多,没关系都是唬人的,最重要的几个目录我已在截图上标出,我们可以展开看下主要目录的详细目录结构:

egg 层

项目结构介绍完,下面就要开始改造之前的代码了,可是这么多代码从哪里动手呢?我们这次主要目的就是将 express 换成 egg ,那当然是从 egg 开始着手改造。

改造之前,我们还需要明白最重要的两个问题,这两个问题一旦被解决,可以说整个项目的改造也完成的差不多了。哪两个问题呢?

  1. 作为一个服务端框架 egg 要怎样与 webpack 结合?
  2. 使用 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 在初始状态下应该是空的。类似与下图

控制器文件以 /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 函数的内部实现逻辑:

  1. 如果是生产环境,那就非常简单,只需要使用数据,然后用 egg 提供的 ctx.render 渲染出指定的模板文件就可以了
  2. 如果是开发环境,则需要先请求自身 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 中写入图片等静态资源,有两种方式:

  1. /public/ 前缀这种绝对路径的手法,这种方法,需要注意的是:egg 的 local 配置文件 /config/config.local.js 中改写了 egg-static 的静态资源指向为 /src/static/。所以在 dev 环境图片资源是能够正常访问到的。但是由于生产环境下的 egg-static 的静态资源指向默认是 /app/public ,并且绝对路径的图片引用形式不会被被 webpack 识别处理,所以一定要保证生产环境下 /app/public 文件夹下有该图片资源,否则就是 404 资源请求。如果使用这种图片引用方式,推荐使用 copyWebpack 之类的插件做生产环境的静态资源的拷贝处理。
  2. 写成 ../ 的相对路径形式,相对路径的请求形式,是能够正常被 webpack 识别处理和复制的,所以并不需要开发者做额外处理。只是,由于 ejs 是由 includes 功能的,有时候我们可能会引入一些公用的 ejs 代码块,而这些代码块中很有可能是有图片等引用资源的。这个时候要注意,由于这块是 includes 的文件,最后 includes 文件会被拼接到主文件中,然后再丢给 html-loader 解析,所以这块的图片路径需要写主文件下的相对路径,不然就找不到图片。

如上图,两种写法获得的图片明显是不一样的,上面一种未经过 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⭐️ ,多谢~ 你的点赞,将是我持续输出的动力😃❤️


往期内容推荐

  1. 【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构
  2. 【基础】HTTP、TCP/IP 协议的原理及应用
  3. 彻底弄懂节流和防抖
  4. 浏览器下的 Event Loop
  5. 面试官:说说作用域和闭包吧
  6. 面试官:说说执行上下文吧
  7. 面试官:说说原型链和继承吧
  8. 面试官:说说 JS 中的模块化吧
  9. 面试官:说说 let 和 const 吧