2024. 5. 20. 02:31ㆍ카테고리 없음
들어가며
Next 공식문서에 Script태그를 보면 다음과 같은 설명이 나와있다.
- beforeInteractive: Load before any Next.js code and before any page hydration occurs.
- afterInteractive: (default) Load early but after some hydration on the page occurs.
- lazyOnload: Load during browser idle time.
하지만 자꾸 제대로 이해했다는 느낌이 들지 않아서 직접 Next의 깃헙을 들어가 코드를 까봤다. 매번 라이브러리/프레임워크의 코드를 까보려고 시도할 때마다 실패했지만, 언젠간 늘겠지 하는 마음이다.
afterInteractive, lazyOnload
// client/script.tsx
function Script(props: ScriptProps): JSX.Element | null {
const {
id,
src = '',
onLoad = () => {},
onReady = null,
strategy = 'afterInteractive',
onError,
stylesheets,
...restProps
} = props
// ...
const hasLoadScriptEffectCalled = useRef(false)
useEffect(() => {
if (!hasLoadScriptEffectCalled.current) {
if (strategy === 'afterInteractive') {
loadScript(props)
} else if (strategy === 'lazyOnload') {
loadLazyScript(props)
}
hasLoadScriptEffectCalled.current = true
}
}, [props, strategy])
// ....
return null
}
매우 간단한 코드다. useEffect에서 strategy가 afterInteractive일 경우 loadScript를 실행하고, lazyOnload일 경우 loadLazyScript를 실행한다.
이해를 위해 lazyOnload부터 살펴보자.
function loadLazyScript(props: ScriptProps) {
if (document.readyState === 'complete') {
requestIdleCallback(() => loadScript(props))
} else {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
}
}
이 역시도 간단한 코드인데 document.readystate가 complete이면 즉 document가 모두 로딩이 됐으면 requestIdleCallback으로 loadScript를 호출한다. (위에서 본 loadScript와 동일하다.) 아직 로딩이 다 안됐다면 로드가 완료되면 동일한 동작을 수행한다.
이제 loadScript만 이해하면 된다.
const loadScript = (props: ScriptProps): void => {
const {
src,
id,
onLoad = () => {},
onReady = null,
dangerouslySetInnerHTML,
children = '',
strategy = 'afterInteractive',
onError,
stylesheets,
} = props
// 5. LoadCache는 로드가 완료된 Script를 관리
if (cacheKey && LoadCache.has(cacheKey)) {
return
}
// 4. ScriptCache는 처리중인 Script를 관리
if (ScriptCache.has(src)) {
LoadCache.add(cacheKey)
ScriptCache.get(src).then(onLoad, onError)
return
}
const afterLoad = () => {
if (onReady) {
onReady()
}
LoadCache.add(cacheKey)
}
const el = document.createElement('script')
const loadPromise = new Promise<void>((resolve, reject) => {
// 3. 로드가 완료되면
el.addEventListener('load', function (e) {
resolve()
if (onLoad) {
onLoad.call(this, e)
}
afterLoad()
})
el.addEventListener('error', function (e) {
reject(e)
})
}).catch(function (e) {
if (onError) {
onError(e)
}
})
// 1. ScriptCache에 프로미스 추가
if (dangerouslySetInnerHTML) {
// ..
} else if (children) {
// ...
} else if (src) {
el.src = src
ScriptCache.set(src, loadPromise)
}
// ...
// 2. body 맨밑에 script태그 추가
document.body.appendChild(el)
}
1. ScriptCache에 loadPromise를 추가한다.
2. body 맨밑에 script 태그를 추가한다. 이 때 script가 최종적으로 로드가 완료되면..
3. onLoad를 호출하고 afterLoad를 통해 onReady를 호출하고 LoadCache에 추가하게 된다.
4. 만약 같은 src (cacheKey)를 가졌지만 다른 onLoad를 가진 Script 태그의 onLoad가 실행을 보장해준다. (여기서 왜 LoadCache에 추가해주는진 모르겠다. 3개의 Script가 같은 src를 가졌는데 다 onLoad가 다르면 3번째 Script는 실행안되는거 아닌가?)
5. 이미 로드가 완료된 스크립트라면 아예 실행을 해주지 않는다.
여기서 onLoad는 스크립트가 로드된 후 딱 한번만 실행되는 콜백이다. 반면 onReady는 컴포넌트가 다시 마운트될때마다 호출을 해주어야 한다. 하지만 위의 코드만으로는 이 설명이 이해가 가지 않는다.
맨처음 소개한 Script 컴포넌트 코드에서 빼먹은 코드가 있다. 다시 살펴보자.
// client/script.tsx
function Script(props: ScriptProps): JSX.Element | null {
const {
id,
src = '',
onLoad = () => {},
onReady = null,
strategy = 'afterInteractive',
onError,
stylesheets,
...restProps
} = props
// 이 이펙트가 생략돼있었다.
const hasOnReadyEffectCalled = useRef(false)
useEffect(() => {
const cacheKey = id || src
if (!hasOnReadyEffectCalled.current) {
if (onReady && cacheKey && LoadCache.has(cacheKey)) {
onReady()
}
hasOnReadyEffectCalled.current = true
}
}, [onReady, id, src])
const hasLoadScriptEffectCalled = useRef(false)
useEffect(() => {
if (!hasLoadScriptEffectCalled.current) {
if (strategy === 'afterInteractive') {
loadScript(props)
} else if (strategy === 'lazyOnload') {
loadLazyScript(props)
}
hasLoadScriptEffectCalled.current = true
}
}, [props, strategy])
// ....
return null
}
주석 처리한 부분의 useEffect를 활용해 스크립트의 로드가 완료가 돼있으면 매번 onReady를 실행시킬 수 있다.
beforeInteractive
추가적으로 beforeInteractive는 만약 ssr에서 실행이 됐다면 LoadCache에 등록을 한다. 만약 아니라면 useEffect를 거치지 않고 (어떠한 hydration 이전에 실행되기 위해) loadScript함수를 통해 실행된다.
function Script(props: ScriptProps): JSX.Element | null {
const {
id,
src = '',
onLoad = () => {},
onReady = null,
strategy = 'afterInteractive',
onError,
stylesheets,
...restProps
} = props
// ...
if (strategy === 'beforeInteractive' || strategy === 'worker') {
if (updateScripts) {
scripts[strategy] = (scripts[strategy] || []).concat([
{
id,
src,
onLoad,
onReady,
onError,
...restProps,
},
])
updateScripts(scripts)
}
// 이 부분이다.
else if (getIsSsr && getIsSsr()) {
LoadCache.add(id || src)
} else if (getIsSsr && !getIsSsr()) {
loadScript(props)
}
}
return null
}
beforeInteractive같은 경우에 일반적으로 _documents에 두는 것이 권장된다. 이 부분은 어디에서 확인할 수 있을까? pages의 _document 파일에 가보면 실마리를 얻을 수 있다.
function handleDocumentScriptLoaderItems(
scriptLoader: { beforeInteractive?: any[] },
__NEXT_DATA__: NEXT_DATA,
props: any
): void {
// ...
// beforeInteractive Script만 모은다.
React.Children.forEach(combinedChildren, (child: any) => {
if (!child) return;
if (child.type?.__nextScript) {
if (child.props.strategy === 'beforeInteractive') {
scriptLoader.beforeInteractive = (
scriptLoader.beforeInteractive || []
).concat([{ ...child.props }]);
}
}
});
__NEXT_DATA__.scriptLoader = scriptLoader.beforeInteractive || [];
}
//////////////
export function Html() {
const {
inAmpMode,
docComponentsRendered,
locale,
scriptLoader,
__NEXT_DATA__,
} = useHtmlContext();
docComponentsRendered.Html = true;
// 여기서 해당 함수 호출
handleDocumentScriptLoaderItems(scriptLoader, __NEXT_DATA__, props);
return (
<html
// ...
/>
);
}
Html 컴포넌트에서 handleDocumentScriptLoaderItems 함수를 이용해 beforeInteractive Script들만을 모으고,
function getPreNextScripts(context: HtmlProps, props: OriginProps) {
const { scriptLoader, disableOptimizedLoading, crossOrigin } = context
// ....
const beforeInteractiveScripts = (scriptLoader.beforeInteractive || [])
.filter((script) => script.src)
.map((file: ScriptProps, index: number) => {
const { strategy, ...scriptProps } = file
return (
<script
{...scriptProps}
key={scriptProps.src || index}
defer={scriptProps.defer ?? !disableOptimizedLoading}
nonce={props.nonce}
data-nscript="beforeInteractive"
crossOrigin={props.crossOrigin || crossOrigin}
/>
)
})
return (
<>
// ...
{beforeInteractiveScripts}
</>
)
}
////
export class Head extends React.Component<HeadProps> {
static contextType = HtmlContext
context!: HtmlProps
getPreNextScripts() {
return getPreNextScripts(this.context, this.props)
}
render (
// ....
{!disableOptimizedLoading &&
!disableRuntimeJS &&
this.getPreNextScripts()}
// ....
)
}
Head 컴포넌트에서 getPreNextScripts()를 통해 beforeInteractive Script들을 script 태그로 전환을 해준다. 문득 context는 어디서 전해주는지 궁금했는데, server/render.tsx 파일에서 찾을 수 있었다. (이 부분은 정확하진 않다.)
// server/render.tsx
export async function renderToHTMLImpl{
// ...
const document = (
<HtmlContext.Provider value={htmlProps}>
{documentResult.documentElement(htmlProps)}
</HtmlContext.Provider>
)
// ...
}
마치며
다른 오픈소스의 코드를 소개하는 글처럼 논리정연한 글을 작성하고 싶었지만 얼렁뚱땅 넘어간 부분도 많고 내 스스로도 이해하지 못한 부분이 있는듯하다. 하지만 공식문서에서 이해가 가지 않던 부분에 대해선 내 나름대로 이해가 갔기에 이 정도로 마무리해보려고 한다.