React+Webpack效能優化

後排的風過發表於2019-03-03

本文主要講下React配合Webpack的一些優化,原專案在這裡,有空會持續更新,歡迎關注和start,另外還有個無法使用HtmlWebpackPlugin插入chunks的issues請求哪位大佬幫忙解決下,謝謝~

構建優化

loaders

  • 儘量少使用不同的loaders/plugins
  • 使用 include 欄位指明要轉換的目錄,使用exclude排除目錄:
module.exports = {
  ...
  module: {
    rules: [
      {
        test: /.js$/,
        include: path.resolve(__dirname, `src`),
        exclude: path.resolve(__dirname, `node_modules`),
        loader: `babel-loader`
      }
    ]
  }
};
複製程式碼

resolve

  • 儘量減少resolve.modules, resolve.extensions, resolve.mainFiles, resolve.descriptionFiles的值的數量

  • resolve.modules:

    使用resolve.modules指定模組目錄的路徑:

    module.exports = {
        ...
        resolve: {
          modules: [path.resolve(__dirname, `node_modules`)]
        }
    };
    複製程式碼
  • resolve.alias:

    resolve.alias使Webpack直接使用庫的壓縮版本,不再對庫進行解析,還可以使用別名方便引用檔案:

    module.exports = {
        ...
        resolve: {
          alias: {
            Components: path.resolve(__dirname, `src/components/`),
            Utils: path.resolve(__dirname, `src/utils/`),
            react: patch.resolve(__dirname, `./node_modules/react/dist/react.min.js`)
          }
        }
    };
    複製程式碼

    例如這樣就可以直接使用React的壓縮版本,每次構建時不必再次解析。還可以通過別名引用檔案,而不必再打長長的引用路徑:

    import ReactComponent from `Components/ReactComponent`;
    複製程式碼

    但這樣的缺點是會無法使用Tree-Shaking,所以一般對React這種整體性比較強的庫使用比較好,而像lodash這樣的工具庫還是使用Tree-Shaking去除多餘程式碼。

  • resolve.extensions:

    設定要解析檔案字尾,預設值為:

    module.exports = {
        ...
        resolve: {
          extensions: [`.wasm`, `.mjs`, `.js`, `.json`]
        }
    };
    複製程式碼

    可以設定為自己要解析的檔案型別,加快尋找速度:

    module.exports = {
        ...
        resolve: {
          extensions: [`.js`, `.json`, `jsx`]
        }
    };
    複製程式碼

externals

使用externals可以防止某些庫被打包,而通過其他方式引用庫(如CDN),這樣做的好處是當更新程式碼時不會影響庫程式碼的快取,使用者只需下載新的程式碼即可。當然我們也可以使用chunk來把不常更新的庫打包在另一個檔案,我們下面再講。

例如,從CDN引入React:

<script crossorigin src="https://unpkg.com/react@16/umd/react.production.min.js" defer></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js" defer></script>
<script src="./dist/index.js" defer></script>
複製程式碼
module.exports = {
    ...
    externals: {
      react: `React`,
      `react-dom`: `ReactDOM`
    },
}
複製程式碼

devtool

使用devtool是很耗效能的,如果不需要用到它的話就不要設定它,如果需要用到且質量要很好可設為source-map,不過這是非常耗時的,如果可以接受質量比較差的話,可使用cheap-source-map,官方推薦使用的是效能比較好質量比較差的cheap-module-eval-source-map

splitChunks

Webpack 4之後把公共程式碼提取工具從CommonChunksPlugin換成更好的SplitChunksPlugin。下面這個例子不使用externals,而是把React和ReactDOM提取到公共模組程式碼。

module.exports = {
  ...
  // externals: {
  //   react: `React`,
  //   `react-dom`: `ReactDOM`
  // },
  optimization: {
    ...
    splitChunks: {
      chunks: `all`,
      name: true,
      automaticNameDelimiter: `-`,  // 模組間的連線符,預設為"~"
      cacheGroups: {
        vendors: {
          test: /[\/]node_modules[\/]/,
          priority: -10  // 優先順序,越小優先順序越高
        },
        default: {  // 預設設定,可被重寫
          minChunks: 2,
          priority: -20,
          reuseExistingChunk: true  // 如果本來已經把程式碼提取出來,則重用存在的而不是重新產生
        }
      }
    }
  },
}
複製程式碼

mode

mode可取值有:

  • production:構建模式,會自動啟用一些構建相關的外掛,如壓縮程式碼。
module.exports = {
+  mode: `production`,
-  plugins: [
-    new UglifyJsPlugin(/* ... */),
-    new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") }),
-    new webpack.optimize.ModuleConcatenationPlugin(),
-    new webpack.NoEmitOnErrorsPlugin()
-  ]
}
複製程式碼
  • development:開發模式,會啟動一些開發相關的優化外掛。
module.exports = {
+ mode: `development`
- devtool: `eval`,
- plugins: [
-   new webpack.NamedModulesPlugin(),
-   new webpack.NamedChunksPlugin(),
-   new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("development") }),
- ]
}
複製程式碼
  • node

babel、Tree-Shaking

這裡使用的版本為babel 7。因為現在大多數瀏覽器都已經支援ES6的語法,所以如果所有程式碼都轉為ES5的話可能會產生大量的多餘程式碼,所以這裡只轉換部分程式碼,那要相容低版本的瀏覽器怎麼辦呢,別急,下面會講到一些解決辦法,我們先來看下babel配置:

{
    "presets":  [
        [
            "@babel/react",
            {
                "modules": false  // 關閉babel的模組轉換,才能使用Webpack的Tree-Shaking功能
            }
        ]
    ],
    "plugins": [
        "@babel/plugin-proposal-class-properties",  // class,這個要放在前面,否則可能會報錯
        "@babel/plugin-transform-classes",  // class
        "@babel/plugin-transform-arrow-functions",  // 箭頭函式
        "@babel/plugin-transform-template-literals"  // 字串模板
    ]
}
複製程式碼

當一些庫的package.jsonsideEffects有設定時,就可以很好地支援Tree-Shaking,如lodash:

{
  "name": "lodash",
  "sideEffects": false
}
複製程式碼

happypack

使用happypack可開啟多執行緒來加速處理loader:

var HappyPack = require(`happypack`);

module.exports = {
    ...
    rules: [
      {
        test: /.(js|jsx)$/,
        include: path.resolve(__dirname, `src`),
        exclude: path.resolve(__dirname, `node_modules`),
        use: `happypack/loader?id=babel`
      },
    ],
    plugins: [
      new HappyPack({
        id: `babel`,
        loaders:[`babel-loader?cacheDirectory`]
      }),
    ],
}
複製程式碼

其他

把程式碼構建到ES6+

上面說到轉換程式碼到ES5的話會很耗時且可能有很多多餘程式碼,因為現在大多數瀏覽器都已經支援ES6語法,現在我們來看看如何相容較低版本的瀏覽器。

  1. modulenomodule:

可以使用<script type="module" src="index.js"></script>來載入ES6+的程式碼,因為支援這個屬性的瀏覽器必定會支援async/awaitPromiseclass這些屬性,而不支援的瀏覽器則會選擇忽略它,不進行載入。

所以也還需要一份ES5的指令碼來相容低版本的瀏覽器,使用<script nomodule src="index.es5.js"></script>來載入ES5程式碼,可以識別nomodule的瀏覽器會忽略它,而不能識別它的低版本瀏覽器則會載入它。這樣就可以做到相容到低版本的瀏覽器而較新的瀏覽器使用程式碼量少很多的ES6+程式碼。

但是這個方法也有缺點:當使用splitChunks把程式碼分為較多的模組時,需要產生大量兩個版本的程式碼。

  1. 動態polyfill
<script src="https://cdn.polyfill.io/v2/polyfill.min.js"></script>
複製程式碼

它會通過分析請求頭資訊中的 UserAgent 實現自動載入瀏覽器所需的 polyfills。如果你使用較新的版本訪問上面的連線會發現沒有多少程式碼,而用IE則會產生很多。這樣我們就可以使用ES6+的程式碼和動態polyfill來相容低版本瀏覽器,但是動態polyfill不能支援class和箭頭函式等等這些特性,所以就需要按上面那樣配置babel來把這些轉換成ES5的。想知道更多動態polyfill可以點這裡

開發優化

避免使用構建時才使用到的工具

有一些工具在開發時是不需要用到的,如果用了可能會大大減慢生成程式碼的速度,如UglifyJsPlugin,在開發時不需要將程式碼進行壓縮,還有以下工具也避免在開發時用到:

  • UglifyJsPlugin
  • ExtractTextPlugin
  • [hash]/[chunkhash]
  • AggressiveSplittingPlugin
  • AggressiveMergingPlugin
  • ModuleConcatenationPlugin

不要輸出路徑資訊

module.exports = {
  // ...
  output: {
    pathinfo: false
  }
};
複製程式碼

關閉部分構建優化

module.exports = {
  ...
  optimization: {
    removeAvailableModules: false,
    removeEmptyChunks: false,
    splitChunks: false,
  }
};
複製程式碼

React優化

因為React的HTML元素都是寫在JS檔案中,所以一般導致構建出的JS檔案非常大,而在載入和執行JS的漫長過程中,使用者的瀏覽器一直顯示的都是白屏狀態,首屏渲染的時間變得非常的長,不使用服務端渲染的話可以按以下方法進行一些改善。

新增首屏loading

可通過使用HtmlWebpackPlugin外掛來為html檔案新增loading,而不至於白屏。

var loading = {
  ejs: fs.readFileSync(path.resolve(__dirname, `template/loading.ejs`)),
  css: fs.readFileSync(path.resolve(__dirname, `template/loading.css`)),
};

module.exports = {
    ...
    plugins: [
        new HtmlWebpackPlugin({
          template: path.resolve(__dirname, `template/index.ejs`),
          hash: true,
          loading: loading,  // 在React渲染完前新增loading
        }),
        new ScriptExtHtmlWebpackPlugin({  // 給script標籤加上defer
          defaultAttribute: `defer`
        }), 
    ]
}
複製程式碼

具體的模板程式碼看這裡

prerender-spa-plugin

prerender-spa-plugin可以生成單頁面應用的首屏到HTML,原理是通過puppeteer訪問相應路徑抓取相應的內容,這裡因為我一直裝不上puppeteer,所以就不深入講了。

module.exports = {
    ...
    new PrerenderSpaPlugin(
      // Absolute path to compiled SPA
      path.resolve(__dirname, `../dist`),
      // List of routes to prerender
      [`/`]
    )
}
複製程式碼

React Loadable

可以使用它來動態import React的元件,可以把一些不是那麼重要的元件先分離到chunks,然後再動態引入,可以提升渲染首屏的速度:

import Loading from `./src/components/Loading`;
import ReactDOM from `react-dom`;
import Loadable from `react-loadable`;

const LoadableApp = Loadable({
  loader: () => import(`./src/App`),
  loading: Loading,
});

ReactDOM.render(LoadableApp, document.querySelector(`#root`));

複製程式碼

暫時就寫這麼多優化的地方,以後有空會持續更新,有什麼問題歡迎一起討論~

相關文章