背景
在用Node.js+Webpack構建的方式進行開發時, 我們希望能實現修改代碼能實時刷新頁面UI的效果.
這個特性webpack本身是支持的, 而且基於koa也有現成的koa-webpack-hot-middleware 和 koa-webpack-dev-middleware 封裝好的組件支持.
不過這里如果需要支持Node.js服務器端修改代碼自動重啟webpack自動編譯功能就需要cluster來實現.
今天這里要講的是如何在koa和egg應用實現Node.js應用重啟中的webpack熱更新功能. 要實現egg項目中webpack友好的開發體驗, 需要解決如下三個問題.
問題
- 如何解決Node.js服務器端代碼修改應用重啟避免webpack重新編譯.
- 如何訪問js,css,image等靜態資源.
- 如何處理本地開發webpack熱更新內存存儲讀取和線上應用本機文件讀取邏輯分離.
基於koa的webpack編譯和熱更新實現
在koa項目中, 通過koa-webpack-dev-middleware和koa-webpack-hot-middleware可以實現webpack編譯內存存儲和熱更新功能, 代碼如下:
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實現, 大概思路如下:
通過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通信機制以及自動重啟功能.
- worker和agent通信機制: https://eggjs.org/zh-cn/core/cluster-and-ipc.html
- 實現egg項目服務器代碼修改項目自動重啟的功能可以使用egg-development插件.
app.js (worker) 通過 檢測webpack 編譯進度
-
通過
app.messenger.sendToAgent
向agent發送消息 -
通過
app.messenger.on
監聽agent發送過來的消息
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實例, 而不是一個是因為在測試時發現編譯會導致熱更新沖突.
- 啟動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內存獲取文件內容, 下面簡單封裝一下:
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工程解決方案
- egg-vue-webpack-boilerplate基於Vue多頁面和單頁面服務器渲染同構工程骨架項目
- egg-view-vue egg view plugin for vue
- egg-view-vue-ssr vue server side render solution for egg-view-vue
- egg-webpack webpack dev server plugin for egg, support read file in memory and hot reload
- egg-webpack-vue egg webpack building solution for vue
- easywebpack programming instead of configuration, webpack is no longer complex