router.js 6.5 KB
Newer Older
N
nkzawa 已提交
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259
import { parse } from 'url'
import evalScript from './eval-script'
import shallowEquals from './shallow-equals'

export default class Router {
  constructor (initialData) {
    this.subscriptions = []

    const { Component } = initialData
    const { pathname } = location
    const route = toRoute(pathname)

    this.currentRoute = route
    this.currentComponent = Component.displayName
    this.currentComponentData = initialData

    // set up the component cache (by route keys)
    this.components = { [route]: initialData }

    // in order for `e.state` to work on the `onpopstate` event
    // we have to register the initial route upon initialization
    const url = pathname + (location.search || '') + (location.hash || '')
    this.replace(Component, url)

    this.onPopState = this.onPopState.bind(this)
    window.addEventListener('unload', () => {})
    window.addEventListener('popstate', this.onPopState)
  }

  onPopState (e) {
    this.abortComponentLoad()
    const cur = this.currentComponent
    const pathname = location.pathname
    const url = pathname + (location.search || '') + (location.hash || '')
    const { fromComponent, route } = e.state || {}
    if (fromComponent && cur && fromComponent === cur) {
      // if the component has not changed due
      // to the url change, it means we only
      // need to notify the subscriber about
      // the URL change
      this.set(url)
    } else {
      this.fetchComponent(route || url, (err, data) => {
        if (err) {
          // the only way we can appropriately handle
          // this failure is deferring to the browser
          // since the URL has already changed
          location.reload()
        } else {
          this.currentRoute = route || toRoute(pathname)
          this.currentComponent = data.Component.displayName
          this.currentComponentData = data
          this.set(url)
        }
      })
    }
  }

  update (route, data) {
    data.Component = evalScript(data.component).default
    delete data.component
    this.components[route] = data
    if (route === this.currentRoute) {
      let cancelled = false
      const cancel = () => { cancelled = true }
      this.componentLoadCancel = cancel
      getInitialProps(data, (err, dataWithProps) => {
        if (cancel === this.componentLoadCancel) {
          this.componentLoadCancel = false
        }
        if (cancelled) return
        if (err) throw err
        this.currentComponentData = dataWithProps
        this.notify()
      })
    }
  }

  goTo (url, fn) {
    this.change('pushState', null, url, fn)
  }

  back () {
    history.back()
  }

  push (fromComponent, url, fn) {
    this.change('pushState', fromComponent, url, fn)
  }

  replace (fromComponent, url, fn) {
    this.change('replaceState', fromComponent, url, fn)
  }

  change (method, component, url, fn) {
    this.abortComponentLoad()

    const set = (name) => {
      this.currentComponent = name
      const state = name
        ? { fromComponent: name, route: this.currentRoute }
        : {}
      history[method](state, null, url)
      this.set(url)
      if (fn) fn(null)
    }

    const componentName = component && component.displayName
    if (component && !componentName) {
      throw new Error('Initial component must have a unique `displayName`')
    }

    if (this.currentComponent &&
        componentName !== this.currentComponent) {
      this.fetchComponent(url, (err, data) => {
        if (!err) {
          this.currentRoute = toRoute(url)
          this.currentComponentData = data
          set(data.Component.displayName)
        }
        if (fn) fn(err, data)
      })
    } else {
      set(componentName)
    }
  }

  set (url) {
    const parsed = parse(url, true)
    if (this.urlIsNew(parsed)) {
      this.pathname = parsed.pathname
      this.query = parsed.query
      this.notify()
    }
  }

  urlIsNew ({ pathname, query }) {
    return this.pathname !== pathname || !shallowEquals(query, this.query)
  }

  fetchComponent (url, fn) {
    const pathname = parse(url, true)
    const route = toRoute(pathname)

    let cancelled = false
    let componentXHR = null
    const cancel = () => {
      cancelled = true

      if (componentXHR && componentXHR.abort) {
        componentXHR.abort()
      }
    }

    if (this.components[route]) {
      const data = this.components[route]
      getInitialProps(data, (err, dataWithProps) => {
        if (cancel === this.componentLoadCancel) {
          this.componentLoadCancel = false
        }
        if (cancelled) return
        fn(err, dataWithProps)
      })
      this.componentLoadCancel = cancel
      return
    }

    const componentUrl = toJSONUrl(route)

    componentXHR = loadComponent(componentUrl, (err, data) => {
      if (cancel === this.componentLoadCancel) {
        this.componentLoadCancel = false
      }
      if (err) {
        if (!cancelled) fn(err)
      } else {
        // we update the cache even if cancelled
        if (!this.components[route]) {
          this.components[route] = data
        }
        if (!cancelled) fn(null, data)
      }
    })

    this.componentLoadCancel = cancel
  }

  abortComponentLoad () {
    if (this.componentLoadCancel) {
      this.componentLoadCancel()
      this.componentLoadCancel = null
    }
  }

  notify () {
    this.subscriptions.forEach(fn => fn())
  }

  subscribe (fn) {
    this.subscriptions.push(fn)
    return () => {
      const i = this.subscriptions.indexOf(fn)
      if (~i) this.subscriptions.splice(i, 1)
    }
  }
}

// every route finishing in `/test/` becomes `/test`

export function toRoute (path) {
  return path.replace(/\/$/, '') || '/'
}

export function toJSONUrl (route) {
  return ('/' === route ? '/index' : route) + '.json'
}

export function loadComponent (url, fn) {
  return loadJSON(url, (err, data) => {
    if (err && fn) fn(err)
    const { component, props } = data
    const Component = evalScript(component).default
    getInitialProps({ Component, props }, fn)
  })
}

function loadJSON (url, fn) {
  const xhr = new XMLHttpRequest()
  xhr.onload = () => {
    let data

    try {
      data = JSON.parse(xhr.responseText)
    } catch (err) {
      fn(new Error('Failed to load JSON for ' + url))
      return
    }

    fn(null, data)
  }
  xhr.onerror = () => {
    if (fn) fn(new Error('XHR failed. Status: ' + xhr.status))
  }
  xhr.open('GET', url)
  xhr.send()

  return xhr
}

function getInitialProps (data, fn) {
  const { Component: { getInitialProps } } = data
  if (getInitialProps) {
    Promise.resolve(getInitialProps({}))
    .then((props) => fn(null, { ...data, props }))
    .catch(fn)
  } else {
    fn(null, data)
  }
}