閱讀975 返回首頁    go 阿裏雲 go 技術社區[雲棲]


一個由mobx observer引發的React Route路由失效問題探究

1. 問題描述

最近一直在使用React + React Router(v4.1.1) + Mobx做項目開發,相比繁瑣的React + React Rotuer + Redux方案,爽的不要不要的,當然前提你得忍受Object.defineProperty攔截getter/setter帶來的各種黑魔法問題。咳咳,這裏不是Mobx大戰Redux,就此打住。想了解的人可以去看一下女神Preethi Kasireddy在React Conf 2017上的演講

最近開發過程中確遇到一個問題,這裏跟大家分享一下。
屏幕快照 2017-07-21 下午3.08.45.png
問題頁麵如上,整個頁麵利用React Router做路由切換。當用戶點擊左邊菜單欄進行進行路由切換的時候,雖然瀏覽器地址欄裏URL信息已經發生變更, 但是頁麵並沒有進行刷新。路由配置代碼如下:

export default function RouterConfig() {
  const homePath = '/home';
  const getComponentRoutes = () => {
    const routeArr = [];
    const pushRoute = path => {
      routeArr.push(<Route key={path} path={path} component={PastyContainer} />);
    };
    for (const item of sideData.common) {
      if (!_.isEmpty(item.children)) {
        for (const childrenItem of item.children) {
          pushRoute(childrenItem.path);
        }
      } else {
        pushRoute(item.path);
      }
    }
    return routeArr;
  };
  return (
    <Router history={history}>
      <TopBar>
        <Switch>
          <Route exact path={homePath} component={Home} />
          <Route path="*">
            <SideBar theme="dark" data={sideData.common}>
              <Switch>
                {getComponentRoutes()}
              </Switch>
            </SideBar>
          </Route>
        </Switch>
      </TopBar>
    </Router>
  );
}

2. React Route v4.0路由原理

想最終問題根源,想來了解一下React Route原理是不可避免的了。

2.1 React Route 的核心依賴History

history is a JavaScript library that lets you easily manage session history anywhere JavaScript runs. history abstracts away the differences in various environments and provides a minimal API that lets you manage the history stack, navigate, confirm navigation, and persist state between sessions.

簡而言之,React Route核心就是利用History的replace/push和listen的能力在前端完成路由的切換。這裏不做詳細介紹,更多關於History的介紹,可以參考其官方文檔。

2.2 Link、Router、 Switch、 Route

Link, Router, Switch, Route是React-Route中最核心的幾個API了。

2.2.1 Link

其中Link能力類比html中的<a>標簽, 利用Link可以實現頁麵跳轉。上圖中側邊欄中所有可盡心頁麵跳轉都利用了該組件,其實現原理想必所有做過前端開發的人應該都能想到:通過監聽onClick事件,在listener中執行history.replace/push完成頁麵跳轉。

2.2.2 Router

Router組件的是整個路由結構中頂層組件,其主要作用是通過監聽history.listen,捕獲路由變換,並將其置於React Context中,其核心代碼如下:

class Router extends React.Component {
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        history: this.props.history,
        route: {
          location: this.props.history.location,
          match: this.state.match
        }
      }
    }
  }
  computeMatch(pathname) {
    return {
      path: '/',
      url: '/',
      params: {},
      isExact: pathname === '/'
    }
  }
  componentWillMount() {
    this.unlisten = history.listen(() => {
      this.setState({
        match: this.computeMatch(history.location.pathname)
      })
    })
  }
  componentWillUnmount() {
    this.unlisten()
  }
  render() {
    const { children } = this.props
    return children ? React.Children.only(children) : null
  }
}

2.2.3 Route

這應該是整個React Router中最核心的功能了。基本作用就是從context中撈取pathname並與用戶定義的path進行匹配,如果匹配成功,則渲染響應組件。

class Route extends React.Component {
  getChildContext() {
    return {
      router: {
        ...this.context.router,
        route: {
          location: this.props.location || this.context.router.route.location,
          match: this.state.match
        }
      }
    }
  }
  computeMatch({ computedMatch, location, path, strict, exact }, router) {
  }

  componentWillReceiveProps(nextProps, nextContext) {
    this.setState({
      match: this.computeMatch(nextProps, nextContext.router)
    })
  }

  render() {
    const props = { match, location, history, staticContext }
    return (
      component ? ( // component prop gets first priority, only called if there's a match
        match ? React.createElement(component, props) : null
      ) : render ? ( // render prop is next, only called if there's a match
        match ? render(props) : null
      ) : children ? ( // children come last, always called
        typeof children === 'function' ? (
          children(props)
        ) : !isEmptyChildren(children) ? (
          React.Children.only(children)
        ) : (
          null
        )
      ) : (
        null
      )
    )
  }
}

export default Route

2.2.3 Switch

這裏還用到了Switch方法,Switch的作用是渲染第一個子組件(<Route>, <Redirect>)

class Switch extends React.Component {
  render() {
    React.Children.forEach(children, element => {
      // 遍曆子組件的props, 隻渲染低一個匹配到pathname的Route
      const { path: pathProp, exact, strict, from } = element.props
      const path = pathProp || from
      if (match == null) {
        child = element
        match = path ? matchPath(location.pathname, { path, exact, strict }) : route.match
      }
    })
    return match ? React.cloneElement(child, { location, computedMatch: match }) : null
  }
}

3. Mobx-React中的observer

The observer function / decorator can be used to turn ReactJS components into reactive components. It wraps the component's render function in mobx.autorun to make sure that any data that is used during the rendering of a component forces a re-rendering upon change.

從代碼層麵來看, 主要針對ComponentDidMount, componentWillUnmount, componentDidUpdate(mixinLifecicleEvents)三個接口進行修改。同時如果用戶沒有重寫shouldComponentUpdate, 也會優化shouldeComponentUpdate

export function observer(arg1, arg2) {
  const target = componentClass.prototype || componentClass;
  mixinLifecycleEvents(target)
  componentClass.isMobXReactObserver = true;
  return componentClass;
}
function mixinLifecycleEvents(target) {
  patch(target, "componentWillMount", true);
  [
    "componentDidMount",
    "componentWillUnmount",
    "componentDidUpdate"
  ].forEach(function(funcName) {
    patch(target, funcName)
  });
  if (!target.shouldComponentUpdate) {
    // 如果沒有重寫, 則利用覆蓋
    target.shouldComponentUpdate = reactiveMixin.shouldComponentUpdate;
  }
}

那在詳細看一下,Mobx針對這幾個接口都做了哪些事情:

function patch(target, funcName, runMixinFirst = false) {
  const base = target[funcName];
  const mixinFunc = reactiveMixin[funcName];
  const f = !base
    ? mixinFunc
    : runMixinFirst === true
        ? function() {
          mixinFunc.apply(this, arguments);
          base.apply(this, arguments);
        }
        : function() {
          base.apply(this, arguments);
          mixinFunc.apply(this, arguments);
        }
  ;
  target[funcName] = f;
}

const reactiveMixin = {
  componentWillMount: function() {
    makePropertyObservableReference.call(this, "props")
    makePropertyObservableReference.call(this, "state")
    const initialRender = () => {
      reaction = new Reaction(`${initialName}#${rootNodeID}.render()`, () => {});
      reactiveRender.$mobx = reaction;
      this.render = reactiveRender;
      return reactiveRender();
    };
    const reactiveRender = () => {
      reaction.track(() => {
        rendering = extras.allowStateChanges(false, baseRender);
        return rendering;
    };
    this.render = initialRender;
  },

  componentWillUnmount: function() {
    this.render.$mobx && this.render.$mobx.dispose();
    this.__$mobxIsUnmounted = true;
  },

  componentDidMount: function() {
    if (isDevtoolsEnabled) {
      reportRendering(this);
    }
  },

  componentDidUpdate: function() {
    if (isDevtoolsEnabled) {
      reportRendering(this);
    }
  },

  shouldComponentUpdate: function(nextProps, nextState) {
    if (this.state !== nextState) {
      return true;
    }
    return isObjectShallowModified(this.props, nextProps);
  }
};
  • componentDidMount, componentDidUpdate裏麵隻是提供debug相關的report。
  • componentWillMount裏做兩件事情
    1. 首先會攔截pros/state的get/set, 通過mobx的Atom賦予state, props Observable的能力。
    2. 重寫render方法(this.render = initRender)
  • render
    1. 第一次 render 時:
      • 初始化一個 Reaction
      • 在 reaction.track 裏執行 baseRender,建立依賴關係
    2. 有數據修改時:
      • 觸發 render 的執行 (由於在 reaction.track 裏執行,所以會重新建立依賴關係)
  • shouldComponentUpdate類似PureRenderMixin, 隻做shadow比對,若數據不發生變化,則不進行重新渲染。

4. 問題分析

了解了這些背景知識後,我們再來看一下當前這個問題:

首先我們通過history.listen(()=>{})觀察發現,用戶觸發Link點擊事件時,路由變化被我們的回調函數所捕獲。問題並不可能出現在Link 和 listen過程。

那麼React Router是在Router這個組件中創建history.listen回調的。當Url發生變化,觸發history.listen注冊的回調後,會通過修改state, 觸發Router Render過程,默認情況下,會觸發他的子組件Render過程。而當Route發生componentWillReceiveProps時,會通過Router的getChildContext方法,拿到變化的URL。

通過Debug我們發現,TopBar的render,Switch, Route的render過程都沒有觸發。而TopBar中有部分狀態托管在mobx model中,所有問題差不多可以定位到:因為TopBar外層封裝了observer,而observer又會重寫shouldComponentUpdate,shouldComponentUpdate攔截了後續render過程,導致沒有觸發到後續Route組件的shouldComponentUpdate過程。

5. 問題解決

其實,用戶在使用connect, observer這樣會重寫shouldComponentUpdate或者PureComponent都會遇到相同的問題,React Router Guide針對此問題做了詳細描述。總體解法思路:通過傳入props繞過shouldComponentUpdate觸發render。
對於Router來說,路由的變化會反應在location的變化,所有將location傳入props中,會是不錯的繞過shouldComponentUpdate觸發render的方式。那獲取location的方法目前有兩種:

  1. Route如果匹配到路由,會注入location到待渲染組件的props中。所以我們可以直接將TopBar封裝到Route中:

    const TopBarWithRoute = () => (
    <TopBar>
      <Switch>
        <Route exact path={homePath} component={Home} />
        <Route path="*">
          <SideBar theme="dark" data={sideData.common}>
            <Switch>
              {componentRoutes()}
            </Switch>
          </SideBar>
        </Route>
      </Switch>
    </TopBar>
    );
    return (
    <Router history={history}>
      <Route component={TopBarWithRoute} />
    </Router>
    );
    
  2. React Router提供了一個Hoc組件withRouter,利用此組件可以將location注入到TopBar中:

    const TopBarWithRouter = withRouter(TopBar);
    return (
    <Router history={history}>
      <TopBarWithRouter>
        <Switch>
          <Route exact path={homePath} component={Home} />
          <Route path="*">
            <SideBar theme="dark" data={sideData.common}>
              <Switch>
                {componentRoutes()}
              </Switch>
            </SideBar>
          </Route>
        </Switch>
      </TopBarWithRouter>
    </Router>
    );
    
    

6. 參考文章:

最後更新:2017-07-27 15:32:50

  上一篇:go  阿裏雲前端周刊 - 第 17 期
  下一篇:go  ODPS Studio 2.7.0 版本發布啦!