일반적으로 라우팅은 웹 서버의 역할이다. 클라이언트에서 특정 주소를 웹서버에게 요청하면 서버는 이에 맞는 화면을 클라이언트에게 제공하는 것이다.
클라이언트에게 라우팅역할을 맞길 수 도 있다. 웹 서버가 항상 같은 리소스를 제공하고 브라우져가 이 리소스를 바탕으로 요청 주소에 따라 화면을 렌더링 하는 방식이다. (react라면 component rendering)
const App = () => {
const { pathname } = window.location;
return (
<>
{pathname === "/order" && <OrderPage />}
{pathname === "/cart" && <CartPage />}
{!["/order", "/cart"].includes(pathname) && <ProductPage />}
</>
);
};
export default App;
url에 따라서 알맞은 랜더링을 시도하려하는데
devServer: {
static: path.resolve(__dirname, "public"),
port: process.env.PORT,
historyApiFallback: true,
},
webpack server에서는(webpack.config.js에 따라) /cart, /order 같은것이 public 에 있는지 확인을 하게 된다.
이때 찾을 수 없어서 404를 return 하게 되는데 이를 해결하기 위해서
historyApiFallback: true를 주면 404인 경우에 index.html 을 return 하게 된다.
Link
브라우저가 하이퍼링크를 처리하는 기본 동작이 href 값을 서버로 요청하는 것이다. 이 요청을 전달하기전에 앱에서 라우팅을 해야한다. event#preventDefault를 통해서 이 기본동작을 막자
Router
브라우저 기본 동작을 취소하면서 생긴 2가지 할일
- 컴포넌트 렌더링: 요청한 주소에 해당하는 컴포넌트 렌더링
- 주소 변경: 요청한 주소를 브라우져 주소창에 표시
export const routerContext = React.createContext({});
routerContext.displayName = "RouterContext";
export class Router extends React.Component {
constructor(props) {
super(props);
this.state = {
path: window.location.pathname,
};
this.handleChangePath = this.handleChangePath.bind(this);
}
handleChangePath(path) {
this.setState({ path });
}
render() {
const contextValue = {
path: this.state.path,
changePath: this.handleChangePath,
};
return (
<routerContext.Provider value={contextValue}>
{this.props.children}
</routerContext.Provider>
);
}
}
export const Link = ({ to, ...rest }) => (
<routerContext.Consumer>
{({ path, changePath }) => {
const handleClick = (e) => {
e.preventDefault();
if (to !== path) changePath(to);
};
return <a {...rest} href={to} onClick={handleClick} />;
}}
</routerContext.Consumer>
);
export const Routes = ({ children }) => {
return (
<routerContext.Consumer>
{({ path }) => {
let selectedRoute = null;
React.Children.forEach(children, (child) => {
// react element인지 검사한다.
if (!React.isValidElement(child)) return;
// fragment (빈 element) 검사한다.
if (child.type === React.Fragment) return;
// Route Component인지 검사한다.
if (!child.props.path || !child.props.element) return;
// 요청 경로를 검사한다.
if (child.props.path !== path.replace(/\?.*$/,"")) return;
selectedRoute = child.props.element;
});
return selectedRoute;
}}
</routerContext.Consumer>
);
};
export const Route = () => null;
Router Component는 path를 state로 가지고 있으면서 reactContext Provider 역할을 한다.
즉 Provider component 안에서 Consumer들이 등록한 handler들을 호출하는 역할이다. (emitter#set)
Link는 browser의 url handling을 방지하고
Consumer로써 전달 받은 to (url) 가 현재 path랑 나르면 changePath를 호출한다.
이떄 path, changePath는 Provider가 전달해준 value이다.
Routes 는 상위 component로 부터 전달받은 children props가 Route element 로써 path, element prop들을 가지고 있는지 확인한다.
매칭되는 path의 element를 rendering할 수 있게 return 한다.
const App = () => (
<MyRouter.Router>
<MyRouter.Routes>
<MyRouter.Route
path="/cart"
element={<CartPage></CartPage>}
></MyRouter.Route>
<MyRouter.Route
path="/order"
element={<OrderPage></OrderPage>}
></MyRouter.Route>
<MyRouter.Route
path="/"
element={<ProductPage></ProductPage>}
></MyRouter.Route>
</MyRouter.Routes>
</MyRouter.Router>
);
Routes Consumer에게 3개의 Route Component들이 전달이 된다.
const Navbar = () => (
<nav className="Navbar">
<MyRouter.Link className="active" to="/">
메뉴목록
</MyRouter.Link>
<MyRouter.Link to="/order">주문내역</MyRouter.Link>
</nav>
);
이때 navBar의 link를 클릭했다면 Link에 의해서 order로 Provider의 state가 변경되고
Provider는 ComponentDidUpdate 시점에 자신의 하위 Component를 re rendering 시킨다.
Routes가 re render되면서 Route중 matching 되는 녀석을 rendering 시키게 된다.
주소창 주소 변경하기
Link를 클릭하면 요청 주소에 해당하는 컴포넌트로 화면이 교체되지만 브라우져 주소창의 값은 그대로 남아있다. 브라우저는 하이퍼링크를 클릭하면 브라우져 주소창의 값을 요청 주소로 바꾸는데 Link가 기본 동작을 취소 했기 때문이다.
DOM의 Window객체는 history 객체를 통해 브라우저의 세션 기록에 접근할 수 있는 방법을 제공한다.
history는 사용자를 자신의 방문기록 앞과 뒤로 보내고 기록스택의 콘텐츠도 조작할 수 있는 유용한 메서드 속성을 가진다.
handleChangePath(path) {
this.setState({ path });
window.history.pushState("", "", path);
}
pushState를 통해 url 또한 변경이 잘 된다.
하지만 history에 현재 root("/") 가 등록이 되어있지 않기에 뒤로가기를 눌러도 해당 root page로 이동되지 않는다.
export const routerContext = React.createContext({});
routerContext.displayName = "RouterContext";
export class Router extends React.Component {
constructor(props) {
super(props);
this.state = {
path: window.location.pathname,
};
this.handleChangePath = this.handleChangePath.bind(this);
this.handleOnpopstate = this.handleOnpopstate.bind(this);
}
handleChangePath(path) {
this.setState({ path });
window.history.pushState({ path }, "", path);
}
handleOnpopstate(event) {
console.log("handleOnpopstate", event);
const nextPath = event.state && event.state.path;
if (!nextPath) return;
this.setState({ path: nextPath });
}
componentDidMount() {
console.log("componentDidMount")
window.addEventListener("popstate", this.handleOnpopstate);
window.history.replaceState({ path: this.state.path },"");
}
componentWillUnmount() {
window.removeEventListener("popstate", this.handleOnpopstate);
}
render() {
const contextValue = {
path: this.state.path,
changePath: this.handleChangePath,
};
return (
<routerContext.Provider value={contextValue}>
{this.props.children}
</routerContext.Provider>
);
}
}
Router가 mount되는 시점에 replaceState를 통해서 path: this.state.path 를 history 에 저장을 해준다.
사진 처럼 state에 "/"가 추가되고 /order로 간후에 뒤로가기를 눌렀을 때 저장된 history.state를 통해 이동이 가능해진다.
뒤로가기 ,앞으로가기 눌렀을 때 popstate event가 발행이된다. 이때 popstate event에 path "/"가 들어있으니 이를 통해 event handler로 등록한handleOnpopstate를 통해서 Router의 state가 변경이되고
state가 변경이 되었으니 re rendering되면서 알맞은 component를 re render하게 된다.
const OrderableProductItem = ({ product }) => {
return (
<MyRouter.routerContext.Consumer>
{({ changePath }) => {
const handleClick = () => {
console.log("// TODO 장바구니 화면으로 이동");
changePath("/cart");
};
return <ProductItem product={product} onClick={handleClick} />;
}}
</MyRouter.routerContext.Consumer>
);
};
ProductPage에 있는 주문하기 버튼을 클릭하면 Router가 return 한 Provider를 통해서 changePath 함수를 받아 올 수 있다.
changePath를 통해 Router의 state가 변경이되고 지정한 path 로 re rendering이 진행이 된다.
고차 컴포넌트
- 횡단 관심사를 분리하는데 사용한다.
- 횡단 관심사의 예시는 Security 나 logging 같은게 있겠다.
비지니스 로직은 아니지만 여러 모듈에서 쓰여야 하는 것들이다.
class Header extends React.Component {
render() {
return <header>header</header>;
}
}
class Button extends React.Component {
handleClick = () => {
this.props.log("click");
};
render() {
return <button onClick={this.handleClick}>button</button>;
}
}
const withLogging = (WrappedComponent) => {
function log(message) {
console.log(`[${getComponentName(WrappedComponent)}] ${message}`);
}
class WithLogging extends React.Component {
render() {
const enhancedProps = {
log,
};
return (
<WrappedComponent {...this.props} {...enhancedProps}></WrappedComponent>
);
}
componentDidMount() {
log("mount");
}
}
return WithLogging;
};
const EnhancedHeader = withLogging(Header);
const EnhancedButton = withLogging(Button);
export default () => (
<>
<EnhancedHeader></EnhancedHeader>
<EnhancedButton></EnhancedButton>
</>
)
일종의 프록시 처럼 한번 감싸서 횡단 관심사인 로깅을 대체 할 수 있게 되었다.
export const withRouter = (WrappedComponent) => {
const WithRouter = (props) => (
<routerContext.Consumer>
{({ path, changePath }) => {
const navigate = (nextPath) => {
if (path !== nextPath) changePath(nextPath);
};
const enhancedProps = {
navigate,
};
return (
<WrappedComponent {...props} {...enhancedProps}></WrappedComponent>
);
}}
</routerContext.Consumer>
);
return WithRouter;
};
routerContext#Consumer 를 사용해서 changePath를 호출하여 Router state를 변경하는 로직은 많은 버튼에서 공통적으로 쓰이게 될 로직이다.
해당 로직이 횡단 관심사에 해당함으로 고차 컴포넌트를 만들고 이를 사용해본다.
const OrderableProductItem = ({ product, navigate }) => {
const handleClick = () => {
navigate(`/cart`);
};
return <ProductItem product={product} onClick={handleClick} />;
};
export default MyRouter.withRouter(OrderableProductItem);
enhanced props로 전달받은 navigate를 사용하여 기존의 기능을 정상적으로 수행할 수 있다.
WithRouter.displayName = `WithRouter(${getComponentName(WrappedComponent)})`
디버깅 하기 편하게 displayName을 적용하면 withRouter라고 브라우저 Component개발툴에 나온다.
export const withRouter = (WrappedComponent) => {
const WithRouter = (props) => (
<routerContext.Consumer>
{({ path, changePath }) => {
const navigate = (nextPath) => {
if (path !== nextPath) changePath(nextPath);
};
const match = (comparedPath) => path === comparedPath;
const enhancedProps = {
navigate,
match,
};
return <WrappedComponent {...props} {...enhancedProps} />;
}}
</routerContext.Consumer>
);
WithRouter.displayName = `WithRouter(${getComponentName(WrappedComponent)})`;
return WithRouter;
};
고차 컴포넌트에 match를 추가하고
const Navbar = ({match}) => (
<nav className="Navbar">
<MyRouter.Link className={match("/") ? "active": ""} to="/">
메뉴목록
</MyRouter.Link>
<MyRouter.Link className={match("/order") ? "active" : ""} to="/order">주문내역</MyRouter.Link>
</nav>
);
export default MyRouter.withRouter(Navbar);
Navbar에 현재 url에 따라서 active 하게 빛이 나게 변경하였다.
export const withRouter = (WrappedComponent) => {
const WithRouter = (props) => (
<routerContext.Consumer>
{({ path, changePath }) => {
const navigate = (nextPath) => {
if (path !== nextPath) changePath(nextPath);
};
const match = (comparedPath) => path === comparedPath;
const params = () => {
const params = new URLSearchParams(window.location.search);
const paramsObject = {};
for (const [key, value] of params) {
paramsObject[key] = value;
}
return paramsObject;
};
const enhancedProps = {
navigate,
match,
params,
};
return <WrappedComponent {...props} {...enhancedProps} />;
}}
</routerContext.Consumer>
);
WithRouter.displayName = `WithRouter(${getComponentName(WrappedComponent)})`;
return WithRouter;
};
params를 통해 url의 queryString을 prasing하여 URLSearchParams 객체를 return하는 props를 추가하였다.
async fetch() {
const { productId } = this.props.params();
if (!productId) return;
try {
const product = await ProductApi.fetchProduct(productId);
this.setState({ product });
} catch (e) {
console.error(e);
}
}
CartPage도 고차컴포넌트로 감싸져 있으니 params를 그대로 가져와서 fetch하기전에 알맞은 queryString 의 productId를 전달하여서 OrderPage에서도 제대로 된 product api를 호출할 수 있게 하였다.
'FrontEnd > [리액트 2부] 고급 주제와 훅' 카테고리의 다른 글
[3.1장 클래스/함수 컴포넌트][3.2장 상태훅] (0) | 2024.03.11 |
---|---|
[2.4장 다이얼로그1][2.5장 다이얼로그2] (0) | 2024.03.07 |
[2.1장 컨텍스트] (0) | 2024.03.01 |
[1.4장 장바구니 화면] (0) | 2024.02.27 |
[1.2장 상품목록 화면] [1.3장 주문내역 화면] (0) | 2024.02.22 |