路由即组件

路由即组件

在 React Router 3.x 中,我们只能静态地独立地声明路由配置,而不能与 React 组件混合声明。实际上 React 组件树本身就已然声明了视图的层次结构,我们在路由配置中再重复地声明这个视图的层次结构不免有所冗余。React Router 4 则着眼于所谓 Declarative Composability,即其不仅仅提供独立的路由声明,而是视作一系列导航组件集合;也就是所谓的路由即组件( Route is Component ),可以方便地集成进 React 组件树中。并且 React Router 4 提供了多种 Router,除了可以运行在浏览器环境中,还可以运行在 React Native 或者将路由信息存放在内存中从而能够运行在任意的 JavaScript 引擎中。本部分我们会介绍些 React Router V4 的基础概念,但是并不会急于详细描述 React Router V4 的配置与语法,而是着眼于抽丝剥茧地探索 React Router V4 的构建之道。React Router V4 秉持着所谓路由即组件的概念,那么我们可以想象我们期待的某个待路由的组件的声明方式应该如下这样可以混用路由与其他元素:

const Home = () => <h2>Home</h2>;

const About = () => <h2>About</h2>;

const Topic = ({ topicId }) => <h3>{topicId}</h3>;

const Topics = ({ match }) => {
  const items = [
    { name: "Rendering with React", slug: "rendering" },
    { name: "Components", slug: "components" },
    { name: "Props v. State", slug: "props-v-state" }
  ];

  return (
    <div>
      <h2>Topics</h2> 
      <ul>
        {items.map(({ name, slug }) => (
          <li key={name}>
            <Link to={`${match.url}/${slug}`}>{name}</Link> 
          </li>
        ))}
      </ul>
      {items.map(({ name, slug }) => (
        <Route
          key={name}
          path={`${match.path}/${slug}`}
          render={() => <Topic topicId={name} />}
        />
      ))}
      <Route
        exact
        path={match.url}
        render={() => <h3>Please select a topic.</h3>}
      />
       
    </div>
  );
};

const App = () => (
  <div>
    <ul>
      <li>
        <Link to="/">Home</Link>
      </li>
      <li>
        <Link to="/about">About</Link>
      </li>  <li>
        <Link to="/topics">Topics</Link>
      </li> 
    </ul>
    <hr />
    <Route exact path="/" component={Home} />
    <Route path="/about" component={About} />
    <Route path="/topics" component={Topics} />
  </div>
);

上述代码中Route 会在当前 URL 与 path 属性值相符的时候渲染相关组件,而 Link 提供了声明式的,易使用的方式来在应用内进行跳转。换言之,Link 组件允许你更新当前 URL,而 Route 组件则是根据 URL 渲染组件。

路由组件

我们首先来考量下如何构建Route组件,包括其暴露的 API,即 Props。在我们上面的示例中,我们会发现Route组件包含三个 Props:exactpath 以及 component。这也就意味着我们的propTypes声明如下:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
}

这里有一些微妙的细节需要考虑,首先对于 path 并没有设置为必须参数,这是因为我们认为对于没有指定关联路径的 Route 组件应该自动默认渲染。而component参数也没有被设置为必须是因为我们提供了其他的方式进行渲染,譬如 render 函数:

<Route
  path="/settings"
  render={({ match }) => {
    return <Settings authed={isAuthed} match={match} />;
  }}
/>

render 函数允许你方便地使用内联函数来创建 UI 而不是创建新的组件,因此我们也需要将该函数设置为 propTypes:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
  render: PropTypes.func,
}

在确定了 Route 需要接收的组件参数之后,我们需要来考量其实际功能;Route 核心的功能在于能够当 URL 与 path 属性相一致时执行渲染操作。基于这个论断,我们首先需要实现判断是否匹配的功能,如果判断为匹配则执行渲染否则返回空值。我们在这里将该函数命名为 matchPatch,那么此时整个 Route 组件的 render 函数定义如下:

class Route extends Component {
  // ...
  // 判断路径是否匹配
  const match = matchPath(
    location.pathname, // 全局 DOM 变量
    { path, exact }
  )

  if (!match) {
    // 不匹配则直接返回
    return null
  }

  if (component) {
    // 如果设置了 Component 对象,则挂载该对象
    return React.createElement(component, { match })
  }

  if (render) {
    // 否则调用渲染函数
    return render({ match })
  }

  return null;
 }

现在的 Route 看起来已经相对明确了,当路径相匹配的时候才会执行界面渲染,否则返回为空。

变化监听

现在我们再回过头来考虑客户端路由中常见的跳转策略,一般来说用户只有两种方式会更新当前 URL。一种是用户点击了某个锚标签或者直接操作history 对象的 replace/push 方法;另一种是用户点击前进/后退按钮。无论哪一种方式都要求我们的路由系统能够实时监听 URL 的变化,并且在 URL 发生变化时及时地做出响应,渲染出正确的页面。我们首先来考虑下如何处理用户点击前进/后退按钮。React Router 使用 History  的 .listen 方法来监听当前 URL 的变化,其本质上还是直接监听 HTML5 的 popstate 事件。popstate 事件会在用户点击某个前进/后退按钮的时候触发;而在每次重渲染的时候,每个 Route 组件都会重现检测当前 URL 是否匹配其预设的路径参数。

class Route extends Component {
  // ...

  componentWillMount() {
    // 监听状态变化

    addEventListener("popstate", this.handlePop);
  }

  componentWillUnmount() {
    removeEventListener("popstate", this.handlePop);
  }

  handlePop = () => {
    this.forceUpdate();
  }; // ...
}

你会发现上面的代码与之前的相比多了挂载与卸载 popstate 监听器的功能,其会在组件挂载时添加一个 popstate 监听器;当监听到 popstate 事件被触发时,我们会调用 forceUpdate 函数来强制进行重渲染。总结而言,无论我们在系统中设置了多少的路由组件,它们都会独立地监听 popstate 事件并且相应地执行重渲染操作。

路径匹配

接下来我们继续讨论 matchPath 这个 Route 组件中至关重要的函数,它负责决定当前路由组件的 path 参数是否与当前 URL 相一致。这里还必须提下我们设置的另一个 Route 的参数 exact,其用于指明路径匹配策略;当 exact 值被设置为 true 时,仅当路径完全匹配于 location.pathname 才会被认为匹配成功:

path location.pathname exact matches?
/one /one/two true no
/one /one/two false yes

让我们深度了解下 matchPath 函数的工作原理,该函数的签名如下:

const match = matchPath(location.pathname, { path, exact });

其中函数的返回值 match 应该根据路径是否匹配的情况返回为空或者一个对象。基于这些推导我们可以得出 matchPatch 的原型:

const matchPath = (pathname, options) => {
  const { exact = false, path } = options;

  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true
    };
  }
};

这里我们使用 ES6 的解构赋值,当某个属性未定义时我们使用预定义地默认值,即 false。我在上文提及的 path 非必要参数的具体支撑实现就在这里,我们首先进行空检测,当发现 path 为未定义或者为空时则直接返回匹配成功。接下来继续考虑具体执行匹配的部分,React Router 使用了 pathToRegex  来检测是否匹配,即可以用简单的正则表达式:

const matchPath = (pathname, options) => {
  ...

  const match = new RegExp(`^${path}`).exec(pathname)
}

这里使用的 .exec 函数,会在包含指定的文本时返回一个数组,否则返回空值;下表即是当我们的路由设置为 /topics/components 时具体的返回:

path location.pathname return value
/ /topics/components ['/']
/about /topics/components null
/topics /topics/components ['/topics']
/topics/rendering /topics/components null
/topics/components /topics/components ['/topics/components']
/topics/props-v-state /topics/components null
/topics /topics/components ['/topics']

这里大家就会看出来,我们会为每个 <Route> 实例创建一个 match 对象。在获取到 match 对象之后,我们需要再做如下判断是否匹配:

const matchPath = (pathname, options) => {
  // ...

  if (!match) {
    // 不匹配则返回空
    return null;
  }

  const url = match[0];
  const isExact = pathname === url;

  if (exact && !isExact) {
    // 判断是否需要精确匹配
    return null;
  }

  return {
    path,
    url,
    isExact
  };
};

跳转组件

上文我们已经提及通过监听 popstate 状态来响应用户点击前进/后退事件,现在我们来考虑通过构建 Link 组件来处理用户通过点击锚标签进行跳转的事件。Link 组件的 API 应该如下所示:

<Link to="/some-path" replace={false} />

其中的 to 是一个指向跳转目标地址的字符串,而 replace 则是布尔变量来指定当用户点击跳转时是替换 history 栈中的记录还是插入新的记录。基于上述的 API 设计,我们可以得到如下的组件声明:

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool
  };
}

现在我们已经知道 Link 组件的渲染函数中需要返回一个锚标签,不过我们的前提是要避免每次用户切换路由的时候都进行整页的刷新,因此我们需要为每个锚标签添加一个点击事件的处理器:

class Link extends Component {
  // ...
  handleClick = event => {
    const { replace, to } = this.props;
    event.preventDefault(); // route here.
  };

  render() {
    const { to, children } = this.props;
    return (
      <a href={to} onClick={this.handleClick}>
          {children} {" "}
      </a>
    );
  }
}

这里实际的跳转操作我们还是执行 History  中的抽象的pushreplace 函数,在使用 browserHistory 的情况下我们本质上还是使用 HTML5 中的 pushStatereplaceState 函数。pushStatereplaceState 函数都要求输入三个参数,首先是一个与最新的历史记录相关的对象,在 React Router 中我们并不需要该对象,因此直接传入一个空对象;第二个参数是标题参数,我们同样不需要改变该值,因此直接传入空即可;最后第三个参数则是我们需要的,用于指明新的相对地址的字符串:

const historyPush = path => {
  history.pushState({}, null, path);
};

const historyReplace = path => {
  history.replaceState({}, null, path);
};

而后在 Link 组件内,我们会根据 replace 参数来调用 historyPush 或者 historyReplace 函数:

class Link extends Component {
  // ...
  handleClick = event => {
    const { replace, to } = this.props;
    event.preventDefault();

    replace ? historyReplace(to) : historyPush(to);
  }; // ...
}

路由注册

现在我们需要考虑如何保证用户点击了 Link 组件之后触发全部路由组件的检测与重渲染。在我们上面实现的 Link 组件中,用户执行跳转之后浏览器的显示地址会发生变化,但是页面尚不能重新渲染;我们声明的 Route 组件并不能收到相应的通知。为了解决这个问题,我们需要追踪那些显现在界面上实际被渲染的 Route 组件并且当路由变化时调用它们的 forceUpdate 方法。React Router 主要通过有机组合 setStatecontext 以及 history.listen 方法来实现该功能。每个 Route 组件被挂载时我们会将其加入到某个数组中,然后当位置变化时,我们可以遍历该数组然后对每个实例调用 forceUpdate 方法:

let instances = [];

const register = comp => instances.push(comp);

const unregister = comp => instances.splice(instances.indexOf(comp), 1);

这里我们创建了两个函数,当 Route 挂载时调用 register 函数,而卸载时调用 unregister 函数。然后无论何时调用 historyPush 或者 historyReplace 函数时都会遍历实例数组中的对象的渲染方法,此时我们的 Route 组件就需要声明为如下样式:

class Route extends Component {
  // ...
  componentWillMount() {
    addEventListener("popstate", this.handlePop);
    register(this);
  }

  componentWillUnmount() {
    unregister(this);
    removeEventListener("popstate", this.handlePop);
  } // ...
}

然后我们需要更新 historyPushhistoryReplace 函数:

const historyPush = path => {
  history.pushState({}, null, path);
  instances.forEach(instance => instance.forceUpdate());
};

const historyReplace = path => {
  history.replaceState({}, null, path);
  instances.forEach(instance => instance.forceUpdate());
};

这样的话就保证了无论何时用户点击 <Link> 组件之后,在位置显示变化的同时,所有的 <Route> 组件都能够被通知到并且执行重匹配与重渲染。现在我们完整的路由解决方案就成形了。另外,React Router API 中提供了所谓 <Redirect> 组件,允许执行路由跳转操作:

class Redirect extends Component {
  static defaultProps = {
    push: false
  };

  static propTypes = {
    to: PropTypes.string.isRequired,
    push: PropTypes.bool.isRequired
  };

  componentDidMount() {
    const { to, push } = this.props;

    push ? historyPush(to) : historyReplace(to);
  }

  render() {
    return null;
  }
}

注意这个组件并没有真实地进行界面渲染,而是仅仅进行了简单的跳转操作。到这里本文也就告一段落了,希望能够帮助你去了解 React Router V4 的设计思想以及 Just Component 的接口理念。我一直说 React 会让你成为更加优秀地开发者,而 React Router 则会是你不小的助力。

上一页
下一页