@mpxjs/core
Version:
mpx runtime core
729 lines (685 loc) • 23 kB
JavaScript
import { useEffect, useSyncExternalStore, useRef, useMemo, createElement, memo, forwardRef, useImperativeHandle, useContext, Fragment, cloneElement, createContext } from 'react'
import * as ReactNative from 'react-native'
import { ReactiveEffect } from '../../observer/effect'
import { watch } from '../../observer/watch'
import { del, reactive, set } from '../../observer/reactive'
import { hasOwn, isFunction, noop, isObject, isArray, getByPath, collectDataset, hump2dash, dash2hump, callWithErrorHandling, wrapMethodsWithErrorHandling, error, setFocusedNavigation } from '@mpxjs/utils'
import MpxProxy from '../../core/proxy'
import { BEFOREUPDATE, ONLOAD, UPDATED, ONSHOW, ONHIDE, ONRESIZE, REACTHOOKSEXEC } from '../../core/innerLifecycle'
import mergeOptions from '../../core/mergeOptions'
import { queueJob, hasPendingJob } from '../../observer/scheduler'
import { createSelectorQuery, createIntersectionObserver } from '@mpxjs/api-proxy'
import MpxKeyboardAvoidingView from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/mpx-keyboard-avoiding-view'
import {
IntersectionObserverContext,
KeyboardAvoidContext,
RouteContext
} from '@mpxjs/webpack-plugin/lib/runtime/components/react/dist/context'
import { PortalHost, useSafeAreaInsets } from '../env/navigationHelper'
import { useInnerHeaderHeight } from '../env/nav'
const ProviderContext = createContext(null)
function getSystemInfo () {
const windowDimensions = ReactNative.Dimensions.get('window')
const screenDimensions = ReactNative.Dimensions.get('screen')
return {
deviceOrientation: windowDimensions.width > windowDimensions.height ? 'landscape' : 'portrait',
size: {
screenWidth: screenDimensions.width,
screenHeight: screenDimensions.height,
windowWidth: windowDimensions.width,
windowHeight: windowDimensions.height
}
}
}
function createEffect (proxy, componentsMap) {
const update = proxy.update = () => {
// react update props in child render(async), do not need exec pre render
// if (proxy.propsUpdatedFlag) {
// proxy.updatePreRender()
// }
if (proxy.isMounted()) {
proxy.callHook(BEFOREUPDATE)
proxy.pendingUpdatedFlag = true
}
proxy.stateVersion = Symbol()
proxy.onStoreChange && proxy.onStoreChange()
}
update.id = proxy.uid
const getComponent = (tagName) => {
if (!tagName) return null
if (tagName === 'block') return Fragment
const appComponentsMap = global.__appComponentsMap || {}
const generichash = proxy.target.generichash || ''
const genericComponentsMap = global.__mpxGenericsMap?.[generichash] || {}
const component = componentsMap[tagName] || genericComponentsMap[tagName] || appComponentsMap[tagName]
return component ? component.displayName ? component : component() : getByPath(ReactNative, tagName)
}
const innerCreateElement = (type, ...rest) => {
if (!type) return null
return createElement(type, ...rest)
}
proxy.effect = new ReactiveEffect(() => {
// reset instance
proxy.target.__resetInstance()
return callWithErrorHandling(proxy.target.__injectedRender.bind(proxy.target), proxy, 'render function', [innerCreateElement, getComponent])
}, () => queueJob(update), proxy.scope)
// render effect允许自触发
proxy.toggleRecurse(true)
}
function getRootProps (props, validProps) {
const rootProps = {}
for (const key in props) {
const altKey = dash2hump(key)
if (!hasOwn(validProps, key) && !hasOwn(validProps, altKey) && key !== 'children') {
rootProps[key] = props[key]
}
}
return rootProps
}
const instanceProto = {
setData (data, callback) {
return this.__mpxProxy.forceUpdate(data, { sync: true }, callback)
},
triggerEvent (eventName, eventDetail) {
const props = this.__props
const handler = props && (props['bind' + eventName] || props['catch' + eventName] || props['capture-bind' + eventName] || props['capture-catch' + eventName])
if (handler && typeof handler === 'function') {
const timeStamp = +new Date()
const dataset = collectDataset(props)
const id = props.id || ''
const eventObj = {
type: eventName,
timeStamp,
target: {
id,
dataset,
targetDataset: dataset
},
currentTarget: {
id,
dataset
},
detail: eventDetail
}
handler.call(this, eventObj)
}
},
getPageId () {
return this.__pageId
},
selectComponent (selector) {
return this.__selectRef(selector, 'component')
},
selectAllComponents (selector) {
return this.__selectRef(selector, 'component', true)
},
createSelectorQuery () {
return createSelectorQuery().in(this)
},
createIntersectionObserver (opt) {
return createIntersectionObserver(this, opt, this.__intersectionCtx)
},
// 触发页面范围内的所有observer的计算
__triggerIntersectionObserver () {
const intersectionObservers = this.__intersectionCtx
for (const key in this.__intersectionCtx) {
intersectionObservers[key].throttleMeasure()
}
},
__resetInstance () {
this.__dispatchedSlotSet = new WeakSet()
},
__iter (val, fn) {
let i, l, keys, key
const result = []
if (isArray(val) || typeof val === 'string') {
for (i = 0, l = val.length; i < l; i++) {
result.push(fn.call(this, val[i], i))
}
} else if (typeof val === 'number') {
for (i = 0; i < val; i++) {
result.push(fn.call(this, i + 1, i))
}
} else if (isObject(val)) {
keys = Object.keys(val)
for (i = 0, l = keys.length; i < l; i++) {
key = keys[i]
result.push(fn.call(this, val[key], key, i))
}
}
return result
},
__getProps () {
const props = this.__props
const validProps = this.__validProps
const propsData = {}
Object.keys(validProps).forEach((key) => {
if (hasOwn(props, key)) {
propsData[key] = props[key]
} else {
const altKey = hump2dash(key)
if (hasOwn(props, altKey)) {
propsData[key] = props[altKey]
} else {
let field = validProps[key]
if (isFunction(field) || field === null) {
field = {
type: field
}
}
// 处理props默认值
propsData[key] = field.value
}
}
})
return propsData
},
__getSlot (name, slot) {
const { children } = this.__props
if (children) {
let result = []
if (isArray(children) && !hasOwn(children, '__slot')) {
children.forEach(child => {
if (hasOwn(child, '__slot')) {
if (child.__slot === name) result.push(...child)
} else if (child?.props?.slot === name) {
result.push(child)
}
})
} else {
if (hasOwn(children, '__slot')) {
if (children.__slot === name) result.push(...children)
} else if (children?.props?.slot === name) {
result.push(children)
}
}
result = result.filter(item => {
if (!isObject(item) || this.__dispatchedSlotSet.has(item)) return false
this.__dispatchedSlotSet.add(item)
return true
})
if (!result.length) return null
result.__slot = slot
return result
}
return null
}
}
function createInstance ({ propsRef, type, rawOptions, currentInject, validProps, componentsMap, pageId, intersectionCtx, relation, parentProvides }) {
const instance = Object.create(instanceProto, {
dataset: {
get () {
const props = propsRef.current
return collectDataset(props)
},
enumerable: true
},
id: {
get () {
const props = propsRef.current
return props.id
},
enumerable: true
},
__props: {
get () {
return propsRef.current
},
enumerable: false
},
__pageId: {
get () {
return pageId
},
enumerable: false
},
__intersectionCtx: {
get () {
return intersectionCtx
},
enumerable: false
},
__validProps: {
get () {
return validProps
},
enumerable: false
},
__injectedRender: {
get () {
return currentInject.render || noop
},
enumerable: false
},
__getRefsData: {
get () {
return currentInject.getRefsData || noop
},
enumerable: false
},
__parentProvides: {
get () {
return parentProvides || null
},
enumerable: false
}
})
if (type === 'component') {
Object.defineProperty(instance, '__componentPath', {
get () {
return currentInject.componentPath || ''
},
enumerable: false
})
}
if (relation) {
Object.defineProperty(instance, '__relation', {
get () {
return relation
},
enumerable: false
})
}
// bind this & assign methods
if (rawOptions.methods) {
Object.entries(rawOptions.methods).forEach(([key, method]) => {
instance[key] = method.bind(instance)
})
}
if (type === 'page') {
const props = propsRef.current
instance.route = props.route.name
global.__mpxPagesMap = global.__mpxPagesMap || {}
global.__mpxPagesMap[props.route.key] = [instance, props.navigation]
setFocusedNavigation(props.navigation)
// App onLaunch 在 Page created 之前执行
if (!global.__mpxAppHotLaunched && global.__mpxAppOnLaunch) {
global.__mpxAppOnLaunch(props.navigation)
}
}
const proxy = instance.__mpxProxy = new MpxProxy(rawOptions, instance)
proxy.created()
if (type === 'page') {
const props = propsRef.current
const loadParams = {}
// 此处拿到的props.route.params内属性的value被进行过了一次decode, 不符合预期,此处额外进行一次encode来与微信对齐
if (isObject(props.route.params)) {
for (const key in props.route.params) {
loadParams[key] = encodeURIComponent(props.route.params[key])
}
}
proxy.callHook(ONLOAD, [loadParams])
}
Object.assign(proxy, {
onStoreChange: null,
stateVersion: Symbol(),
subscribe: (onStoreChange) => {
if (!proxy.effect) {
createEffect(proxy, componentsMap)
proxy.stateVersion = Symbol()
}
proxy.onStoreChange = onStoreChange
return () => {
proxy.effect && proxy.effect.stop()
proxy.effect = null
proxy.onStoreChange = null
}
},
getSnapshot: () => {
return proxy.stateVersion
}
})
// react数据响应组件更新管理器
if (!proxy.effect) {
createEffect(proxy, componentsMap)
}
return instance
}
function hasPageHook (mpxProxy, hookNames) {
const options = mpxProxy.options
const type = options.__type__
return hookNames.some(h => {
if (mpxProxy.hasHook(h)) {
return true
}
if (type === 'page') {
return isFunction(options.methods && options.methods[h])
} else if (type === 'component') {
return options.pageLifetimes && isFunction(options.pageLifetimes[h])
}
return false
})
}
const triggerPageStatusHook = (mpxProxy, event) => {
mpxProxy.callHook(event === 'show' ? ONSHOW : ONHIDE)
const pageLifetimes = mpxProxy.options.pageLifetimes
if (pageLifetimes) {
const instance = mpxProxy.target
isFunction(pageLifetimes[event]) && pageLifetimes[event].call(instance)
}
}
const triggerResizeEvent = (mpxProxy) => {
const type = mpxProxy.options.__type__
const systemInfo = getSystemInfo()
const target = mpxProxy.target
mpxProxy.callHook(ONRESIZE, [systemInfo])
if (type === 'page') {
target.onResize && target.onResize(systemInfo)
} else {
const pageLifetimes = mpxProxy.options.pageLifetimes
pageLifetimes && isFunction(pageLifetimes.resize) && pageLifetimes.resize.call(target, systemInfo)
}
}
function usePageEffect (mpxProxy, pageId) {
useEffect(() => {
let unWatch
const hasShowHook = hasPageHook(mpxProxy, [ONSHOW, 'show'])
const hasHideHook = hasPageHook(mpxProxy, [ONHIDE, 'hide'])
const hasResizeHook = hasPageHook(mpxProxy, [ONRESIZE, 'resize'])
if (hasShowHook || hasHideHook || hasResizeHook) {
if (hasOwn(pageStatusMap, pageId)) {
unWatch = watch(() => pageStatusMap[pageId], (newVal) => {
if (newVal === 'show' || newVal === 'hide') {
triggerPageStatusHook(mpxProxy, newVal)
} else if (/^resize/.test(newVal)) {
triggerResizeEvent(mpxProxy)
}
}, { sync: true })
}
}
return () => {
unWatch && unWatch()
}
}, [])
}
let pageId = 0
const pageStatusMap = global.__mpxPageStatusMap = reactive({})
function usePageStatus (navigation, pageId) {
navigation.pageId = pageId
if (!hasOwn(pageStatusMap, pageId)) {
set(pageStatusMap, pageId, '')
}
useEffect(() => {
const focusSubscription = navigation.addListener('focus', () => {
pageStatusMap[pageId] = 'show'
})
const blurSubscription = navigation.addListener('blur', () => {
pageStatusMap[pageId] = 'hide'
})
return () => {
focusSubscription()
blurSubscription()
del(pageStatusMap, pageId)
}
}, [navigation])
}
function usePagePreload (route) {
const name = route.name
useEffect(() => {
const timer = setTimeout(() => {
const preloadRule = global.__preloadRule || {}
const { packages } = preloadRule[name] || {}
if (packages?.length > 0) {
const downloadChunkAsync = mpxGlobal.__mpx.config?.rnConfig?.downloadChunkAsync
if (typeof downloadChunkAsync === 'function') {
callWithErrorHandling(() => downloadChunkAsync(packages))
}
}
}, 800)
return () => {
clearTimeout(timer)
}
}, [])
}
const RelationsContext = createContext(null)
const checkRelation = (options) => {
const relations = options.relations || {}
let hasDescendantRelation = false
let hasAncestorRelation = false
Object.keys(relations).forEach((path) => {
const relation = relations[path]
const type = relation.type
if (['child', 'descendant'].includes(type)) {
hasDescendantRelation = true
} else if (['parent', 'ancestor'].includes(type)) {
hasAncestorRelation = true
}
})
return {
hasDescendantRelation,
hasAncestorRelation
}
}
function getLayoutData (headerHeight) {
const screenDimensions = ReactNative.Dimensions.get('screen')
const windowDimensions = ReactNative.Dimensions.get('window')
// 在横屏状态下 screen.height = window.height + bottomVirtualHeight
// 在正常状态 screen.height = window.height + bottomVirtualHeight + statusBarHeight
const isLandscape = screenDimensions.height < screenDimensions.width
const bottomVirtualHeight = isLandscape ? screenDimensions.height - windowDimensions.height : ((screenDimensions.height - windowDimensions.height - ReactNative.StatusBar.currentHeight) || 0)
return {
left: 0,
top: headerHeight,
// 此处必须为windowDimensions.width,在横屏状态下windowDimensions.width才符合预期
width: windowDimensions.width,
height: screenDimensions.height - headerHeight - bottomVirtualHeight,
// ios为0 android为实际statusbar高度
statusBarHeight: ReactNative.StatusBar.currentHeight || 0,
bottomVirtualHeight: bottomVirtualHeight,
isLandscape: isLandscape
}
}
export function PageWrapperHOC (WrappedComponent, pageConfig = {}) {
return function PageWrapperCom ({ navigation, route, ...props }) {
const rootRef = useRef(null)
const keyboardAvoidRef = useRef(null)
const intersectionObservers = useRef({})
const currentPageId = useMemo(() => ++pageId, [])
const routeContextValRef = useRef({
navigation,
pageId: currentPageId
})
const currentPageConfig = Object.assign({}, global.__mpxPageConfig, pageConfig)
if (!navigation || !route) {
// 独立组件使用时要求传递navigation
error('Using pageWrapper requires passing navigation and route')
return null
}
const headerHeight = useInnerHeaderHeight(currentPageConfig)
navigation.layout = getLayoutData(headerHeight)
useEffect(() => {
const dimensionListener = ReactNative.Dimensions.addEventListener('change', ({ screen }) => {
navigation.layout = getLayoutData(headerHeight)
})
return () => dimensionListener?.remove()
}, [])
usePagePreload(route)
usePageStatus(navigation, currentPageId)
const withKeyboardAvoidingView = (element) => {
return createElement(KeyboardAvoidContext.Provider,
{
value: keyboardAvoidRef
},
createElement(MpxKeyboardAvoidingView,
{
style: {
flex: 1
},
contentContainerStyle: {
flex: 1
}
},
element
)
)
}
// android存在第一次打开insets都返回为0情况,后续会触发第二次渲染后正确
navigation.insets = useSafeAreaInsets()
return withKeyboardAvoidingView(
createElement(ReactNative.View,
{
style: {
flex: 1,
backgroundColor: currentPageConfig?.backgroundColor || '#fff',
// 解决页面内有元素定位relative left为负值的时候,回退的时候还能看到对应元素问题
overflow: 'hidden'
},
ref: rootRef
},
createElement(RouteContext.Provider,
{
value: routeContextValRef.current
},
createElement(IntersectionObserverContext.Provider,
{
value: intersectionObservers.current
},
createElement(PortalHost,
null,
createElement(WrappedComponent, {
...props,
navigation,
route,
id: currentPageId
})
)
)
)
)
)
}
}
export function getDefaultOptions ({ type, rawOptions = {}, currentInject }) {
rawOptions = mergeOptions(rawOptions, type, false)
const componentsMap = currentInject.componentsMap
if (rawOptions.components) {
Object.entries(rawOptions.components).forEach(([key, item]) => {
componentsMap[key] = () => item
})
}
const validProps = Object.assign({}, rawOptions.props, rawOptions.properties)
const { hasDescendantRelation, hasAncestorRelation } = checkRelation(rawOptions)
if (rawOptions.methods) rawOptions.methods = wrapMethodsWithErrorHandling(rawOptions.methods)
const defaultOptions = memo(forwardRef((props, ref) => {
const instanceRef = useRef(null)
const propsRef = useRef(null)
const intersectionCtx = useContext(IntersectionObserverContext)
const { pageId } = useContext(RouteContext) || {}
const parentProvides = useContext(ProviderContext)
let relation = null
if (hasDescendantRelation || hasAncestorRelation) {
relation = useContext(RelationsContext)
}
propsRef.current = props
let isFirst = false
if (!instanceRef.current) {
isFirst = true
instanceRef.current = createInstance({ propsRef, type, rawOptions, currentInject, validProps, componentsMap, pageId, intersectionCtx, relation, parentProvides })
}
const instance = instanceRef.current
useImperativeHandle(ref, () => {
return instance
})
const proxy = instance.__mpxProxy
let hooksResult = proxy.callHook(REACTHOOKSEXEC, [props])
if (isObject(hooksResult)) {
hooksResult = wrapMethodsWithErrorHandling(hooksResult, proxy)
if (isFirst) {
const onConflict = proxy.createProxyConflictHandler('react hooks result')
Object.keys(hooksResult).forEach((key) => {
if (key in proxy.target) {
onConflict(key)
}
proxy.target[key] = hooksResult[key]
})
} else {
Object.assign(proxy.target, hooksResult)
}
}
if (!isFirst) {
// 处理props更新
Object.keys(validProps).forEach((key) => {
if (hasOwn(props, key)) {
instance[key] = props[key]
} else {
const altKey = hump2dash(key)
if (hasOwn(props, altKey)) {
instance[key] = props[altKey]
}
}
})
}
useEffect(() => {
if (proxy.pendingUpdatedFlag) {
proxy.pendingUpdatedFlag = false
proxy.callHook(UPDATED)
}
})
usePageEffect(proxy, pageId)
useEffect(() => {
proxy.mounted()
return () => {
proxy.unmounted()
proxy.target.__resetInstance()
// 热更新下会销毁旧页面并创建新页面组件,且旧页面组件销毁时机晚于新页面组件创建,此时__mpxPagesMap中存储的为新页面组件,不应该删除
// 所以需要判断路由表中存储的页面实例是否为当前页面实例
if (type === 'page') {
const routeKey = props.route.key
if (global.__mpxPagesMap[routeKey] && global.__mpxPagesMap[routeKey][0] === instance) {
delete global.__mpxPagesMap[routeKey]
}
}
}
}, [])
useSyncExternalStore(proxy.subscribe, proxy.getSnapshot)
if ((rawOptions.options?.disableMemo)) {
proxy.memoVersion = Symbol()
}
const finalMemoVersion = useMemo(() => {
if (!hasPendingJob(proxy.update)) {
proxy.finalMemoVersion = Symbol()
}
return proxy.finalMemoVersion
}, [proxy.stateVersion, proxy.memoVersion])
let root = useMemo(() => proxy.effect.run(), [finalMemoVersion])
if (root && root.props.ishost) {
// 对于组件未注册的属性继承到host节点上,如事件、样式和其他属性等
const rootProps = getRootProps(props, validProps)
rootProps.style = Object.assign({}, root.props.style, rootProps.style)
// update root props
root = cloneElement(root, rootProps)
}
const provides = proxy.provides
if (provides) {
root = createElement(ProviderContext.Provider, { value: provides }, root)
}
if (hasDescendantRelation) {
const relationProvide = useMemo(() => {
const componentPath = instance.__componentPath
if (relation) {
return Object.assign({}, relation, { [componentPath]: instance })
} else {
return {
[componentPath]: instance
}
}
}, [relation])
return createElement(
RelationsContext.Provider,
{
value: relationProvide
},
root
)
} else {
return root
}
}))
if (rawOptions.options?.isCustomText) {
defaultOptions.isCustomText = true
}
if (type === 'page') {
return PageWrapperHOC(defaultOptions, currentInject.pageConfig)
}
return defaultOptions
}