support next/head

This commit is contained in:
nkzawa 2016-10-07 10:57:31 +09:00
parent 9150521c55
commit 89f96cc160
8 changed files with 268 additions and 7 deletions

View file

@ -3,13 +3,15 @@ import ReactDOM from 'react-dom'
import App from '../lib/app'
import Link from '../lib/link'
import Css from '../lib/css'
import Head from '../lib/head'
const modules = new Map([
['react', React],
['react-dom', ReactDOM],
['next/app', App],
['next/link', Link],
['next/css', Css]
['next/css', Css],
['next/head', Head]
])
/**

69
client/head-manager.js Normal file
View file

@ -0,0 +1,69 @@
import HTMLDOMPropertyConfig from 'react/lib/HTMLDOMPropertyConfig'
const DEFAULT_TITLE = ''
export default class HeadManager {
updateHead (head) {
const tags = {}
head.forEach((h) => {
const components = tags[h.type] || []
components.push(h)
tags[h.type] = components
})
this.updateTitle(tags.title ? tags.title[0] : null)
const types = ['meta', 'base', 'link', 'style', 'script']
types.forEach((type) => {
this.updateElements(type, tags[type] || [])
})
}
updateTitle (component) {
let title
if (component) {
const { children } = component.props
title = 'string' === typeof children ? children : children.join('')
} else {
title = DEFAULT_TITLE
}
if (title !== document.title) document.title = title
}
updateElements (type, components) {
const headEl = document.getElementsByTagName('head')[0]
const oldTags = Array.prototype.slice.call(headEl.querySelectorAll(type + '.next-head'))
const newTags = components.map(reactElementToDOM).filter((newTag) => {
for (let i = 0, len = oldTags.length; i < len; i++) {
const oldTag = oldTags[i]
if (oldTag.isEqualNode(newTag)) {
oldTags.splice(i, 1)
return false
}
}
return true
})
oldTags.forEach((t) => t.parentNode.removeChild(t))
newTags.forEach((t) => headEl.appendChild(t))
}
}
function reactElementToDOM ({ type, props }) {
const el = document.createElement(type)
for (const p in props) {
if (!props.hasOwnProperty(p)) continue
if ('children' === p || 'dangerouslySetInnerHTML' === p) continue
const attr = HTMLDOMPropertyConfig.DOMAttributeNames[p] || p.toLowerCase()
el.setAttribute(attr, props[p])
}
const { children, dangerouslySetInnerHTML } = props
if (dangerouslySetInnerHTML) {
el.innerHTML = dangerouslySetInnerHTML.__html || ''
} else if (children) {
el.textContent = 'string' === typeof children ? children : children.join('')
}
return el
}

View file

@ -2,6 +2,7 @@ import { createElement } from 'react'
import { render } from 'react-dom'
import evalScript from './eval-script'
import Router from './router'
import HeadManager from './head-manager'
import DefaultApp from '../lib/app'
const {
@ -12,7 +13,8 @@ const App = app ? evalScript(app).default : DefaultApp
const Component = evalScript(component).default
const router = new Router({ Component, props })
const headManager = new HeadManager()
const container = document.getElementById('__next')
const appProps = { Component, props, router }
const appProps = { Component, props, router, headManager }
render(createElement(App, { ...appProps }), container)

82
lib/head.js Normal file
View file

@ -0,0 +1,82 @@
import React from 'react'
import sideEffect from './side-effect'
class Head extends React.Component {
static contextTypes = {
headManager: React.PropTypes.object
}
render () {
return null
}
componentWillUnmount () {
this.context.headManager.updateHead([])
}
}
function reduceComponents (components) {
return components
.map((c) => c.props.children)
.filter((c) => !!c)
.map((children) => React.Children.toArray(children))
.reduce((a, b) => a.concat(b), [])
.reverse()
.filter(unique())
.reverse()
.map((c) => {
const className = (c.className ? c.className + ' ' : '') + 'next-head'
return React.cloneElement(c, { className })
})
}
function mapOnServer (head) {
return head
}
function onStateChange (head) {
if (this.context && this.context.headManager) {
this.context.headManager.updateHead(head)
}
}
const METATYPES = ['name', 'httpEquiv', 'charSet', 'itemProp']
// returns a function for filtering head child elements
// which shouldn't be duplicated, like <title/>.
function unique () {
const tags = new Set()
const metaTypes = new Set()
const metaCategories = {}
return (h) => {
switch (h.type) {
case 'title':
case 'base':
if (tags.has(h.type)) return false
tags.add(h.type)
break
case 'meta':
for (let i = 0, len = METATYPES.length; i < len; i++) {
const metatype = METATYPES[i]
if (!h.props.hasOwnProperty(metatype)) continue
if ('charSet' === metatype) {
if (metaTypes.has(metatype)) return false
metaTypes.add(metatype)
} else {
const category = h.props[metatype]
const categories = metaCategories[metatype] || new Set()
if (categories.has(category)) return false
categories.add(category)
metaCategories[metatype] = categories
}
}
break
}
return true
}
}
export default sideEffect(reduceComponents, onStateChange, mapOnServer)(Head)

101
lib/side-effect.js Normal file
View file

@ -0,0 +1,101 @@
import React, { Component } from 'react'
export default function withSideEffect (reduceComponentsToState, handleStateChangeOnClient, mapStateOnServer) {
if (typeof reduceComponentsToState !== 'function') {
throw new Error('Expected reduceComponentsToState to be a function.')
}
if (typeof handleStateChangeOnClient !== 'function') {
throw new Error('Expected handleStateChangeOnClient to be a function.')
}
if (typeof mapStateOnServer !== 'undefined' && typeof mapStateOnServer !== 'function') {
throw new Error('Expected mapStateOnServer to either be undefined or a function.')
}
function getDisplayName (WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component'
}
return function wrap (WrappedComponent) {
if (typeof WrappedComponent !== 'function') {
throw new Error('Expected WrappedComponent to be a React component.')
}
const mountedInstances = new Set()
let state
let shouldEmitChange = false
function emitChange (component) {
state = reduceComponentsToState([...mountedInstances])
if (SideEffect.canUseDOM) {
handleStateChangeOnClient.call(component, state)
} else if (mapStateOnServer) {
state = mapStateOnServer(state)
}
}
function maybeEmitChange (component) {
if (!shouldEmitChange) return
shouldEmitChange = false
emitChange(component)
}
class SideEffect extends Component {
// Try to use displayName of wrapped component
static displayName = `SideEffect(${getDisplayName(WrappedComponent)})`
static contextTypes = WrappedComponent.contextTypes
// Expose canUseDOM so tests can monkeypatch it
static canUseDOM = 'undefined' !== typeof window
static peek () {
return state
}
static rewind () {
if (SideEffect.canUseDOM) {
throw new Error('You may only call rewind() on the server. Call peek() to read the current state.')
}
maybeEmitChange()
const recordedState = state
state = undefined
mountedInstances.clear()
return recordedState
}
componentWillMount () {
mountedInstances.add(this)
shouldEmitChange = true
}
componentDidMount () {
maybeEmitChange(this)
}
componentWillUpdate () {
shouldEmitChange = true
}
componentDidUpdate () {
maybeEmitChange(this)
}
componentWillUnmount () {
mountedInstances.delete(this)
shouldEmitChange = false
emitChange(this)
}
render () {
return <WrappedComponent>{ this.props.children }</WrappedComponent>
}
}
return SideEffect
}
}

View file

@ -15,7 +15,8 @@ export default function bundle (src, dst) {
{
[require.resolve('react')]: 'react',
[require.resolve('../../lib/link')]: 'next/link',
[require.resolve('../../lib/css')]: 'next/css'
[require.resolve('../../lib/css')]: 'next/css',
[require.resolve('../../lib/head')]: 'next/head'
}
],
resolveLoader: {

View file

@ -26,7 +26,8 @@ const babelOptions = {
{ src: `npm:${babelRuntimePath}`, expose: 'babel-runtime' },
{ src: `npm:${require.resolve('react')}`, expose: 'react' },
{ src: `npm:${require.resolve('../../lib/link')}`, expose: 'next/link' },
{ src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' }
{ src: `npm:${require.resolve('../../lib/css')}`, expose: 'next/css' },
{ src: `npm:${require.resolve('../../lib/head')}`, expose: 'next/head' }
]
]
],

View file

@ -3,6 +3,7 @@ import { createElement } from 'react'
import { renderToString, renderToStaticMarkup } from 'react-dom/server'
import fs from 'mz/fs'
import Document from '../lib/document'
import Head from '../lib/head'
import App from '../lib/app'
import { StyleSheetServer } from '../lib/css'
@ -28,10 +29,12 @@ export async function render (path, req, res, { dir = process.cwd(), dev = false
return renderToString(app)
})
const head = Head.rewind() || []
const doc = createElement(Document, {
head: [],
html: html,
css: css,
html,
head,
css,
data: { component },
hotReload: false,
dev