!98 inula,inulax,inula-router 累计问题修复

Merge pull request !98 from xuan/main
This commit is contained in:
openInula-robot 2023-12-05 12:00:39 +00:00 committed by Gitee
commit 7076320eaa
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
22 changed files with 214 additions and 114 deletions

View File

@ -10,7 +10,7 @@
### 核心能力
#### 响应式API
#### 响应式API(实验性功能可在reactive分支查看代码或使用npm仓中experiment版本体验)
* openInula 通过最小化重新渲染的范围从而进行高效的UI渲染。这种方式避免了虚拟 DOM 的开销,使得 openInula 在性能方面表现出色。
* openInula 通过比较变化前后的 JavaScript 对象以细粒度的依赖追踪机制来实现响应式更新,无需用户过度关注性能优化。

View File

@ -16,7 +16,7 @@
import Inula from 'openinula';
import { useLayoutEffect, useRef, reduxAdapter, InulaNode } from 'openinula';
import { connect, ReactReduxContext } from 'react-redux';
import { Store } from 'redux';
import type { Store } from 'redux';
import { History, Location, Router } from '../router';
import { Action, DefaultStateType, Navigation } from '../history/types';
import { ActionMessage, onLocationChanged } from './actions';
@ -130,9 +130,14 @@ function getConnectedRouter(type: StoreType) {
);
};
const ConnectedHRouterWithContext = (props: any) => {
const { store, ...rest } = props;
return <ConnectedRouter store={store} storeType={type} {...rest} />;
};
// 针对不同的Store类型使用对应的connect函数
if (type === 'InulaXCompat') {
return hConnect(null as any, mapDispatchToProps)(ConnectedRouterWithContext as any);
return hConnect(null as any, mapDispatchToProps)(ConnectedHRouterWithContext as any);
}
if (type === 'Redux') {
return connect(null, mapDispatchToProps)(ConnectedRouterWithContext);

View File

@ -107,7 +107,7 @@ export function createHashHistory<S = DefaultStateType>(option: HashHistoryOptio
warning(state !== undefined, 'Hash history does not support state, it will be ignored');
const action = Action.push;
const location = createLocation<S>(history.location, to, undefined, '');
const location = createLocation<S>(history.location, to, state, '');
transitionManager.confirmJumpTo(location, action, getUserConfirmation, isJump => {
if (!isJump) {
@ -132,7 +132,7 @@ export function createHashHistory<S = DefaultStateType>(option: HashHistoryOptio
function replace(to: To, state?: S) {
warning(state !== undefined, 'Hash history does not support state, it will be ignored');
const action = Action.replace;
const location = createLocation<S>(history.location, to, undefined, '');
const location = createLocation<S>(history.location, to, state, '');
transitionManager.confirmJumpTo(location, action, getUserConfirmation, isJump => {
if (!isJump) {

View File

@ -28,8 +28,8 @@ export function createPath(path: Partial<Path>): string {
}
export function parsePath(url: string): Partial<Path> {
let pathname = url || '/';
const parsedPath: Partial<Path> = {
pathname: url || '/',
search: '',
hash: '',
};
@ -38,16 +38,16 @@ export function parsePath(url: string): Partial<Path> {
if (hashIdx > -1) {
const hash = url.substring(hashIdx);
parsedPath.hash = hash === '#' ? '' : hash;
url = url.substring(0, hashIdx);
pathname = pathname.substring(0, hashIdx);
}
const searchIdx = url.indexOf('?');
if (searchIdx > -1) {
const search = url.substring(searchIdx);
parsedPath.search = search === '?' ? '' : search;
url = url.substring(0, searchIdx);
pathname = pathname.substring(0, searchIdx);
}
parsedPath.pathname = url;
parsedPath.pathname = pathname;
return parsedPath;
}
@ -116,12 +116,12 @@ export function stripBasename(path: string, prefix: string): string {
export function createMemoryRecord<T, S>(initVal: S, fn: (arg: S) => T) {
let visitedRecord: T[] = [fn(initVal)];
function getDelta(to: S, form: S): number {
let toIdx = visitedRecord.lastIndexOf(fn(to));
function getDelta(toKey: S, formKey: S): number {
let toIdx = visitedRecord.lastIndexOf(fn(toKey));
if (toIdx === -1) {
toIdx = 0;
}
let fromIdx = visitedRecord.lastIndexOf(fn(form));
let fromIdx = visitedRecord.lastIndexOf(fn(formKey));
if (fromIdx === -1) {
fromIdx = 0;
}

View File

@ -24,27 +24,42 @@ import { parsePath } from '../history/utils';
type NavLinkProps = {
to: Partial<Location> | string | ((location: Location) => string | Partial<Location>);
isActive?: (match: Matched | null, location: Location) => boolean;
isActive?<P extends { [K in keyof P]?: string }>(match: Matched<P> | null, location: Location): boolean;
exact?: boolean;
strict?: boolean;
sensitive?: boolean;
className?: string | ((isActive: boolean) => string);
activeClassName?: string;
[key: string]: any;
} & LinkProps;
} & Omit<LinkProps, 'className'>;
type Page = 'page';
function NavLink<P extends NavLinkProps>(props: P) {
const { to, isActive, ...rest } = props;
const { to, isActive, exact, strict, sensitive, className, activeClassName, ...rest } = props;
const context = useContext(Context);
const toLocation = typeof to === 'function' ? to(context.location) : to;
const { pathname } = typeof toLocation === 'string' ? parsePath(toLocation) : toLocation;
const match = pathname ? matchPath(context.location.pathname, pathname) : null;
const match = pathname ? matchPath(context.location.pathname, pathname, {
exact: exact,
strictMode: strict,
caseSensitive: sensitive,
}) : null;
const isLinkActive = match && isActive ? isActive(match, context.location) : false;
const isLinkActive = !!(isActive ? isActive(match, context.location) : match);
let classNames = typeof className === 'function' ? className(isLinkActive) : className;
if (isLinkActive) {
classNames = [activeClassName, classNames].filter(Boolean).join('');
}
const page: Page = 'page';
const otherProps = {
'aria-current': isLinkActive ? page : false,
className: classNames,
'aria-current': isLinkActive ? page : undefined,
...rest,
};

View File

@ -43,15 +43,19 @@ export type RouteProps<P extends Record<string, any> = {}, Path extends string =
function Route<Path extends string, P extends Record<string, any> = GetURLParams<Path>>(props: RouteProps<P, Path>) {
const context = useContext(RouterContext);
const { computed, location, path } = props;
let { children, component, render } = props;
const { computed, location, path, component, render, strict, sensitive, exact } = props;
let { children } = props;
let match: Matched<P> | null;
const routeLocation = location || context.location;
if (computed) {
match = computed;
} else if (path) {
match = matchPath<P>(routeLocation.pathname, path);
match = matchPath<P>(routeLocation.pathname, path, {
strictMode: strict,
caseSensitive: sensitive,
exact: exact,
});
} else {
match = context.match;
}

View File

@ -29,22 +29,27 @@ function Router<P extends RouterProps>(props: P) {
const { history, children = null } = props;
const [location, setLocation] = useState(props.history.location);
const pendingLocation = useRef<Location | null>(null);
const unListen = useRef<null | (() => void)>(null);
const isMount = useRef<boolean>(false);
// 在Router加载时就监听history地址变化以保证在始渲染时重定向能正确触发
const unListen = useRef<null | (() => void)>(
history.listen(arg => {
if (unListen.current === null) {
unListen.current = history.listen(arg => {
pendingLocation.current = arg.location;
}),
);
});
}
// 模拟componentDidMount和componentWillUnmount
useLayoutEffect(() => {
isMount.current = true;
if (unListen.current) {
unListen.current();
}
// 监听history中的位置变化
unListen.current = history.listen(arg => {
setLocation(arg.location);
if (isMount.current) {
setLocation(arg.location);
}
});
if (pendingLocation.current) {
@ -53,6 +58,7 @@ function Router<P extends RouterProps>(props: P) {
return () => {
if (unListen.current) {
isMount.current = false;
unListen.current();
unListen.current = null;
pendingLocation.current = null;

View File

@ -40,7 +40,7 @@ export type Matched<P = any> = {
const defaultOption: Required<ParserOption> = {
// url匹配时是否大小写敏感
caseSensitive: true,
caseSensitive: false,
// 是否严格匹配url结尾的/
strictMode: false,
// 是否完全精确匹配

View File

@ -20,10 +20,11 @@ import RouterContext from './context';
function withRouter<C extends ComponentType>(Component: C) {
function ComponentWithRouterProp(props: any) {
const { wrappedComponentRef, ...rest } = props;
const { history, location, match } = useContext(RouterContext);
const routeProps = { history: history, location: location, match: match };
return <Component {...props} {...routeProps} />;
return <Component {...routeProps} {...rest} ref={wrappedComponentRef} />;
}
return ComponentWithRouterProp;

View File

@ -24,5 +24,13 @@
"watch-test": "yarn test --watch --dev"
},
"files": ["build/**/*", "README.md"],
"types": "./build/@types/index.d.ts"
"types": "./build/@types/index.d.ts",
"exports": {
".": {
"default": "./index.js"
},
"./package.json":"./package.json",
"./jsx-runtime": "./jsx-runtime.js",
"./jsx-dev-runtime": "./jsx-dev-runtime.js"
}
}

View File

@ -473,4 +473,61 @@ describe('Fragment', () => {
expect(LogUtils.getNotClear()).toEqual([]);
expect(container.textContent).toBe('1');
});
it('Fragment 设置相同的key不会重新渲染组件', () => {
const { useState, useRef, act, Fragment } = Inula;
let setFn;
const didMount = jest.fn();
const didUpdate = jest.fn();
const App = () => {
const [list, setList] = useState([
{ text: 'Apple', id: 1 },
{ text: 'Banana', id: 2 },
]);
setFn = setList;
return (
<>
{list.map(item => {
return (
<Fragment key={item.id}>
<Child val={item.text} />
</Fragment>
);
})}
</>
);
};
const Child = ({ val }) => {
const mount = useRef(false);
useEffect(() => {
if (!mount.current) {
didMount();
mount.current = true;
} else {
didUpdate();
}
});
return <div>{val}</div>;
};
act(() => Inula.render(<App />, container));
expect(didMount).toHaveBeenCalledTimes(2);
act(() => {
setFn([
{ text: 'Apple', id: 1 },
{ text: 'Banana', id: 2 },
{ text: 'Grape', id: 3 },
]);
});
// 数组前两项Key不变子组件更新第三个子组件会挂载
expect(didMount).toHaveBeenCalledTimes(3);
expect(didUpdate).toHaveBeenCalledTimes(2);
});
});

View File

@ -14,7 +14,6 @@
*/
import * as Inula from '../../../src/index';
const { useState } = Inula;
describe('FunctionComponent Test', () => {
it('渲染无状态组件', () => {
const App = props => {
@ -90,39 +89,4 @@ describe('FunctionComponent Test', () => {
Inula.render(<App />, container);
expect(container.querySelector('div').style['_values']['--max-segment-num']).toBe(10);
});
it('函数组件渲染中重新发生setState不触发整个应用重新更新', () => {
function CountLabel({ count }) {
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) {
setPrevCount(count);
setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
return (
<>
<h1>{count}</h1>
<Child trend={trend} />
{trend && <p>The count is {trend}</p>}
</>
);
}
let count = 0;
function Child({ trend }) {
count++;
return <div>{trend}</div>;
}
let update;
function App() {
const [count, setCount] = useState(0);
update = setCount;
return <CountLabel count={count} />;
}
Inula.render(<App />, container);
update(1);
expect(count).toBe(2);
})
});

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
import * as Inula from '../../../src/index';
import { getLogUtils } from '../jest/testUtils';
describe('StrictMode Component test', () => {
const LogUtils = getLogUtils();
const { useState, useEffect, useRef, render, act } = Inula;
it('StrictMode is same to Fragment', () => {
const Parent = () => {
const [, setS] = useState('1');
return (
<Inula.StrictMode>
<button id="btn" onClick={() => setS(prevState => prevState + '!')}>
Click
</button>
<Child />
</Inula.StrictMode>
);
};
const Child = () => {
const isMount = useRef(false);
useEffect(() => {
if (!isMount.current) {
LogUtils.log('didMount');
isMount.current = true;
} else {
LogUtils.log('didUpdate');
}
});
return null;
};
act(() => render(<Parent />, container));
// 子组件初始化,会挂载一次
expect(LogUtils.getAndClear()).toStrictEqual(['didMount']);
const button = container.querySelector('#btn');
// 父组件State更新子组件也会更新一次
act(() => button.click());
expect(LogUtils.getAndClear()).toStrictEqual(['didUpdate']);
});
});

View File

@ -21,6 +21,7 @@ import { findDOMByClassInst } from '../renderer/vnode/VNodeUtils';
import { listenSimulatedDelegatedEvents } from '../event/EventBinding';
import { Callback } from '../renderer/Types';
import { InulaNode } from '../types';
import { EVENT_KEY } from './DOMInternalKeys';
function createRoot(children: any, container: Container, callback?: Callback) {
// 清空容器
@ -89,7 +90,7 @@ function findDOMNode(domOrEle?: Element): null | Element | Text {
// 情况根节点监听器
function removeRootEventLister(container: Container) {
const events = (container as any).$EV;
const events = (container as any)[EVENT_KEY];
if (events) {
Object.keys(events).forEach(event => {
const listener = events[event];

View File

@ -22,9 +22,12 @@ import type { Container, Props } from './DOMOperator';
import { DomComponent, DomText, TreeRoot } from '../renderer/vnode/VNodeTags';
const INTERNAL_VNODE = '_inula_VNode';
const INTERNAL_PROPS = '_inula_Props';
const INTERNAL_NONDELEGATEEVENTS = '_inula_NonDelegatedEvents';
const randomKey = Math.random().toString(16).slice(2);
const INTERNAL_VNODE = `_inula_VNode_${randomKey}`;
const INTERNAL_PROPS = `_inula_Props_${randomKey}`;
const INTERNAL_NONDELEGATEEVENTS = `_inula_nonDelegatedEvents_${randomKey}`;
export const HANDLER_KEY = `_inula_valueChangeHandler_${randomKey}`;
export const EVENT_KEY = `_inula_ev_${randomKey}`;
// 通过 VNode 实例获取 DOM 节点
export function getDom(vNode: VNode): Element | Text | null {

View File

@ -18,7 +18,7 @@
* change事件
*/
const HANDLER_KEY = '_valueChangeHandler';
import { HANDLER_KEY } from '../DOMInternalKeys';
// 判断是否是 check 类型
function isCheckType(dom: HTMLInputElement): boolean {

View File

@ -18,7 +18,7 @@
*/
import { allDelegatedInulaEvents, portalDefaultDelegatedEvents, simulatedDelegatedEvents } from './EventHub';
import { isDocument } from '../dom/utils/Common';
import { getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys';
import { EVENT_KEY, getNearestVNode, getNonDelegatedListenerMap } from '../dom/DOMInternalKeys';
import { asyncUpdates, runDiscreteUpdates } from '../renderer/TreeBuilder';
import { handleEventMain } from './InulaEventMain';
import { decorateNativeEvent } from './EventWrapper';
@ -73,8 +73,8 @@ export function lazyDelegateOnRoot(currentRoot: VNode, eventName: string) {
const nativeFullName = isCapture ? nativeEvent + 'capture' : nativeEvent;
// 事件存储在DOM节点属性避免多个VNode(root和portal)对应同一个DOM, 造成事件重复监听
currentRoot.realNode.$EV = currentRoot.realNode.$EV ?? {};
const events = currentRoot.realNode.$EV;
currentRoot.realNode[EVENT_KEY] = currentRoot.realNode[EVENT_KEY] ?? {};
const events = currentRoot.realNode[EVENT_KEY];
if (!events[nativeFullName]) {
events[nativeFullName] = listenToNativeEvent(nativeEvent, currentRoot.realNode, isCapture);

View File

@ -142,7 +142,13 @@ function triggerInulaEvents(
const target = nativeEvent.target || nativeEvent.srcElement!;
// 触发普通委托事件
const listenerList: ListenerUnitList = getCommonListeners(nativeEvtName, vNode, nativeEvent, target, isCapture);
const listenerList: ListenerUnitList = getCommonListeners(
nativeEvtName,
vNode,
nativeEvent as MouseEvent,
target,
isCapture
);
let mouseEnterListeners: ListenerUnitList = [];
if (inulaEventToNativeMap.get('onMouseEnter')!.includes(nativeEvtName)) {

View File

@ -91,6 +91,10 @@ function mergeData(state, data) {
}
export function createStore(reducer: Reducer, preloadedState?: any, enhancers?: StoreEnhancer): ReduxStoreHandler {
if (typeof preloadedState === 'function' && typeof enhancers === 'undefined') {
enhancers = preloadedState;
preloadedState = undefined;
}
const store = createStoreX({
id: 'defaultStore',
state: { stateWrapper: preloadedState },
@ -107,6 +111,7 @@ export function createStore(reducer: Reducer, preloadedState?: any, enhancers?:
return;
} // NOTE: reducer should never return undefined, in this case, do not change state
state.stateWrapper = result;
return action;
},
},
options: {

View File

@ -15,7 +15,7 @@
import type { VNode } from '../Types';
import { FlagUtils } from '../vnode/VNodeFlags';
import { TYPE_COMMON_ELEMENT, TYPE_FRAGMENT, TYPE_PORTAL } from '../../external/JSXElementType';
import { TYPE_COMMON_ELEMENT, TYPE_FRAGMENT, TYPE_PORTAL, TYPE_STRICT_MODE } from '../../external/JSXElementType';
import { DomText, DomPortal, Fragment, DomComponent } from '../vnode/VNodeTags';
import {
updateVNode,
@ -35,9 +35,9 @@ enum DiffCategory {
ARR_NODE = 'ARR_NODE',
}
// 检查是不是被 FRAGMENT 包裹
function isNoKeyFragment(child: any) {
return child != null && child.type === TYPE_FRAGMENT && child.key === null;
// 检查是不是被 FRAGMENT 或 StrictMode 包裹
function isNoKeyFragmentOrStrictMode(child: any) {
return child != null && (child.type === TYPE_FRAGMENT || child.type === TYPE_STRICT_MODE) && child.key === null;
}
// 清除单个节点
@ -159,7 +159,7 @@ function getNewNode(parentNode: VNode, newChild: any, oldNode: VNode | null) {
const key = oldNode !== null ? oldNode.key : newChild.key;
resultNode = createFragmentVNode(key, newChild.props.children);
} else {
resultNode = updateVNode(oldNode, newChild);
resultNode = updateVNode(oldNode, newChild.props.children);
}
break;
}
@ -631,7 +631,7 @@ export function createChildrenByDiff(
newChild: any,
isComparing: boolean
): VNode | null {
const isFragment = isNoKeyFragment(newChild);
const isFragment = isNoKeyFragmentOrStrictMode(newChild);
newChild = isFragment ? newChild.props.children : newChild;
// 1. 没有新节点直接把vNode标记为删除

View File

@ -18,19 +18,12 @@ import type { VNode } from '../Types';
import { getLastTimeHook, setLastTimeHook, setCurrentHook, getNextHook } from './BaseHook';
import { HookStage, setHookStage } from './HookStage';
const NESTED_UPDATE_LIMIT = 50;
// state updated in render phrase
let hasUpdatedInRender = false;
function resetGlobalVariable() {
setHookStage(null);
setLastTimeHook(null);
setCurrentHook(null);
}
export function markUpdatedInRender() {
hasUpdatedInRender = true;
}
// hook对外入口
export function runFunctionWithHooks<Props extends Record<string, any>, Arg>(
funcComp: (props: Props, arg: Arg) => any,
@ -52,14 +45,8 @@ export function runFunctionWithHooks<Props extends Record<string, any>, Arg>(
setHookStage(HookStage.Update);
}
let comp = funcComp(props, arg);
const comp = funcComp(props, arg);
if (hasUpdatedInRender) {
resetGlobalVariable();
processing.oldHooks = processing.hooks;
setHookStage(HookStage.Update);
comp = runFunctionAgain(funcComp, props, arg);
}
// 设置hook阶段为null用于判断hook是否在函数组件中调用
setHookStage(null);
@ -76,22 +63,3 @@ export function runFunctionWithHooks<Props extends Record<string, any>, Arg>(
return comp;
}
function runFunctionAgain<Props extends Record<string, any>, Arg>(
funcComp: (props: Props, arg: Arg) => any,
props: Props,
arg: Arg
) {
let reRenderTimes = 0;
let childElements;
while (hasUpdatedInRender) {
reRenderTimes++;
if (reRenderTimes > NESTED_UPDATE_LIMIT) {
throw new Error('Too many setState called in function component');
}
hasUpdatedInRender = false;
childElements = funcComp(props, arg);
}
return childElements;
}

View File

@ -21,7 +21,6 @@ import { setStateChange } from '../render/FunctionComponent';
import { getHookStage, HookStage } from './HookStage';
import type { VNode } from '../Types';
import { getProcessingVNode } from '../GlobalVar';
import { markUpdatedInRender } from './HookMain';
// 构造新的Update数组
function insertUpdate<S, A>(action: A, hook: Hook<S, A>): Update<S, A> {