koa和egg項目webpack熱更新實現

背景

在用Node.js+Webpack構建的方式進行開發時, 咱們但願能實現修改代碼能實時刷新頁面UI的效果.
這個特性webpack自己是支持的, 並且基於koa也有現成的koa-webpack-hot-middleware 和 koa-webpack-dev-middleware 封裝好的組件支持.
不過這裏若是須要支持Node.js服務器端修改代碼自動重啓webpack自動編譯功能就須要cluster來實現.css

今天這裏要講的是如何在koa和egg應用實現Node.js應用重啓中的webpack熱更新功能. 要實現egg項目中webpack友好的開發體驗, 須要解決以下三個問題.html

問題

  • 如何解決Node.js服務器端代碼修改應用重啓避免webpack從新編譯.
  • 如何訪問js,css,image等靜態資源.
  • 如何處理本地開發webpack熱更新內存存儲讀取和線上應用本機文件讀取邏輯分離.

基於koa的webpack編譯和熱更新實現

在koa項目中, 經過koa-webpack-dev-middleware和koa-webpack-hot-middleware能夠實現webpack編譯內存存儲和熱更新功能, 代碼以下:vue

const compiler = webpack(webpackConfig);
const devMiddleware = require('koa-webpack-dev-middleware')(compiler, options);
const hotMiddleware = require('koa-webpack-hot-middleware')(compiler, options);
app.use(devMiddleware);
app.use(hotMiddleware);

若是按照上面實現, 能夠知足修改修改客戶端代碼實現webpack自動變編譯和UI界面熱更新的功能, 但若是是修改Node.js服務器端代碼重啓後就會發現webpack會從新編譯,
這不是咱們要的效果.緣由是由於middleware是依賴app的生命週期, 當app銷燬時, 對應webpack compiler實例也就沒有了, 重啓時會從新執行middleware初始化工做.
針對這個咱們能夠經過Node.js cluster實現, 大概思路以下:node

經過cluster worker 啓動App應用

if (cluster.isWorker) {
  const koa = require('koa');
    app.listen(8888, () =>{
        app.logger.info('The server is running on port: 9999');
    });
}

經過cluster master 啓動一個新的koa應用, 並啓動 webpack 編譯.

const cluster = require('cluster');
const chokidar = require('chokidar');

if (cluster.isMaster) {
  const koa = require('koa');
  const app = koa();
  const compiler = webpack([clientWebpackConfig,serverWebpackConfig]);
  const devMiddleware = require('koa-webpack-dev-middleware')(compiler);
  const hotMiddleware = require('koa-webpack-hot-middleware')(compiler);
  app.use(devMiddleware);
  app.use(hotMiddleware);

  let worker = cluster.fork();
  chokidar.watch(config.dir, config.options).on('change', path =>{
    console.log(`${path} changed`);
    worker.kill();
    worker = cluster.fork().on('listening', (address) =>{
      console.log(`[master] listening: worker ${worker.id}, pid:${worker.process.pid} ,Address:${address.address } :${address.port}`);
    });
  });
}

經過chokidar庫監聽文件夾的文件修改, 而後重啓worker, 這樣就能保證webpack compiler實例不被銷燬.

const watchConfig = {
        dir: [ 'controller', 'middleware', 'lib', 'model', 'app.js', 'index.js' ],
        options: {}
    };
    let worker = cluster.fork();
    chokidar.watch(watchConfig.dir, watchConfig.options).on('change', path =>{
        console.log(`${path} changed`);
        worker && worker.kill();
        worker = cluster.fork().on('listening', (address) =>{
            console.log(`[master] listening: worker ${worker.id}, pid:${worker.process.pid} ,Address:${address.address } :${address.port}`);
        });
});

worker 經過process.send 向 master 發現消息, process.on 監聽 master返回的消息

  • 首先咱們看看本地文件讀取的實現, 在context上面掛載readFile方法, 進行view render時, 調用app.context.readFile 方法.
app.context.readFile = function(fileName){
  const filePath = path.join(config.baseDir, config.staticDir, fileName);
  return new Promise((resolve, reject) =>{
    fs.readFile(filePath, CHARSET, function(err, data){
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};
  • 經過覆寫worker app.context.readFile 方法, 這樣進行本地開發時,開啓該插件就能夠無縫的從webpack編譯內存系統裏面讀取文件
app.context.readFile = (fileName) =>{
  return new Promise((resolve, reject) =>{
    process.send({ action: Constant.EVENT_FILE_READ, fileName });
    process.on(Constant.EVENT_MESSAGE, (msg) =>{
      resolve(msg.content);
    });
  });
};

master 經過監聽worker發過來的消息, 獲取webpack編譯進度和讀取webpack compiler內存系統文件內容

cluster.on(Constant.EVENT_MESSAGE, (worker, msg) =>{
        switch (msg.action) {
            case Constant.EVENT_WEBPACK_BUILD_STATE: {
                const data = {
                    action: Constant.EVENT_WEBPACK_BUILD_STATE,
                    state: app.webpack_client_build_success && app.webpack_server_build_success
                };
                worker.send(data);
                break;
            }
            case Constant.EVENT_FILE_READ: {
                const fileName = msg.fileName;
                try {
                    const compiler = app.compiler;
                    const filePath = path.join(compiler.outputPath, fileName);
                    const content = app.compiler.outputFileSystem.readFileSync(filePath).toString(Constant.CHARSET);
                    worker.send({ fileName, content });
                } catch (e) {
                    console.log(`read file ${fileName} error`, e.toString());
                }
                break;
            }
            default:
                break;
        }
});

基於egg的webpack編譯和熱更新實現

經過上面koa的實現思路, egg實現就更簡單了. 由於egg已經內置了worker和agent通訊機制以及自動重啓功能.webpack

app.js (worker) 經過 檢測webpack 編譯進度

  • 經過app.messenger.sendToAgent 向agent發送消息git

  • 經過app.messenger.on 監聽agent發送過來的消息github

app.use(function* (next) {
  if (app.webpack_server_build_success && app.webpack_client_build_success) {
    yield* next;
  } else {
    const serverData = yield new Promise(resolve => {
      this.app.messenger.sendToAgent(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, {
        webpackBuildCheck: true,
      });
      this.app.messenger.on(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, data => {
        resolve(data);
      });
    });
    app.webpack_server_build_success = serverData.state;

    const clientData = yield new Promise(resolve => {
      this.app.messenger.sendToAgent(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, {
        webpackBuildCheck: true,
      });
      this.app.messenger.on(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, data => {
        resolve(data);
      });
    });

    app.webpack_client_build_success = clientData.state;

    if (!(app.webpack_server_build_success && app.webpack_client_build_success)) {
      if (app.webpack_loading_text) {
        this.body = app.webpack_loading_text;
      } else {
        const filePath = path.resolve(__dirname, './lib/template/loading.html');
        this.body = app.webpack_loading_text = fs.readFileSync(filePath, 'utf8');
      }
    } else {
      yield* next;
    }
  }
});

app.messenger.on(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, data => {
  app.webpack_server_build_success = data.state;
});

app.messenger.on(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, data => {
  app.webpack_client_build_success = data.state;
});

agent.js 啓動koa實例和webpack編譯流程

這裏client和server編譯單獨啓動koa實例, 而不是一個是由於在測試時發現編譯會致使熱更新衝突.web

  • 啓動webpack client 編譯模式, 負責編譯browser運行文件(js,css,image等靜態資源)
'use strict';

const webpack = require('webpack');
const koa = require('koa');
const cors = require('kcors');
const app = koa();
app.use(cors());
const Constant = require('./constant');
const Utils = require('./utils');

module.exports = agent => {

  const config = agent.config.webpack;
  const webpackConfig = config.clientConfig;
  const compiler = webpack([webpackConfig]);

  compiler.plugin('done', compilation => {
    // Child extract-text-webpack-plugin:
    compilation.stats.forEach(stat => {
      stat.compilation.children = stat.compilation.children.filter(child => {
        return child.name !== 'extract-text-webpack-plugin';
      });
    });
    agent.messenger.sendToApp(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, { state: true });
    agent.webpack_client_build_success = true;
  });

  const devMiddleware = require('koa-webpack-dev-middleware')(compiler, {
    publicPath: webpackConfig.output.publicPath,
    stats: {
      colors: true,
      children: true,
      modules: false,
      chunks: false,
      chunkModules: false,
    },
    watchOptions: {
      ignored: /node_modules/,
    },
  });

  const hotMiddleware = require('koa-webpack-hot-middleware')(compiler, {
    log: false,
    reload: true,
  });

  app.use(devMiddleware);
  app.use(hotMiddleware);

  app.listen(config.port, err => {
    if (!err) {
      agent.logger.info(`start webpack client build service: http://127.0.0.1:${config.port}`);
    }
  });

  agent.messenger.on(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, () => {
    agent.messenger.sendToApp(Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, { state: agent.webpack_client_build_success });
  });

  agent.messenger.on(Constant.EVENT_WEBPACK_READ_CLIENT_FILE_MEMORY, data => {
    const fileContent = Utils.readWebpackMemoryFile(compiler, data.filePath);
    if (fileContent) {
      agent.messenger.sendToApp(Constant.EVENT_WEBPACK_READ_CLIENT_FILE_MEMORY_CONTENT, {
        fileContent,
      });
    } else {
      agent.logger.error(`webpack client memory file[${data.filePath}] not exist!`);
      agent.messenger.sendToApp(Constant.EVENT_WEBPACK_READ_CLIENT_FILE_MEMORY_CONTENT, {
        fileContent: '',
      });
    }
  });
};
  • 啓動webpack server 編譯模式, 負責編譯服務器端Node運行文件
'use strict';

const webpack = require('webpack');
const koa = require('koa');
const cors = require('kcors');
const app = koa();
app.use(cors());
const Constant = require('./constant');
const Utils = require('./utils');

module.exports = agent => {
  const config = agent.config.webpack;
  const serverWebpackConfig = config.serverConfig;
  const compiler = webpack([serverWebpackConfig]);

  compiler.plugin('done', () => {
    agent.messenger.sendToApp(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, { state: true });
    agent.webpack_server_build_success = true;
  });

  const devMiddleware = require('koa-webpack-dev-middleware')(compiler, {
    publicPath: serverWebpackConfig.output.publicPath,
    stats: {
      colors: true,
      children: true,
      modules: false,
      chunks: false,
      chunkModules: false,
    },
    watchOptions: {
      ignored: /node_modules/,
    },
  });

  app.use(devMiddleware);

  app.listen(config.port + 1, err => {
    if (!err) {
      agent.logger.info(`start webpack server build service: http://127.0.0.1:${config.port + 1}`);
    }
  });

  agent.messenger.on(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, () => {
    agent.messenger.sendToApp(Constant.EVENT_WEBPACK_SERVER_BUILD_STATE, { state: agent.webpack_server_build_success });
  });

  agent.messenger.on(Constant.EVENT_WEBPACK_READ_SERVER_FILE_MEMORY, data => {
    const fileContent = Utils.readWebpackMemoryFile(compiler, data.filePath);
    if (fileContent) {
      agent.messenger.sendToApp(Constant.EVENT_WEBPACK_READ_SERVER_FILE_MEMORY_CONTENT, {
        fileContent,
      });
    } else {
      // agent.logger.error(`webpack server memory file[${data.filePath}] not exist!`);
      agent.messenger.sendToApp(Constant.EVENT_WEBPACK_READ_SERVER_FILE_MEMORY_CONTENT, {
        fileContent: '',
      });
    }
  });
};
  • 掛載 webpack 內存讀取實例到app上面, 方便業務擴展實現, 代碼以下:

咱們經過worker向agent發送消息, 就能夠從webpack內存獲取文件內容, 下面簡單封裝一下:promise

class FileSystem {

  constructor(app) {
    this.app = app;
  }

  readClientFile(filePath, fileName) {
    return new Promise(resolve => {
      this.app.messenger.sendToAgent(Constant.EVENT_WEBPACK_READ_CLIENT_FILE_MEMORY, {
        filePath,
        fileName,
      });
      this.app.messenger.on(Constant.EVENT_WEBPACK_READ_CLIENT_FILE_MEMORY_CONTENT, data => {
        resolve(data.fileContent);
      });
    });
  }

  readServerFile(filePath, fileName) {
    return new Promise(resolve => {
      this.app.messenger.sendToAgent(Constant.EVENT_WEBPACK_READ_SERVER_FILE_MEMORY, {
        filePath,
        fileName,
      });
      this.app.messenger.on(Constant.EVENT_WEBPACK_READ_SERVER_FILE_MEMORY_CONTENT, data => {
        resolve(data.fileContent);
      });
    });
  }
}

在app/extend/application.js 掛載webpack實例服務器

const WEBPACK = Symbol('Application#webpack');
module.exports = {
  get webpack() {
    if (!this[WEBPACK]) {
      this[WEBPACK] = new FileSystem(this);
    }
    return this[WEBPACK];
  },
};

本地開發webpack熱更新內存存儲讀取和線上應用文件讀取邏輯分離

基於上面編譯流程實現和webpack實例, 咱們很容易實現koa方式的本地開發和線上運行代碼分離. 下面咱們就以vue 服務器渲染render實現爲例:

在egg-view插件開發規範中,咱們會在ctx上面掛載render方法, render方法會根據文件名進行文件讀取, 模板與數據編譯, 從而實現模板的渲染.以下就是controller的調用方式:

exports.index = function* (ctx) {
  yield ctx.render('index/index.js', Model.getPage(1, 10));
};

其中最關鍵的一步是根據文件名進行文件讀取, 只要view插件設計時, 把文件讀取的方法暴露出來(例如上面的koa的readFile),就能夠實現本地開發webpack熱更新內存存儲讀取.

  • vue view engine設計實現:
const Engine = require('../../lib/engine');
const VUE_ENGINE = Symbol('Application#vue');

module.exports = {

  get vue() {
    if (!this[VUE_ENGINE]) {
      this[VUE_ENGINE] = new Engine(this);
    }
    return this[VUE_ENGINE];
  },
};
class Engine {
  constructor(app) {
    this.app = app;
    this.config = app.config.vue;
    this.cache = LRU(this.config.cache);
    this.fileLoader = new FileLoader(app, this.cache);
    this.renderer = vueServerRenderer.createRenderer();
    this.renderOptions = Object.assign({
      cache: this.cache,
    }, this.config.renderOptions);
  }

  createBundleRenderer(code, renderOptions) {
    return vueServerRenderer.createBundleRenderer(code, Object.assign({}, this.renderOptions, renderOptions));
  }

  * readFile(name) {
    return yield this.fileLoader.load(name);
  }

  render(code, data = {}, options = {}) {
    return new Promise((resolve, reject) => {
      this.createBundleRenderer(code, options.renderOptions).renderToString(data, (err, html) => {
        if (err) {
          reject(err);
        } else {
          resolve(html);
        }
      });
    });
  }
}
  • ctx.render 方法
class View {
  constructor(ctx) {
    this.app = ctx.app;
  }

  * render(name, locals, options = {}) {
    // 咱們經過覆寫app.vue.readFile便可改變文件讀取邏輯
    const code = yield this.app.vue.readFile(name);
    return this.app.vue.render(code, { state: locals }, options);
  }

  renderString(tpl, locals) {
    return this.app.vue.renderString(tpl, locals);
  }
}

module.exports = View;

服務器view渲染插件實現 egg-view-vue

  • 經過webpack實例覆寫app.vue.readFile 改變從webpack內存讀取文件內容.
if (app.vue) {
  app.vue.readFile = fileName => {
    const filePath = path.isAbsolute(fileName) ? fileName : path.join(app.config.view.root[0], fileName);
    if (/\.js$/.test(fileName)) {
      return app.webpack.fileSystem.readServerFile(filePath, fileName);
    }
    return app.webpack.fileSystem.readClientFile(filePath, fileName);
  };
}

app.messenger.on(app.webpack.Constant.EVENT_WEBPACK_CLIENT_BUILD_STATE, data => {
  if (data.state) {
    const filepath = app.config.webpackvue.build.manifest;
    const promise = app.webpack.fileSystem.readClientFile(filepath);
    promise.then(content => {
      fs.writeFileSync(filepath, content, 'utf8');
    });
  }
});

webpack + vue 編譯插件實現 egg-webpack-vue

egg+webpack+vue工程解決方案

相關文章
相關標籤/搜索