從零搭建前端開發環境----React+Ts+Webpack基礎搭建

lxnxbnq發表於2023-03-11
我的掘金

前端開發開發環境系列文章的 github 在這,如果您在看的過程中發現了什麼不足和錯誤,感謝您能指出!

雖然目前市面上有很多的前端腳手架以及一體化的框架,比如create-react-app、umi等。但是作為一個程式設計師,自己寫過更有助於提升在開發過程中發現問題和解決問題的能力。

Webpack基礎

Webpack是一個靜態模組打包器,它將所有的資源都看作模組。透過一個或多個入口,構建一個依賴圖譜(dependency graph)。然後將所有模組組合成一個或多個bundle

<div align="center">
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f6c514b8cdf54d19b073a85dc297390a~tplv-k3u1fbpfcp-zoom-1.image" width = "300" alt="" align=center />
</div>

可以透過一個簡單的例子來初步瞭解Webpack

比如: 我們想要使用es6的箭頭函式來寫一個功能,但是有的瀏覽器不支援(IE6-11或其他老版本瀏覽器)。那麼這個使用者在載入這個js資源的時候就會報錯。

但這顯然不是我們想要的結果,這時候就需要用到webpack或像gulp這樣的構建工具來幫助我們將es6的語法轉化成低版本瀏覽器可相容的程式碼。

那麼用webpack來配置一個構建工具時如下:

  1. 建立一個目錄,並yarn init初始化一個包管理器
  2. 安裝webpack yarn install webpack webpack-cli -D
  3. 想要將es6轉化為es5語法,需要用到babel外掛對程式碼進行編譯,所以需要安裝babel和相應的loader yarn add @babel/core @babel/preset-env babel-loader -D
  4. 配置.babelrc

    {
     "presets": [
         [
             "@babel/preset-env",
             {
                 "modules": false
             }
         ]
     ]
    }
  5. 建立src/index.js 入口

    const sum = (a, b) => a + b;
    console.log(sum(1, 2))
  6. 建立輸出檔案 dist/html

    <!DOCTYPE html>
    <html lang="en">
    <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>Document</title>
    </head>
    <body>
     <script src="./bundle.js"></script>
    </body>
    </html>
  7. 然後就是配置webpack.config.js

    const webpack = require('webpack');
    const path = require('path');
    
    const config = {
      entry: './src/index.js',
      output: {
     path: path.resolve(__dirname, 'dist'),
     filename: 'bundle.js'
      },
      module: {
     rules: [
       {
         test: /\.js$/,
         use: 'babel-loader',
         exclude: /node_modules/
       }
     ]
      }
    };
    
    module.exports = config;
  8. 最後透過構建命令./node_modules/.bin/webpack --config webpack.config.js --mode development 執行配置,會生成一個dist/bundle.js檔案,這就是轉換後的js檔案
/*
 * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
 * This devtool is neither made for production nor for readable output files.
 * It uses "eval()" calls to create a separate source file in the browser devtools.
 * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
 * or disable the default devtool with "devtool: false".
 * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
 */
/******/ (() => { // webpackBootstrap
/******/     var __webpack_modules__ = ({

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/***/ (() => {

eval("var sum = function sum(a, b) {\n  return a + b;\n};\nconsole.log(sum(1, 2));\n\n//# sourceURL=webpack://webpack-config/./src/index.js?");

/***/ })

/******/     });
/************************************************************************/
/******/     
/******/     // startup
/******/     // Load entry module and return exports
/******/     // This entry module can't be inlined because the eval devtool is used.
/******/     var __webpack_exports__ = {};
/******/     __webpack_modules__["./src/index.js"]();
/******/     
/******/ })()
;

上面這個例子就使用了webpack的幾個核心概念

  1. 入口 entry

在webpack的配置檔案中透過配置entry告訴webpack所有模組的入口在哪裡

  1. 輸出 output

output配置編譯後的檔案存放在哪裡,以及如何命名

  1. loader

loader其實就是一個pure function,它幫助webpack透過不同的loader處理各種型別的資源,我們這裡就是透過babel-loader處理js資源,然後透過babel的配置,將輸入的es6語法轉換成es5語法再輸出

  1. 外掛 plugin

上面的例子暫時沒有用到,不過也很好理解,plugin就是loader的增強版,loader只能用來轉換不同型別的模組,而plugin能執行的任務更廣。包括打包最佳化、資源管理、注入環境變數等。簡單來說就是loader能做的plugin可以做,loader不能做的plugin也能做

以上就是webpack的核心概念了

新增Webpack配置

解析React + TS

瞭解了Webpack的基礎後進行下面的操作

  1. 首先是安裝需要的庫

yarn add react react-dom react-hot-loader -S

yarn add typescript ts-loader @hot-loader/react-dom -D

  1. 修改babel
{
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false
      }
    ],
    '@babel/preset-react'
  ],
  plugins: [
    'react-hot-loader/babel'
  ]
}
  1. 配置tsconfig.json

    {
     "compilerOptions": {
         "outDir": "./dist/",
         "sourceMap": true,
         "strict": true,
         "noImplicitReturns": true,
         "noImplicitAny": true,
         "module": "es6",
         "moduleResolution": "node",
         "target": "es5",
         "allowJs": true,
         "jsx": "react",
     },
     "include": [
         "./src/**/*"
     ]
    }
  2. 然後就是配置解析react和ts的 loader

webpack.config.js

const config = {
    ...
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/, // 新增加了jsx,對React語法的解析
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.ts(x)?$/, // 對ts的解析
        loader: 'ts-loader',
        exclude: /node_modules/
      }
    ]
  },
  ...
};

module.exports = config;

解析圖片和字型

1. 下載loader

yarn add file-loader url-loader -D

2. 修改webpack配置

const config = {
    ...
  module: {
    rules: [
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/, // 解析字型資源
        use: 'file-loader'
      },
      {
        test: /\.(png|jpg|jpeg|gif)$/, // 解析圖片資源,小於10kb的圖解析為base64
        use: [
            {
                loader: 'url-loader',
                options: {
                    limit: 10240
                }
            }
        ]
      },
    ]
  },
  ...
};

解析css、less,使用MiniCssExtractPlugin將js中的css分離出來,形成單獨的css檔案,並使用postcss-loader生成相容各瀏覽器的css

1. 安裝loader

yarn add css-loader style-loader less less-loader mini-css-extract-plugin postcss-loader autoprefixer -D

2. 配置postcss.config.js

module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};

3. 配置webpack

這裡寫了兩個差不多的css-loader的配置,因為在專案中會同時遇到使用全域性樣式和區域性(對應頁面的)css樣式。所以,配置了兩個,使用exclude和css-loader中的options.modules: true來區分, 當建立的css檔名中帶有module的就表示為區域性css,反之為全域性樣式

// 引入plugin
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const config = {
    ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1
            }
          },
          'postcss-loader'
        ],
        exclude: /\.module\.css$/
      },
      {
        test: /\.css$/,
        use: [
          MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: {
              importLoaders: 1,
              modules: true
            }
          },
          'postcss-loader'
        ],
        include: /\.module\.css$/
      },
      {
        test: /\.less$/,
        use: [
          MiniCssExtractPlugin.loader,
          'css-loader',
          'less-loader'
        ]
      }
    ]
  },

  plugins: [
    new MiniCssExtractPlugin()
  ],

  ...
};

使用檔案指紋策略(hash、chunkhash、contenthash)

為什麼會有這些配置呢?因為瀏覽器會有快取,該技術是加快網站的訪問速度。但是當我們用webpack生成js和css檔案時,內容雖然變化了,但是檔名沒有變化。所以瀏覽器預設的是資源並沒有更新。所以需要配合hash生成不同的檔名。下面就介紹一下這三種有什麼不同

fullhash

該計算是跟整個專案的構建相關,就是當你在用這個作為配置時,所有的js和css檔案的hash都和專案的構建hash一樣

chunkhash

hash是根據整個專案的,它導致所有檔案的hash都一樣,這樣就會發生一個檔案內容改變,使整個專案的hash也會變,那所有的檔案的hash都會變。這就導致了瀏覽器或CDN無法進行快取了。

而chunkhash就是解決這個問題的,它根據不同的入口檔案,進行依賴分析、構建對應的chunk,生成不同的雜湊

比如 a.87b39097.js -> 1a3b44b6.js 都是使用chunkhash生成的檔案
那麼當b.js裡面內容發生變化時,只有b的hash會發生變化,a檔案還是a.87b39097.js
b檔案可能就變成了2b3c66e6.js

contenthash

再細化,a.js和a.css同為一個chunk (87b39097),a.js內容發生變化,但是a.css沒有變化,打包後它們的hash卻全都變化了,那麼重新載入css資源就是對資源的浪費。

而contenthash則會根據資源內容建立出唯一的hash,也就是內容不變,hash就不變

所以,根據以上我們可以總結出在專案中hash是不能用的,chunkhash和contenthash需要配合使用

webpack配置如下


const config = {
  output: {
    path: path.resolve(__dirname, 'dist'),
    // chunkhash根據入口檔案進行依賴解析
    filename: '[name].[chunkhash:8].js'
  },
  module: {
    rules: [
      {
        test: /\.(woff|woff2|eot|ttf|otf)$/,
        type: "asset/resource",
        generator: {
          filename: 'fonts/[hash:8].[ext].[query]'
        },
        // use: 'file-loader',
      },
      {
        test: /\.(png|jpg|jpeg|gif)$/,
        // webpack5中使用資源模組代替了url-loader、file-loader、raw-loader
        type: "asset",
        generator: {
          filename: 'imgs/[hash:8].[ext].[query]'
        },
        parser: {
          dataUrlCondition: {
            maxSize: 4 * 1024 // 4kb
          }
        }
        // use: [
        //   {
        //     loader: 'url-loader',
        //     options: {
        //       // 檔案內容的hash,md5生成
        //       name: 'img/[name].[hash:8].[ext]',
        //       limit: 10240,
        //     },
        //   },
        // ],
      },
    ]
  },
  plugins: [
    ...
    new MiniCssExtractPlugin({
        filename: `[name].[contenthash:8].css`
    }),
    ...
  ],
};

module.exports = (env, argv) => {
  if (argv.hot) {
    // Cannot use 'contenthash' when hot reloading is enabled.
    config.output.filename = '[name].[fullhash].js';
  }

  return config;
};

增加第三方庫

React Router

React Router一共有6種 Router Components,分別是BrowserRouter、HashRouter、MemoryRouter、NativeRouter、Router、StaticRouter。
詳細請看這裡

安裝&配置React-router

  1. 安裝react-routeryarn add react-router-dom
  2. 安裝@babel/plugin-syntax-dynamic-import來支援動態import yarn add @babel/plugin-syntax-dynamic-import -D
  3. 將動態匯入外掛新增到babel中

    {
     "plugins": ["@babel/plugin-syntax-dynamic-import"]
    }

webpack配置

在這篇文章中,我們要做的是一個單頁應用,所以使用的是React Router6 BroserRouter, 它是基於html5規範的window.history來實現路由狀態管理的。
它不同於使用hash來保持UI和url同步。使用了BrowserRouter後,每次的url變化都是一次資源請求。所以在使用時,需要在Webpack中配置,以防止載入頁面時出現404

webpack.config.js


const config = {
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'main.js',
    // 配置中的path是資源輸出的絕對路徑,而publicPath則是配置靜態資源的相對路徑
    // 也就是說 靜態資源最終訪問路徑 = output.publicPath + 資源loader或外掛等配置路徑 
    // 所以,上面輸出的main.js的訪問路徑就是{__dirname}/dist/dist/main.js
    publicPath: '/dist',
    ...
  },
  devServer: {
    // 將所有的404請求redirect到 publicPath指定目錄下的index.html上
    historyApiFallback: true,
    ...
  },
}

關於publicPath請看這裡

新增相關程式碼

編寫react-router配置,使用React.lazy 和React.Suspense來配合import實現動態載入, 它的本質就是透過路由來分割程式碼成不同元件,Promise來引入元件,實現只有在透過路由訪問某個元件的時候再進行載入和渲染來實現動態匯入

config/routes.tsx

import React from 'react';

const routes = [
  {
    path: '/',
    component: React.lazy(() => import('../src/pages/Home/index')),
  },
  {
    path: '/mine',
    component: React.lazy(() => import('../src/pages/Mine/index')),
    children: [
      {
        path: '/mine/bus',
        component: React.lazy(() => import('../src/pages/Mine/Bus/index')),
      },
      {
        path: '/mine/cart',
        component: React.lazy(() => import('../src/pages/Mine/Cart/index')),
      },
    ],
  },
];

export default routes;

src/pates/root.tsx

import React, { Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import routes from '@/config/routes';

const Loading: React.FC = () => <div>loading.....</div>;

const CreateHasChildrenRoute = (route: any) => {
  return (
    <Route key={route.path} path={route.path}>
      <Route
        index
        element={
          <Suspense fallback={<Loading />}>
            <route.component />
          </Suspense>
        }
      />
      {RouteCreator(route.children)}
    </Route>
  );
};

const CreateNoChildrenRoute = (route: any) => {
  return (
    <Route
      key={route.path}
      path={route.path}
      element={
        <Suspense fallback={<Loading />}>
          <route.component />
        </Suspense>
      }
    />
  );
};

const RouteCreator = (routes: any) => {
  return routes.map((route: any) => {
    if (route.children && !!route.children.length) {
      return CreateHasChildrenRoute(route);
    } else {
      return CreateNoChildrenRoute(route);
    }
  });
};

const Root: React.FC = () => {
  return (
    <BrowserRouter>
      <Routes>{RouteCreator(routes)}</Routes>
    </BrowserRouter>
  );
};

export default Root;

App.tsx

import * as React from 'react';
import { sum } from '@/src/utils/sum';
import Header from './components/header';
import img1 from '@/public/imgs/ryo.jpeg';
import img2 from '@/public/imgs/亂菊.jpeg';
import img3 from '@/public/imgs/weather.jpeg';
import Root from './pages/root';

const App: React.FC = () => {
  return (
    <React.StrictMode>
      <Root />
    </React.StrictMode>
  );
};

export default App;

index.tsx

import * as React from 'react';
import { createRoot } from 'react-dom/client';

import App from './App';
import './styles.css';
import './styles.less';

const container = document.getElementById('app');
createRoot(container!).render(<App />);

redux

在一箇中大型的專案中,統一的狀態管理是必不可少的。尤其是在元件層級較深的React專案中,可以透過redux和react-redux來跨層級傳輸元件(透過實現react的Context)。它的好處如下:

  1. 避免一個屬性層層傳遞,程式碼混亂
  2. view(檢視)和model(模型)的分離,使得邏輯更清晰
  3. 多個元件共享一個資料,如使用者資訊、一個父元件與多個子元件

可以看一下這篇關於redux概念和原始碼分析的文章

使用官方推薦的@reduxjs/toolkit來統一管理在redux開發過程中經常使用到的middleware

1. 安裝庫

yarn add redux react-redux @reduxjs/toolkit

2. 建立一個redux的初始管理入口 src/core/store.ts

import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';

/**
 * 建立一個Redux store,同時自動的配置Redux DevTools擴充套件,方便在開發過程中檢查
 **/
const store = configureStore({
});

// 定義RootState和AppDispatch是因為使用的是TS作為開發語言,
// RootState透過store來自己推斷型別
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>;

export default store;

3. 在對應目錄下建立不同元件的state

pages/mine/model/mine.ts

import { AppThunk, RootState } from '@/src/core/store';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { fetchCount } from '@/src/service/api';

// 為每個單獨的store定義一個型別
interface CounterState {
  value: number;
}

// 初始state
const initialState: CounterState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: 'mine',
  initialState,
  reducers: {
    increment: (state) => {
      // 在redux-toolkit中使用了immutablejs ,它允許我們可以在reducers中寫“mutating”邏輯
      //(這裡需要提一下redux的reducer本身是個純函式,即相同的輸入,總是會的到相同的輸出,並且在執行過程中沒有任何副作用。而這裡的state.value+=1 實際就是state.value = state.value + 1,它修改了傳入的值,這就是副作用。雖然例子中是簡單型別,並不會修改源資料,但是如果儲存的資料為引用型別時會給你的專案帶來意想不到的bug),
      // 這就不符合redux對於reducer純函式的定義了,所以使用immutablejs。讓你可以寫看似“mutating”的邏輯。但是實際上並不會修改源資料
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // Use the PayloadAction type to declare the contents of `action.payload`
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

// 支援非同步dispatch的thunk,專案中比較常見,因為很多時候需要和後端互動,獲取到後端資料,然後再儲存到store中
export const asyncIncrement =
  (amount: number): AppThunk =>
  async (dispatch, getState) => {
    // selectCount(getState());
    const response = await fetchCount(amount);
    dispatch(incrementByAmount(response.data));
  };

// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// Other code such as selectors can use the imported `RootState` type
// export const selectCount = (state: RootState) => state.mine.value;

export default counterSlice.reducer;

4. 在檢視中dispatch action 和使用state

mine/index.tsx

import React from 'react';
import { decrement, increment, asyncIncrement } from './model/mine';
import { useAppSelector, useAppDispatch } from '@/src/utils/typedHooks';

const Mine: React.FC = () => {
  // The `state` arg is correctly typed as `RootState` already
  const count = useAppSelector((state) => state.mine.value);
  const dispatch = useAppDispatch();

  return (
    <div>
      <button
        aria-label="Increment value"
        onClick={() => dispatch(increment())}
      >
        Increment
      </button>
      <span>{count}</span>
      <button
        aria-label="Decrement value"
        onClick={() => dispatch(decrement())}
      >
        Decrement
      </button>
      <button
        aria-label="Decrement value"
        onClick={() => dispatch(asyncIncrement(2))}
      >
        asyncIncrement
      </button>
    </div>
  );
};

export default Mine;

對axios進行二次封裝

1.安裝

yarn add axios

2.建立src/core/request.ts

import axios from 'axios';

// const { REACT_APP_ENV } = process.env;
const config: any = {
  // baseURL: 'http://127.0.0.1:8001',
  timeout: 30 * 1000,
  headers: {},
};

// 構建例項
const instance = axios.create(config);

// axios方法對映
const InstanceMaper = {
  get: instance.get,
  post: instance.post,
  delete: instance.delete,
  put: instance.put,
  patch: instance.patch,
};

const request = (
  url: string,
  opts: {
    method: 'get' | 'post' | 'delete' | 'put' | 'patch';
    [key: string]: any;
  }
) => {
  instance.interceptors.request.use(
    function (config) {
      // Do something before request is sent
      // 當某個介面需要許可權時,攜帶token。如果沒有token,重定向到/login
      if (opts.auth) {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        config.headers.satoken = localStorage.getItem('satoken');
      }
      return config;
    },
    function (error) {
      // Do something with request error
      return Promise.reject(error);
    }
  );

  // Add a response interceptor
  instance.interceptors.response.use(
    function (response) {
      // Any status code that lie within the range of 2xx cause this function to trigger
      // Do something with response data
      console.log('response:', response);

      // http狀態碼
      if (response.status !== 200) {
        console.log('網路請求錯誤');
        return response;
      }

      // 後端返回的狀態,表示請求成功
      if (response.data.success) {
        console.log(response.data.message);

        return response.data.data;
      }

      return response;
    },
    function (error) {
      // Any status codes that falls outside the range of 2xx cause this function to trigger
      // Do something with response error
      return Promise.reject(error);
    }
  );

  const method = opts.method;
  return InstanceMaper[method](url, opts.data);
};

export default request;

3. 配置webpack

在開發環境中,前端因為瀏覽器的同源限制,是不能跨域訪問後端介面的。所以當我們以webpack作為開發環境的工具後,需要配置devServer的 proxy


module.exports = {

  devServer: {
    proxy: {
      '/api': 'http://127.0.0.1:8001',
    },
  },
}

到這裡,一個基礎的前端開發環境就搭建完成了,我們總結一下

總結

  1. 我們透過一個簡單的webpack例子瞭解了entry、output、loader、plugin這4個核心概念
  2. 透過webpack和babel配合來解析react程式碼,並將不同的檔案作為了一個module進行打包
  3. 透過增加第三方庫對專案進行擴充套件

其他文章

相關文章