replace
는 push
와 다르게 브라우저에 새로 히스토리가 쌓이지 않습니다. 그쵸 ?
근데 오늘 신기한 거를 봤습니다. 코드부터 볼까요 ?
import { useRouter } from "next/router";
export default function Home() {
const router = useRouter();
const handleClick = () => {
router.replace("/blog");
};
return <button onClick={handleClick}>블로그 가기</button>;
}
그리고 next.config.js
를 확인해 보겠습니다.
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
rewrites: async () => {
return [
{
source: "/blog",
destination: "https://code-to-money.tistory.com",
},
];
},
};
export default nextConfig;
이제 테스트해보면 이런 결과가 나옵니다.
이상하게 히스토리가 생기는군요...
왜 이런 현상이 생기는지 Nextjs 코드를 살펴보겠습니다.
/**
* Performs a `replaceState` with arguments
* @param url of the route
* @param as masks `url` for the browser
* @param options object you can define `shallow` and other options
*/
replace(url: Url, as?: Url, options: TransitionOptions = {}) {
;({ url, as } = prepareUrlAs(this, url, as))
return this.change('replaceState', url, as, options)
}
이건 방금 사용된 replace
함수입니다. change
함수가 실행되는군요.
change
함수안에서 replace가 push처럼 동작하는 케이스가 있는데요. 바로 handleHardNavigation
함수가 실행될 때입니다.
function handleHardNavigation({
url,
router,
}: {
url: string
router: Router
}) {
// ensure we don't trigger a hard navigation to the same
// URL as this can end up with an infinite refresh
if (url === addBasePath(addLocale(router.asPath, router.locale))) {
throw new Error(
`Invariant: attempted to hard navigate to the same URL ${url} ${location.href}`
)
}
window.location.href = url
}
handleHardNavigation
는 window.location.href를 사용하기 때문에 브라우저 히스토리가 쌓입니다.
이제 다시 change
함수로 돌아가서 handleHardNavigation 함수가 언제 실행되는지 확인해 보겠습니다.
handleHardNavigation 함수는 여러 곳에서 호출되는데요. console.log로 어디서 실행되는지 확인해 본 결과
if (rewritesResult.externalDest) {
handleHardNavigation({ url: as, router: this })
return true
}
여기서 실행이 됐습니다. rewritesResult.externalDest 이게 true 일 때 실행되는 것으로 보입니다. rewritesResult.externalDest는
const rewritesResult = resolveRewrites(
addBasePath(addLocale(cleanedAs, nextState.locale), true),
pages,
rewrites,
query,
(p: string) => resolveDynamicRoute(p, pages),
this.locales
)
여기서 확인할 수 있습니다. resolveRewrites 함수를 살펴보면 externalDest 값이 false였다가
if (!rewrite.destination) {
// this is a proxied rewrite which isn't handled on the client
externalDest = true
return true
}
!rewrite.destination이면 true로 바뀌네요.
이제
resolveRewrites(
addBasePath(addLocale(cleanedAs, nextState.locale), true),
pages,
rewrites,
query,
(p: string) => resolveDynamicRoute(p, pages),
this.locales
)
여기서 파라미터로 넣어주는 rewrites
를 확인해 볼 건데
;[pages, { __rewrites: rewrites }] = await Promise.all([
this.pageLoader.getPageList(),
getClientBuildManifest(),
this.pageLoader.getMiddleware(),
])
여기서 getClientBuildManifest
이 함수를 통해 얻을 수 있습니다.
export function getClientBuildManifest() {
if (self.__BUILD_MANIFEST) {
return Promise.resolve(self.__BUILD_MANIFEST)
}
const onBuildManifest = new Promise<Record<string, string[]>>((resolve) => {
// Mandatory because this is not concurrent safe:
const cb = self.__BUILD_MANIFEST_CB
self.__BUILD_MANIFEST_CB = () => {
resolve(self.__BUILD_MANIFEST!)
cb && cb()
}
})
return resolvePromiseWithTimeout(
onBuildManifest,
MS_MAX_IDLE_DELAY,
markAssetError(new Error('Failed to load client build manifest'))
)
}
이 함수를 보면 window
에 있는__BUILD_MANIFEST
를 값을 알아야 합니다.
__BUILD_MANIFEST
이 값은 어디에 있을까요 ?? 잠깐 next js 어플리케이션으로 되돌아가서 .next/static/development를 확인해 보겠습니다.
_buildManifest.js
를 확인해 보면
self.__BUILD_MANIFEST = (function (a) {
return {
__rewrites: {
afterFiles: [{ has: a, source: "\u002Fblog", destination: a }],
beforeFiles: [],
fallback: [],
},
"/": ["static\u002Fchunks\u002Fpages\u002Findex.js"],
"/_error": ["static\u002Fchunks\u002Fpages\u002F_error.js"],
sortedPages: ["\u002F", "\u002F_app", "\u002F_error"],
};
})(void 0);
self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB();
self.__BUILD_MANIFEST
에 값을 할당해 줍니다. 근데 이상한 부분이 있네요.
저희가 아까 설정한
rewrites: async () => {
return [
{
source: "/blog",
destination: "https://code-to-money.tistory.com",
},
];
},
여기에는 분명 https://code-to-money.tistory.com 이 값을 넣었는데...
undefined가 들어간 모습입니다.
뭔가 복잡하니 지금까지의 분석을 정리해 보겠습니다.
- router.replace는 특정 조건에서 히스토리가 쌓인다.
- 여러 조건이 있지만 현재 케이스에서는
rewritesResult.externalDest
이true
일 때 실행된다. rewritesResult.externalDest
이 값은 rewrite의 destination 값이 없으면 발생하는데- rewrite의 destination 값은
self.__BUILD_MANIFEST_CB
값을 가져오고 self.__BUILD_MANIFEST_CB
은 .next/static/development/_buildManifest.js에서 넣어준다.
이제 _buildManifest.js
가 만들어지는 과정을 확인하면 원인을 찾을 수 있을 거 같습니다.
이 _buildManifest.js
는 next dev
명령어를 실행시키면 만들어집니다. 그리고 next js는 webpack 이란 번들러를 사용하는데요. webpack 관련 코드를 보기 위해 packages/next/src/build/webpack-config.ts를 확인해 봤습니다.
이 파일에 getBaseWebpackConfig
함수를 보다 보면
isClient &&
new BuildManifestPlugin({
buildId,
rewrites,
isDevFallback,
appDirEnabled: hasAppDir,
clientRouterFilters,
}),
이런 코드가 있습니다. BuildManifest면... 뭔가 _buildManifest.js
와 관련이 있는거 같은데요 ? 살펴보겠습니다.
export default class BuildManifestPlugin {
private buildId: string
private rewrites: CustomRoutes['rewrites']
private isDevFallback: boolean
private appDirEnabled: boolean
private clientRouterFilters?: Parameters<typeof generateClientManifest>[2]
constructor(options: {
buildId: string
rewrites: CustomRoutes['rewrites']
isDevFallback?: boolean
appDirEnabled: boolean
clientRouterFilters?: Parameters<typeof generateClientManifest>[2]
}) {
this.buildId = options.buildId
this.isDevFallback = !!options.isDevFallback
this.rewrites = {
beforeFiles: [],
afterFiles: [],
fallback: [],
}
this.appDirEnabled = options.appDirEnabled
this.clientRouterFilters = options.clientRouterFilters
this.rewrites.beforeFiles = options.rewrites.beforeFiles.map(processRoute)
this.rewrites.afterFiles = options.rewrites.afterFiles.map(processRoute)
this.rewrites.fallback = options.rewrites.fallback.map(processRoute)
}
BuildManifestPlugin
클래스에 constructor를 확인해 보면 rewrites 안에 지정해 준 값들을 processRoute
을 태우는 모습입니다. 어 ! 근데 저의 next.config에 rewrites는
rewrites: async () => {
return [
{
source: "/blog",
destination: "https://code-to-money.tistory.com",
},
{
source: "/blogtest",
destination: "https://code-to-money.tistory.com.test",
},
];
},
beforeFiles, afterFiles, fallback값을 각각 지정하지 않고 배열을 return하는대요.
rewrites
에 beforeFiles, afterFiles, fallback를 각각 입력하지 않고 배열을 return 하는 경우에는
if (
!Array.isArray(_rewrites) &&
typeof _rewrites === 'object' &&
Object.keys(_rewrites).every(
(key) =>
key === 'beforeFiles' || key === 'afterFiles' || key === 'fallback'
)
) {
beforeFiles = _rewrites.beforeFiles || []
afterFiles = _rewrites.afterFiles || []
fallback = _rewrites.fallback || []
} else {
afterFiles = _rewrites as any
}
afterFiles로 병합됩니다.
자 그러면 options.rewrites.afterFiles.map(processRoute)
이 코드가 실행되는데
processRoute
이 함수는
export const processRoute = (r: Rewrite) => {
const rewrite = { ...r }
// omit external rewrite destinations since these aren't
// handled client-side
if (!rewrite?.destination?.startsWith('/')) {
delete (rewrite as any).destination
}
return rewrite
}
여기서 확인할 수 있네요. 클라이언트 측에서는 외부 URL을 rewrite 하는 작업을 수행하지 않기 때문에 destination 값이 /
로 시작하지 않는다면 없애버리는 모습입니다.
요약
- router.replace는 특정 조건에서 히스토리가 쌓인다.
- 여러 조건이 있지만 현재 케이스에서는
rewritesResult.externalDest
이true
일 때 실행된다. rewritesResult.externalDest
이 값은 rewrite의 destination 값이 없으면 발생하는데- rewrite의 destination 값은
self.__BUILD_MANIFEST_CB
값을 가져오고 self.__BUILD_MANIFEST_CB
은 .next/static/development/_buildManifest.js에서 넣어준다._buildManifest.js
는 webpack의BuildManifestPlugin
이 만들어준다.BuildManifestPlugin
클래스는 constructor에서processRoute
함수를 통해/
로 시작하지 않는destination
를 지운다.
혹시 잘못된 정보가 있다면 알려주세요 :)
'frontend' 카테고리의 다른 글
프론트엔드 SEO 구글 검색 엔진과 최적화 (0) | 2024.06.20 |
---|---|
(React 19 + Next js 15 + Panda Css) 2. SVGR 적용 (1) | 2024.06.15 |
브라우저의 동작 원리 (0) | 2024.06.11 |
(React 19 + Next js 15 + Panda Css) 1. 프로젝트 세팅 (1) | 2024.06.11 |
웹 어셈블리 찍먹 ! (0) | 2024.05.23 |