Next Script 컴포넌트 코드 읽어보기

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>
  )
  
  // ...
}

 

 

마치며

 

다른 오픈소스의 코드를 소개하는 글처럼 논리정연한 글을 작성하고 싶었지만 얼렁뚱땅 넘어간 부분도 많고 내 스스로도 이해하지 못한 부분이 있는듯하다. 하지만 공식문서에서 이해가 가지 않던 부분에 대해선 내 나름대로 이해가 갔기에 이 정도로 마무리해보려고 한다.