未验证 提交 c8cd77a8 编写于 作者: J Janicklas Ralph 提交者: GitHub

Script loader component (#18281)

上级 6e4632ef
......@@ -367,6 +367,9 @@ export default async function getBaseWebpackConfig(
// Which makes bundles slightly smaller, but also skips parsing a module that we know will result in this alias
'next/head': 'next/dist/next-server/lib/head.js',
'next/router': 'next/dist/client/router.js',
'next/experimental-script': config.experimental.scriptLoader
? 'next/dist/client/experimental-script.js'
: '',
'next/config': 'next/dist/next-server/lib/runtime-config.js',
'next/dynamic': 'next/dist/next-server/lib/dynamic.js',
next: NEXT_PROJECT_ROOT,
......
import React, { useEffect, useContext } from 'react'
import { ScriptHTMLAttributes } from 'react'
import { HeadManagerContext } from '../next-server/lib/head-manager-context'
import { DOMAttributeNames } from './head-manager'
import requestIdleCallback from './request-idle-callback'
const ScriptCache = new Map()
const LoadCache = new Set()
interface Props extends ScriptHTMLAttributes<HTMLScriptElement> {
strategy?: 'defer' | 'lazy' | 'dangerouslyBlockRendering' | 'eager'
id?: string
onLoad?: () => void
onError?: () => void
children?: React.ReactNode
preload?: boolean
}
const loadScript = (props: Props) => {
const {
src = '',
onLoad = () => {},
dangerouslySetInnerHTML,
children = '',
id,
onError,
} = props
const cacheKey = id || src
if (ScriptCache.has(src)) {
if (!LoadCache.has(cacheKey)) {
LoadCache.add(cacheKey)
// Execute onLoad since the script loading has begun
ScriptCache.get(src).then(onLoad, onError)
}
return
}
const el = document.createElement('script')
const loadPromise = new Promise((resolve, reject) => {
el.addEventListener('load', function () {
resolve()
if (onLoad) {
onLoad.call(this)
}
})
el.addEventListener('error', function () {
reject()
if (onError) {
onError()
}
})
})
if (src) {
ScriptCache.set(src, loadPromise)
LoadCache.add(cacheKey)
}
if (dangerouslySetInnerHTML) {
el.innerHTML = dangerouslySetInnerHTML.__html || ''
} else if (children) {
el.textContent =
typeof children === 'string'
? children
: Array.isArray(children)
? children.join('')
: ''
} else if (src) {
el.src = src
}
for (const [k, value] of Object.entries(props)) {
if (value === undefined) {
continue
}
const attr = DOMAttributeNames[k] || k.toLowerCase()
el.setAttribute(attr, value)
}
document.body.appendChild(el)
}
export default function Script(props: Props) {
const {
src = '',
onLoad = () => {},
dangerouslySetInnerHTML,
children = '',
strategy = 'defer',
onError,
preload = false,
...restProps
} = props
// Context is available only during SSR
const { updateScripts, scripts } = useContext(HeadManagerContext)
useEffect(() => {
if (strategy === 'defer') {
loadScript(props)
} else if (strategy === 'lazy') {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
}
}, [strategy, props])
if (strategy === 'dangerouslyBlockRendering') {
const syncProps: Props = { ...restProps }
for (const [k, value] of Object.entries({
src,
onLoad,
onError,
dangerouslySetInnerHTML,
children,
})) {
if (!value) {
continue
}
if (k === 'children') {
syncProps.dangerouslySetInnerHTML = {
__html:
typeof value === 'string'
? value
: Array.isArray(value)
? value.join('')
: '',
}
} else {
;(syncProps as any)[k] = value
}
}
return <script {...syncProps} />
} else if (strategy === 'defer') {
if (updateScripts && preload) {
scripts.defer = (scripts.defer || []).concat([src])
updateScripts(scripts)
}
} else if (strategy === 'eager') {
if (updateScripts) {
scripts.eager = (scripts.eager || []).concat([
{
src,
onLoad,
onError,
...restProps,
},
])
updateScripts(scripts)
}
}
return null
}
const DOMAttributeNames: Record<string, string> = {
export const DOMAttributeNames: Record<string, string> = {
acceptCharset: 'accept-charset',
className: 'class',
htmlFor: 'for',
......
export * from './dist/client/experimental-script'
export { default } from './dist/client/experimental-script'
module.exports = require('./dist/client/experimental-script')
......@@ -297,9 +297,9 @@ export default async function exportPage({
} else {
/**
* This sets environment variable to be used at the time of static export by head.tsx.
* Using this from process.env allows targetting both serverless and SSR by calling
* Using this from process.env allows targeting both serverless and SSR by calling
* `process.env.__NEXT_OPTIMIZE_FONTS`.
* TODO(prateekbh@): Remove this when experimental.optimizeFonts are being clened up.
* TODO(prateekbh@): Remove this when experimental.optimizeFonts are being cleaned up.
*/
if (optimizeFonts) {
process.env.__NEXT_OPTIMIZE_FONTS = JSON.stringify(true)
......
......@@ -3,6 +3,8 @@ import React from 'react'
export const HeadManagerContext: React.Context<{
updateHead?: (state: any) => void
mountedInstances?: any
updateScripts?: (state: any) => void
scripts?: any
}> = React.createContext({})
if (process.env.NODE_ENV !== 'production') {
......
......@@ -186,6 +186,7 @@ export type DocumentProps = DocumentInitialProps & {
headTags: any[]
unstable_runtimeJS?: false
devOnlyCacheBusterQueryString: string
scriptLoader: { defer?: string[]; eager?: any[] }
locale?: string
}
......
......@@ -57,6 +57,7 @@ const defaultConfig: { [key: string]: any } = {
optimizeImages: false,
optimizeCss: false,
scrollRestoration: false,
scriptLoader: false,
},
future: {
excludeDefaultMomentLocales: false,
......
......@@ -210,6 +210,7 @@ function renderDocument(
appGip,
unstable_runtimeJS,
devOnlyCacheBusterQueryString,
scriptLoader,
locale,
locales,
defaultLocale,
......@@ -234,6 +235,7 @@ function renderDocument(
gip?: boolean
appGip?: boolean
devOnlyCacheBusterQueryString: string
scriptLoader: any
}
): string {
return (
......@@ -276,6 +278,7 @@ function renderDocument(
headTags,
unstable_runtimeJS,
devOnlyCacheBusterQueryString,
scriptLoader,
locale,
...docProps,
})}
......@@ -557,6 +560,8 @@ export async function renderToHTML(
let head: JSX.Element[] = defaultHead(inAmpMode)
let scriptLoader: any = {}
const AppContainer = ({ children }: any) => (
<RouterContext.Provider value={router}>
<AmpStateContext.Provider value={ampState}>
......@@ -565,6 +570,10 @@ export async function renderToHTML(
updateHead: (state) => {
head = state
},
updateScripts: (scripts) => {
scriptLoader = scripts
},
scripts: {},
mountedInstances: new Set(),
}}
>
......@@ -1000,6 +1009,7 @@ export async function renderToHTML(
gip: hasPageGetInitialProps ? true : undefined,
appGip: !defaultAppGetInitialProps ? true : undefined,
devOnlyCacheBusterQueryString,
scriptLoader,
})
if (process.env.NODE_ENV !== 'production') {
......
......@@ -256,27 +256,55 @@ export class Head extends Component<
}
getPreloadMainLinks(files: DocumentFiles): JSX.Element[] | null {
const { assetPrefix, devOnlyCacheBusterQueryString } = this.context
const {
assetPrefix,
devOnlyCacheBusterQueryString,
scriptLoader,
} = this.context
const preloadFiles = files.allFiles.filter((file: string) => {
return file.endsWith('.js')
})
return !preloadFiles.length
? null
: preloadFiles.map((file: string) => (
<link
key={file}
nonce={this.props.nonce}
rel="preload"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
as="script"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
))
return [
...(scriptLoader.eager || []).map((file) => (
<link
key={file.src}
nonce={this.props.nonce}
rel="preload"
href={file.src}
as="script"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
)),
...preloadFiles.map((file: string) => (
<link
key={file}
nonce={this.props.nonce}
rel="preload"
href={`${assetPrefix}/_next/${encodeURI(
file
)}${devOnlyCacheBusterQueryString}`}
as="script"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
)),
...(scriptLoader.defer || []).map((file: string) => (
<link
key={file}
nonce={this.props.nonce}
rel="preload"
href={file}
as="script"
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
)),
]
}
makeStylesheetInert(node: ReactNode): ReactNode[] {
......@@ -583,6 +611,22 @@ export class NextScript extends Component<OriginProps> {
})
}
getPreNextScripts() {
const { scriptLoader } = this.context
return (scriptLoader.eager || []).map((file: string) => {
return (
<script
{...file}
nonce={this.props.nonce}
crossOrigin={
this.props.crossOrigin || process.env.__NEXT_CROSS_ORIGIN
}
/>
)
})
}
getScripts(files: DocumentFiles) {
const {
assetPrefix,
......@@ -750,6 +794,7 @@ export class NextScript extends Component<OriginProps> {
/>
)}
{!disableRuntimeJS && this.getPolyfillScripts()}
{!disableRuntimeJS && this.getPreNextScripts()}
{disableRuntimeJS ? null : this.getDynamicChunks(files)}
{disableRuntimeJS ? null : this.getScripts(files)}
</>
......
module.exports = {
experimental: { scriptLoader: true },
}
import '../styles/styles.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp
import * as React from 'react'
/// @ts-ignore
import Document, { Main, NextScript, Head } from 'next/document'
export default class MyDocument extends Document {
constructor(props) {
super(props)
const { __NEXT_DATA__, ids } = props
if (ids) {
__NEXT_DATA__.ids = ids
}
}
render() {
return (
<html>
<Head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Voces"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</html>
)
}
}
import Script from 'next/experimental-script'
const Page = () => {
return (
<div class="container">
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=defer"
preload
></Script>
<div>index</div>
</div>
)
}
export default Page
import Script from 'next/experimental-script'
const Page = () => {
return (
<div class="container">
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=eager"
strategy="eager"
></Script>
<div>page1</div>
</div>
)
}
export default Page
import Script from 'next/experimental-script'
const Page = () => {
return (
<div class="container">
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=dangerouslyBlockRendering"
strategy="dangerouslyBlockRendering"
></Script>
<div>page2</div>
</div>
)
}
export default Page
import Script from 'next/experimental-script'
const Page = () => {
return (
<div class="container">
<Script>
{`(window.onload = function () {
const newDiv = document.createElement('div')
newDiv.id = 'onload-div'
document.querySelector('.container').appendChild(newDiv)
})`}
</Script>
<Script
id="script"
src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js?a=lazy"
strategy="lazy"
></Script>
<div>page3</div>
</div>
)
}
export default Page
import Script from 'next/experimental-script'
const url =
'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js'
const Page = () => {
return (
<div class="container">
<Script
src={url}
id="script1"
onLoad={() => {
// eslint-disable-next-line no-undef
document.getElementById('text').textContent += _.repeat('a', 3)
}}
></Script>
<Script
src={url}
id="script2"
onLoad={() => {
// eslint-disable-next-line no-undef
document.getElementById('text').textContent += _.repeat('b', 3)
}}
></Script>
<Script
src={url}
id="script3"
onLoad={() => {
// eslint-disable-next-line no-undef
document.getElementById('text').textContent += _.repeat('c', 3)
}}
></Script>
<div id="text"></div>
</div>
)
}
export default Page
body {
font-family: 'Arial', sans-serif;
padding: 20px 20px 60px;
max-width: 680px;
margin: 0 auto;
}
/* eslint-env jest */
import { join } from 'path'
import {
renderViaHTTP,
nextServer,
startApp,
stopApp,
nextBuild,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import cheerio from 'cheerio'
jest.setTimeout(1000 * 60 * 5)
let appDir = join(__dirname, '..')
let server
let appPort
describe('Script Loader', () => {
beforeAll(async () => {
await nextBuild(appDir)
const app = nextServer({
dir: appDir,
dev: false,
quiet: true,
})
server = await startApp(app)
appPort = server.address().port
})
afterAll(() => {
stopApp(server)
})
it('priority defer', async () => {
let browser
try {
browser = await webdriver(appPort, '/')
await waitFor(1000)
const script = await browser.elementById('script')
const src = await script.getAttribute('src')
const scriptPreload = await browser.elementsByCss(
`link[rel=preload][href="${src}"]`
)
const endScripts = await browser.elementsByCss(
'#script ~ script[src^="/_next/static/"]'
)
const endPreloads = await browser.elementsByCss(
`link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]`
)
// Renders script tag
expect(script).toBeDefined()
// Renders preload
expect(scriptPreload.length).toBeGreaterThan(0)
// Script is inserted at the end
expect(endScripts.length).toBe(0)
//Preload is defined at the end
expect(endPreloads.length).toBe(0)
} finally {
if (browser) await browser.close()
}
})
it('priority lazy', async () => {
let browser
try {
browser = await webdriver(appPort, '/page3')
await browser.waitForElementByCss('#onload-div')
await waitFor(1000)
const script = await browser.elementById('script')
const endScripts = await browser.elementsByCss(
'#script ~ script[src^="/_next/static/"]'
)
// Renders script tag
expect(script).toBeDefined()
// Script is inserted at the end
expect(endScripts.length).toBe(0)
} finally {
if (browser) await browser.close()
}
})
it('priority eager', async () => {
const html = await renderViaHTTP(appPort, '/page1')
const $ = cheerio.load(html)
const script = $('#script')
const src = script.attr('src')
// Renders script tag
expect(script).toBeDefined()
// Preload is inserted at the beginning
expect(
$(
`link[rel=preload][href="${src}"] ~ link[rel=preload][href^="/_next/static/"]`
).length &&
!$(
`link[rel=preload][href^="/_next/static/chunks/main"] ~ link[rel=preload][href="${src}"]`
).length
).toBeTruthy()
// Preload is inserted after fonts and CSS
expect(
$(
`link[rel=stylesheet][href^="/_next/static/css"] ~ link[rel=preload][href="${src}"]`
).length
).toBeGreaterThan(0)
expect(
$(
`link[rel=stylesheet][href="https://fonts.googleapis.com/css?family=Voces"] ~ link[rel=preload][href="${src}"]`
).length
).toBeGreaterThan(0)
// Script is inserted before NextScripts
expect(
$('#__NEXT_DATA__ ~ #script ~ script[src^="/_next/static/chunks/main"]')
.length
).toBeGreaterThan(0)
})
it('priority dangerouslyBlockRendering', async () => {
const html = await renderViaHTTP(appPort, '/page2')
const $ = cheerio.load(html)
// Script is inserted in place
expect($('.container #script').length).toBeGreaterThan(0)
})
it('onloads fire correctly', async () => {
let browser
try {
browser = await webdriver(appPort, '/page4')
await waitFor(3000)
const text = await browser.elementById('text').text()
expect(text).toBe('aaabbbccc')
} finally {
if (browser) await browser.close()
}
})
})
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册