React Router — WEB

1,206 阅读10分钟

参考链接: reacttraining.com/react-route…

安装

npm install --save react-router-dom

例子

function App() {
  return (
    <Router>
      <div>
        <nav>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/user">User</Link>
            </li>
            <li>
              <Link to="/user/1">User 1</Link>
            </li>
          </ul>
        </nav>
        <Switch>
          <Route path="/user/:id" component={UserId} />
          <Route path="/user" component={User} />
          <Route path="/" component={Home} />
        </Switch>
      </div>
    </Router>
  );
}
  
function Home() {
  return <h2>Home</h2>;
}

function User() {
  return <h2>User</h2>;
}

function UserId() {
  return <h2>User 1</h2>;
}


基础组件

BrowserRouter 和 HashRouter

路由渲染方式,传统的 / 或者 hash 跳转

  • <BrowserRouter>

  • <HashRouter>

路由匹配组件 Switch 和 Route

渲染

如果 <Switch> 组件下的 <Route> 与当前 URL 匹配,则渲染对应 Route 组件中的元素,如果没有匹配的 URL,则 render null

路由匹配规则

注意:不是整个 URL (只要 URL 能将 Route 中的规则能从头到尾都匹配上,就算匹配成功)

例如将上例修改下 /user/:iduser 两条路由规则的顺序,点击 User 1 渲染的也是 User(因为 /user 也能匹配到 /user/1)

<Route path="/user" component={User} />
<Route path="/user/:id" component={UserId} />

所以,<Route path="/"> 不能写在头部,不然所有的路由都会被匹配到这条规则

如果需要完全匹配整个路由的话,可以使用 exact

<Router exact path="/"> 表示只有路由为 / 的时候,才会渲染,其他不渲染)

<Route exact path="/" component={Home} />
<Route exact path="/user" component={User} />
<Route exact path="/user/:id" component={UserId} />

路由导航

Link

React Route 提供 <Link> 组件来生成路由导航,渲染出 <a> 标签

NavLink

<NavLink> 是个特殊的导航标签,当路由匹配的时候,可以渲染相关元素

  • activeClassName(string):设置选中样式,默认值为 active
  • activeStyle(object):当元素被选中时,为此元素添加样式
  • exact(bool):为 true 时,只有当导致和完全匹配 classstyle 才会应用
  • strict(bool):为 true 时,在确定为位置是否与当前 URL 匹配时,将考虑位置 pathname 后的斜线
  • isActive(func): 判断链接是否激活的额外逻辑的功能
<NavLink to="/user" activeStyle={{backgroundColor: "yellow"}} exact>User</NavLink>

Redirect

路由重定向

<Route exact path="/">
  <Redirect to="/user" />
</Route>


服务端渲染

参考链接:Using React Router 4 with Server-Side Rendering

例子

import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter as Router, Route, Switch, Link } from 'react-router-dom';

import fs from 'fs';
import path from 'path';
import express from 'express';

const app = express();

function App() {
    return (
        <div>
            <ul>
              <li><Link to="/" >Home</Link></li>
              <li><Link to="/todos">todos</Link></li>
              <li><Link to="/posts">posts</Link></li>
            </ul>
            <Switch>
              <Route exact path="/">Home</Route>
              <Route exact path="/todos">todos</Route>
              <Route exact path="/posts">posts</Route>
            </Switch>
          </div>
      )
}

app.get('/*', function (req, res) {
  const context = {};
  const app = renderToString(
    <Router location={req.url} context={context}>
        <App />
    </Router>
  );

  fs.readFile(path.resolve('./index.html'), 'utf8', (err, data) => {
    if (err) {
      console.error('Something went wrong:', err);
      return res.status(500).send('Oops, better luck next time!');
    }

    return res.send(
      data.replace('<div id="root"></div>', `<div id="root">${app}</div>`)
    );
  })
})

app.listen(5000, () => {
  console.log(`😎 Server is listening on port 5000`);
});

Redirect

在静态路由中,使用 <Redirect> 进行重定向时,React Router 会自动将带有重定向 URL 的属性添加到 context 对象中

//...
function App() {
    ...
    <Redirect to='/todos' />
    ...
}

fs.readFile(path.resolve('./index.html'), 'utf8', (err, data) => {
    ...
    if(context.url) {
        console.log(context.url);
        return res.redirect(301, context.url);
    }
    ...
})

添加具体的 context 属性

添加到 staticContext 属性中

例如 301、302、401、404 等状态值

function Status({code, children}) {
 return (
   <Route
      render={({staticContext}) => {
        if(staticContext) staticContext.status= code;
        return children;
      }}
    />
  )
}

function NotFound() {
  return (
    <Status code={404}>
      <div>
        <h1>Sorry, can't find that.</h1>
      </div>
    </Status>
  )
}

function App() {
  return (
    <div>
      <ul>
        <li><Link to="/" >Home</Link></li>
        <li><Link to="/todos">todos</Link></li>
        <li><Link to="/posts">posts</Link></li>
      </ul>
      <Switch>
        <Route exact path="/">Home</Route>
        {/* <Route exact path="/todos">todos</Route> */}
        <Route exact path="/posts">posts</Route>
        <NotFound />
      </Switch>
    </div>
  )
}

app.get('/*', function (req, res) {
    ...
    if(context.status === 404) {
        res.status(404);
    }
    ...
})

数据加载

数据加载完后,再渲染页面

小例子

  • 获取数据接口 ( isomorphic-fetch )
// loadData.js
import 'isomorphic-fetch';

export default resourceType => {
  return fetch(`https://jsonplaceholder.typicode.com/${resourceType}`)
    .then(res => {
      return res.json();
    })
    .then(data => {
      // only keep 10 first results
      return data.filter((_, idx) => idx < 10);
    });
};
  • 路由文件,可以使用 loadData 用于加载数据
// router.js
import App from './App';
import Home from './Home';
import Posts from './Posts';
import Todos from './Todos';
import NotFound from './NotFound';

import loadData from './loadData';

const Routes = [
  {
    path: '/',
    exact: true,
    component: Home
  },
  {
    path: '/posts',
    component: Posts,
    loadData: () => loadData('posts')
  },
  {
    path: '/todos',
    component: Todos,
    loadData: () => loadData('todos')
  },
  {
    component: NotFound
  }
];

export default Routes;
  • 在服务端使用 matchPath 找到当前路由,并判断当前路由是否具有 loadData 属性。如果有的话,则调用 loadData 方法去获取数据,并将数据添加到服务端响应中(下例中,还将数据添加到了浏览器全局变量 __ROUTE_DATA__ 中)
// index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter as Router, matchPath } from 'react-router-dom';

import express from 'express';

import fs from 'fs';
import path from 'path';
import serialize from 'serialize-javascript';

import Routes from './routes';
import App from './app';

const app = express();

app.get('/*', function(req, res) {
    const currentRoutes = Routes.find(route => matchPath(req.url, route)) || {};
    let promise;
    
    if(currentRoutes.loadData) {
        promise = currentRoutes.loadData();
    } else {
        promise = Promise.resolve(null);
    }
    
    promise.then(data => {
        const context = {data};  // 加载到的数据将被添加到 context 中
        
        const app = renderString(
            <Router location={req.url} context={context}>
                <App />
            </Router>
        );
        
        fs.readFile(path.resolve('./index.html'), (err, indexData) => {
            if(err) {
                console.error('Something went wrong', err);
                return res.status(500).send('Oops, better luck next time!')
            }
            
            if(context.status === 404) {
                res.status(404);
            }
            
            if(context.url) {
                return res.redirect(301, context.url)
            }
            
            return res.send(
                indexData
                    .replace('<div id="root"></div>', `<div id="root">${app}</div>`)
                    .replace(
                        '</body>',
                        `<script>window.__ROUTE_DATA__ = ${data}</script></body>`
                    )
            );
        })
    });
})

app.listen(5000, () => {
  console.log(`😎 Server is listening on port 5000`);
});
  • 上例中,加载到的数据已被添加到 context 对象中,在服务端渲染时,我们将从 staticContext 对象中获取到
// todo.js
import React from 'react';
import loadData from './loadData';

class Todos extends React.Component {
  constructor(props) {
    super(props);

    if (props.staticContext && props.staticContext.data) {
      this.state = {
        data: props.staticContext.data
      };
    } else {
      this.state = {
        data: []
      };
    }
  }

  componentDidMount() {      // 在浏览器端才会调用
    // why of a use setTimeout?
    // This is just so that we can for the next JavaScript tick to ensure that __ROUTE_DATA__ 
    // is available.
    setTimeout(() => {   
      if (window.__ROUTE_DATA__) {
        this.setState({
          data: window.__ROUTE_DATA__
        });
        delete window.__ROUTE_DATA__;
      } else {
        loadData('todos').then(data => {   // 此时是浏览器端再发请求,与服务端无关
          this.setState({
            data
          });
        });
      }
    }, 0);
  }

  render() {
    const { data } = this.state;
    return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>;
  }
}

export default Todos;
  • React Router Config 提供 matchRoutesrenderRoutes 方法

如果匹配嵌套路由的话, matchPath 只能匹配到单条,matchRoutes 则可以匹配到所有的

例如下例,

{
    path: '/todos',
    component: Todos,
    loadData: () => loadData(),
    routes: [
        {
            path: '/todos/:id',
            exact: true,
            component: TodosId,
            loadData: () => loadData()
        }
    ]
}

mathPath 匹配到的结果

const currentRoutes = Routes.find(route => matchPath(req.url, route)) || {};
console.log(currentRoutes);

/**
{ 
    path: '/todos',
    component: [Function: Todos],
    loadData: [Function: loadData],
    routes: [
        { 
            path: '/todos/:id',
            exact: true,
            component: [Function: TodosId],
            loadData: [Function: loadData]
        }
    ]
}
**/

matchRoutes 匹配到的结果

const matchingRoutes = matchRoutes(Routes, req.url);
console.log(matchingRoutes);

/**
[
    { 
        route: { 
            path: '/todos',
            component: [Function: Todos],
            loadData: [Function: loadData],
            routes: [Array]
        },
        match: { path: '/todos', url: '/todos', isExact: false, params: {}}
    },
    {
        route: { 
            path: '/todos/:id',
            exact: true,
            component: [Function: TodosId],
            loadData: [Function: loadData]
        },
        match: { 
            path: '/todos/:id',
            url: '/todos/1',
            isExact: true,
            params: [Object]
        }
    }
]
**/

所以如果是有嵌套路由的话,则可以使用 matchRoutes

import { matchRoutes } from 'react-router-config';

// ...

const matchingRoutes = matchRoutes(Routes, req.url);

let promises = [];

matchingRoutes.forEach(route => {
  if (route.loadData) {
    promises.push(route.loadData());
  }
});

Promise.all(promises).then(dataArr => {
  // render our app, do something with dataArr, send response
});

// ...

renderRoutes:接受一个路由静态文件,返回 Route 组件;当使用 matchRoutes 的时候,应该使用 renderRoutes 方法

// app.js
import React from 'react';
import { renderRoutes } from 'react-router-config';
import { Switch, NavLink } from 'react-router-dom';

import Routes from './routes';

import Todos from './Todos';

export default props => {
  return (
    <div>
      <ul>
        <li><NavLink to="/" >Home</NavLink></li>
        <li><NavLink to="/todos">todos</NavLink></li>
        <li><NavLink to="/posts">posts</NavLink></li>
      </ul>
      <Switch>
        {renderRoutes(Routes)}
      </Switch>
    </div>
  );
};
  • 上述例子


路由懒加载

使用 @loadable/component 插件为例

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

export default function Home() {
    return (
        <h2>Home</h2>
    )
}

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

export default function User() {
    return (
        <h2>User</h2>
    )
}

// app.js
import React from 'react';
import loadable from "@loadable/component";

import {
    BrowserRouter as Router,
    Switch,
    Route,
    Link
} from 'react-router-dom'

function Loading() {
  return (
    <div>
      等待....
    </div>
  )
}

const HomeComponent = loadable(() => import('./home'), {
  fallback: <Loading />
});

const UserComponent = loadable(() => import('./user'), {
  fallback: <Loading />
});

function App() {
  return (
    <Router>
      <div>
        <ul>
          <li>
            <Link to='/'>Home</Link>
          </li>
          <li>
            <Link to='/user'>User</Link>
          </li>
        </ul>
        <Switch>
          <Route exact path='/' component={HomeComponent} />
          <Route exact path='/user' component={UserComponent} />
        </Switch>
      </div>
    </Router>
  )
}

ReactDOM.render(<App />, document.getElementById('root'));

可见,User 组件只有在点击使用的时候,才会被加载到


滚动还原

为什么需要有滚动还原:Automatic scroll restoration in Single Page Applications (SPA)

  • 浏览器按回退键,滚动位置可能会有跳动
  • 不同浏览器的表现可能不一致

都还原到网页顶部

function ScrollToTop() {
  const {pathname} = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}

const App = () => (
  <Router>
    <ScrollToTop />
    <nav>
      <ul>
        <li>
          <Link to='/home'>Home</Link>
        </li>
        <li>
          <Link to='/users'>Users</Link>
        </li>
      </ul>
    </nav>
    <Switch>
      <Route exact path='/home' component={Home} />
      <Route exact path='/users' component={User} />
    </Switch>
  </Router>
)
  • 不加 <ScrollToTop /> 前,滚动条不会被改变

  • <ScrollToTop /> 后,路由跳转,还是浏览器点击前进/后退,都会置顶

如果想在切换 tab 的时候,不置顶,则可修改如下

import { useEffect } from "react";

function ScrollToTopOnMount() {
  useEffect(() => {
    window.scrollTo(0, 0);
  }, []);

  return null;
}

动态路由

传统的路由都是需要这种路由配置文件

// express
app.get("/", handleIndex);
app.get("/invoices", handleInvoices);

app.listen();

// Angular
const appRoutes: Routes = [
  {
    path: "crisis-center",
    component: CrisisListComponent
  }
];

@NgModule({
  imports: [RouterModule.forRoot(appRoutes)]
})
export class AppModule {}

React Routerv4 版本后,开始脱离这种静态路由文件配置模式,而是直接将路由写在 render

如下例,点击 dashboard 将显示 dashboard,不点击的话,则 render null

const App = () => (
  <BrowserRouter>
    <Dashboard />
  </BrowserRouter>
)

const Dashboard = () => (
  <div>
    <nav>
      <Link to="/dashboard">Dashboard</Link>
    </nav>
    <div>
      <Route path='/dashboard'>
        Dashboard
      </Route>
    </div>
  </div>
)

子路由

新版的 React Router,路由可以像一个组件一样定义

const App = () => (
  <BrowserRouter>
    <div>
      <Route path="/tacos" component={Tacos} />
    </div>
  </BrowserRouter>
)

const Tacos = ({match}) => (
  <div>
    <Route path={match.url + "/carnitas"}>
      carnitas
    </Route>
  </div>
)

响应式路由

有了动态路由之后,就可以通过媒体查询实现动态的路由匹配

const App = () => (
  <BrowserRouter>
    <div>
      <Route path="/invoices" component={Invoices} />
    </div>
  </BrowserRouter>
)

const Invoices = () => {
  return (
    <div>
      <Media query="(max-width: 599px)">   // react-media是React的CSS媒体查询组件
        {matches => 
          matches ? (
            <Switch>
              <Route exact path="/invoices/small">
                small
              </Route>
              <Redirect from="/invoices" to="/invoices/small" />
            </Switch>
          ) : (
            <Switch>
              <Route exact path="/invoices/large">
                Large
              </Route>
              <Redirect from="/invoices" to="/invoices/large" />
            </Switch>
          )
        }
      </Media>
    </div>
  )
}

也可参考这篇文章:Conditional Routing with React Router v4


测试

使用 <MemoryRouter>

  • 例子:
// app.js
import React from 'react';
import {
  Route, 
  Link
} from 'react-router-dom'

const App = () => (
  <div>
    <Route
      exact
      path="/"
      render={() => (
        <div>
          <h1>Welcome</h1>
        </div>
      )}
    />
    <Route
      path="/dashboard"
      render={() => (
        <div>
          <h1>Dashboard</h1>
          <Link to="/" id="click-me">
            Home
          </Link>
        </div>
      )}
    />
  </div>
);

export default App;
// app.test.js
import React from 'react';
import { render, unmountComponentAtNode } from "react-dom";
import { MemoryRouter } from "react-router-dom";
import { act } from 'react-dom/test-utils';

import App from './app';

let container = null;
beforeEach(() => {
  // 创建一个 DOM 元素作为渲染目标
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // 退出时进行清理
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it('navigates home when you click the link', async () => {
  render(
    <MemoryRouter initialEntries={['/dashboard']}>
      <App />
    </MemoryRouter>,
    container
  )

  act(() => {
    const goHomeLink = document.querySelector('#click-me');
    goHomeLink.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(document.body.textContent).toBe('Welcome');
})

  • 测试 location

官网中说,不应该在用例中,经常测试 locationhistory,但是如果需要的话(例如验证地址栏中是否设置了新的查询参数),则可以在测试中更新一条可以更新变量的路由

it("clicking filter links updates product query params", () => {
  let history, location;
  render(
    <MemoryRouter initialEntries={["/dashboard"]}>
      <App />
      <Route
        path="*"
        render={({ history, location }) => {
          history = history;
          location = location;
          return null;
        }}
      />
    </MemoryRouter>,
    container
  );

  act(() => {
    // example: click a <Link> to /products?id=1234
  });

  // assert about url
  expect(location.pathname).toBe("/products");
  const searchParams = new URLSearchParams(location.search);
  expect(searchParams.has("id")).toBe(true);
  expect(searchParams.get("id")).toEqual("1234");
});

其他方案

  • 如果你的测试环境具有浏览器全局变量 window.locationwindow.history(这是通过 JSDOMJest 中的默认设置,但您无法重置测试之间的历史记录),则也可以使用 BrowserRouter

  • 您可以将基本路由器与历史包中的历史道具一起使用,而不是将自定义路由传递给 MemoryRouter

// app.test.js
import { createMemoryHistory } from "history";
import { Router } from "react-router";

test("redirects to login page", () => {
  const history = createMemoryHistory();
  render(
    <Router history={history}>
      <App signedInUser={null} />
    </Router>,
    node
  );
  expect(history.location.pathname).toBe("/login");
});

Redux 整合

例如当

  • 该组件通过 connect() 连接到 redux
  • 组件不是路由组件(<Route component={SomeConnectedThing} />someConnectedThing组件)

这些情况下,当路由变化时,组件却没有更新

解决方法:

// before
export default connect(mapStateToProps)(Something)

// after
import { withRouter } from 'react-router-dom'
export default withRouter(connect(mapStateToProps)(Something))

深度整合

例如

  • 同步路由和 store 中的数据
  • 通过 dispath actions 进行导航
  • Have support for time travel debugging for route changes in the Redux devtools.

官方建议:上述情况的话, 不要在 Redux store 中使用路由,如果要使用的话,建议使用 Connected React Router( React Router v4Redux 的第三方绑定)


静态路由

react-router-config