管理系统的登录控制?手写一个发布订阅模型!

796 阅读7分钟

最近做了一个后台的项目,既然是后台管理系统,登录的控制自然是少不了的。

接到需求——后台系统!花了几乎半天搞出来了Webpack配置、搞出来了React Router、搞出来了 React 代码基本的结构,下一步就是搞所谓的“登录逻辑”了。

正好 React v16 大变,而自己最近又有些时候没写过React了,便不妨借这次机会熟悉一下React的新API吧!听说React新出的 Context API 可以“取代Redux”,那这次登录逻辑就用 Context 写吧!

根组件里的 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 组件之外,一无是处……

把逻辑提取出来

全局访问

那就把代码放到全局呗……

window 对象?(有这种想法的同学请面壁思过)

那么如何避免使用全局变量又能解决数据存储的问题呢?那就是是“沙盒模式”。沙盒模式,是JS非常普遍的一个设计模式,它通过闭包的原理将数据维持在一个函数作用于中,而通过返回值内的函数引用这个函数包体内的变量的方式,形成闭包,而只有通过该函数的返回函数才能访问和修改该闭包内的数据,从而起来了数据保护的作用。

嗯,又是那个叫“闭包”的玩意。

但是,我们现在有了“模块化”。当我们 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 的功能……

总结

我这个前端小菜狗就是这样在不知不觉中把登录部分的代码抽象出来了一套发布订阅模型。