管理系統的登陸控制?手寫一個發佈訂閱模型!

最近作了一個後臺的項目,既然是後臺管理系統,登陸的控制天然是少不了的。javascript

接到需求——後臺系統!花了幾乎半天搞出來了Webpack配置、搞出來了React Router、搞出來了 React 代碼基本的結構,下一步就是搞所謂的「登陸邏輯」了。前端

正好 React v16 大變,而本身最近又有些時候沒寫過React了,便不妨藉此次機會熟悉一下React的新API吧!據說React新出的 Context API 能夠「取代Redux」,那此次登陸邏輯就用 Context 寫吧!java

根組件裏的 React Context 來傳遞登陸狀態

雖然離開React有些時日了,可是它新的 Context API 看起來仍是很「美味」的。因而,三兩下,一套 Context 就出如今了入口文件裏:後端

// 登陸以前的默認登陸信息
// 在能夠看到我設計的「登陸信息」的數據結構和內容
const defaultLoginInfo = {
  username: '',
  token: false,
  ident: false, 
};

class Main extends React.Component {
  constructor(props) {
    super(props);
    // 寫登陸信息到 this.state
    // 經過 toStore 判斷是否也要寫 Storage
    this.updateLogin = (info, toStore = true) => {
      this.setState(prevState => {
        let nv = {...prevState.userLoginInfo, ...info};
        toStore && this.storeLoginInfo(nv);
        return {userLoginInfo: nv};
      });
    };

    // 從 sesionStorage 中取回登陸信息
    this.retrieveLoginInfo = () => {
      let stored = sessionStorage.getItem('userLoginInfo');
      // 這裏須要一個 try ... catch ... ,可是爲了代碼易讀我給刪除了
      return stored ? JSON.parse(stored) : {};
    };
    
    // 將登陸信息存入 Storage
    this.storeLoginInfo = (val) => {
      sessionStorage.setItem('userLoginInfo', JSON.stringify(val));
    };

    this.state = {
      userLoginInfo: {
        ...defaultLoginInfo,
        update: this.updateLogin,   // 向子組件暴露方法
        // 在主組件初紿化時經過覆蓋默認登陸信息來生成實際用的登陸信息
        ...this.retrieveLoginInfo(),
        exit: () => this.updateLogin({...defaultLoginInfo}), // 向子組件暴露方法 - 退出登陸
      },
    };
  }

  render() {} // .....

  componentDidMount() {
    this.updateLogin(this.retrieveLoginInfo(), false);
  }
}

複製代碼

能夠說是很簡陋了——這就是我用Context API 替代 Redux 的第一個做品。但它很簡明,也工做得很好——直到我想爲登陸部分加些新想法……設計模式

嘮叨幾句:(我眼中)管理系統的登陸功能要有的功能點

  1. 登陸狀態持久化。什麼用戶權限啊,身份啊,token啊,都要存到 Storage 裏面。
  2. 登陸狀態全局可訪問,包括在 React 組件之外。
  3. Storage 丟失後有提示,引導用戶從新登陸。
  4. 引導從新登陸不可以使用頁面跳轉,可使用彈框。(想像一下:用戶填完了一大堆表單,但是程序檢測到登陸失效給跳轉了……)

從這幾點出發來看,原來的代碼在 React 組件以外,一無可取……緩存

把邏輯提取出來

全局訪問

那就把代碼放到全局唄……session

window 對象?(有這種想法的同窗請面壁思過)數據結構

那麼如何避免使用全局變量又能解決數據存儲的問題呢?那就是是「沙盒模式」。沙盒模式,是JS很是廣泛的一個設計模式,它經過閉包的原理將數據維持在一個函數做用於中,而經過返回值內的函數引用這個函數包體內的變量的方式,造成閉包,而只有經過該函數的返回函數才能訪問和修改該閉包內的數據,從而起來了數據保護的做用。閉包

嗯,又是那個叫「閉包」的玩意。ide

可是,咱們如今有了「模塊化」。當咱們 import 一個模塊的時候,這個模塊的聲明會保持在一個獨立的做用域中,且一直存在。可使用 exports 來實現「沙盒」的效果。除了導出的函數,其它對外界都不可見。(話說 Webpack 的模塊不也是用閉包來實現的嗎?)

到處同步(發佈訂閱)

分析一下:

哪裏須要發佈?

  • 「退出登陸」按鈕
  • 後端 API 告訴我「登陸信息不對」
  • 前端主動發現登陸信息損壞

哪裏須要訂閱?

  • UI , 也就是根組件的 state

明顯,發佈訂閱是合適的。

代碼

// 登陸過時檢測
const checkExpireTime = info => {
  return Date.now() > info.expireTime && info.expireTime >= 0;
};

// 負責 Storage 操做:取回 + 存入
function retrieveLoginInfo() {
  let stored = sessionStorage.getItem('userLoginInfo');
  if(stored) {
    try {
      let info = { ...defaultLoginInfo, ...JSON.parse(stored) };
      if(checkExpireTime(info) || !info.token) {
        exitLogin();
        return {...defaultLoginInfo};
      }
      return {...defaultLoginInfo, ...info};
    } catch(e) {
      return {...defaultLoginInfo};
    }
  } else {
    exitLogin();
    return {...defaultLoginInfo};
  }
}
function storeLoginInfo(val) {
  return sessionStorage.setItem('userLoginInfo', JSON.stringify(val));
}

// 廣播
function broadcastLoginInfo(info) {
  broadcastList.forEach(curt => {
    curt(info);
  });
}
// 存放 Listener
let broadcastList = [];
function registerLoginInfoBroadcast(callback) {
  if(!broadcastList.includes(callback)) {
    broadcastList.push(callback);
  }
}

// 更新登陸信息 - 相似 Dispacher
function updateLoginInfo(info) {
  if(checkExpireTime(info)) {
    exitLogin();
    return [false, '登陸過時,請從新登陸'];
  } else {
    storeLoginInfo(info);
    broadcastLoginInfo(info);
    return [true];
  }
}

// 一些經常使用動做的提取(咱們要拒絕樣本代碼)
function exitLogin() {
  updateLoginInfo({...defaultLoginInfo});
}
function syncLoginInfo() {
  broadcastLoginInfo(retrieveLoginInfo());
}

export default { 
  update: updateLoginInfo,
  retrieve: retrieveLoginInfo,
  exit: exitLogin,
  registerBroadcast: registerLoginInfoBroadcast,
  sync: syncLoginInfo,
  storeLoginInfo,
  retrieveLoginInfo,
  defaultLoginInfo,
};

複製代碼

如何使用它?

好比說,後臺檢測到 Token 錯誤,想強行清空登陸信息,要怎麼操做?

import loginInfo from '@/utils/path/to/loginInfoStorage.js';
// ...
function RequestApi (respData) {
    // Do some processing
    if([301, 302, 303].indexOf(respData.status.code) !== -1) {
        loginInfo.exit(); // 登陸出錯?要自行退出登陸!
    }
}
複製代碼

至於 React 根組件裏,狀況就有些複雜了……

注入 React

在根組件裏:

要記得 Register Listener

// 放在 constructor 或者 componentDidMounted 裏都好 
    loginInfo.registerBroadcast(info => {
      this.updateLoginState(info, false);
    });
複製代碼

Listener 觸發時要更新根組件的 State

this.updateLoginState = (info, toStore = true) => {
      this.setState(prevState => {
        let nv = {...prevState.userLoginInfo, ...info};
        toStore && this.storeLoginInfo(nv);
        let newState = {};
        if(!prevState.useLoginModal || nv.token) {
          newState.userLoginInfo = {...nv};
        }
        return newState;
      });
    };
複製代碼

隨 Context 傳給子組件的函數也不能忘

this.state = {
      userLoginInfo: {
        ...this.retrieveLoginInfo(),
        update: this.updateLoginInfo,
        exit: () => {
            // 還有其它功能
            loginInfo.exit();
        },
      },
    };
複製代碼

爲了突出本質,以上只是我簡化後的代碼。完整的代碼(見下文)還有登陸彈框等功能。

注意

  • 做爲訂閱者,Listener 裏不要再調用 update(發佈者) (令我想起了 componentDidUpdate)

順便一提:登陸彈框

原本不是本文討論範圍,但這裏也讓我頗費心思,實現得也不很好。此處不妨講講。

Storage 丟失後有提示,引導用戶從新登陸。引導從新登陸不可以使用頁面跳轉,可使用彈框。

一個棘手的問題是,框能夠彈出來,但框背後的管理界面UI不能變。

個人思路是:state 裏的 loginInfo 分兩種——真實反映實際登陸狀態的 actualLoginInfo 和爲UI專供的 userLoginInfo。React router 和其它UI組件的 render 根據 userLoginInfo 來作判斷和渲染,登陸彈框則使用 actualLoginInfo。 下面就是我實際使用的代碼了。只是這個思路很不優雅。

注意的幾點:

  1. useLoginModal - 使用登陸彈框仍是路由跳轉到一整個登陸頁?
  2. 未登陸且useLoginModal 爲 true 時顯示登陸彈窗
class Main extends Component {
  constructor(props) {
    super(props);

    this.updateLoginState = (info, toStore = true) => {
      this.setState(prevState => {
        let nv = {...prevState.userLoginInfo, ...info};
        toStore && this.storeLoginInfo(nv);
        let newState = {
          actualLoginInfo: {...nv},
        };
        if(!prevState.useLoginModal || nv.token) {
          newState.userLoginInfo = {...nv};
          newState.useLoginModal = !!info.token; // 登陸後默認使用登陸彈窗
        }
        return newState;
      });
    };

    loginInfo.registerBroadcast(info => {
      // 顯示登陸彈窗就不修改登陸相關的UI狀態
      this.updateLoginState(info, false);
    });

    this.retrieveLoginInfo = loginInfo.retrieve;
    this.updateLoginInfo = loginInfo.update;
    
    // [NOTE] 須要在渲染<Route>以前讀入登陸狀態
    // 不然刷新以後URL會由於Route未渲染而丟失
    this.state = {
      userLoginInfo: {
        ...this.retrieveLoginInfo(),
        // 升級登陸信息
        update: this.updateLoginInfo,
        // 在UI中使用此函數來退出登陸
        // config.useLoginModal
        // - true 改寫登陸狀態、不修改登陸相關的UI狀態、顯示登陸彈窗
        // - false 改寫登陸狀態、修改登陸相關的UI狀態、回到登陸頁面
        exit: (config = {}) => {
          if(config.useLoginModal || false) {
            this.setState({
              useLoginModal: true,
            }, () => {
              loginInfo.exit();
            });
          } else {
            this.setState({
              useLoginModal: false,
            }, () => {
              loginInfo.exit();
            });
          }
        },
      },
      useLoginModal: false,
      actualLoginInfo: {},
    };
    
  render() {
    return (
      <UserCtx.Provider value={this.state.userLoginInfo}>
        <UserCtx.Consumer>
        {info => (
          <main styleName="main-container">
            <HashRouter>
              <LocaleProvider locale={zh_CN}>
                <>
                  <Switch>
                    {(!info.token) && <Route path="/login" component={withRouterLogin} />}
                    {info.token    && <Route path="/admin" component={withRouterAdmin} />}
                    <Redirect to={info.token ? '/admin' : '/login'} />
                  </Switch>
                </>
              </LocaleProvider>
            </HashRouter>
            {/* 未登陸且useLoginModal時顯示登陸彈窗 */}
            <Modal
              title="請先登陸帳戶"
              visible={this.state.useLoginModal && !this.state.actualLoginInfo.token}
              footer={false}
              width={370}
              closable={false}
            >
              <LoginForm />
            </Modal>
          </main>
        )}
        </UserCtx.Consumer>
      </UserCtx.Provider>
    );
  }
    
}
複製代碼

TODO

不少地方還有待完善:

  • 每次都要去 Storage.get! 能夠加個緩存嗎?
  • 能夠封裝成一個類或者構造函數?這樣更加通用!
  • 還沒寫取消 Listener 的功能……

總結

我這個前端小菜狗就是這樣在不知不覺中把登陸部分的代碼抽象出來了一套發佈訂閱模型。

相關文章
相關標籤/搜索