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

前言

Github 完整项目地址

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

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

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

项目结构

【实战】webpack4 + ejs + egg 多页应用项目最终解决方案_第1张图片
1603243229(1).jpg

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

【实战】webpack4 + ejs + egg 多页应用项目最终解决方案_第2张图片
1603243252(1).jpg

egg 层

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

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

  1. 作为一个服务端框架 egg 要怎样与 webpack 结合?
  2. 使用 ejs 作为模板引擎,要怎样在 dev 环境和 prod 环境正确将 ejs 渲染成 html 并显示在页面上?

egg + webpack

在动手处理 egg 层之前,我们需要先去官方文档上了解一下这个框架。 由于是阿里旗下产品,所以框架本身的稳定性、生态建设程度和文档的友好性肯定是有保证的。

egg 是一款基于 koa 开发的 “企业级应用框架”,简单理解就是在 koa 上又封装了一层,把什么 requestresponse 以及相关的一切操作方法都简化封装了,让普通开发者能更容易的使用,将更多的精力放在 996 啊不是,是业务开发上,就是所谓的伸手就来。

egg 奉行 “约定优于配置” 的原则,这一点在和 express 一对比就立马体现出来。express 就约束程度而言和 jquery 差不多,随便写。心之所向,哪里都是 router ,至于 middlewareservicecontroller,那是什么东西??

对于 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-ejsegg-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 ,下面我们看看控制器做了什么。

由于此项目是个模板框架,后端代码并不会涉及到数据库和中间件,因此不需要 middlewareservice,不过如果你想以此为起点进行二次项目开发,这两个几乎是必不可少的。

由于 /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 函数的内部实现逻辑:

  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-staticwebpack 配置的不同地址下去找,找到哪个就返回哪个。

当然,生产环境下的静态资源访问由于不会有 webpack 直接参与,就不会存在这个问题了,你可以使用 egg-static 配置在同项目下,也可以使用 nginx 跨项目进行静态资源转发配置。

隐藏的细节彩蛋

写到这里,基本项目已经几乎完成了。剩下还有一些细节需要注意,我写在这里,提醒大家也提醒自己:

图片资源路径

我们如果在 ejs 中写入图片等静态资源,有两种方式:

  1. /public/ 前缀这种绝对路径的手法,这种方法,需要注意的是:egglocal 配置文件 /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 解析,所以这块的图片路径需要写主文件下的相对路径,不然就找不到图片。

[图片上传失败...(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⭐️ ,多谢~ 你的点赞,将是我持续输出的动力❤️

你可能感兴趣的:(【实战】webpack4 + ejs + egg 多页应用项目最终解决方案)