koot
Version:
Koot.js - React isomorphic framework created by CMUX
435 lines (369 loc) • 18.6 kB
JavaScript
import React from 'react'
import HTMLTool from './HTMLTool'
import { renderToString } from 'react-dom/server'
import { createMemoryHistory, RouterContext, match } from 'react-router'
import { Provider } from 'react-redux'
import { syncHistoryWithStore } from 'react-router-redux'
import htmlInject from './inject'
import { localeId } from '../i18n'
import {
setStore,
setHistory,
setExtender,
setPageinfo,
// setFetchdata,
} from '../'
import componentExtender from '../React/component-extender'
import pageinfo from '../React/pageinfo'
import {
get as getStyles,
} from '../React/styles'
// import fetchdata from '../React/fetchdata'
import { changeLocaleQueryKey } from '../defaults/defines'
import { publicPathPrefix } from '../defaults/webpack-dev-server'
const path = require('path')
const defaultEntrypoints = require('../defaults/entrypoints')
const getChunkmap = require('../utils/get-chunkmap')
const getClientFilePath = require('../utils/get-client-file-path')
const readClientFile = require('../utils/read-client-file')
const getSWPathname = require('../utils/get-sw-pathname')
// const log = require('../libs/log')
const error = require('debug')('SYSTEM:isomorphic:error')
const injectOnceCache = {}
// 设置全局常量
setExtender(componentExtender)
setPageinfo(pageinfo)
// setFetchdata(fetchdata)
export default class ReactIsomorphic {
createKoaMiddleware(options = {
routes: [],
configStore: () => { },
onServerRender: () => { },
inject: { /*key: value*/ } // 在html中会这样替换 <script>inject_[key]</script> => value
}) {
/*
同构中间件流程:
根据router计算出渲染页面需要的数据,并把渲染需要的数据补充到store中
补充服务端提供的信息数据到store中
把同构时候服务端预处理数据补充到store中
把react部分渲染出html片段,并插入到html中
html 处理:
向html中注入引用文件链接
把同构时候服务端预处理数据补充到html中
调整样式位置,从下到上
*/
// 设置常量
const { template, onServerRender, inject, configStore, routes } = options
const ENV = process.env.WEBPACK_BUILD_ENV
// 配置 html 注入内容
// html [只更新1次]的部分(启动/重启服务器后不会更改的部分)
const injectOnce = {
// js: inject.js ? inject.js.map((js) => `<script src="${js}" defer></script>`).join('') : '', // 引用js文件标签
// css: inject.css ? inject.css.map((css) => `<link rel="stylesheet" href="${css}">`).join('') : '', // 引用css文件标签
}
// 处理 chunkmap
const chunkmap = getChunkmap(true)
let entrypoints = {}
let filemap = {}
// 分析当前 i18n 模式
const i18nEnabled = JSON.parse(process.env.KOOT_I18N)
const i18nLocales = i18nEnabled
? JSON.parse(process.env.KOOT_I18N_LOCALES)
: []
const i18nType = i18nEnabled
? JSON.parse(process.env.KOOT_I18N_TYPE)
: undefined
const isI18nDefault = (i18nType === 'default')
// 针对 i18n 分包形式的项目,单次注入按语言缓存
const assetsInjectOnce = !isI18nDefault
if (isI18nDefault) {
for (let l in chunkmap) {
const thisLocaleId = l.substr(0, 1) === '.' ? l.substr(1) : l
entrypoints[thisLocaleId] = chunkmap[l]['.entrypoints']
filemap[thisLocaleId] = chunkmap[l]['.files']
injectOnceCache[thisLocaleId] = {
pathnameSW: getSWPathname(thisLocaleId)
}
}
} else {
entrypoints = chunkmap['.entrypoints']
filemap = chunkmap['.files']
injectOnceCache.pathnameSW = getSWPathname()
}
// koa 中间件结构
// 每次请求时均会执行
return async (ctx, next) => {
// console.log(' ')
// console.log('ctx.url', ctx.url)
// console.log('ctx.originalUrl', ctx.originalUrl)
// console.log('ctx.origin', ctx.origin)
// console.log('ctx.href', ctx.href)
// console.log('ctx.path', ctx.path)
// console.log('ctx.querystring', ctx.querystring)
// console.log('ctx.search', ctx.search)
// console.log('ctx.hash', ctx.hash)
// console.log(' ')
const url = ctx.path + ctx.search
try {
// if (__DEV__) {
// console.log(' ')
// log('server', 'Server rendering...')
// }
const memoryHistory = createMemoryHistory(url)
const store = configStore()
const history = syncHistoryWithStore(memoryHistory, store)
// 根据router计算出渲染页面需要的数据,并把渲染需要的数据补充到store中
const { redirectLocation, renderProps } = await asyncReactRouterMatch({ history, routes, location: url })
// 判断是否重定向页面
if (redirectLocation) return ctx.redirect(redirectLocation.pathname + redirectLocation.search)
if (!renderProps) return await next()
// 设置常量
setStore(store)
setHistory(history)
// 补充服务端提供的信息数据到store中
if (typeof onServerRender === 'function')
await onServerRender({ ctx, store })
// 把同构时候服务端预处理数据补充到store中
await ServerRenderDataToStore({ store, renderProps, ctx })
// 把同构时候服务端预处理数据补充到html中(根据页面逻辑动态修改html内容)
const htmlTool = await ServerRenderHtmlExtend({ store, renderProps, ctx })
// 把react部分渲染出html片段,并插入到html中
const reactHtml = renderToString(
<Provider store={store} >
<RouterContext {...renderProps} />
</Provider>
)
// const filterResult = filterStyle(reactHtml)
const styles = getStyles()
const reactStyles = Object.keys(styles)
.map(wrapper => (
`<style id=${wrapper}>${styles[wrapper].css}</style>`
))
.join('')
const thisInjectOnceCache = assetsInjectOnce ? injectOnceCache : injectOnceCache[localeId]
const thisFilemap = assetsInjectOnce ? filemap : filemap[localeId]
const thisEntrypoints = assetsInjectOnce ? entrypoints : entrypoints[localeId]
// console.log(chunkmap)
// console.log(filemap)
// console.log(entrypoints)
// console.log(localeId)
// console.log(thisInjectOnceCache)
// console.log(thisFilemap)
// console.log(thisEntrypoints)
// global.koaCtxOrigin = ctx.origin
// 配置 html 注入内容
// html [实时更新]的部分
const injectRealtime = {
htmlLang: localeId ? ` lang="${localeId}"` : '',
title: htmlTool.getTitle(),
metas: `<!--${__KOOT_INJECT_METAS_START__}-->${htmlTool.getMetaHtml()}<!--${__KOOT_INJECT_METAS_END__}-->`,
styles: (() => {
if (!assetsInjectOnce || typeof thisInjectOnceCache.styles === 'undefined') {
let r = ''
if (typeof thisFilemap['critical.css'] === 'string') {
if (ENV === 'prod')
r += `<style id="__koot-critical-styles" type="text/css">${readClientFile('critical.css')}</style>`
if (ENV === 'dev')
r += `<link id="__koot-critical-styles" media="all" rel="stylesheet" href="${getClientFilePath('critical.css')}" />`
}
thisInjectOnceCache.styles = r
}
return thisInjectOnceCache.styles + reactStyles
})(),
react: reactHtml,
scripts: (() => {
if (!assetsInjectOnce || typeof thisInjectOnceCache.scriptsInBody === 'undefined') {
let r = ''
// 优先引入 critical
if (Array.isArray(thisEntrypoints.critical)) {
thisEntrypoints.critical
.filter(file => path.extname(file) === '.js')
.forEach(file => {
if (ENV === 'prod')
r += `<script type="text/javascript">${readClientFile(true, file)}</script>`
if (ENV === 'dev')
r += `<script type="text/javascript" src="${getClientFilePath(true, file)}"></script>`
})
}
// 引入其他入口
// Object.keys(thisEntrypoints).filter(key => (
// key !== 'critical' && key !== 'polyfill'
// ))
// let entryToRender = defaultEntrypoints
// if (__DEV__) {
// const { entryClientHMR } = require('../defaults/webpack-dev-server')
// entryToRender = [
// entryClientHMR,
// ...defaultEntrypoints
// ]
// }
defaultEntrypoints.forEach(key => {
if (Array.isArray(thisEntrypoints[key])) {
thisEntrypoints[key].forEach(file => {
if (ENV === 'prod')
r += `<script type="text/javascript" src="${getClientFilePath(true, file)}" defer></script>`
if (ENV === 'dev')
r += `<script type="text/javascript" src="${getClientFilePath(true, file)}" defer></script>`
})
}
})
// 如果设置了 PWA 自动注册 Service-Worker,在此注册
const pwaAuto = typeof process.env.KOOT_PWA_AUTO_REGISTER === 'string'
? JSON.parse(process.env.KOOT_PWA_AUTO_REGISTER)
: false
if (pwaAuto && typeof thisInjectOnceCache.pathnameSW === 'string') {
r += `<script id="__koot-pwa-register-sw" type="text/javascript">`
if (ENV === 'prod')
r += `if ('serviceWorker' in navigator) {`
+ `navigator.serviceWorker.register("${thisInjectOnceCache.pathnameSW}",`
+ `{scope: '/'}`
+ `)`
+ `.catch(err => {console.log('👩💻 Service Worker SUPPORTED. ERROR', err)})`
+ `}else{console.log('👩💻 Service Worker not supported!')}`
if (ENV === 'dev')
r += `console.log('👩💻 No Service Worker for DEV mode.')`
r += `</script>`
}
thisInjectOnceCache.scriptsInBody = r
}
return `<script type="text/javascript">${htmlTool.getReduxScript(store)}</script>`
+ thisInjectOnceCache.scriptsInBody
})(),
}
if (i18nEnabled) {
const localeIds = i18nLocales.map(arr => arr[0])
// console.log('localeIds', localeIds)
// console.log('ctx.query', ctx.query)
// console.log('ctx.querystring', ctx.querystring)
injectRealtime.metas += localeIds
.map(l => {
const href = (typeof ctx.query[changeLocaleQueryKey] === 'string')
? ctx.href.replace(
new RegExp(`${changeLocaleQueryKey}=[a-zA-Z]+`),
`${changeLocaleQueryKey}=${l}`
)
: ctx.href + (ctx.querystring ? `&` : (
ctx.href.substr(ctx.href.length - 1) === '?'
? ''
: `?`
)) + `${changeLocaleQueryKey}=${l}`
return `<link rel="alternate" hreflang="${l}" href="${href}" />`
})
.join('')
}
const injectResult = Object.assign({}, injectRealtime, injectOnce, inject)
// 响应给客户端
let html = htmlInject(template, injectResult)
if (__DEV__) {
delete thisInjectOnceCache.styles
delete thisInjectOnceCache.scriptsInBody
// delete thisInjectOnceCache.pathnameSW
// 开发模式:替换 localhost
const origin = ctx.origin.split('://')[1]
// origin = origin.split(':')[0]
html = html.replace(
/:\/\/localhost:([0-9]+)/mg,
`://${origin}/${publicPathPrefix}`
)
}
ctx.body = html
// global.koaCtxOrigin = undefined
} catch (e) {
// console.error('Server-Render Error Occures: %s', e.stack)
error('Server-Render Error Occures: %O', e.stack)
ctx.status = 500
ctx.body = e.message
ctx.app.emit('error', e, ctx)
}
}
}
}
// location 解构:
// { history, routes, location }
function asyncReactRouterMatch(location) {
return new Promise((resolve, reject) => {
match(location, (error, redirectLocation, renderProps) => {
if (error) {
return reject(error)
}
resolve({ redirectLocation, renderProps })
})
})
}
/**
* 服务端渲染时扩展redux的store方法
* 注:组件必须是redux包装过的组件
*
* @param {any} store
* @param {any} renderProps
* @returns
*/
function ServerRenderDataToStore({ store, renderProps, ctx }) {
const SERVER_RENDER_EVENT_NAME = 'onServerRenderStoreExtend'
let serverRenderTasks = []
for (let component of renderProps.components) {
// component.WrappedComponent 是redux装饰的外壳
if (component && component.WrappedComponent && component.WrappedComponent[SERVER_RENDER_EVENT_NAME]) {
// 预处理异步数据的
const tasks = component.WrappedComponent[SERVER_RENDER_EVENT_NAME]({
store,
renderProps,
ctx,
})
if (Array.isArray(tasks)) {
serverRenderTasks = serverRenderTasks.concat(tasks)
} else if (tasks.then) {
serverRenderTasks.push(tasks)
}
}
}
return Promise.all(serverRenderTasks)
}
/**
* 服务端渲染时候扩展html的方法
* 注:组件必须是redux包装过的组件
*
* @param {any} store
* @param {any} renderProps
* @returns
*/
function ServerRenderHtmlExtend({ store, renderProps, ctx }) {
const SERVER_RENDER_EVENT_NAME = 'onServerRenderHtmlExtend'
const htmlTool = new HTMLTool()
// component.WrappedComponent 是redux装饰的外壳
let func
for (let component of renderProps.components) {
if (component && component.WrappedComponent && component.WrappedComponent[SERVER_RENDER_EVENT_NAME]) {
func = component.WrappedComponent[SERVER_RENDER_EVENT_NAME]
}
}
if (typeof func === 'function')
func({
htmlTool,
store,
renderProps,
ctx,
})
return htmlTool
}
// TODO: move to ImportStyle npm
// 样式处理
// serverRender 的时候,react逻辑渲染的css代码会在html比较靠后的地方渲染出来,
// 为了更快的展现出正常的网页样式,在服务端处理的时候用正则表达式把匹配到的css
// 移动到html的header里,让页面展现更快。
// function filterStyle(htmlString) {
// // 获取样式代码
// let styleCollectionString = htmlString
// .replace(/\r\n/gi, '')
// .replace(/\n/gi, '')
// .match(/<div id="styleCollection(.*?)>(.*?)<\/div>/gi)[0]
// // 提取 css
// let style = styleCollectionString.substr(styleCollectionString.indexOf('>') + 1, styleCollectionString.length)
// style = style.substr(0, style.length - 6)
// // 去掉 <div id="styleCollection">...</div>
// let html = htmlString.replace(/\n/gi, '').replace(styleCollectionString, '')
// return {
// html,
// style
// }
// }