手寫一個React-Redux,玩轉React的Context API


上一篇文章我們手寫了一個Redux,但是單純的Redux只是一個狀態機,是沒有UI呈現的,所以一般我們使用的時候都會配合一個UI庫,比如在React中使用Redux就會用到React-Redux這個庫。這個庫的作用是將Redux的狀態機和React的UI呈現綁定在一起,當你dispatch action改變state的時候,會自動更新頁面。本文還是從它的基本使用入手來自己寫一個React-Redux,然后替換官方的NPM庫,並保持功能一致。

本文全部代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux

基本用法

下面這個簡單的例子是一個計數器,跑起來效果如下:

Jul-02-2020 16-44-04

要實現這個功能,首先我們要在項目里面添加react-redux庫,然后用它提供的Provider包裹整個ReactApp的根組件:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux'
import store from './store'
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

上面代碼可以看到我們還給Provider提供了一個參數store,這個參數就是Redux的createStore生成的store,我們需要調一下這個方法,然后將返回的store傳進去:

import { createStore } from 'redux';
import reducer from './reducer';

let store = createStore(reducer);

export default store;

上面代碼中createStore的參數是一個reducer,所以我們還要寫個reducer:

const initState = {
  count: 0
};

function reducer(state = initState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return {...state, count: state.count + 1};
    case 'DECREMENT':
      return {...state, count: state.count - 1};
    case 'RESET':
      return {...state, count: 0};
    default:
      return state;
  }
}

export default reducer;

這里的reduce會有一個初始state,里面的count0,同時他還能處理三個action,這三個action對應的是UI上的三個按鈕,可以對state里面的計數進行加減和重置。到這里其實我們React-Redux的接入和Redux數據的組織其實已經完成了,后面如果要用Redux里面的數據的話,只需要用connectAPI將對應的state和方法連接到組件里面就行了,比如我們的計數器組件需要count這個狀態和加一,減一,重置這三個action,我們用connect將它連接進去就是這樣:

import React from 'react';
import { connect } from 'react-redux';
import { increment, decrement, reset } from './actions';

function Counter(props) {
  const { 
    count,
    incrementHandler,
    decrementHandler,
    resetHandler
   } = props;

  return (
    <>
      <h3>Count: {count}</h3>
      <button onClick={incrementHandler}>計數+1</button>
      <button onClick={decrementHandler}>計數-1</button>
      <button onClick={resetHandler}>重置</button>
    </>
  );
}

const mapStateToProps = (state) => {
  return {
    count: state.count
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    incrementHandler: () => dispatch(increment()),
    decrementHandler: () => dispatch(decrement()),
    resetHandler: () => dispatch(reset()),
  }
};

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter)

上面代碼可以看到connect是一個高階函數,他的第一階會接收mapStateToPropsmapDispatchToProps兩個參數,這兩個參數都是函數。mapStateToProps可以自定義需要將哪些state連接到當前組件,這些自定義的state可以在組件里面通過props拿到。mapDispatchToProps方法會傳入dispatch函數,我們可以自定義一些方法,這些方法可以調用dispatchdispatch action,從而觸發state的更新,這些自定義的方法也可以通過組件的props拿到,connect的第二階接收的參數是一個組件,我們可以猜測這個函數的作用就是將前面自定義的state和方法注入到這個組件里面,同時要返回一個新的組件給外部調用,所以connect其實也是一個高階組件。

到這里我們匯總來看下我們都用到了哪些API,這些API就是我們后面要手寫的目標:

Provider: 用來包裹根組件的組件,作用是注入Reduxstore

createStore: Redux用來創建store的核心方法,我們另一篇文章已經手寫過了

connect:用來將statedispatch注入給需要的組件,返回一個新組件,他其實是個高階組件。

所以React-Redux核心其實就兩個API,而且兩個都是組件,作用還很類似,都是往組件里面注入參數,Provider是往根組件注入storeconnect是往需要的組件注入statedispatch

在手寫之前我們先來思考下,為什么React-Redux要設計這兩個API,假如沒有這兩個API,只用Redux可以嗎?當然是可以的!其實我們用Redux的目的不就是希望用它將整個應用的狀態都保存下來,每次操作只用dispatch action去更新狀態,然后UI就自動更新了嗎?那我從根組件開始,每一級都把store傳下去不就行了嗎?每個子組件需要讀取狀態的時候,直接用store.getState()就行了,更新狀態的時候就store.dispatch,這樣其實也能達到目的。但是,如果這樣寫,子組件如果嵌套層數很多,每一級都需要手動傳入store,比較丑陋,開發也比較繁瑣,而且如果某個新同學忘了傳store,那后面就是一連串的錯誤了。所以最好有個東西能夠將store全局的注入組件樹,而不需要一層層作為props傳遞,這個東西就是Provider!而且如果每個組件都獨立依賴Redux會破壞React的數據流向,這個我們后面會講到。

React的Context API

React其實提供了一個全局注入變量的API,這就是context api。假如我現在有一個需求是要給我們所有組件傳一個文字顏色的配置,我們的顏色配置在最頂級的組件上,當這個顏色改變的時候,下面所有組件都要自動應用這個顏色。那我們可以使用context api注入這個配置:

先使用React.createContext創建一個context

// 我們使用一個單獨的文件來調用createContext
// 因為這個返回值會被Provider和Consumer在不同的地方引用
import React from 'react';

const TestContext = React.createContext();

export default TestContext;

使用Context.Provider包裹根組件

創建好了context,如果我們要傳遞變量給某些組件,我們需要在他們的根組件上加上TestContext.Provider,然后將變量作為value參數傳給TestContext.Provider:

import TestContext from './TestContext';

const setting = {
  color: '#d89151'
}

ReactDOM.render(
  <TestContext.Provider value={setting}>
  	<App />
  </TestContext.Provider>,
  document.getElementById('root')
);

使用Context.Consumer接收參數

上面我們使用Context.Provider將參數傳遞進去了,這樣被Context.Provider包裹的所有子組件都可以拿到這個變量,只是拿這個變量的時候需要使用Context.Consumer包裹,比如我們前面的Counter組件就可以拿到這個顏色了,只需要將它返回的JSXContext.Consumer包裹一下就行:

// 注意要引入同一個Context
import TestContext from './TestContext';

// ... 中間省略n行代碼 ...
// 返回的JSX用Context.Consumer包裹起來
// 注意Context.Consumer里面是一個方法,這個方法就可以訪問到context參數
// 這里的context也就是前面Provider傳進來的setting,我們可以拿到上面的color變量
return (
    <TestContext.Consumer>
      {context => 
        <>
          <h3 style={{color:context.color}}>Count: {count}</h3>
          <button onClick={incrementHandler}>計數+1</button>&nbsp;&nbsp;
          <button onClick={decrementHandler}>計數-1</button>&nbsp;&nbsp;
          <button onClick={resetHandler}>重置</button>
        </>
      }
    </TestContext.Consumer>
  );

上面代碼我們通過context傳遞了一個全局配置,可以看到我們文字顏色已經變了:

image-20200703171322676

使用useContext接收參數

除了上面的Context.Consumer可以用來接收context參數,新版React還有useContext這個hook可以接收context參數,使用起來更簡單,比如上面代碼可以這樣寫:

const context = useContext(TestContext);

return (
    <>
      <h3 style={{color:context.color}}>Count: {count}</h3>
      <button onClick={incrementHandler}>計數+1</button>&nbsp;&nbsp;
      <button onClick={decrementHandler}>計數-1</button>&nbsp;&nbsp;
      <button onClick={resetHandler}>重置</button>
    </>
);

所以我們完全可以用context api來傳遞redux store,現在我們也可以猜測React-ReduxProvider其實就是包裝了Context.Provider,而傳遞的參數就是redux store,而React-ReduxconnectHOC其實就是包裝的Context.Consumer或者useContext。我們可以按照這個思路來自己實現下React-Redux了。

手寫Provider

上面說了Provider用了context api,所以我們要先建一個context文件,導出需要用的context

// Context.js
import React from 'react';

const ReactReduxContext = React.createContext();

export default ReactReduxContext;

這個文件很簡單,新建一個context再導出就行了,對應的源碼看這里

然后將這個context應用到我們的Provider組件里面:

import React from 'react';
import ReactReduxContext from './Context';

function Provider(props) {
  const {store, children} = props;

  // 這是要傳遞的context
  const contextValue = { store };

  // 返回ReactReduxContext包裹的組件,傳入contextValue
  // 里面的內容就直接是children,我們不動他
  return (
    <ReactReduxContext.Provider value={contextValue}>
      {children}
    </ReactReduxContext.Provider>
  )
}

Provider的組件代碼也不難,直接將傳進來的store放到context上,然后直接渲染children就行,對應的源碼看這里

手寫connect

基本功能

其實connect才是React-Redux中最難的部分,里面功能復雜,考慮的因素很多,想要把它搞明白我們需要一層一層的來看,首先我們實現一個只具有基本功能的connect

import React, { useContext } from 'react';
import ReactReduxContext from './Context';

// 第一層函數接收mapStateToProps和mapDispatchToProps
function connect(mapStateToProps, mapDispatchToProps) {
  // 第二層函數是個高階組件,里面獲取context
  // 然后執行mapStateToProps和mapDispatchToProps
  // 再將這個結果組合用戶的參數作為最終參數渲染WrappedComponent
  // WrappedComponent就是我們使用connext包裹的自己的組件
  return function connectHOC(WrappedComponent) {

    function ConnectFunction(props) {
      // 復制一份props到wrapperProps
      const { ...wrapperProps } = props;

      // 獲取context的值
      const context = useContext(ReactReduxContext);

      const { store } = context;  // 解構出store
      const state = store.getState();   // 拿到state

      // 執行mapStateToProps和mapDispatchToProps
      const stateProps = mapStateToProps(state);
      const dispatchProps = mapDispatchToProps(store.dispatch);

      // 組裝最終的props
      const actualChildProps = Object.assign({}, stateProps, dispatchProps, wrapperProps);

      // 渲染WrappedComponent
      return <WrappedComponent {...actualChildProps}></WrappedComponent>
    }

    return ConnectFunction;
  }
}

export default connect;

觸發更新

用上面的Providerconnect替換官方的react-redux其實已經可以渲染出頁面了,但是點擊按鈕還不會有反應,因為我們雖然通過dispatch改變了store中的state,但是這種改變並沒有觸發我們組件的更新。之前Redux那篇文章講過,可以用store.subscribe來監聽state的變化並執行回調,我們這里需要注冊的回調是檢查我們最終給WrappedComponentprops有沒有變化,如果有變化就重新渲染ConnectFunction,所以這里我們需要解決兩個問題:

  1. 當我們state變化的時候檢查最終給到ConnectFunction的參數有沒有變化
  2. 如果這個參數有變化,我們需要重新渲染ConnectFunction

檢查參數變化

要檢查參數的變化,我們需要知道上次渲染的參數和本地渲染的參數,然后拿過來比一下就知道了。為了知道上次渲染的參數,我們可以直接在ConnectFunction里面使用useRef將上次渲染的參數記錄下來:

// 記錄上次渲染參數
const lastChildProps = useRef();
useLayoutEffect(() => {
  lastChildProps.current = actualChildProps;
}, []);

注意lastChildProps.current是在第一次渲染結束后賦值,而且需要使用useLayoutEffect來保證渲染后立即同步執行。

因為我們檢測參數變化是需要重新計算actualChildProps,計算的邏輯其實都是一樣的,我們將這塊計算邏輯抽出來,成為一個單獨的方法childPropsSelector:

function childPropsSelector(store, wrapperProps) {
  const state = store.getState();   // 拿到state

  // 執行mapStateToProps和mapDispatchToProps
  const stateProps = mapStateToProps(state);
  const dispatchProps = mapDispatchToProps(store.dispatch);

  return Object.assign({}, stateProps, dispatchProps, wrapperProps);
}

然后就是注冊store的回調,在里面來檢測參數是否變了,如果變了就強制更新當前組件,對比兩個對象是否相等,React-Redux里面是采用的shallowEqual,也就是淺比較,也就是只對比一層,如果你mapStateToProps返回了好幾層結構,比如這樣:

{
  stateA: {
    value: 1
  }
}

你去改了stateA.value是不會觸發重新渲染的,React-Redux這樣設計我想是出於性能考慮,如果是深比較,比如遞歸去比較,比較浪費性能,而且如果有循環引用還可能造成死循環。采用淺比較就需要用戶遵循這種范式,不要傳入多層結構,這點在官方文檔中也有說明。我們這里直接抄一個它的淺比較:

// shallowEqual.js 
function is(x, y) {
  if (x === y) {
    return x !== 0 || y !== 0 || 1 / x === 1 / y
  } else {
    return x !== x && y !== y
  }
}

export default function shallowEqual(objA, objB) {
  if (is(objA, objB)) return true

  if (
    typeof objA !== 'object' ||
    objA === null ||
    typeof objB !== 'object' ||
    objB === null
  ) {
    return false
  }

  const keysA = Object.keys(objA)
  const keysB = Object.keys(objB)

  if (keysA.length !== keysB.length) return false

  for (let i = 0; i < keysA.length; i++) {
    if (
      !Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])
    ) {
      return false
    }
  }

  return true
}

在回調里面檢測參數變化:

// 注冊回調
store.subscribe(() => {
  const newChildProps = childPropsSelector(store, wrapperProps);
  // 如果參數變了,記錄新的值到lastChildProps上
  // 並且強制更新當前組件
  if(!shallowEqual(newChildProps, lastChildProps.current)) {
    lastChildProps.current = newChildProps;

    // 需要一個API來強制更新當前組件
  }
});

強制更新

要強制更新當前組件的方法不止一個,如果你是用的Class組件,你可以直接this.setState({}),老版的React-Redux就是這么干的。但是新版React-Redux用hook重寫了,那我們可以用React提供的useReducer或者useStatehook,React-Redux源碼用了useReducer,為了跟他保持一致,我也使用useReducer:

function storeStateUpdatesReducer(count) {
  return count + 1;
}

// ConnectFunction里面
function ConnectFunction(props) {
  // ... 前面省略n行代碼 ... 
  
  // 使用useReducer觸發強制更新
  const [
    ,
    forceComponentUpdateDispatch
  ] = useReducer(storeStateUpdatesReducer, 0);
  // 注冊回調
  store.subscribe(() => {
    const newChildProps = childPropsSelector(store, wrapperProps);
    if(!shallowEqual(newChildProps, lastChildProps.current)) {
      lastChildProps.current = newChildProps;
      forceComponentUpdateDispatch();
    }
  });
  
  // ... 后面省略n行代碼 ...
}

connect這塊代碼主要對應的是源碼中connectAdvanced這個類,基本原理和結構跟我們這個都是一樣的,只是他寫的更靈活,支持用戶傳入自定義的childPropsSelector和合並stateProps, dispatchProps, wrapperProps的方法。有興趣的朋友可以去看看他的源碼:https://github.com/reduxjs/react-redux/blob/master/src/components/connectAdvanced.js

到這里其實已經可以用我們自己的React-Redux替換官方的了,計數器的功能也是支持了。但是下面還想講一下React-Redux是怎么保證組件的更新順序的,因為源碼中很多代碼都是在處理這個。

保證組件更新順序

前面我們的Counter組件使用connect連接了redux store,假如他下面還有個子組件也連接到了redux store,我們就要考慮他們的回調的執行順序的問題了。我們知道React是單向數據流的,參數都是由父組件傳給子組件的,現在引入了Redux,即使父組件和子組件都引用了同一個變量count,但是子組件完全可以不從父組件拿這個參數,而是直接從Redux拿,這樣就打破了React本來的數據流向。在父->子這種單向數據流中,如果他們的一個公用變量變化了,肯定是父組件先更新,然后參數傳給子組件再更新,但是在Redux里,數據變成了Redux -> 父,Redux -> 子完全可以根據Redux的數據進行獨立更新,而不能完全保證父級先更新,子級再更新的流程。所以React-Redux花了不少功夫來手動保證這個更新順序,React-Redux保證這個更新順序的方案是在redux store外,再單獨創建一個監聽者類Subscription

  1. Subscription負責處理所有的state變化的回調
  2. 如果當前連接redux的組件是第一個連接redux的組件,也就是說他是連接redux的根組件,他的state回調直接注冊到redux store;同時新建一個Subscription實例subscription通過context傳遞給子級。
  3. 如果當前連接redux的組件不是連接redux的根組件,也就是說他上面有組件已經注冊到redux store了,那么他可以拿到上面通過context傳下來的subscription,源碼里面這個變量叫parentSub,那當前組件的更新回調就注冊到parentSub上。同時再新建一個Subscription實例,替代context上的subscription,繼續往下傳,也就是說他的子組件的回調會注冊到當前subscription上。
  4. state變化了,根組件注冊到redux store上的回調會執行更新根組件,同時根組件需要手動執行子組件的回調,子組件回調執行會觸發子組件更新,然后子組件再執行自己subscription上注冊的回調,觸發孫子組件更新,孫子組件再調用注冊到自己subscription上的回調。。。這樣就實現了從根組件開始,一層一層更新子組件的目的,保證了父->子這樣的更新順序。

Subscription

所以我們先新建一個Subscription類:

export default class Subscription {
  constructor(store, parentSub) {
    this.store = store
    this.parentSub = parentSub
    this.listeners = [];        // 源碼listeners是用鏈表實現的,我這里簡單處理,直接數組了

    this.handleChangeWrapper = this.handleChangeWrapper.bind(this)
  }

  // 子組件注冊回調到Subscription上
  addNestedSub(listener) {
    this.listeners.push(listener)
  }

  // 執行子組件的回調
  notifyNestedSubs() {
    const length = this.listeners.length;
    for(let i = 0; i < length; i++) {
      const callback = this.listeners[i];
      callback();
    }
  }

  // 回調函數的包裝
  handleChangeWrapper() {
    if (this.onStateChange) {
      this.onStateChange()
    }
  }

  // 注冊回調的函數
  // 如果parentSub有值,就將回調注冊到parentSub上
  // 如果parentSub沒值,那當前組件就是根組件,回調注冊到redux store上
  trySubscribe() {
      this.parentSub
        ? this.parentSub.addNestedSub(this.handleChangeWrapper)
        : this.store.subscribe(this.handleChangeWrapper)
  }
}

Subscription對應的源碼看這里

改造Provider

然后在我們前面自己實現的React-Redux里面,我們的根組件始終是Provider,所以Provider需要實例化一個Subscription並放到context上,而且每次state更新的時候需要手動調用子組件回調,代碼改造如下:

import React, { useMemo, useEffect } from 'react';
import ReactReduxContext from './Context';
import Subscription from './Subscription';

function Provider(props) {
  const {store, children} = props;

  // 這是要傳遞的context
  // 里面放入store和subscription實例
  const contextValue = useMemo(() => {
    const subscription = new Subscription(store)
    // 注冊回調為通知子組件,這樣就可以開始層級通知了
    subscription.onStateChange = subscription.notifyNestedSubs
    return {
      store,
      subscription
    }
  }, [store])

  // 拿到之前的state值
  const previousState = useMemo(() => store.getState(), [store])

  // 每次contextValue或者previousState變化的時候
  // 用notifyNestedSubs通知子組件
  useEffect(() => {
    const { subscription } = contextValue;
    subscription.trySubscribe()

    if (previousState !== store.getState()) {
      subscription.notifyNestedSubs()
    }
  }, [contextValue, previousState])

  // 返回ReactReduxContext包裹的組件,傳入contextValue
  // 里面的內容就直接是children,我們不動他
  return (
    <ReactReduxContext.Provider value={contextValue}>
      {children}
    </ReactReduxContext.Provider>
  )
}

export default Provider;

改造connect

有了Subscription類,connect就不能直接注冊到store了,而是應該注冊到父級subscription上,更新的時候除了更新自己還要通知子組件更新。在渲染包裹的組件時,也不能直接渲染了,而是應該再次使用Context.Provider包裹下,傳入修改過的contextValue,這個contextValue里面的subscription應該替換為自己的。改造后代碼如下:

import React, { useContext, useRef, useLayoutEffect, useReducer } from 'react';
import ReactReduxContext from './Context';
import shallowEqual from './shallowEqual';
import Subscription from './Subscription';

function storeStateUpdatesReducer(count) {
  return count + 1;
}

function connect(
  mapStateToProps = () => {}, 
  mapDispatchToProps = () => {}
  ) {
  function childPropsSelector(store, wrapperProps) {
    const state = store.getState();   // 拿到state

    // 執行mapStateToProps和mapDispatchToProps
    const stateProps = mapStateToProps(state);
    const dispatchProps = mapDispatchToProps(store.dispatch);

    return Object.assign({}, stateProps, dispatchProps, wrapperProps);
  }

  return function connectHOC(WrappedComponent) {
    function ConnectFunction(props) {
      const { ...wrapperProps } = props;

      const contextValue = useContext(ReactReduxContext);

      const { store, subscription: parentSub } = contextValue;  // 解構出store和parentSub
      
      const actualChildProps = childPropsSelector(store, wrapperProps);

      const lastChildProps = useRef();
      useLayoutEffect(() => {
        lastChildProps.current = actualChildProps;
      }, [actualChildProps]);

      const [
        ,
        forceComponentUpdateDispatch
      ] = useReducer(storeStateUpdatesReducer, 0)

      // 新建一個subscription實例
      const subscription = new Subscription(store, parentSub);

      // state回調抽出來成為一個方法
      const checkForUpdates = () => {
        const newChildProps = childPropsSelector(store, wrapperProps);
        // 如果參數變了,記錄新的值到lastChildProps上
        // 並且強制更新當前組件
        if(!shallowEqual(newChildProps, lastChildProps.current)) {
          lastChildProps.current = newChildProps;

          // 需要一個API來強制更新當前組件
          forceComponentUpdateDispatch();

          // 然后通知子級更新
          subscription.notifyNestedSubs();
        }
      };

      // 使用subscription注冊回調
      subscription.onStateChange = checkForUpdates;
      subscription.trySubscribe();

      // 修改傳給子級的context
      // 將subscription替換為自己的
      const overriddenContextValue = {
        ...contextValue,
        subscription
      }

      // 渲染WrappedComponent
      // 再次使用ReactReduxContext包裹,傳入修改過的context
      return (
        <ReactReduxContext.Provider value={overriddenContextValue}>
          <WrappedComponent {...actualChildProps} />
        </ReactReduxContext.Provider>
      )
    }

    return ConnectFunction;
  }
}

export default connect;

到這里我們的React-Redux就完成了,跑起來的效果跟官方的效果一樣,完整代碼已經上傳GitHub了:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-redux

下面我們再來總結下React-Redux的核心原理。

總結

  1. React-Redux是連接ReactRedux的庫,同時使用了ReactRedux的API。
  2. React-Redux主要是使用了Reactcontext api來傳遞Reduxstore
  3. Provider的作用是接收Redux store並將它放到context上傳遞下去。
  4. connect的作用是從Redux store中選取需要的屬性傳遞給包裹的組件。
  5. connect會自己判斷是否需要更新,判斷的依據是需要的state是否已經變化了。
  6. connect在判斷是否變化的時候使用的是淺比較,也就是只比較一層,所以在mapStateToPropsmapDispatchToProps中不要反回多層嵌套的對象。
  7. 為了解決父組件和子組件各自獨立依賴Redux,破壞了React父級->子級的更新流程,React-Redux使用Subscription類自己管理了一套通知流程。
  8. 只有連接到Redux最頂級的組件才會直接注冊到Redux store,其他子組件都會注冊到最近父組件的subscription實例上。
  9. 通知的時候從根組件開始依次通知自己的子組件,子組件接收到通知的時候,先更新自己再通知自己的子組件。

參考資料

官方文檔:https://react-redux.js.org/

GitHub源碼:https://github.com/reduxjs/react-redux/

文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd

“前端進階知識”系列文章源碼GitHub地址: https://github.com/dennis-jiang/Front-End-Knowledges

QR1270


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM