1. 程式人生 > >electron-vue架構解析3-開發環境啟動流程分析(原)

electron-vue架構解析3-開發環境啟動流程分析(原)

上一節我們介紹了生產環境的打包流程,這一節我們來看開發環境的啟動流程。

該框架主要修改是對開發環境的優化,包括了於開發環境的配置檔案隔離,主程序和渲染程序配置檔案隔離,編譯過程提示等功能,因此這一節內容才是整個框架的核心。
我們從開發人員用到的啟動命令說起。
從package中我們看到啟動命令就是:

"dev": "node .electron-vue/dev-runner.js",

也就是在終端使用npm run dev就可以了,而這個命令對應的指令碼就是dev-runner.js。

function init() {
    greeting();

    Promise.all([startRenderer(), startMain()])
        .then(() => {
            startElectron();
        })
        .catch(err => {
            console.error(err);
        });
}

這是指令碼執行時執行的唯一方法,其中的greeting()就是在終端輸出一個Log,如圖所示:
這裡寫圖片描述

然後做了三個操作分別啟動了渲染程序、主程序和Electron:

  • startRenderer()
  • startMain()
  • startElectron()

下面我們逐個來看具體的啟動流程。

1.渲染程序啟動過程分析

function startRenderer() {
    return new Promise((resolve, reject) => {
        //載入webpack配置檔案
        rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client'
)].concat(rendererConfig.entry.renderer); //建立webpack const compiler = webpack(rendererConfig); //建立webpack-hot-middleware hotMiddleware = webpackHotMiddleware(compiler, { log: false, heartbeat: 2500 }); //編譯狀態監控 compiler.plugin('compilation'
, compilation => { compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => { hotMiddleware.publish({action: 'reload'}); cb(); }); }); compiler.plugin('done', stats => { logStats('Renderer', stats); }); //建立webpack-dev-server const server = new WebpackDevServer( compiler, { contentBase: path.join(__dirname, '../'), quiet: true, before(app, ctx) { app.use(hotMiddleware); ctx.middleware.waitUntilValid(() => { resolve(); }); } } ); server.listen(9080); }); }

在這個方法裡,共完成了三個操作:

  • 建立webpack物件
  • 利用webpack物件來建立W**ebpackDevServer物件
  • 監聽webpack編譯過程

我們分別來看這三個操作的具體情況。

1.1.渲染程序建立webpack物件

webpack物件建立時使用的配置檔案是我們分析的重點。我們來看webpack的配置檔案,也就是rendererConfig變數:

rendererConfig.entry.renderer = [path.join(__dirname, 'dev-client')].concat(rendererConfig.entry.renderer);

這說明webpack的配置檔案來自於兩個檔案:dev-client模組和rendererConfig.entry.renderer變數。
這裡看一下原始碼就知道,concat方法連線而成的陣列中包含了兩個元素,一個是”dev-client”,另一個是根目錄的”../src/renderer/main.js”檔案,也就是說,webpack的entry引數實際上是這種形式:

entry: {
    renderer: ['dev-client', path.join(__dirname, '../src/renderer/main.js')]
},

再來看一下output引數內容(在webpack.renderer.config.js):

output: {
    filename: '[name].js',
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, '../dist/electron')
},

這種用法包含了三個資訊:

  • webpack將會把dev-client 模組 和main.js 檔案同時打包進output指定的檔案中
  • dev-client是一個模組,根據原始碼查詢,這個模組就是dev-client.js檔案
  • entry內容以key–value形式定義,那麼output中的name變數就是entry中的key

綜合來說,這種用法的作用就是:同時把dev-client.js和main.js檔案打包,輸出到根目錄下的/dist/electron/render.js檔案中。
這個資訊非常重要,後面要用到。
接下來我們先來解決這兩個檔案的作用。

渲染程序的dev-client配置檔案

這個檔案內容很簡單,直接貼出原始碼:

const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true');
//註冊webpack-hot-middleware監聽器
hotClient.subscribe(event => {
    //這裡只處理了Main程序傳送的"compiling"的事件,實際上在Render程序中還發送了"reload"的訊息
    if (event.action === 'compiling') {
      ...
      <div id="dev-client">
        Compiling Main Process...
      </div>
    `;
    }
});

我們直接來說作用,他負責在編譯過程中,在介面上顯示“Compiling Main Process…”的提示語。
效果如下:
這裡寫圖片描述
至於他如何檢測編譯過程,我們稍後來說。

渲染程序的webpack.renderer.config配置檔案

接下來我們來看渲染程序的主配置檔案的主要內容:

//將vue模組列為白名單
let whiteListedModules = ['vue'];
let rendererConfig = {
    //指定sourcemap方式
    devtool: '#cheap-module-eval-source-map',
    entry: {
        renderer: path.join(__dirname, '../src/renderer/main.js')
    },
    externals: [
        //編譯白名單
        ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d))
    ],
    module: {
        rules: [
            {
                test: /\.css$/,
                use: ExtractTextPlugin.extract({
                    fallback: 'style-loader',
                    use: 'css-loader'
                })
            },
            {
                test: /\.html$/,
                use: 'vue-html-loader'
            },
            {
                test: /\.js$/,
                use: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.node$/,
                use: 'node-loader'
            },
            {
                test: /\.vue$/,
                use: {
                    loader: 'vue-loader',
                    options: {
                        extractCSS: process.env.NODE_ENV === 'production',
                        loaders: {
                            sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
                            scss: 'vue-style-loader!css-loader!sass-loader'
                        }
                    }
                }
            },
            {
                test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    query: {
                        limit: 10000,
                        name: 'imgs/[name]--[folder].[ext]'
                    }
                }
            },
            {
                test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
                loader: 'url-loader',
                options: {
                    limit: 10000,
                    name: 'media/[name]--[folder].[ext]'
                }
            },
            {
                test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
                use: {
                    loader: 'url-loader',
                    query: {
                        limit: 10000,
                        name: 'fonts/[name]--[folder].[ext]'
                    }
                }
            }
        ]
    },
    node: {
        //根據版本資訊確定__dirname和__filename的行為
        __dirname: process.env.NODE_ENV !== 'production',
        __filename: process.env.NODE_ENV !== 'production'
    },
    plugins: [
        //css檔案分離
        new ExtractTextPlugin('styles.css'),
        //自動生成html首頁
        new HtmlWebpackPlugin({
            filename: 'index.html',
            template: path.resolve(__dirname, '../src/index.ejs'),
            minify: {
                collapseWhitespace: true,
                removeAttributeQuotes: true,
                removeComments: true
            },
            nodeModules: process.env.NODE_ENV !== 'production'
                ? path.resolve(__dirname, '../node_modules')
                : false
        }),
        //熱更新模組
        new webpack.HotModuleReplacementPlugin(),
        //在編譯出現錯誤時,使用 NoEmitOnErrorsPlugin 來跳過輸出階段
        new webpack.NoEmitOnErrorsPlugin()
    ],
    output: {
        filename: '[name].js',
        libraryTarget: 'commonjs2',
        path: path.join(__dirname, '../dist/electron')
    },
    resolve: {
        alias: {
            //在程式碼中使用@代表renderer目錄
            '@': path.join(__dirname, '../src/renderer'),
            //精確指定vue特指vue.esm.js檔案
            'vue$': 'vue/dist/vue.esm.js'
        },
        extensions: ['.js', '.vue', '.json', '.css', '.node']
    },
    //指定編譯為 Electron 渲染程序
    target: 'electron-renderer'
};

這個配置檔案的主要配置項都給出了註釋,作用很容易理解,這裡不再詳細介紹,webpack更多配置可以檢視這裡

1.2.渲染程序利用webpack物件來建立WebpackDevServer物件

我們先來區分一下三個概念:

  • webpack本身只是打包工具,不具備熱更新功能

  • An express-style development middleware for use with webpack bundles and allows for serving of the files emitted from webpack. This should be used for development only.
    The middleware installs itself as a webpack plugin, and listens for compiler events.
    他是一個運行於記憶體中的(非常重要的概念,作用稍後會提到)一個檔案系統,可以檢測檔案改動自動編譯(到記憶體中)。

  • Use webpack with a development server that provides live reloading. This should be used for development only. It uses webpack-dev-middleware under the hood, which provides fast in-memory access to the webpack assets.
    他是利用webpack-hot-middleware搭建了Express的伺服器,從而實現實時重新整理的功能。

瞭解了這三個概念之後,我們來看具體如何建立WebpackDevServer物件,其實很簡單,把我們剛才建立的webpack作為引數來初始化WebpackDevServer即可:

//建立webpackHotMiddleware
hotMiddleware = webpackHotMiddleware(compiler, {
    log: false,
    heartbeat: 2500
});

//建立WebpackDevServer
const server = new WebpackDevServer(
    //以webpack物件作為引數
    compiler,
    {
        contentBase: path.join(__dirname, '../'),
        quiet: true,
        before(app, ctx) {
            //使用webpackHotMiddleware
            app.use(hotMiddleware);
            ctx.middleware.waitUntilValid(() => {
                resolve();
            });
        }
    }
);

//伺服器執行在9080埠
server.listen(9080);

這個建立過程很清晰,需要注意的是,webpackHotMiddleware的heartbeat的引數作用並不是檢測檔案的頻率,而是保持伺服器連結存活的心跳頻率:

heartbeat - How often to send heartbeat updates to the client to keep the connection alive. Should be less than the client’s timeout setting - usually set to half its value.

1.3.渲染程序監聽webpack編譯過程

經過以上的步驟,渲染程序的初始化就基本上結束了,但是我們看到在建立過程中有兩個“小插曲”:

compiler.plugin('compilation', compilation => {
    compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => {
        hotMiddleware.publish({action: 'reload'});
        cb();
    });
});

compiler.plugin('done', stats => {
    logStats('Renderer', stats);
});

其中第二個logStats作用就是在終端螢幕上輸出編譯過程(後面我們在看main程序編譯過程也會輸出到終端中),如圖所示:
這裡寫圖片描述
而第一個hotMiddleware.publish()作用是什麼呢?
這其實是一個鉤子函式,檢測webpack的編譯狀態,把其中的html-webpack-plugin-after-emit狀態,釋出到webpackHotMiddleware中。

我們可以檢測多種狀態,比如
- html-webpack-plugin-before-html-processing
- html-webpack-plugin-after-html-processing
- html-webpack-plugin-after-emit
- …

然後我們就可以在渲染程序中檢測到這一狀態。
然後還記得之前的dev-client模組嗎?我們再來看一下原始碼:

const hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true');
//註冊webpack-hot-middleware監聽器
hotClient.subscribe(event => {
    //這裡只處理了Main程序傳送的"compiling"的事件,實際上在Render程序中還發送了"reload"的訊息
    if (event.action === 'compiling') {
      ...
      <div id="dev-client">
        Compiling Main Process...
      </div>
    `;
    }
});

也就是說,在整個webpack-hot-middleware編譯過程中傳送的編譯訊息,將會在介面展示一個提示框提示開發者,編譯器正在進行的工作。
從這一點來看也發現,該框架的作者對細節的把握多麼的重視。

2.主程序過程分析

前面分析了渲染程序的啟動過程,現在來看主程序的啟動過程。

function startMain() {
    return new Promise((resolve, reject) => {
        mainConfig.entry.main = [path.join(__dirname, '../src/main/index.dev.js')].concat(mainConfig.entry.main);
        //建立主程序的webpack
        const compiler = webpack(mainConfig);

        compiler.plugin('watch-run', (compilation, done) => {
            //向webpack-hot-middleware釋出"compiling"的訊息,用於頁面顯示
            hotMiddleware.publish({action: 'compiling'});
            done();
        });

        compiler.watch({}, (err, stats) => {
            if (err) {
                return;
            }
            ...
            if (electronProcess && electronProcess.kill) {
                //主程序檔案發生改變,重啟Electron
                manualRestart = true;
                process.kill(electronProcess.pid);
                electronProcess = null;
                startElectron();

                setTimeout(() => {
                    manualRestart = false;
                }, 5000);
            }

            resolve();
        });
    });
}

主程序的啟動和渲染程序非常相似,共經歷了三個步驟:

  • 建立webpack
  • 實時釋出hotMiddleware狀態(用於頁面展示編譯過程)
  • 主程序的程式碼”熱更新”

下面我們也來分別介紹這三個過程。

2.1.主程序的webpack建立過程

webpack建立的關鍵在於配置檔案,和渲染程序一樣,主程序也有兩個配置檔案:

  • /src/main/index.dev.js
  • /.electron-vue/webpack.main.config.js

下面分別介紹他們內容。

主程序的index.dev.js配置檔案

我們直接來看這個檔案內容。

process.env.NODE_ENV = 'development'

//安裝`electron-debug`工具
require('electron-debug')({ showDevTools: true })

//安裝Vue的一個chrome開發工具`vue-devtools`
require('electron').app.on('ready', () => {
  let installExtension = require('electron-devtools-installer')
  installExtension.default(installExtension.VUEJS_DEVTOOLS)
    .then(() => {})
    .catch(err => {
      console.log('Unable to install `vue-devtools`: \n', err)
    })
})
...

這個配置檔案的作用就是安裝了electron-debugvue-devtools兩個工具,其中vue-devtools工具因為網路原因無法安裝,可以自己手動安裝。

主程序的webpack.main.config.js配置檔案

這個檔案主要配置項如下:

let mainConfig = {
    entry: {
        main: path.join(__dirname, '../src/main/index.js')
    },
    externals: [
        ...Object.keys(dependencies || {})
    ],
    module: {
        rules: [
            {
                test: /\.js$/,
                use: 'babel-loader',
                exclude: /node_modules/
            },
            {
                test: /\.node$/,
                use: 'node-loader'
            }
        ]
    },
    node: {
        __dirname: process.env.NODE_ENV !== 'production',
        __filename: process.env.NODE_ENV !== 'production'
    },
    output: {
        filename: '[name].js',
        libraryTarget: 'commonjs2',
        path: path.join(__dirname, '../dist/electron')
    },
    plugins: [
        new webpack.NoEmitOnErrorsPlugin()
    ],
    resolve: {
        extensions: ['.js', '.json', '.node']
    },
    //編譯為 Electron 主程序
    target: 'electron-main'
};

他和渲染程序最大不同就是主程序處理的檔案很有限,他不會(不需要)處理vue、圖片、css、html等檔案型別。

2.2.主程序的編譯過程跟蹤

這裡和渲染程序的跟蹤稍有不同,我們對比一下:
渲染程序

compiler.plugin('compilation', compilation => {
    compilation.plugin('html-webpack-plugin-after-emit', (data, cb) => {
        hotMiddleware.publish({action: 'reload'});
        cb();
    });
});

主程序

compiler.plugin('watch-run', (compilation, done) => {
    logStats('Main', chalk.white.bold('compiling...'));
    hotMiddleware.publish({action: 'compiling'});
    done();
});

從這裡發現,他們分別監聽了編譯的不同事件。渲染程序監聽的是compilation,而主程序監聽的是watch-run

這裡的”watch-run”只會出現在webpack3.X版本中,在webpack4.x版本上,”watch-run”事件被修改為了”watchRun”事件。
webpack還支援的一些事件包括:
- afterPlugins
- afterResolvers
- environment
- afterEnvironment
- beforeRun
- run
- beforeCompile
- afterCompile
- …

從webpack說明文件看出,”compilation”會在編譯器建立時觸發:

Runs a plugin after a compilation has been created

“watchRun”也是會在編譯器建立之後被觸發,不同的是,這個這個事件只有在”watch mode”模式下才會生效:

Executes a plugin during watch mode after a new compilation is triggered but before the compilation is actually started.

由於渲染程序使用了WebpackDevServer的熱更新,因此可以檢測compilation事件來跟蹤事件。
主程序在初始化過程中,由於沒有使用WebpackDevServer,而是開啟了watch模式,所以可以檢測到這個事件。

2.3.主程序的程式碼”熱更新”

主程序沒有使用WebpackDevServer的方式自動更新介面,而是通過webpack的watch模式,不斷重啟Electron實現的:

compiler.watch({}, (err, stats) => {
    if (err) {
        return;
    }

    if (electronProcess && electronProcess.kill) {
        manualRestart = true;
        process.kill(electronProcess.pid);
        electronProcess = null;
        //重啟Electron
        startElectron();

        setTimeout(() => {
            manualRestart = false;
        }, 5000);
    }

    resolve();
});

3.Electron過程分析

前面兩節分析了主程序和渲染程序的建立流程,在除錯模式命令的最後一步就是開啟Electron:

function init() {
    Promise.all([startRenderer(), startMain()])
        .then(() => {
            startElectron();
        })
        .catch(err => {
        });
}

下面我們來看如何開啟Electron:

function startElectron() {
    electronProcess = spawn(electron, ['--inspect=5858', '.']);
    ...
}

原來是通過node的spawn方法運行了electron,並傳遞了兩個引數。
兩個引數分別代表開啟5858的除錯埠和electron的執行目錄(也就是當前目錄)。
至此,除錯環境執行時的流程就介紹完了,我們用一張流程圖來歸納一下這個過程:
這裡寫圖片描述

下一節我們介紹原始介面的渲染過程。