React-Webpack5-TypeScript打造工程化多頁面應用

語言: CN / TW / HK
ead>

多頁面應用打包

日常工作中大部分場景下我們都是在使用webpack構建傳統單頁面spa應用。

所謂的單頁面應用也就是說打包後的程式碼僅僅生成一份html檔案,基於前端路由js去控制渲染不同的頁面。

當然所謂的多頁面應用簡單來說也就是打包後生成多個html檔案。

這篇文章中我們來重點介紹多頁面應用,文章中涉及的內容純乾貨。我們廢話不多說,一篇文章讓你徹底搞懂所謂工程化的多頁面應用構建。

文章中涉及的模板配置可以點選這裡檢視戳這裡👇

不要忘記給一個star呀大佬們(祈求臉.jpg)

前邊部分是基於基礎配置從零開始搭建一個React+TypeScript+Webpack的講解部分,如果這塊你已經足夠了解了,可以直接跳到 切入多頁面應用 去檢視動態多頁面部分的配置。

最終我們達到的效果如下👇:

1632404783258.gif

原諒我實在是不會gif...

初始化目錄結構

讓我們來先初始化最基礎的工程目錄:

https://github.com/19Qingfeng/pages.git

image.png

讓我們先來安裝webpack以及React:

yarn add -D webpack webpack-cli

yarn add react react-dom

webpack-cliwebpack的命令列工具,用於在命令列中使用webpack

接下來讓我們去分別建立不同的頁面目錄,假設我們存在兩個多頁面應用。

一個editor編輯器頁面,一個home主頁。

安裝完成之後讓我來改變改變目錄檔案:

image.png

建立的專案配置如下,我們分別先來講講這兩個基礎資料夾

  • containers資料夾中存放不同專案中的業務邏輯
  • packages資料夾中存放不同專案中的入口檔案

這兩個檔案中的內容我們先不關心,僅建立好最基礎的檔案目錄。

配置react支援

接下來讓我們的專案先支援最原始的jsx檔案,讓專案支援reactjsx

支援jsx需要額外配置babel去處理jsx檔案,將jsx轉譯成為瀏覽器可以識別的js

這裡我們需要用到如下幾個庫:

  • babel-loader
  • @babel/core
  • @babel/preset-env
  • @babel/plugin-transform-runtime
  • @babel/preset-react

我們來稍微梳理一下這幾個babel的作用,具體babel原理這裡我不進行過分深究。

babel-loader

首先對於我們專案中的jsx檔案我們需要通過一個"轉譯器"將專案中的jsx檔案轉化成js檔案,babel-loader在這裡充當的就是這個轉譯器。

@babel/core

但是babel-loader僅僅識別出了jsx檔案,內部核心轉譯功能需要@babel/core這個核心庫,@babel/core模組就是負責內部核心轉譯實現的。

@babel/preset-env

@babel/prest-envbabel轉譯過程中的一些預設,它負責將一些基礎的es 6+語法,比如const/let...轉譯成為瀏覽器可以識別的低級別相容性語法。

這裡需要注意的是@babel/prest-ent並不會對於一些es6+並沒有內建一些高版本語法的實現比如 Promisepolyfill,你可以將它理解為語法層面的轉化不包含高級別模組(polyfill)的實現。

@babel/plugin-transform-runtime

@babel/plugin-transform-runtime,上邊我們提到了對於一些高版本內建模組,比如Promise/Generate等等@babel/preset-env並不會轉化,所以@babel/plugin-transform-runtime就是幫助我們來實現這樣的效果的,他會在我們專案中如果使用到了Promise之類的模組之後去實現一個低版本瀏覽器的polyfill

其實與@babel/plugin-transform-runtime達到相同的效果還可以直接安裝引入@babel/polyfill,不過相比之下這種方式不被推薦,他存在汙染全域性作用域,全量引入造成提及過大以及模組之間重複注入等缺點。

此時這幾個外掛我們已經可以實現將es6+程式碼進行編譯成為瀏覽器可以識別的低版本相容性良好的js程式碼了,不過我們還缺少最重要一點。

目前這些外掛處理的都是js檔案,我們得讓她能夠識別並處理jsx檔案。

@babel/preset-react

此時就引入了我們至關重要的@babel/preset-react這個外掛,在jsx中我們使用的jsx標籤實質上最終會被編譯成為:

image.png

有興趣的朋友可以看看我之前的這篇文章React中的jsx原理解析

最終我們希望將.jsx檔案轉化為js檔案同時將jsx標籤轉化為React.createElement的形式,此時我們就需要額外使用babel的另一個外掛-@babel/preset-react

@babel/preset-react是一組預設,所謂預設就是內建了一系列babel plugin去轉化jsx程式碼成為我們想要的js程式碼。

babel所需要的配置這裡我們已經講完了需要用到的包和對應的作用,因為babel涉及的編譯原理部分的直接特別多所以我們這裡僅僅瞭解如何配置就可以了,有興趣的朋友可以移步babel官網去詳細檢視。

專案babel配置

接下來讓我們來安裝這5個外掛,並且在webpack中進行配置:

js yarn add -D @babel/core @babel/preset-env babel-loader @babel/plugin-transform-runtime @babel/preset-react

建立基礎webpack配置

當我們安裝完成上邊的編譯工具後,我們就來建立一個基礎的webpack.config.js來使用它來轉譯我們的jsx檔案:

image.png

我們來在專案跟目錄下建立一個scripts/webpack.base.js檔案。

關於webpack對於程式碼的轉譯,所謂轉譯直白來講也就是webpack預設只能處理基於js json的內容。

如果我們想讓webpack處理我們的jsx內容,就需要配置loader告訴它,

"嘿,webpack碰到.jsx字尾的檔案使用這個loader來處理。"

我們來編寫基礎的babel-loader配置:

webpack.base.js

```js // scripts/webpack.base.js const path = require('path');

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { main: path.resolve(__dirname, '../src/packages/home/index.jsx'), }, module: { rules: [ { test: /.jsx?$/, use: 'babel-loader', }, ], }, }; ```

此時我們已經告訴webpack,如果遇到jsx或者js程式碼,我們需要使用babel-loader進行處理,通過baebel將專案中的js/jsx檔案處理成為低版本瀏覽器可以識別的程式碼。

.babelrc

上邊我們講到了babel-loader僅僅是一個橋樑,真正需要轉譯作用的其他的外掛。接下來就讓我們來使用它:

babel-loader提供了兩種配置方式,一種是直接在webpack配置檔案中編寫options,另一個是官方推薦的在專案目錄下建立.babelrc檔案單獨配置babel

這裡我們採用第二種推薦的配置:

``` // .babelrc { "presets": [ "@babel/preset-env", "@babel/preset-react" ], "plugins": [ [ "@babel/plugin-transform-runtime", { "regenerator": true } ] ]

} ```

packages/home建立入口檔案index.jsx

接下來讓我們在packages/home目錄下建立home業務的入口檔案

```jsx // packages/home/index.jsx import ReactDom from 'react-dom'

const Element =

hello

ReactDom.render(,document.getElementById('root')) ```

到這一步相關基礎配置檔案已經初具模型了,我們需要做的就是當我們呼叫webpack打包我們專案的時候使用我們剛才書寫的webpack.base.js這個配置檔案。

pacakge.json增加build指令碼

我們來稍微修改一下pacakge.json:

json { "name": "pages", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "build": "webpack --config ./scripts/webpack.base.js" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@babel/core": "^7.15.5", "@babel/plugin-transform-runtime": "^7.15.0", "@babel/preset-env": "^7.15.6", "@babel/preset-react": "^7.14.5", "babel-loader": "^8.2.2", "webpack": "^5.53.0", "webpack-cli": "^4.8.0" }, "dependencies": { "react": "^17.0.2", "react-dom": "^17.0.2" } }

這裡我們刪除了初始化的test指令碼,增加了build命令。

接下來讓我們執行npm run build:

image.png

這一步我們已經成功讓webpack識別jsx程式碼並且支援將高版本js轉化為低版本javascript程式碼了。

如果有疑問的話,可以停留下來在想一想大概流程。主要就是:

  1. 建立babel配置轉譯jsx/js內容。
  2. 建立入口檔案。
  3. webpack中對於jsx/js內容使用babel-loader呼叫babel配置好的預設和外掛進行轉譯。

接下來讓我們繼續來支援TypeScript吧!

配置TypeScript支援

針對TypeScript程式碼的支援其實業記憶體在兩種編譯方式:

  1. 直接通過TypeScript去編譯ts/tsx程式碼。
  2. 通過babel進行轉譯。

其實這兩種方式都是可以達到編譯TypeScript程式碼成為JavaScript並且相容低版本瀏覽器程式碼的。

有興趣的朋友可以自行搜尋這兩種方式的差異,平常在一些類庫的打包時我會直接使用tsc結合tsconfig.js編譯ts程式碼。

日常工作中,大部分情況我個人還是會使用babel進行轉譯,因為涉及到業務往往是需要css等靜態資源與ts程式碼一起打包,那麼使用babel + webpack其實可以很好的一次性囊括了所有的資源編譯過程。

babel支援Typescirpt

babel內建了一組預設去轉譯TypeScript程式碼 --@babel/preset-typescript

接下來讓我們來使用@babel/preset-typescript預設來支援TypeScript語法吧。

npm install --save-dev @babel/preset-typescript

安裝完成之後讓我們一步一步來修改之前的配置:

首先我們先來修改之前.babelrc配置檔案,讓babel支援轉譯的ts檔案:

json { "presets": [ "@babel/preset-env", "@babel/preset-react", + "@babel/preset-typescript" ], "plugins": [ [ "@babel/plugin-transform-runtime", { "regenerator": true } ] ] }

這裡我們在presets添加了@babel/preset-typescrpt預設去讓babel支援typescript語法。

此時我們的babel已經可以識別TypeScript語法了

webpack支援ts/tsx檔案

不要忘記同時修改我們的webpackbabel-loader的匹配規則:

```js // webpack.base.jf const path = require('path');

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { // 這裡修改jsxtsx main: path.resolve(__dirname, '../src/packages/home/index.tsx'), }, module: { rules: [ { // 同時認識ts jsx js tsx 檔案 test: /.(t|s)x?$/, use: 'babel-loader', }, ], }, }; ```

這裡我們將ts,js,tsx,jsx檔案都交給babel-loader處理。

初始化tsconfig.json

現在,我們專案中已經可以支援tsx檔案的編寫,同時也支援編譯ts檔案為低版本js檔案了。

在使用Ts時,通常我們需要配置typescript的配置檔案,沒錯就是tsconfig.json

也許你已經見到過很多次tsconfig.json了,接下來讓我們去安裝typescript並且初始化吧~

專案內安裝Ts:

yarn add -D typescript

呼叫tsc --init命令初始化tsconfig.json:

npx tsc --init

關於npx命令和npm的關係,之後我會在另一篇文章中細細講述。瞭解他在這裡的用途:呼叫當前專案內node_modules/typescript/bin的可執行檔案執行init命令就可以了。

現在我們的目錄應該是這樣的:

image.png

雖然說我們使用babel進行的編譯,tsconfig.json並不會在編譯時生效。但是tsconfig.json中的配置非常影響我們的開發體驗,接下來我們就來稍微修改一下一下它吧。

配置tsconfig.json

首先我們來找到對應的jsx選項:

image.png

他的作用是指定jsx的生成什麼樣的程式碼,簡單來說也就是jsx程式碼將被轉化成為什麼。

這裡我們將它修改為react

image.png

接下來我們來修改一下ts中的模組解析規則,將它修改為node:

"moduleResolution": "node",

image.png

這裡暫時我們先修改這兩個配置,後續配置我們會在後邊的講解中漸進式的進行配置。

推薦一本開源的電子書,這裡羅列了大部分tsconfig.json配置資訊

處理報錯

我們已經在專案中完美支援了typescript,接下里讓我們把pacakges/home/index.jsx改為packages/home/index.tsx吧.

修改完成後綴後我們再來看看我們想專案檔案:

image.png

我們來一個一個解決這些報錯:

首先我們引用第三方包在TypeScript檔案時,簡單來說它會尋找對應包的package.json中的type欄位查詢對應的型別定義檔案。

reactreact-dom這兩個包程式碼中都不存在對應的型別宣告,所以我們需要單獨安裝他們對應的型別宣告檔案:

yarn add -D @types/react-dom @types/react

大多數額外的型別定義包,你可在這裡找到

安裝完成之後,我們重新再看看當前的index.tsx檔案:

image.png

此時,僅剩下一個報錯了。讓我們來仔細定位一下錯誤。ts告訴我們ReactDom.render方法中傳入的引數型別不相容。嗯,本質上是我們react語法寫錯了。修改後的程式碼如下:

image.png

此時我們的專案已經可以完成支援typescriptreact了。

webpack配置靜態資源支援

一個成熟的專案只能有ts怎麼能夠呢? 畢竟一個成熟的業務仔怎麼脫離css的魔抓呢😂

也許你之前接觸過webpack5之前的靜態資源處理,file-loader,url-loader,row-loader這些loader是不是聽起來特別熟悉。

webpack預設是不支援非js檔案的,所以在webpack5之前我們通過loader的方式返回可執行的js指令碼檔案,內部將處理這些webpack不認識的檔案。

webpack 5+版本之後,這些loader的作用都已經被內建了~

接下來我們來看看應該如何配置,具體對應的作用可以檢視webpack資源模組

處理圖片,檔案資原始檔

資源模組型別(asset module type),通過新增 4 種新的模組型別,來替換所有這些 loader:

  • asset/resource 傳送一個單獨的檔案並匯出 URL。之前通過使用 file-loader 實現。
  • asset/inline 匯出一個資源的 data URI。之前通過使用 url-loader 實現。
  • asset/source 匯出資源的原始碼。之前通過使用 raw-loader 實現。
  • asset 在匯出一個 data URI 和傳送一個單獨的檔案之間自動選擇。之前通過使用 url-loader,並且配置資源體積限制實現。

當在 webpack 5 中使用舊的 assets loader(如 file-loader/url-loader/raw-loader 等)和 asset 模組時,你可能想停止當前 asset 模組的處理,並再次啟動處理,這可能會導致 asset 重複,你可以通過將 asset 模組的型別設定為 'javascript/auto' 來解決。

瞭解了assets模組的用途之後,我們來試著配置它來處理靜態資源:

```js const path = require('path');

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { main: path.resolve(__dirname, '../src/packages/home/index.tsx'), }, module: { rules: [ { // 同時認識ts jsx js tsx 檔案 test: /.(t|j)sx?$/, use: 'babel-loader', }, { test: /.(png|jpe?g|svg|gif)$/, type:'asset/inline' }, { test: /.(eot|ttf|woff|woff2)$/, type: 'asset/resource', generator: { filename: 'fonts/[hash][ext][query]', }, }, ], }, }; ``` 這一步我們已經關於圖片和字型檔案配置已經配置完畢了,接下來我們來修改一下目錄結構驗證一下我們的配置是否生效:

驗證配置效果

修改packages

首先讓我們先來修改packagespackages資料夾之前講過是存放多頁面應用中每個頁面的入口檔案的。讓我們在home資料夾下先新建一個app.tsx: ```tsx // src/packages/home/app.tsx import React from 'react' import ReactDom from 'react-dom' // 這裡App僅接著就會降到 它就是就是一個React FunctionComponent import { App } from '../../containers/home/app.tsx'

ReactDom.render(, document.getElementById('root')) ```

修改containers

containers資料夾中的內容是存放每個頁面應用不同的業務邏輯的部分。

讓我們在containers中新建app.tsx作為跟應用,以及同級目錄下新建assets,styles,views三個目錄:

  • assets存放當前模組相關靜態資源,比如圖片,字型檔案等。
  • styles存在當前模組相關樣式檔案,我們還沒有配置相關樣式檔案的處理,之後會詳細介紹。
  • views存放當前模組下相關頁面邏輯頁面拆分

我們給assets中新建一個images資料夾,放入一張logo,圖片。

修改app.tsx內容如下: ```tsx import React from 'react' import Banner from './assets/image/banner.png'

const App: React.FC = () => { return

Hello,This is pages!

}

export { App } ```

yarn build

我們當前的目錄結構如下:

image.png

基本的目錄結構我們已經搭建成功,接下來讓我們執行yarn build

首先根據webpack中的入口檔案會去尋找packages/home/index.tsx,我們在index.tsx中引入了對應的containers/app.tsxwebpack會基於我們的import語法去處理模組依賴關係構建模組。

同時因為我們在app.tsx中引入了圖片

js // webpack.base.js { test: /\.(png|jpe?g|svg|gif)$/, type: 'asset/inline' },

此時type:'assets/inline'針對圖片的處理就會生效了!

讓我們來看看build之後的檔案:

image.png

asset/inline會講資原始檔內建成為base64url-loader是相同的作用。

當前目前webpackasset/inline模組會將所有資源轉化為base64在行內遷入,如果要達到url-loaderlimit的配置需要禁用asset/inline使用url-loader處理。

webpack asset/inline issue可以點選這裡檢視

解決報錯

細心的你可能已經發現了,目前我們專案中存在兩個問題:

  1. ts檔案中針對image的引入,ts並不能正確的識別。

image.png

解決這個問題的方式其實很簡單,我們定義一個image.d.ts在根目錄下:

ts declare module '*.svg'; declare module '*.png'; declare module '*.jpg'; declare module '*.jpeg'; declare module '*.gif'; declare module '*.bmp'; declare module '*.tiff';

declare為宣告語法,意思為宣告ts全域性模組,這樣我們就可以正常引入對應的資源了。

  1. 我們在index.tsx中引入了對應的app.tsx,當存在後綴時ts會進行報錯提示:

image.png

接下來讓我們來解決這個問題吧。其實無法就是引入檔案時預設字尾名的問題:

  • 目前webpack不支援預設字尾名.tsx
  • tsconfig.json中是支援字尾名.tsx,所以顯示宣告會提示錯誤。

我們來統一這兩個配置:

別名統一

修改webpack別名配置

```js // webpack.base.js const path = require('path');

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { main: path.resolve(__dirname, '../src/packages/home/index.tsx'), }, resolve: { alias: { '@src': path.resolve(__dirname, '../src'), '@packages': path.resolve(__dirname, '../src/packages'), '@containers': path.resolve(__dirname, '../src/containers'), }, mainFiles: ['index', 'main'], extensions: ['.ts', '.tsx', '.scss', 'json', '.js'], }, module: { rules: [ { // 同時認識ts jsx js tsx 檔案 test: /.(t|j)sx?$/, use: 'babel-loader', }, { test: /.(png|jpe?g|svg|gif)$/, type: 'asset/inline' }, { test: /.(eot|ttf|woff|woff2)$/, type: 'asset/resource', generator: { filename: 'fonts/[hash][ext][query]', }, }, ], }, }; ```

這裡我們添加了resolve的引數,配置了別名@src,@package,@container

以及當我們不書寫檔案字尾時,預設的解析規則extensions規則。

同時還配置了mainFiles,解析資料夾路徑~

這個三個配置都比較基礎,就不過多深入了哈。如果仍然還是不是很懂,用到的時候多翻翻慢慢也就記住啦!

讓我們來嘗試修改index.tsx,使用別名來引入:

image.png

此時我們發現並沒有路徑提示,這個!是真的無法接受!

原因是我們是基於typescript開發,所以ts檔案中並不知道我們在webpack中配置的別名路徑。

所以我們需要做的就是同步修改tsconfig.json,讓tsconfig.json也支援別名以及剛才的配置,達到最佳的開發體驗。

修改tsconfig.json別名配置

我們來修改tsconfig.json,讓ts同步支援引入:

json // tsconfig.json ... "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ "paths": { "@src/*": ["./src/*"], "@packages/*": ["./src/packages/*"], "@containers/*": ["./src/containers/*"], },

如果要配置paths那麼一定是要配置baseUrl的,所謂baseUrl就是我們的paths是相對於那個路徑開始的。

所以我們在paths中新增對應的別名路徑就可以完成配置,讓ts也可以合理解析出我們的類型別名。

此時我們再來看看:

image.png

已經可以正確出現路徑提示了,是不是非常nice

針對路徑/檔案目錄解析規則配置我們目前就告一段落了~

配置css/sass

接下來我們給專案新增一些樣式來美化它。

這裡其實React專案有太多有關css的爭吵了,但是無論如何我們是都要在webpack中針對css進行處理的。

這裡我選擇使用sass前處理器進行演示,其他less等都是同理。

針對於sass檔案,同樣是webpack不認識的檔案。咱們同樣是需要loader 去處理。

這裡用到的loader如下:

  • sass-loader
  • resolve-url-loader
  • postcss-loader
  • css-loader
  • MiniCssExtractPlugin.loader

我們來一個一個來分析這些loader的作用的對應的配置:

sass-loader

針對於sass檔案我們首先一定是要使用sass編譯成為css的,所以我們首先需要對.scss結尾的檔案進行編譯成為css檔案。

這裡我們需要安裝:

yarn add -D sass-loader sass

sass-loader 需要預先安裝 Dart Sass 或 Node Sass(可以在這兩個連結中找到更多的資料)。這可以控制所有依賴的版本, 並自由的選擇使用的 Sass 實現。

sass-loader的作用就類似我們之前講到過的babel-loader,可以將它理解成為一個橋樑,sass轉譯成為css的核心是由node-sass或者dart-sass去進行編譯工作的。

resolve-url-loader

上一步我們已經講到過sass-loadersass檔案轉化為css檔案。

但是這裡有一個致命的問題,就是關於webpackscss檔案中

由於 Saass 的實現沒有提供 url 重寫的功能,所以相關的資源都必須是相對於輸出檔案(ouput)而言的。

  • 如果生成的 CSS 傳遞給了 css-loader,則所有的 url 規則都必須是相對於入口檔案的(例如:main.scss)。
  • 如果僅僅生成了 CSS 檔案,沒有將其傳遞給 css-loader,那麼所有的 url 都是相對於網站的根目錄的。

所以針對於sass編譯後的css檔案中的路徑是不正確的,並不是我們想要的相對路徑模式。

想要解決路徑引入的問題業內有很多現成的辦法,比如通過

  1. 路徑變數定義引入路徑
  2. 定義別名,sass中使用別名引入路徑
  3. resolve-url-loader

這裡我們採用resolve-url-loader來處理檔案引入路徑問題。

不要忘記yarn add -D resolve-url-loader

postcss-loader

PostCSS是什麼?或許,你會認為它是前處理器、或者後處理器等等。其實,它什麼都不是。它可以理解為一種外掛系統。

針對於postcss其實我這裡並不打算深入去講解,它是babel一樣都是兩個龐然大物。擁有自己獨立的體系,在這裡你需要清楚的是我們使用postcss-loader處理生成的css

第一步首先安裝post-css對應的內容:

yarn add -D postcss-loader postcss

postcss-loader同時支援直接在loader中配置規則選項,也支援單獨建立檔案配置,這裡我們選擇單獨使用檔案進行配置:

我們在專案根目錄下新建一個postcss.config.js的檔案: js module.exports = { plugins: [ require('autoprefixer'), require('cssnano')({ preset: 'default', }), ], }

這裡我們使用到了兩個postcss的外掛:

  • autoprefixer外掛的作用是為我們的css內容新增瀏覽器廠商字首相容。
  • cssnano的作用是儘可能小的壓縮我們的css程式碼。

接下來我們去安裝這兩個外掛: shell yarn add -D cssnano autoprefixer@latest

css-loader

css-loader是解析我們css檔案中的@import/require語句分析的.

shell yarn add -D css-loader

MiniCssExtractPlugin.loader

這個外掛將 CSS 提取到單獨的檔案中。它為每個包含CSSJS 檔案建立一個 CSS 檔案。它支援按需載入 CSS 和 SourceMaps

這裡需要提一下他和style-loader的區別,這裡我們使用了MiniCssExtractPlugin代替了style-loader

style-loader會將生成的css新增到htmlheader標籤內形成內斂樣式,這顯然不是我們想要的。所以這裡我們使用MiniCssExtractPlugin.loader的作用就是拆分生成的css成為獨立的css檔案。

yarn add -D mini-css-extract-plugin

生成sass最終配置檔案

接下來我們來生成sass檔案的最終配置檔案:

```js // webapck.base.js const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin')

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { main: path.resolve(__dirname, '../src/packages/home/index.tsx'), }, resolve: { alias: { '@src': path.resolve(__dirname, '../src'), '@packages': path.resolve(__dirname, '../src/packages'), '@containers': path.resolve(__dirname, '../src/containers'), }, mainFiles: ['index', 'main'], extensions: ['.ts', '.tsx', '.scss', 'json', '.js'], }, module: { rules: [ { test: /.(t|j)sx?$/, use: 'babel-loader', }, { test: /.(sa|sc)ss$/, use: [ { loader: MiniCssExtractPlugin.loader, }, 'css-loader', 'postcss-loader', { loader: 'resolve-url-loader', options: { keepQuery: true, }, }, { loader: 'sass-loader', options: { sourceMap: true }, }, ], }, { test: /.(png|jpe?g|svg|gif)$/, type: 'asset/inline' }, { test: /.(eot|ttf|woff|woff2)$/, type: 'asset/resource', generator: { filename: 'fonts/[hash][ext][query]', }, }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'assets/[name].css', }), ] }; ```

js // postcss.config.js module.exports = { plugins: [ require('autoprefixer'), require('cssnano')({ preset: 'default', }), ], }

完成這些配置之後,同時我們在containers/src/home/styles目錄中新建一個sass檔案index.scss:

讓我們來寫一些簡單的樣式檔案: css .body { background-color: red; }

image.png

這之後,讓我們重新執行yarn build

檢查生成的dist檔案我們發現,我們的sass被成功的編譯成為了css檔案並且刪除了多於空格(進行了壓縮)

image.png

這一步我們scss的基礎配置也已經完成了!

配置html頁面

當前我們所有涉及的都是針對單頁面應用的配置,此時我們迫切需要一個html展示頁面。

此時就引入我們的主角,我們後續的多頁面應用也需要機遇這個外掛生成html頁面

html-webpack-plugin,其實看到這裡我相信大家對這個外掛原本就已經耳熟能詳了。

簡單介紹一下它的作用: 這個外掛為我們生成 HTML 檔案,同時可以支援自定義html模板。

多頁面應用主要基於它的chunks這個屬性配置,我們這裡先買個關子。

讓我們來使用一下這個外掛:

SHELL yarn add --dev html-webpack-plugin

我們在專案根目錄下建立一個public/index.html

```html

Document

```

我們使用這個檔案作為外掛的模板檔案,同時與入口檔案中的ReactDom.reander(...,document.getElementById('root'))進行呼應,在頁面建立一個id=rootdiv作為渲染節點。

```js // webpack.base.js const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const htmlWebpackPlugin = require('html-webpack-plugin');

module.exports = { // 入口檔案,這裡之後會著重強調 entry: { main: path.resolve(__dirname, '../src/packages/home/index.tsx'), }, resolve: { alias: { '@src': path.resolve(__dirname, '../src'), '@packages': path.resolve(__dirname, '../src/packages'), '@containers': path.resolve(__dirname, '../src/containers'), }, mainFiles: ['index', 'main'], extensions: ['.ts', '.tsx', '.scss', 'json', '.js'], }, module: { rules: [ { test: /.(t|j)sx?$/, use: 'babel-loader', }, { test: /.(sa|sc)ss$/, use: [ { loader: MiniCssExtractPlugin.loader, }, 'css-loader', 'postcss-loader', { loader: 'resolve-url-loader', options: { keepQuery: true, }, }, { loader: 'sass-loader', options: { sourceMap: true, }, }, ], }, { test: /.(png|jpe?g|svg|gif)$/, type: 'asset/inline', }, { test: /.(eot|ttf|woff|woff2)$/, type: 'asset/resource', generator: { filename: 'fonts/[hash][ext][query]', }, }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'assets/[name].css', }), // 生成html名稱為index.html // 生成使用的模板為public/index.html new htmlWebpackPlugin({ filename: 'index.html', template: path.resolve(__dirname, '../public/index.html'), }), ], }; ```

此時,當我們再次執行yarn build時,我們生成的dist目錄下會多出一個html檔案,這個html檔案會注入我們打包生成後的的jscss內容。

image.png

開啟index.html,就會展示出我們程式碼中書寫的頁面啦~

配置開發環境預覽

上邊的長篇大論已經能滿足一個SPA單頁面應用的構建了,但是我們總不能每次修改程式碼都需要執行一次打包命令在預覽吧。

這樣的話也太過於麻煩了,別擔心webpack為我們提供了devServer配置,支援我們每次更新程式碼熱過載

我們來使用一下這個功能吧~

首先讓我們在scripts目錄下新建一個webpack.dev.js檔案,表示專門用於開發環境下的打包預覽:

雖然devServer已經內建了hot:true達到熱過載,但是我們仍然需要安裝webpack-dev-server。 ```js // webpack.dev.js const { merge } = require('webpack-merge'); const baseConfig = require('./webpack.base'); const path = require('path');

const devConfig = { mode: 'development', devServer: { // static允許我們在DevServer下訪問該目錄的靜態資源 // 簡單理解來說 當我們啟動DevServer時相當於啟動了一個本地伺服器 // 這個伺服器會同時以static-directory目錄作為跟路徑啟動 // 這樣的話就可以訪問到static/directory下的資源了 static: { directory: path.join(__dirname, '../public'), }, // 預設為true hot: true, // 是否開啟程式碼壓縮 compress: true, // 啟動的埠 port: 9000, }, };

module.exports = merge(devConfig, baseConfig); ```

關於devServer的基礎配置在程式碼中進行了註釋講解,當然還存在一些其他的proxy,onlistening等配置需要的小朋友可以去這裡查閱

這裡需要提到的是webpack-merge這個外掛是基於webpack配置合併的,這裡我們基於webpack.base.jswebpack.dev.js合併匯出了一個配置物件。

接下里再讓我們修改一下pacakge.json下的scripts命令。

devServer需要使用webpack serve啟動。

json ... "scripts": { + "dev": "webpack serve --config ./scripts/webpack.dev.js", "build": "webpack --config ./scripts/webpack.base.js", "test": "echo \"Error: no test specified\" && exit 1" }, ...

接下來讓我們執行yarn dev就可以在localhost:9000訪問到我們剛才需要的頁面並且實時支援熱過載了。

切入多頁面應用

接下來正式進入我們的多入口檔案部分講解。

原理

拆分js

所謂基於webpack實現多頁面應用,簡單來將就是基於多個entry,將js程式碼分為多個entry入口檔案。

比如我在src/packages/editor新建一個入口檔案index.tsx,同時修改webpack中的entry配置為兩個入口檔案,webpack就會基於入口檔案的個數自動進行不同chunk之間的拆分。

簡單來說就是webapck會基於入口檔案去拆分生成的js程式碼成為一個一個chunk

image.png

上邊的配置我們執行yarn build之後生成的dist目錄如下:

image.png

我們可以看到根據不同的入口檔案生成了兩份js檔案,一份是main.js一份是editor.js

我們第一步已經完成了,基於不同的入口檔案打包生成不同的js

拆分html

但是現在我們現在拆分出來的js還是在同一個index.html中進行引入,我們想要達到的效果是main.jsmain.html中引入成為一個頁面。

editor.jseditor.html中引入成為一個單獨的頁面。

要實現這種功能,我們需要在html-webpack-plugin上做手腳。

不知道大家還記不記得我們之前留下的chunks這個關子。

所謂的chunks配置指的是生成的html檔案僅僅包含指定的chunks塊。

這不正是我們想要的嘛!

現在我們打包生成了兩份js檔案分別是editor.jsmain.js,現在我們想生成兩份單獨的html檔案,兩個html檔案中分別引入不同的editor.jsmain.js

此時我們每次打包只需要呼叫兩次htmlWebpackPlugin,一份包含editor這個chunk,一份包含main這個chunk不就可以了嘛。

讓我們來試一試:

image.png

接下來我們執行yarn build來看一看生成的檔案:

image.png

來看看我們生成的htmljs結構,大功告成沒一點問題!這樣的確能解決,這也是基於webpack打包多頁面應用的原理。

可是如果我們存在很多個頁面的話,首先我們每次都需要手動修改入口檔案然後在進行新增一個htmlWebpackPlugin這顯然是不人性的。

同時如果這個專案下有很多個多頁應用,但是我每次開發僅僅關心某一個應用進行開發,比如我負責的是home模組,我並不想使用和關心editor模組。那麼每次我還需要在dev環境下進行打包嗎?顯然是不需要的。

接下來就讓我們嘗試來修改這些配置將它變成自動化且按需打包的工程化配置吧。

工程化多頁配置

工程化原理

我們之前已經講清楚了webpack中的原理了,接下來我們需要實現的過程是:

  1. 每次打包通過node指令碼去執行打包命令。
  2. 每次打包通過命令列互動命令,讀取pacakges下的目錄讓使用者選擇需要打包的頁面。
  3. 當用戶選中對應需要打包的目錄後,通過環境變數注入的方式動態進行打包不同的頁面。

這裡我們需要額外用到一下幾個庫,還不太清楚的同學可以點選去檢視一下文件:

這個庫改進了node的源生模組child_process,用於開啟一個node子程序。

inquirer提供一些列api用於nodejs中和命令列的互動。

chalk為我們的列印帶上豐富的顏色.

shell yarn add -D chalk inquirer execa

實現程式碼

首先讓我們在scripts下建立一個utils的資料夾。

utils/constant.js

constant.js中存放我們關於呼叫指令碼宣告的一些常量:

```js // 規定固定的入口檔名 packages/**/index.tsx const MAIN_FILE = 'index.tsx' const chalk = require('chalk')

// 列印時顏色 const error = chalk.bold.red const warning = chalk.hex('#FFA500') const success = chalk.green

const maps = { success, warning, error, }

// 因為環境變數的注入是通過字串方式進行注入的 // 所以當 打包多個檔案時 我們通過進行連線 比如 home和editor 注入的環境變數為homeeditor // 注入多個包環境變數時的分隔符 const separator = '*'

const log = (message, types) => { console.log(mapstypes) }

module.exports = { MAIN_FILE, log, separator, BASE_PORT, } ```

utils/helper.js

```js const path = require('path') const fs = require('fs') const HtmlWebpackPlugin = require('html-webpack-plugin') const { MAIN_FILE } = require('./constant')

// 獲取多頁面入口資料夾中的路徑 const dirPath = path.resolve(__dirname, '../../src/packages')

// 用於儲存入口檔案的Map物件 const entry = Object.create(null)

// 讀取dirPath中的檔案夾個數 // 同時儲存到entry中 key為資料夾名稱 value為資料夾路徑 fs.readdirSync(dirPath).filter(file => { const entryPath = path.join(dirPath, file) if (fs.statSync(entryPath)) { entry[file] = path.join(entryPath, MAIN_FILE) } })

// 根據入口檔案list生成對應的htmlWebpackPlugin // 同時返回對應wepback需要的入口和htmlWebpackPlugin const getEntryTemplate = packages => { const entry = Object.create(null) const htmlPlugins = [] packages.forEach(packageName => { entry[packageName] = path.join(dirPath, packageName, MAIN_FILE) htmlPlugins.push( new HtmlWebpackPlugin({ template: path.resolve(__dirname, '../../public/index.html'), filename: ${packageName}.html, chunks: ['manifest', 'vendors', packageName], }) ) }) return { entry, htmlPlugins } }

module.exports = { entry, getEntryTemplate, } ```

helper.js中其實主要匯出的getEntryTemplate方法,這個方法輸入一個package的陣列,同時返回對應webpack需要的entryhtml-wepback-plugin組成的陣列。

utils/dev.js

當我們定義好兩個輔助檔案之後,接下來我們就要實現和"使用者"互動的部分了,也就是當用戶呼叫我們這個指令碼之後。

  • 首先動態讀取packages下的目錄,獲取當前專案下所有的頁面檔案。
  • 通過命令互動羅列當前所有頁面,提供給使用者選擇。
  • 使用者選中後,通過execa呼叫webpack命令同時注入環境變數進行根據使用者選中內容打包。

```js const inquirer = require('inquirer')

const execa = require('execa') const { log, separator } = require('./constant') const { entry } = require('./helper')

// 獲取packages下的所有檔案 const packagesList = [...Object.keys(entry)]

// 至少保證一個 if (!packagesList.length) { log('不合法目錄,請檢查src/packages/*/main.tsx', 'warning') return }

// 同時新增一個全選 const allPackagesList = [...packagesList, 'all']

// 呼叫inquirer和使用者互動 inquirer .prompt([ { type: 'checkbox', message: '請選擇需要啟動的專案:', name: 'devLists', choices: allPackagesList, // 選項 // 校驗最少選中一個 validate(value) { return !value.length ? new Error('至少選擇一個專案進行啟動') : true }, // 當選中all選項時候 返回所有packagesList這個陣列 filter(value) { if (value.includes('all')) { return packagesList } return value }, }, ]) .then(res => { const message = 當前選中Package: ${res.devLists.join(' , ')} // 控制檯輸入提示使用者當前選中的包 log(message, 'success') runParallel(res.devLists) })

// 呼叫打包命令 async function runParallel(packages) { // 當前所有入口檔案 const message = 開始啟動: ${packages.join('-')} log(message, 'success') log('\nplease waiting some times...', 'success') await build(packages) }

// 真正打包函式 async function build(buildLists) { // 將選中的包通過separator分割 const stringLists = buildLists.join(separator) // 呼叫通過execa呼叫webapck命令 // 同時注意路徑是相對 執行node命令的cwd的路徑 // 這裡我們最終會在package.json中用node來執行這個指令碼 await execa('webpack', ['server', '--config', './scripts/webpack.dev.js'], { stdio: 'inherit', env: { packages: stringLists, }, }) } ```

dev.js中的邏輯其實很簡單,就是通過命令列和使用者互動獲得使用者想要啟動的專案之後通過使用者選中的packages然後通過execa執行webpack命令同時動態注入一個環境變數。

注入的環境變數是*分割的包名。比如使用者如果選中appeditor那麼就會注入一個packages的環境變數為app*editor

修改webpack.base.js

我們已經可以在命令列中和使用者互動,並且獲得使用者選中的pacakge

此時我們就基於wepback.base.js來修改,達到讀取使用者環境變數進行動態打包的效果:

```js const path = require('path'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const htmlWebpackPlugin = require('html-webpack-plugin'); const { separator } = require('./utils/constant') const { getEntryTemplate } = require('./utils/helper')

// 將packages拆分成為陣列 ['editor','home'] const packages = process.env.packages.split(separator)

// 呼叫getEntryTemplate 獲得對應的entry和htmlPlugins const { entry, htmlPlugins } = getEntryTemplate(packages)

module.exports = { // 動態替換entry entry, resolve: { alias: { '@src': path.resolve(__dirname, '../src'), '@packages': path.resolve(__dirname, '../src/packages'), '@containers': path.resolve(__dirname, '../src/containers'), }, mainFiles: ['index', 'main'], extensions: ['.ts', '.tsx', '.scss', 'json', '.js'], }, module: { rules: [ { test: /.(t|j)sx?$/, use: 'babel-loader', }, { test: /.(sa|sc)ss$/, use: [ { loader: MiniCssExtractPlugin.loader, }, 'css-loader', 'postcss-loader', { loader: 'resolve-url-loader', options: { keepQuery: true, }, }, { loader: 'sass-loader', options: { sourceMap: true, }, }, ], }, { test: /.(png|jpe?g|svg|gif)$/, type: 'asset/inline', }, { test: /.(eot|ttf|woff|woff2)$/, type: 'asset/resource', generator: { filename: 'fonts/[hash][ext][query]', }, }, ], }, plugins: [ new MiniCssExtractPlugin({ filename: 'assets/[name].css', }), // 同時動態生成對應的htmlPlugins ...htmlPlugins ], };

```

因為我們的目錄結構是固定的,所以通過環境變數中的packages傳入getEntryTemplate方法可以獲得對應的入口檔案,以及生成對應的htmlWebpackPlugin

到這一步其實我們已經實現了動態打包的所有邏輯了。

接下來讓我們來替換一下package.json中的指令碼

修改package.json

json "scripts": { - "dev": "webpack serve --config ./scripts/webpack.dev.js", + "dev": "node ./scripts/utils/dev.js", "build": "webpack --config ./scripts/webpack.base.js", "test": "echo \"Error: no test specified\" && exit 1" },

我們將dev命令替換成為執行scripts/utils/dev.js

接下來我們來試一下執行yarn dev:

image.png

我們選中home進行打包:

image.png

image.png

可以看到這次生成的打包結果就真的只有home.htmlhome.js相關的內容了,讓我們開啟localhost:9000/home.html可以看到頁面上出現了我們想要的內容~

此時嘗試去訪問http://localhost:9000/editor.html會得到Cannot GET /editor.html

這一步我們大功告成啦~但是當前只有dev環境下,讓我們接下來來改造production環境下的配置。

改造production環境

production環境的程式碼和dev環境的流程思路是一摸一樣的,只是針對於webpack配置有所不同。

所以我們在scripts新建一個webpack.prod.js檔案:

``js // 這裡我使用了預設的webpack`production下的配置 // 如果你需要額外的配置,可以額外新增配置。 // 這裡提供動態多頁應用的流程 具體壓縮/優化外掛和配置 各位小哥可以去官網檢視配置~ // 之後我也會在文章開頭的github倉庫中提供不同branch去實踐最佳js程式碼壓縮優化 const path = require('path') const { CleanWebpackPlugin } = require('clean-webpack-plugin') const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin') const { merge } = require('webpack-merge') const baseConfig = require('./webpack.base')

const prodConfig = { mode: 'production', devtool: 'source-map', output: { filename: 'js/[name].js', path: path.resolve(__dirname, '../dist'), }, plugins: [ new CleanWebpackPlugin(), new FriendlyErrorsWebpackPlugin() ] }

module.exports = merge(prodConfig, baseConfig) `scripts/build.js`,這裡和`dev`下的思路是一模一樣的,公用程式碼邏輯其實可以拆分~js const inquirer = require('inquirer') const execa = require('execa') const { log, separator } = require('./constant') const { entry } = require('./helper')

const packagesList = [...Object.keys(entry)]

if (!packagesList.length) { log('不合法目錄,請檢查src/packages/*/main.tsx', 'warning') return } const allPackagesList = [...packagesList, 'all']

inquirer .prompt([ { type: 'checkbox', message: '請選擇需要打包的專案:', name: 'buildLists', choices: allPackagesList, // 選項 validate(value) { return !value.length ? new Error('至少選擇一個內容進行打包') : true }, filter(value) { if (value.includes('all')) { return packagesList } return value }, }, ]) .then(res => { // 拿到所有結果進行打包 const message = 當前選中Package: ${res.buildLists.join(' , ')} log(message, 'success') runParallel(res.buildLists) })

function runParallel(packages) { const message = 開始打包: ${packages.join('-')} log(message, 'warning') build(packages) }

async function build(buildLists) { const stringLists = buildLists.join(separator) await execa('webpack', ['--config', './scripts/webpack.prod.js'], { stdio: 'inherit', env: { packages: stringLists, }, }) } ```

這一步其實我們已經完成了動態多頁應用的實現了,原理部分之前也已經講清楚了。

其實核心就是把我環境變數通過execainquirer進行命令列互動動態注入環境變數打包對應選中檔案。

如果對webpack中環境變數還是不太熟悉的同學可以點選這篇文章,Wepback中環境變數的各種姿勢

Eslint & prettier

完成了核心的應用流程打包程式碼,接下來我們來聊一些輕鬆的程式碼檢查。

一份良好的工程架構程式碼規範檢查是必不可少的配置。

prettier

shell yarn add --dev --exact prettier 安裝完成之後我們在專案根目錄下:

shell echo {}> .prettierrc.js

我們來個這個js內容新增一些基礎配置

js module.exports = { printWidth: 100, // 程式碼寬度建議不超過100字元 tabWidth: 2, // tab縮排2個空格 semi: false, // 末尾分號 singleQuote: true, // 單引號 jsxSingleQuote: true, // jsx中使用單引號 trailingComma: 'es5', // 尾隨逗號 arrowParens: 'avoid', // 箭頭函式僅在必要時使用() htmlWhitespaceSensitivity: 'css', // html空格敏感度 }

我們再來新增一份.prettierignoreprettier忽略檢查一些檔案: ``` //.prettierignore /*.min.js /*.min.css

.idea/ node_modules/ dist/ build/ ```

同時讓我們為我們的程式碼基於huskylint-staged新增git hook

具體配置可以參照這裡husky&list-staged

安裝完成後,在我們每次commit時候都會觸發lit-staged自動修復我們匹配的檔案:

image.png

因為我們專案中是ts檔案,所以要稍微修改一下他支援的字尾檔案:

json // package.json ... "lint-staged": { "*.{js,css,md,ts,tsx,jsx}": "prettier --write" } ...

ESlint

Eslint其實就不用多說了,大名鼎鼎嘛。 js yarn add eslint --dev 初始化eslint npx eslint --init

eslint回和我們進行一些列的互動提示,按照提示進行選擇我們需要的配置就可以了:

image.png

prettiereslint共同工作時,他們可能會衝突。我們需要安裝yarn add -D eslint-config-prettie外掛並且覆蓋eslint部分規則。

安裝完成之後,我們稍微修改一下eslint的配置檔案,讓衝突時,優先使用prettier覆蓋eslint規則:

js // .eslint.js module.exports = { "env": { "browser": true, "es2021": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", // 新增`prettier`拓展 用於和`prettier`衝突時覆蓋`eslint`規則 "prettier" ], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 12, "sourceType": "module" }, "plugins": [ "react", "@typescript-eslint" ], "rules": { } };

同時我們來新增.eslintignore忽略掉一些我們的非ts目錄檔案,比如構建的一些指令碼檔案。 *.d.ts scripts/**

寫在最後

關於 Pages ,歡迎大家在留言區留下自己的意見指出對文章中的不足。

相信文章中的程式碼還有很多優化的點,這裡提供給大家的主要是一個流程。

希望大家在評論區留下對於程式碼中存在的不足,或者可以持續優化點的。我們共同探討😊!

後續如果有時間我會將Pages 持續完善然後整合到我cli中去,期待和大家分享cli部分的pages~