【實戰】webpack4 + ejs + egg 多頁應用項目最終解決方案

前言

Github 完整項目地址html

很久都沒有寫過文章了,以前寫過一篇 《【實戰】webpack4 + ejs + express 帶你擼一個多頁應用項目架構》,發佈後我發現你們對於 「簡化多頁應用開發流程」 這塊需求強烈,而且,隨着我將上一篇文章中介紹的多頁開發模式運用到實際項目中後,發現仍是存在一些缺陷的。其中痛點之一就是,使用 express 做爲後臺開發框架。前端

express 當然不錯,可是如今開發講究效率,所謂伸手就來開箱即用,這麼一對比,express 仍是偏向底層,做爲服務端框架,不少東西仍是要本身費心費神找插件 install 看文檔。因而,這一次我準備使用更上層的 egg 做爲替代框架。node

雖然是上一版的進化版,可是不少主要的實現思路是沒有變的,想要詳細瞭解的朋友推薦先看下上一篇 《【實戰】webpack4 + ejs + express 帶你擼一個多頁應用項目架構》,這篇只作關鍵步驟分析,詳細代碼可見 Github 完整項目地址jquery

項目結構

目錄乍一看彷佛有點多,不要緊都是唬人的,最重要的幾個目錄我已在截圖上標出,咱們能夠展開看下主要目錄的詳細目錄結構:webpack

egg 層

項目結構介紹完,下面就要開始改造以前的代碼了,但是這麼多代碼從哪裏動手呢?咱們此次主要目的就是將 express 換成 egg ,那固然是從 egg 開始着手改造。ios

改造以前,咱們還須要明白最重要的兩個問題,這兩個問題一旦被解決,能夠說整個項目的改造也完成的差很少了。哪兩個問題呢?nginx

  1. 做爲一個服務端框架 egg 要怎樣與 webpack 結合?
  2. 使用 ejs 做爲模板引擎,要怎樣在 dev 環境和 prod 環境正確將 ejs 渲染成 html 並顯示在頁面上?

egg + webpack

在動手處理 egg 層以前,咱們須要先去官方文檔上了解一下這個框架。 因爲是阿里旗下產品,因此框架自己的穩定性、生態建設程度和文檔的友好性確定是有保證的。git

egg 是一款基於 koa 開發的 「企業級應用框架」,簡單理解就是在 koa 上又封裝了一層,把什麼 requestresponse 以及相關的一切操做方法都簡化封裝了,讓普通開發者能更容易的使用,將更多的精力放在 996 啊不是,是業務開發上,就是所謂的伸手就來。angularjs

egg 奉行 「約定優於配置」 的原則,這一點在和 express 一對比就立馬體現出來。express 就約束程度而言和 jquery 差很少,隨便寫。心之所向,哪裏都是 router ,至於 middlewareservicecontroller,那是什麼東西??es6

對於 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 中並不會直接存放原始的前端代碼,全部的 es六、樣式和模板文件最後都會被 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-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 解析,因此這塊的圖片路徑須要寫主文件下的相對路徑,否則就找不到圖片。

如上圖,兩種寫法得到的圖片明顯是不同的,上面一種未通過 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 吧
相關文章
相關標籤/搜索