webpack-dev-server原理解析

webpack-dev-server 爲你提供了一個簡單的 web 服務器,可以實時從新加載。如下內容將主要介紹它是如何實現實現靜態資源服務以及熱更新的。javascript

靜態資源服務

webpack-dev-server 會使用當前的路徑做爲請求的資源路徑 ,就是咱們運行webpack-dev-server命令的路徑。能夠經過指定 content-base 來修改這個默認行爲,這個路徑標識的是靜態資源的路徑 。html

contentBase只和咱們的靜態資源相關也就是圖片,數據等,須要和output.publicPath和output.path作一個區分。後面二者指定的是咱們打包出文件存放的路徑,output.path是咱們實際的存放路徑,設置的output.publicPath會在咱們打包出的html用以替換path路徑,可是它所指向的也是咱們的output.path打包的文件。java

例如咱們有這麼一個配置:react

output: {
        filename: '[name].[hash].js', //打包後的文件名稱
        path: path.resolve(__dirname, '.hmbird'), //打包後的路徑,resolve拼接絕對路勁
        publicPath: 'http://localhost:9991/' 
    },

打包出的html模塊webpack

有一個疑問就是咱們contentBase指定的靜態資源路徑下有一個index.html,而且打包出的結果頁也有一個index.html,也就是兩個文件路徑訪問的路徑相同的話,會返回哪個文件?web

  結果就是會返回咱們打包出的結果頁面,靜態資源的優先級是低於打包出的文件的。express

接下來介紹的是咱們的webpack-dev-server是如何提供靜態資源服務的。原理其實就是啓動一個express服務器,調用app.static方法。npm

源碼以下:json

setupStaticFeature() {
    const contentBase = this.options.contentBase;
    const contentBasePublicPath = this.options.contentBasePublicPath;
    if (Array.isArray(contentBase)) {
//1.數組

      contentBase.forEach((item) => {
        this.app.use(contentBasePublicPath, express.static(item));
      });
    } else if (isAbsoluteUrl(String(contentBase))) {
        //2.絕對的url(例如http://www.58.com/src) 不推薦使用,建議經過proxy來進行設置
      this.log.warn(
        'Using a URL as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
      );

      this.log.warn(
        'proxy: {\n\t"*": "<your current contentBase configuration>"\n}'
      );

      // 重定向咱們的請求到contentBase
      this.app.get('*', (req, res) => {
        res.writeHead(302, {
          Location: contentBase + req.path + (req._parsedUrl.search || ''),
        });

        res.end();
      });
    } else if (typeof contentBase === 'number') {
       //3.數字 不推薦使用
      this.log.warn(
        'Using a number as contentBase is deprecated and will be removed in the next major version. Please use the proxy option instead.'
      );

      this.log.warn(
        'proxy: {\n\t"*": "//localhost:<your current contentBase configuration>"\n}'
      );

      // Redirect every request to the port contentBase
      this.app.get('*', (req, res) => {
        res.writeHead(302, {
          Location: `//localhost:${contentBase}${req.path}${req._parsedUrl
            .search || ''}`,
        });

        res.end();
      });
    } else {
       //4.字符串
      // route content request
      this.app.use(
        contentBasePublicPath,
        express.static(contentBase, this.options.staticOptions)
      );
    }
  }

熱更新

經過創建websocket實現服務端和客戶端的雙向通信,當咱們的服務端發生變化時能夠通知客戶端進行頁面的刷新。

實現的方式主要有兩種iframe modeinline modeapi

1. iframe mode  咱們的頁面被嵌套在一個iframe中,當資源改變的時候會從新加載。只須要在路徑中加入webpack-dev-server就能夠了,不須要其餘的任何處理。(http://localhost:9991/webpack-dev-server/index.html)

2. inline mode,再也不單獨引入一個js,而是將建立客戶端soket.io的代碼一同打包進咱們的js中。

webpack-dev-server如何實現HMR(模塊熱更新)呢?也就是在不刷新頁面的狀況下實現頁面的局部刷新。

首先介紹一下使用方式:

第一步:

devServer: {
        hot: true
    }

第二步:

if (module.hot) { module.hot.accept(); }
//這段代碼用於標誌哪一個模塊接收熱加載,若是是代碼入口模塊的話,就是入口模塊接收

Webpack 會從修改模塊開始根據依賴關係往入口方向查找熱加載接收代碼。若是沒有找到的話,默認是會刷新整個頁面的。若是找到的話,會替換那個修改模塊的代碼爲修改後的代碼,而且從修改模塊到接收熱加載之間的模塊的相關依賴模塊都會從新執行返回新模塊值,替換點模塊緩存。

簡單來講就是,有一個index.js引入了一個文件home.js,若是咱們修改了home.js內容,熱加載模塊如在home.js則只更新home.js,若是在index.js則更新index.js和home.js兩個文件的內容。若是兩個文件都沒有熱更新模塊,則刷新整個頁面。

(因爲 Webpack 的熱加載會從新執行模塊,若是是使用 React,而且模塊熱加載寫在入口模塊裏,那麼代碼調整後就會從新執行 render。但因爲組件模塊從新執行返回了新的組件,這時前面掛在的組件狀態就不能保留了,效果就等於刷新頁面。

須要保留組件狀態的話,須要使用 react-hot-loader 來處理。)

webpack-dev-server在咱們的entry中添加的hot模塊內容

//webpack-dev-server/utils/lib/addEntries.js  
if (options.hotOnly) {
hotEntry = require.resolve('webpack/hot/only-dev-server');
} else if (options.hot) {
hotEntry = require.resolve('webpack/hot/dev-server');
}

在咱們的入口文件下添加了兩個webpack的文件 

1. only-dev-server  :檢查模塊的更新

2. dev-server :模塊熱替換的相關內容 

HMR原理

上圖註釋:

綠色是webpack控制區域,藍色是webpack-dev-server控制區域,紅色是文件系統,青色是咱們項目自己。 

第一步:webpack監聽文件變化並打包(1,2)

webpack-dev-middleware 調用 webpack 的 api 對文件系統 watch,當文件發生改變後,webpack 從新對文件進行編譯打包,而後保存到內存中。 打包到了內存中,不生成文件的緣由就在於訪問內存中的代碼比訪問文件系統中的文件更快,並且也減小了代碼寫入文件的開銷

第二步: webpack-dev-middleware對靜態文件的監聽(3)

webpack-dev-server 對文件變化的一個監控,這一步不一樣於第一步,並非監控代碼變化從新打包。當咱們在配置文件中配置了devServer.watchContentBase 爲 true 的時候,Server 會監聽這些配置文件夾中靜態文件的變化,變化後會通知瀏覽器端對應用進行 live reload。注意,這兒是瀏覽器刷新,和 HMR 是兩個概念  

第三步:devServer 通知瀏覽器端文件發生改變(4)

sockjs 在服務端和瀏覽器端創建了一個 webSocket 長鏈接,以便將 webpack 編譯和打包的各個階段狀態告知瀏覽器,最關鍵的步驟仍是 webpack-dev-server 調用 webpack api 監聽 compile的 done 事件,當compile 完成後,webpack-dev-server經過 _sendStatus 方法將編譯打包後的新模塊 hash 值發送到瀏覽器端。  

第四步:webpack 接收到最新 hash 值驗證並請求模塊代碼(5,6)

webpack-dev-server/client 端並不可以請求更新的代碼,也不會執行熱更模塊操做,而把這些工做又交回給了 webpack,webpack/hot/dev-server 的工做就是根據 webpack-dev-server/client 傳給它的信息以及 dev-server 的配置決定是刷新瀏覽器呢仍是進行模塊熱更新。固然若是僅僅是刷新瀏覽器(執行步驟11),也就沒有後面那些步驟了。

第五步:HotModuleReplacement.runtime 對模塊進行熱更新(7,8,9)

是客戶端 HMR 的中樞,它接收到上一步傳遞給他的新模塊的 hash 值,它經過 JsonpMainTemplate.runtime 向 server 端發送 Ajax 請求,服務端返回一個 json,該 json 包含了全部要更新的模塊的 hash 值,獲取到更新列表後,該模塊再次經過 jsonp 請求,獲取到最新的模塊代碼。  

第六步:HotModulePlugin 將會對新舊模塊進行對比(10)

HotModulePlugin 將會對新舊模塊進行對比,決定是否更新模塊,在決定更新模塊後,檢查模塊之間的依賴關係,更新模塊的同時更新模塊間的依賴引用 ,第一個階段是找出 outdatedModules 和 outdatedDependencies。第二個階段從緩存中刪除過時的模塊和依賴。第三個階段是將新的模塊添加到 modules 中,當下次調用 __webpack_require__ (webpack 重寫的 require 方法)方法的時候,就是獲取到了新的模塊代碼了。

 

webpack-dev-server是如何實現從內存中加載打包好的文件的呢?

關鍵就在於webpack-dev-middleware,做用就是,生成一個與webpack的compiler綁定的中間件,而後在express啓動的服務app中調用這個中間件。

這個中間件的主要做用有3個:

1. 經過watch mode,監聽資源的變動,而後自動打包。
2. 使用內存文件系統,快速編譯。
3. 返回中間件,支持express的use格式。

對於 webpack-dev-middleware,最直觀簡單的理解就是一個運行於內存中的文件系統。你定義了 webpack.config,webpack 就能據此梳理出全部模塊的關係脈絡,而 webpack-dev-middleware 就在此基礎上造成一個微型的文件映射系統,每當應用程序請求一個文件——好比說你定義的某個 entry,它匹配到了就把內存中緩存的對應結果做爲文件內容返回給你,反之則進入到下一個中間件。

源碼結構以下:

除去utils等工具方法文件,最主要的文件就是index.js和middleware.js

index.js:watch mode && 輸出到內存

//index.js
export default function wdm(compiler, options = {}) {
    ...
    //綁定鉤子函數
      setupHooks(context);
    ...
    //輸出到內存
    setupOutputFileSystem(context);
    ...
   // 啓動監聽
  context.watching = context.compiler.watch(watchOptions, (error) => {
    if (error) {
      context.logger.error(error);
    }
  });
}

index.js是一箇中間件的容器包裝函數,接受兩個參數:一個是webpack的compiler,另外一個是配置對象,通過一系列處理後返回一箇中間件函數。

主要完成是事件有已上三個:

   setupHooks();

   setupOutputFileSystem()

   context.compiler.watch()

setupHooks

此函數的做用是在 compiler 的 invalid、run、done、watchRun 這 4 個編譯生命週期上,註冊對應的處理方法

//utils/setuohooks.js
...
context.compiler.hooks.watchRun.tap('DevMiddleware', invalid);
context.compiler.hooks.invalid.tap('DevMiddleware', invalid);
context.compiler.hooks.done.tap('DevMiddleware', done);
  • 在 done 生命週期上註冊 done 方法,該方法主要是 report 編譯的信息以及執行 context.callbacks 回調函數
  • 在 invalid、run、watchRun 等生命週期上註冊 invalid 方法,該方法主要是 report 編譯的狀態信息

setupOutputFileSystem

其做用是使用 memory-fs 對象替換掉 compiler 的文件系統對象,讓 webpack 編譯後的文件輸出到內存中

//utils/setupOutputFileSystem.js
import { createFsFromVolume, Volume } from 'memfs';
...
outputFileSystem = createFsFromVolume(new Volume());

context.compiler.watch

調用的就是compiler的watch方法,一旦咱們改動文件,就會從新執行編譯打包。

middleware.js:返回中間件

此文件返回的是一個 express 中間件函數的包裝函數,其核心處理邏輯主要針對 request 請求,根據各類條件判斷,最終返回對應的文件內容

export default function wrapper(context) {
 return function middleware(req, res, next) {
    //1. 定義goNext方法
     function goNext() { ... }
    ...
    //2.請求類型判斷,若請求不包含於配置中(默認 GET、HEAD 請求),則直接調用 goNext() 方法處理請求
     const acceptedMethods = context.options.methods || ['GET', 'HEAD'];

    if (acceptedMethods.indexOf(req.method) === -1) {
      return goNext();
    }
    //3.根據請求的url地址,在內存中尋找對應文件,並構造response返回
    return new Promise((resolve) => {
         function processRequest() {
          ...
         }
          ...
         ready(context, processRequest, req);
     });
 }
}

goNext方法

該方法判斷是不是服務端渲染。若是是,則調用 ready() 方法(此方法即爲 ready.js 文件,做用爲根據 context.state 狀態判斷直接執行回調仍是將回調存儲 callbacks 隊中)。若是不是,則直接調用 next() 方法,流轉至下一個 express 中間件

function goNext() {
      if (!context.options.serverSideRender) {
        return next();
      }

      return new Promise((resolve) => {
        ready(
          context,
          () => {
            // eslint-disable-next-line no-param-reassign
            res.locals.webpack = { devMiddleware: context };

            resolve(next());
          },
          req
        );
      });
    }

ready.js文件

  判斷 context.state 的狀態,將直接執行回調函數 fn,或在 context.callbacks 中添加回調函數 fn。這也解釋了上文提到的另外一個特性 「在編譯期間,中止提供舊版的 bundle 而且將請求延遲到最新的編譯結果完成以後」。若 webpack 還處於編譯狀態,context.state 會被設置爲 false,因此當用戶發起請求時,並不會直接返回對應的文件內容,而是會將回調函數processRequest添加至 context.callbacks 中,而上文中咱們說到在 compile.hooks.done 上註冊了回調函數done,等編譯完成以後,將會執行這個函數,並循環調用 context.callbacks。。

//utils/ready.js
  if (context.state) {
    return callback(context.stats);
  }

  const name = (req && req.url) || callback.name;
  context.logger.info(`wait until bundle finished${name ? `: ${name}` : ''}`);
  context.callbacks.push(callback);

 

processRequest函數

在返回的中間件實例中定義了一個processRequest函數,此方法經過url查找到filename路徑,若是filename不存在直接調用goNext方法,不然的話找到對應文件構造response對象返回。在ready方法中調用processRequest函數。

function processRequest() {
      const filename = getFilenameFromUrl(context, req.url);
        //查找文件
        if (!filename) {
          return resolve(goNext());
        }
      ...
       //構造response對象,並返回
        let content;
        try {
          content = context.outputFileSystem.readFileSync(filename);
        } catch (_ignoreError) {
          return resolve(goNext());
        }
        content = handleRangeHeaders(content, req, res);
        ...
        res.send(content);
}                
相關文章
相關標籤/搜索