lucky0-0
谈谈对 React 新旧生命周期的理解
前言在写这篇文章的时候,React 已经出了 17.0.1 版本了,虽说还来讨论目前 React 新旧生命周期有点晚了,React 两个新生命周期虽然出了很久,但实际开发我却没有用过,因为 React 16 版本后我们直接 React Hook 起飞开发项目。但对新旧生命周期的探索,还是有助于我们更好理解 React 团队一些思想和做法,于是今天就要回顾下这个问题和理解总结,虽然还是 React Hook 写法香,但是依然要深究学习类组件的东西,了解 React 团队的一些思想与做法。本文只讨论 React17 版本前的。React 16 版本后做了什么首先是给三个生命周期函数加上了 UNSAFE:UNSAFE_componentWillMountUNSAFE_componentWillReceivePropsUNSAFE_componentWillUpdate这里并不是表示不安全的意思,它只是不建议继续使用,并表示使用这些生命周期的代码可能在未来的 React 版本(目前 React17 还没有完全废除)存在缺陷,如 React Fiber 异步渲染的出现。同时新增了两个生命周期函数:getDerivedStateFromPropsgetSnapshotBeforeUpdateUNSAFE_componentWillReceivePropsUNSAFE_componentWillReceiveProps(nextProps)
先来说说这个函数,componentWillReceiveProps该子组件方法并不是父组件 props 改变才触发,官方回答是:如果父组件导致组件重新渲染,即使 props 没有更改,也会调用此方法。如果只想处理更改,请确保进行当前值与变更值的比较。先来说说 React 为什么废除该函数,废除肯定有它不好的地方。componentWillReceiveProps函数的一般使用场景是:如果组件自身的某个 state 跟父组件传入的 props 密切相关的话,那么可以在该方法中判断前后两个 props 是否相同,如果不同就根据 props 来更新组件自身的 state。类似的业务需求比如:一个可以横向滑动的列表,当前高亮的 Tab 显然隶属于列表自身的状态,但很多情况下,业务需求会要求从外部跳转至列表时,根据传入的某个值,直接定位到某个 Tab。但该方法缺点是会破坏 state 数据的单一数据源,导致组件状态变得不可预测,另一方面也会增加组件的重绘次数。而在新版本中,官方将更新 state 与触发回调重新分配到了 getDerivedStateFromProps 与 componentDidUpdate 中,使得组件整体的更新逻辑更为清晰。新生命周期方法static getDerivedStateFromProps(props, state)怎么用呢?getDerivedStateFromProps 会在调用 render 方法之前调用,并且在初始挂载及后续更新时都会被调用。它应返回一个对象来更新 state,如果返回 null 则不更新任何内容。从函数名字就可以看出大概意思:使用 props 来派生/更新 state。这就是重点了,但凡你想使用该函数,都必须出于该目的,使用它才是正确且符合规范的。跟getDerivedStateFromProps不同的是,它在挂载和更新阶段都会执行(componentWillReceiveProps挂载阶段不会执行),因为更新 state 这种需求不仅在 props 更新时存在,在 props 初始化时也是存在的。而且getDerivedStateFromProps在组件自身 state 更新也会执行而componentWillReceiveProps方法执行则取决于父组件的是否触发重新渲染,也可以看出getDerivedStateFromProps并不是 componentWillReceiveProps方法的替代品.引起我们注意的是,这个生命周期方法是一个静态方法,静态方法不依赖组件实例而存在,故在该方法内部是无法访问 this 的。新版本生命周期方法能做的事情反而更少了,限制我们只能根据 props 来派生 state,官方是基于什么考量呢?因为无法拿到组件实例的 this,这也导致我们无法在函数内部做 this.fetch()请求,或者不合理的 this.setState()操作导致可能的死循环或其他副作用。有没有发现,这都是不合理不规范的操作,但开发者们都有机会这样用。可如果加了个静态 static,间接强制我们都无法做了,也从而避免对生命周期的滥用。React 官方也是通过该限制,尽量保持生命周期行为的可控可预测,根源上帮助了我们避免不合理的编程方式,即一个 API 要保持单一性,做一件事的理念。如下例子:// before
componentWillReceiveProps(nextProps) {
if (nextProps.isLogin !== this.props.isLogin) {
this.setState({
isLogin: nextProps.isLogin,
});
}
if (nextProps.isLogin) {
this.handleClose();
}
}
// after
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.isLogin !== prevState.isLogin) { // 被对比的props会被保存一份在state里
return {
isLogin: nextProps.isLogin, // getDerivedStateFromProps 的返回值会自动 setState
};
}
return null;
}
componentDidUpdate(prevProps, prevState) {
if (!prevState.isLogin && this.props.isLogin) {
this.handleClose();
}
}
UNSAVE_componentWillMountUNSAFE_componentWillMount() 在挂载之前被调用。它在 render() 之前调用,因此在此方法中同步调用 setState() 不会触发额外渲染。我们应该避免在此方法中引入任何副作用或事件订阅,而是选用componentDidMount()。在 React 初学者刚接触的时候,可能有这样一个疑问:一般都是数据请求放在componentDidMount里面,但放在componentWillMount不是会更快获取数据吗?因为理解是componentWillMount在 render 之前执行,早一点执行就早拿到请求结果;但是其实不管你请求多快,都赶不上首次 render,页面首次渲染依旧处于没有获取异步数据的状态。还有一个原因,componentWillMount是服务端渲染唯一会调用的生命周期函数,如果你在此方法中请求数据,那么服务端渲染的时候,在服务端和客户端都会分别请求两次相同的数据,这显然也我们想看到的结果。特别是有了 React Fiber,更有机会被调用多次,故请求不应该放在componentWillMount中。还有一个错误的使用是在componentWillMount中订阅事件,并在componentWillUnmount中取消掉相应的事件订阅。事实上只有调用componentDidMount后,React 才能保证稍后调用componentWillUnmount进行清理。而且服务端渲染时不会调用componentWillUnmount,可能导致内存泄露。还有人会将事件监听器(或订阅)添加到 componentWillMount 中,但这可能导致服务器渲染(永远不会调用 componentWillUnmount)和异步渲染(在渲染完成之前可能被中断,导致不调用 componentWillUnmount)的内存泄漏。对于该函数,一般情况,如果项目有使用,则是通常把现有 componentWillMount 中的代码迁移至 componentDidMount 即可。UNSAFE_componentWillUpdate当组件收到新的 props 或 state 时,会在渲染之前调用 UNSAFE_componentWillUpdate()。使用此作为在更新发生之前执行准备更新的机会。初始渲染不会调用此方法。注意,不能在该方法中调用 this.setState();在 componentWillUpdate 返回之前,你也不应该执行任何其他操作(例如,dispatch Redux 的 action)触发对 React 组件的更新。首先跟上面两个函数一样,该函数也发生在 render 之前,也存在一次更新被调用多次的可能,从这一点上看就依然不可取了。其次,该方法常见的用法是在组件更新前,读取当前某个 DOM 元素的状态,并在 componentDidUpdate 中进行相应的处理。但 React 16 版本后有 suspense、异步渲染机制等等,render 过程可以被分割成多次完成,还可以被暂停甚至回溯,这导致 componentWillUpdate 和 componentDidUpdate 执行前后可能会间隔很长时间,这导致 DOM 元素状态是不安全的,因为这时的值很有可能已经失效了。而且足够使用户进行交互操作更改当前组件的状态,这样可能会导致难以追踪的 BUG。为了解决这个问题,于是就有了新的生命周期函数:getSnapshotBeforeUpdate(prevProps, prevState)
getSnapshotBeforeUpdate 在最近一次渲染输出(提交到 DOM 节点)之前调用。它使得组件能在发生更改之前从 DOM 中捕获一些信息(例如,滚动位置)。此生命周期的任何返回值将作为第三个参数传入componentDidUpdate(prevProps, prevState, snapshot)与 componentWillUpdate 不同,getSnapshotBeforeUpdate 会在最终的 render 之前被调用,也就是说在 getSnapshotBeforeUpdate 中读取到的 DOM 元素状态是可以保证与 componentDidUpdate 中一致的。虽然 getSnapshotBeforeUpdate 不是一个静态方法,但我们也应该尽量使用它去返回一个值。这个值会随后被传入到 componentDidUpdate 中,然后我们就可以在 componentDidUpdate 中去更新组件的状态,而不是在 getSnapshotBeforeUpdate 中直接更新组件状态。避免了 componentWillUpdate 和 componentDidUpdate 配合使用时将组件临时的状态数据存在组件实例上浪费内存,getSnapshotBeforeUpdate 返回的数据在 componentDidUpdate 中用完即被销毁,效率更高。来看官方的一个例子:class ScrollingList extends React.Component {
constructor(props) {
super(props);
this.listRef = React.createRef();
}
getSnapshotBeforeUpdate(prevProps, prevState) {
// 我们是否在 list 中添加新的 items?
// 捕获滚动位置以便我们稍后调整滚动位置。
if (prevProps.list.length < this.props.list.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop;
}
return null;
}
componentDidUpdate(prevProps, prevState, snapshot) {
// 如果我们 snapshot 有值,说明我们刚刚添加了新的 items,
// 调整滚动位置使得这些新 items 不会将旧的 items 推出视图。
//(这里的 snapshot 是 getSnapshotBeforeUpdate 的返回值)
if (snapshot !== null) {
const list = this.listRef.current;
list.scrollTop = list.scrollHeight - snapshot;
}
}
render() {
return (
<div ref={this.listRef}>{/* ...contents... */}</div>
);
}
}
如果项目中有用到componentWillUpdate的话,升级方案就是将现有的 componentWillUpdate 中的回调函数迁移至 componentDidUpdate。如果触发某些回调函数时需要用到 DOM 元素的状态,则将对比或计算的过程迁移至 getSnapshotBeforeUpdate,然后在 componentDidUpdate 中统一触发回调或更新状态。除了这些,React 16 版本的依然还有大改动,其中引人注目的就是 Fiber,之后我还会抽空写一篇关于 React Fiber 的文章,可以关注我的个人技术博文 Github 仓库,觉得不错的话欢迎 star,给我一点鼓励继续写作吧~
lucky0-0
深入 React 源码 render 阶段的 beginWork 流程
React 渲染的两个阶段React 渲染流程可分为 render 阶段和 commit 阶段。render 阶段render 阶段就是 Reconciler(协调器) 工作的阶段,主要构建 Fiber 树和生成 EffectList,在该阶段会调用组件的 render 方法,收集了需要应用到 DOM 上的变更commit 阶段commit 阶段就是 Renderer(渲染器)工作的阶段,这个阶段会把 render 阶段计算出的变更应用到 DOM 上,该阶段又可分为三个子阶段:before mutation 阶段(执行 DOM 操作前)mutation 阶段(执行 DOM 操作)layout 阶段(执行 DOM 操作后)React 初始化在讲 beginWork 方法之前,让我们跟随源码过下 React 初始化阶段做的事情legacyRenderSubtreeIntoContainer在使用 Reactv17 及以下版本初始化的时候,我们会使用 render 方法将页面挂载到根节点:import { render } from 'react-dom'
render(<div>页面组件</div>, document.getElementById('root'))
通过源码可以看出 ReactDOM.render 实际上返回了函数 legacyRenderSubtreeIntoContainer 的调用结果,主要作用是初始化容器。packages/react-dom/src/client/ReactDOMLegacy.jsexport function render(element, container, callback) {
return legacyRenderSubtreeIntoContainer(null, element, container, false, callback)
}
function legacyRenderSubtreeIntoContainer(parentComponent, children, container, forceHydrate, callback) {
let root = container._reactRootContainer
let fiberRoot
// 没有根组件则需创建
if (!root) {
// 初始化容器并创建 FiberRoot
root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate)
fiberRoot = root._internalRoot
// ...
// 初始化不应该批量更新
unbatchedUpdates(() => {
updateContainer(children, fiberRoot, parentComponent, callback)
})
} else {
fiberRoot = root._internalRoot
// ...
// 将传入的组件进行调度更新,更新 container
updateContainer(children, fiberRoot, parentComponent, callback)
}
return getPublicRootInstance(fiberRoot) // 获取 root 实例
}
legacyRenderSubtreeIntoContainer 函数主要做的事情是初始化容器接下来看看内部执行的 updateContainer 方法做的事情:获取当前 Fiber 节点的 lane(任务优先级)根据优先级创建当前 Fiber 节点的 update 对象,并将其加入更新队列调度更新任务scheduleUpdateOnFiberfunction updateContainer(element, container, parentComponent, callback) {
const current = container.current // 获取当前时间戳
const eventTime = requestEventTime() // 获取更新触发时间
const lane = requestUpdateLane(current) // 获取任务优先级
if (enableSchedulingProfiler) {
markRenderScheduled(lane)
}
const context = getContextForSubtree(parentComponent)
if (container.context === null) {
container.context = context
} else {
container.pendingContext = context
}
// 创建 update 对象
const update = createUpdate(eventTime, lane)
callback = callback === undefined ? null : callback
if (callback !== null) {
update.callback = callback
}
// 将 update 加入 updateQueue
enqueueUpdate(current, update)
// 调度更新任务
scheduleUpdateOnFiber(current, lane, eventTime)
return lane
}
上面代码中,比较重要的是 scheduleUpdateOnFiber 方法,它是整个更新任务的开始,也是 render 阶段的入口,通过该方法来调度任务;具体做的事情:通过当前的更新优先级 lane,把当前 fiber 到 rootFiber 的父级链表上的所有优先级都给更新了。如果当前 fiber 确定更新,则调用 ensureRootIsScheduled。(无论是首次渲染还是后续更新,legacy 下模式最终都会执行 performSyncWorkOnRoot,下文会讲到)packages/react-reconciler/src/ReactFiberWorkLoop.new.jsfunction scheduleUpdateOnFiber(fiber, lane, eventTime) {
// 从触发更新的节点开始向上遍历到 rootFiber,遍历的过程会更新父级链表上所有 Fiber 的任务优先级
const root = markUpdateLaneFromFiberToRoot(fiber, lane)
if (root === null) {
return null
}
// Mark that the root has a pending update.
markRootUpdated(root, lane, eventTime)
if (
// Check if we're inside unbatchedUpdates
(executionContext & LegacyUnbatchedContext) !== NoContext &&
// Check if we're not already rendering
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
// 首次渲染
performSyncWorkOnRoot(root)
} else {
// 根据任务的类型安排调度任务
ensureRootIsScheduled(root, eventTime)
if (executionContext === NoContext) {
flushSyncCallbackQueue()
}
}
}
// 向上递归标记父级链上的优先级 childLanes
function markUpdateLaneFromFiberToRoot(sourceFiber, lane) {
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane) // 更新当前 Fiber
let alternate = sourceFiber.alternate
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane) // 更新缓冲树上的 lane
}
let node = sourceFiber // 当前 fiber
let parent = sourceFiber.return // 当前 fiber 的父级
// 向上递归父级链上的 childLanes
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane)
alternate = parent.alternate
if (alternate !== null) {
alternate.childLanes = mergeLanes(alternate.childLanes, lane)
}
node = parent
parent = parent.return
}
if (node.tag === HostRoot) {
const root = node.stateNode
return root
} else {
return null
}
}
markUpdateLaneFromFiberToRoot 会更新当前 fiber 的优先级 lane, 同时向上递归父级链上的任务优先级 childLanes,在更新过程中确保更新按照正确的顺序和优先级执行。ensureRootIsScheduled 的调用链路(本文只关注同步模式):function ensureRootIsScheduled(root, currentTime) {
// ...
let newCallbackNode
// 同步模式
if (newCallbackPriority === SyncLanePriority) {
newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root))
} else if (newCallbackPriority === SyncBatchedLanePriority) {
newCallbackNode = scheduleCallback(ImmediateSchedulerPriority, performSyncWorkOnRoot.bind(null, root))
} else {
// Concurrent 模式
newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root))
}
}
// 开始执行同步任务
function performSyncWorkOnRoot(root) {
// render 阶段
let exitStatus = renderRootSync(root, lanes)
// commit 阶段
commitRoot(root)
// 退出之前确认有其他的等待中的任务,有则继续更新
ensureRootIsScheduled(root, now())
}
beginWork 流程performUnitOfWork上面源码最后说到了函数 performSyncWorkOnRoot,render 阶段真正开始于这个函数,来看看 renderRootSync 这个函数:function renderRootSync(root, lanes) {
do {
try {
// 同步循环更新
workLoopSync()
break
} catch (thrownValue) {
handleError(root, thrownValue)
}
} while (true)
// workLoopSync 执行完,重置状态,将进入 commit 阶段
workInProgressRoot = null
workInProgressRootRenderLanes = NoLanes
}
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
while (workInProgress !== null) {
performUnitOfWork(workInProgress)
}
}
// 执行当前工作单元
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate
// 处理 current 并返回 child
let next = beginWork(current, unitOfWork, subtreeRenderLanes)
unitOfWork.memoizedProps = unitOfWork.pendingProps
if (next === null) {
// 没有子节点
completeUnitOfWork(unitOfWork)
} else {
// 处理下一个节点
workInProgress = next
}
}
workInProgress :指向当前正在处理的 fiber 节点renderRootSync:执行 workLoopSync 方法,执行完毕后重置状态进入 commit 阶段workLoopSync:通过循环反复判断 workInProgress 是否为 null,不为 null 就执行 performUnitOfWork 函数,直到 workInProgress 为空。performUnitOfWork 方法,它通过 workInProgress fiber 跟已创建的 fiber 连接起来形成 Fiber 树,这个过程为深度优先遍历,而 beginWork 也在这个方法里被调用。beginWork 是向下调和,completeUnitOfWork 是向上归并。beginWork 方法概览function beginWork(current, workInProgress, renderLanes) {
const updateLanes = workInProgress.lanes
// update(当满足条件时可复用 current)
if (current !== null) {
const oldProps = current.memoizedProps // 上一次渲染的 props
const newProps = workInProgress.pendingProps // 当前 props
// props 前后改变 或 有老版本 context 使用并发生变化
if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceiveUpdate = true // 标记组件接收到了新的更新
// 当前 Fiber 节点更新的优先级不够,则子孙节点不需要被调和
// includesSomeLane 用于判断是否是高优先级的任务
} else if (!includesSomeLane(renderLanes, updateLanes)) {
didReceiveUpdate = false
switch (workInProgress.tag) {
case HostRoot:
pushHostRootContext(workInProgress)
resetHydrationState()
break
case HostComponent:
pushHostContext(workInProgress)
break
case ClassComponent: {
const Component = workInProgress.type
if (isLegacyContextProvider(Component)) {
pushLegacyContextProvider(workInProgress)
}
break
}
// ...
}
// 复用 current
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
} else {
if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
didReceiveUpdate = true
} else {
didReceiveUpdate = false
}
}
} else {
// 当前 Fiber 节点未创建
didReceiveUpdate = false
}
workInProgress.lanes = NoLanes
// 根据 workInProgress 的 tag 属性执行 React 不同类型的节点函数
// 这些返回的方法内部都会调用 reconcileChildren
switch (workInProgress.tag) {
// 函数组件
case FunctionComponent: {
return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes)
}
// 类组件
case ClassComponent: {
return updateClassComponent(current, workInProgress, Component, resolvedProps, renderLanes)
}
// ...省略其它 tag
}
}
beginWork 方法整体做的事情:如果当前 Fiber 为 null 表示首次挂载,则直接进入下面第 2 点进行更新;否则 Fiber 走更新流程(current !== null),则需先判断更新情况。更新 Fiber,根据 Fiber 节点 tag 属性的不同,调用不同节点方法,但底层都会调用 reconcileChildren 方法。由于 workInProgress.tag 下的节点类型太多,就不一一举例了,下面就以 updateClassComponent 和 updateFunctionComponent 为例子简单过下:类组件更新函数:updateClassComponent主要工作是对未初始化的 ClassComponent 进行初始化,对已经初始化的组件进行更新复用function updateClassComponent(current, workInProgress, Component, nextProps, renderLanes) {
// 省略 context 处理逻辑...
const instance = workInProgress.stateNode
let shouldUpdate
// 组件实例未创建
if (instance === null) {
if (current !== null) {
current.alternate = null
workInProgress.alternate = null
workInProgress.flags |= Placement
}
// 执行构造函数,得到实例 instance
constructClassInstance(workInProgress, Component, nextProps)
// 挂载类组件
mountClassInstance(workInProgress, Component, nextProps, renderLanes)
// 标记组件需要更新渲染
shouldUpdate = true
} else if (current === null) {
// 组件实例已创建,但 current 为空,即类组件是首次渲染
// 渲染中断,复用类组件实例
shouldUpdate = resumeMountClassInstance(workInProgress, Component, nextProps, renderLanes)
} else {
// 组件实例已创建,不是首次渲染
// 更新
shouldUpdate = updateClassInstance(current, workInProgress, Component, nextProps, renderLanes)
}
// 完成类组件更新
const nextUnitOfWork = finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes)
return nextUnitOfWork
}
function finishClassComponent(current, workInProgress, Component, shouldUpdate, hasContext, renderLanes) {
// ...
// 没更新且没有错误捕获
if (!shouldUpdate && !didCaptureError) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
}
const instance = workInProgress.stateNode
// Rerender
ReactCurrentOwner.current = workInProgress
let nextChildren
// 有错误捕获
if (didCaptureError && typeof Component.getDerivedStateFromError !== 'function') {
// 该类组件没有 getDerivedStateFromError 函数
nextChildren = null
if (enableProfilerTimer) {
stopProfilerTimerIfRunning(workInProgress)
}
} else {
// 该类组件有 getDerivedStateFromError 函数
nextChildren = instance.render()
}
// 有错误捕获强行调和子节点
if (current !== null && didCaptureError) {
forceUnmountCurrentAndReconcile(current, workInProgress, nextChildren, renderLanes)
} else {
// 调和子节点
reconcileChildren(current, workInProgress, nextChildren, renderLanes)
}
workInProgress.memoizedState = instance.state
return workInProgress.child
}
函数更新函数:updateFunctionComponent如当节点是 FunctionComponent 的时候,走函数组件的更新流程。function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
let nextChildren
// 调用 renderWithHooks 执行函数组件更新,返回的即是函数组件 return 的 jsx
nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes)
// 函数组件能否复用 Fiber,取决于 didReceiveUpdate 这个变量
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes)
// 判断 fiber 的子树是否需要更新
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes)
}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork
// 调用 reconcileChildren
reconcileChildren(current, workInProgress, nextChildren, renderLanes)
return workInProgress.child
}
reconcileChildrenreconcileChildren 方法是 Reconciler(协调器) 模块的核心,作用是 diff,向下生成子节点。如果是 mount 的组件,会创建新的子 Fiber 节点如果是 update 的组件,则会继续遍历子节点,通过 diff 算法进行比较,来判断是否可以复用还是生成新的 Fiber 节点function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
if (current === null) {
// 对于 mount 的组件
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes)
} else {
// 对于 update 的组件,则继续深度遍历子节点
workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes)
}
}
mountChildFibers 和 reconcileChildFibers 这两个方法的逻辑基本一致,区别是 reconcileChildFibers 会为生成的 Fiber 节点带上effectTag 属性。可以看出,代码层面区别就是函数的传参不同,该参数 shouldTrackSideEffects 表示是否追踪副作用。export const reconcileChildFibers = ChildReconciler(true)
export const mountChildFibers = ChildReconciler(false)
ChildReconciler 函数内部定义了大量函数,从函数名(delete删除、place插入、update修改、create创建等等)就可以看出这些都是对 Fiber 节点的操作function ChildReconciler(shouldTrackSideEffects) {
function deleteChild(returnFiber, childToDelete) {
if (!shouldTrackSideEffects) {
// Noop.
return;
}
const deletions = returnFiber.deletions;
if (deletions === null) {
returnFiber.deletions = [childToDelete];
returnFiber.flags |= Deletion;
} else {
deletions.push(childToDelete);
}
}
function deleteRemainingChildren(returnFiber, currentFirstChild) {...}
function placeChild(newFiber, lastPlacedIndex, newIndex) {...}
function updateElement(returnFiber, current, element, lanes) {...}
function createChild(returnFiber, newChild, lanes) {...}
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {...}
// ...
}
bailoutOnAlreadyFinishedWork该函数主要用于检测子节点是否需要更新function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
if (current !== null) {
// Reuse previous dependencies
workInProgress.dependencies = current.dependencies
}
if (enableProfilerTimer) {
stopProfilerTimerIfRunning(workInProgress)
}
markSkippedUpdateLanes(workInProgress.lanes)
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 子节点无需更新
return null
} else {
// 子节点需要更新,clone 并返回
cloneChildFibers(current, workInProgress)
return workInProgress.child
}
}
beginWork 流程图最后通过一张流程图来总结 beginWork 流程:这样 render 阶段的大致源码就算过完了,有些内容相对比较粗略,后续会补上 completeUnitOfWork 和 commit 阶段源码等内容的文章。
lucky0-0
深入 React 源码 commit 阶段流程
commit 阶段流程commit 阶段概览function commitRoot(root) {
const renderPriorityLevel = getCurrentPriorityLevel()
// 调度 commitRootImpl
runWithPriority(ImmediateSchedulerPriority, commitRootImpl.bind(null, root, renderPriorityLevel))
return null
}
commit 阶段首先会处理上次还未被完成的 effect 异步函数,其次针对 root 上收集的 effectList 进行处理,其余工作可分为三个子阶段:Before Mutation 阶段(执行 DOM 操作前)Mutation 阶段(执行 DOM 操作)Layout 阶段(执行 DOM 操作后)function commitRootImpl(root, renderPriorityLevel) {
do {
// 执行未执行的 useEffect
flushPassiveEffects(); // 底层会调用 `flushPassiveEffectsImpl` 遍历执行 useEffect 的回调和销毁函数
} while (rootWithPendingPassiveEffects !== null); // 判断有无未执行的 useEffect,有的话先执行
// ...
// 获取 effectList
let firstEffect;
if (finishedWork.flags > PerformedWork) {
// 把 rootFiber 节点 加入到链表中
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
// There is no effect on the root.
firstEffect = finishedWork.firstEffect;
}
// ... 省略其它代码,下文说
// Before Mutation 阶段
commitBeforeMutationEffects();
// Mutation 阶段
commitMutationEffects(root, renderPriorityLevel);
// Layout 阶段
commitLayoutEffects(root, lanes);
}
commitRootImpl 函数的代码很长,上面省略为大致流程,该函数最主要做的事情:针对 rootFiber 节点进行处理,将 rootFiber 节点加入到 effectList 中调用 commitBeforeMutationEffects函数(Before Mutation 阶段)调用 commitMutationEffects 函数(Mutation 阶段)调用 commitLayoutEffects 函数(Layout 阶段)Before Mutation 阶段Before Mutation 阶段的入口函数为 commitBeforeMutationEffects,主要作用是:如果是类组件,判断调用 getSnapshotBeforeUpdate,在 commit 前获取 DOM 相关信息如果是函数组件且使用了 useEffect,则异步调度 useEffect(安排回调)function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const current = nextEffect.alternate
// 处理 blur 与 focus 逻辑
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
// ...
}
const flags = nextEffect.flags
if ((flags & Snapshot) !== NoFlags) {
// 在该函数里会调用 getSnapshotBeforeUpdate
commitBeforeMutationEffectOnFiber(current, nextEffect)
}
if ((flags & Passive) !== NoFlags) {
// 异步调度 useEffect
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects()
return null
})
}
}
nextEffect = nextEffect.nextEffect
}
}
commitBeforeMutationEffectOnFiber 函数:里面会根据不同的 finishedWork.tag 进行处理,如果是 ClassComponent,则会执行 getSnapshotBeforeUpdate 生命周期,并将返回值赋值给 fiber 对象的 __reactInternalSnapshotBeforeUpdate 属性function commitBeforeMutationEffectOnFiber(current, finishedWork) {
switch (finishedWork.tag) {
// ...
case ClassComponent: {
if (finishedWork.flags & Snapshot) {
if (current !== null) {
const prevProps = current.memoizedProps
const prevState = current.memoizedState
const instance = finishedWork.stateNode
// 执行 getSnapshotBeforeUpdate 生命周期
const snapshot = instance.getSnapshotBeforeUpdate(
finishedWork.elementType === finishedWork.type
? prevProps
: resolveDefaultProps(finishedWork.type, prevProps),
prevState
)
instance.__reactInternalSnapshotBeforeUpdate = snapshot
}
}
return
}
}
}
Mutation 阶段Mutation 阶段的入口函数为 commitMutationEffects,主要作用是:遍历 effectList,根据 effectTag 分别处理操作 DOM 节点(如 Placement | Update | Deletion | Hydrating)function commitMutationEffects(root, renderPriorityLevel) {
// 遍历 effectList
while (nextEffect !== null) {
const flags = nextEffect.flags
// ...
const primaryFlags = flags & (Placement | Update | Deletion | Hydrating)
switch (primaryFlags) {
// 插入 DOM
case Placement: {
commitPlacement(nextEffect)
nextEffect.flags &= ~Placement
break
}
// 更新组件和 DOM
case PlacementAndUpdate: {
// 将变化的 DOM 都插入到页面上
commitPlacement(nextEffect)
nextEffect.flags &= ~Placement
// 更新
const current = nextEffect.alternate
commitWork(current, nextEffect)
break
}
// ...
// 更新组件
case Update: {
const current = nextEffect.alternate
commitWork(current, nextEffect)
break
}
// 卸载
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel)
break
}
}
nextEffect = nextEffect.nextEffect
}
}
Placement(插入)调用了 commitPlacement 方法:获取当前 Fiber 节点的父 Fiber 对应的 DOM 节点和插入位置,根据父节点对应的 DOM 是否为 container,执行 insertOrAppendPlacementNodeIntoContainer 或 insertOrAppendPlacementNode 进行插入操作function commitPlacement(finishedWork) {
// 找到最近的 Fiber 节点
const parentFiber = getHostParentFiber(finishedWork)
// 获取父级 DOM 节点
const parentStateNode = parentFiber.stateNode
// 获取兄弟 DOM 节点
const before = getHostSibling(finishedWork)
// 是否 container
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent)
} else {
insertOrAppendPlacementNode(finishedWork, before, parent)
}
}
Update(更新)调用 commitWork,处理由 useLayoutEffect 创建的 effectfunction commitWork(current, finishedWork) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent:
case Block: {
// 调用 useLayoutEffect 的销毁函数
commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork)
return
}
}
}
function commitHookEffectListUnmount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null
if (lastEffect !== null) {
const firstEffect = lastEffect.next
let effect = firstEffect
do {
if ((effect.tag & tag) === tag) {
// 如果有定义 effect 的销毁函数,则执行
const destroy = effect.destroy
effect.destroy = undefined
if (destroy !== undefined) {
destroy()
}
}
effect = effect.next
} while (effect !== firstEffect)
}
}
HookLayout | HookHasEffect 是一个位运算表达式,表示同时具备两种标识,即表示是 useLayoutEffect 类型的 effectDeletion(删除)调用 commitDeletion 方法,当 fiber.tag 为 ClassComponent 时,执行 componentWillUnmount 生命周期钩子,从页面移除 Fiber 节对应 DOM 节点function commitDeletion(finishedRoot, current, renderPriorityLevel) {
if (supportsMutation) {
unmountHostComponents(finishedRoot, current, renderPriorityLevel)
} else {
// Detach refs and call componentWillUnmount() on the whole subtree.
commitNestedUnmounts(finishedRoot, current, renderPriorityLevel)
}
const alternate = current.alternate
detachFiberMutation(current)
if (alternate !== null) {
detachFiberMutation(alternate)
}
}
Layout 阶段Layout 阶段的入口函数为 commitLayoutEffects,作用:针对类组件:调用生命周期 componentDidMount 和 componentDidUpdate,执行 setState 的第二个参数回调函数针对函数组件:执行 useLayoutEffect 的回调函数function commitLayoutEffects(root, committedLanes) {
// 遍历 effectList
while (nextEffect !== null) {
const flags = nextEffect.flags
// 调用生命周期钩子和 hook
if (flags & (Update | Callback)) {
const current = nextEffect.alternate
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes)
}
// 赋值 ref
if (flags & Ref) {
commitAttachRef(nextEffect)
}
nextEffect = nextEffect.nextEffect
}
}
function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedLanes) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// 循环 FunctionComponent 上的 effect 链
// 执行 useLayoutEffect 的回调
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork)
// 收集 useEffect 需要执行回调和销毁函数
schedulePassiveEffects(finishedWork)
return
}
case ClassComponent: {
const instance = finishedWork.stateNode
if (finishedWork.flags & Update) {
if (current === null) {
// 节点首次挂载,执行 componentDidMount
instance.componentDidMount()
} else {
const prevProps =
finishedWork.elementType === finishedWork.type
? current.memoizedProps
: resolveDefaultProps(finishedWork.type, current.memoizedProps)
const prevState = current.memoizedState
// 更新
instance.componentDidUpdate(prevProps, prevState, instance.__reactInternalSnapshotBeforeUpdate)
}
}
const updateQueue = finishedWork.updateQueue
if (updateQueue !== null) {
// 执行 setState 的第二个参数回调
commitUpdateQueue(finishedWork, updateQueue, instance)
}
return
}
// case ...
}
}
针对函数组件,会调用 commitHookEffectListMount 和 schedulePassiveEffects 方法// 只处理由 useLayoutEffect 创建的 effect
function commitHookEffectListMount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null
if (lastEffect !== null) {
const firstEffect = lastEffect.next
let effect = firstEffect
do {
if ((effect.tag & tag) === tag) {
// 执行副作用 effect 创建函数,并将返回值(cleanup 函数)存储到 `destroy` 属性上
// 在 Mutation 阶段已经执行了 useLayoutEffect 的销毁函数,在此执行创建函数,确保每次 useLayoutEffect 的执行的卸载与创建顺序正确
const create = effect.create
effect.destroy = create()
}
effect = effect.next
} while (effect !== firstEffect)
}
}
// 收集 useEffect 待执行的回调和销毁函数
function schedulePassiveEffects(finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const { next, tag } = effect;
if (
// HookPassive 标识 useEffect
(tag & HookPassive) !== NoHookEffect &&
// 依赖数组没有发生变化
(tag & HookHasEffect) !== NoHookEffect
) {
// 收集待执行的销毁函数到数组
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
// 收集待执行的回调函数到数组
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}
总结commit 阶段可分为三个子阶段:Before Mutation 阶段:读取组件变更前的状态。其中针对类组件会调用 getSnapshotBeforeUpdate 生命周期函数;调度 useEffectMutation 阶段:更新 DOM 节点的阶段。如插入、更新以及删除操作。针对类组件会调用 componentWillUnmount 生命周期函数;针对函数组件,则会执行 useLayoutEffect 的销毁函数Layout 阶段:调用类组件的 componentDidMount、componentDidUpdate 生命周期函数,执行 setState 的第二个回调参数;针对函数组件会执行 useLayoutEffect 的 effect 回调,并处理 useLayoutEffect以上就是各个 commit 阶段的大致工作,当然描述的内容肯定不全,只是挑出一些重点,具体各位可以细看源码。
lucky0-0
深入 React 源码 render 阶段的 completeWork 流程
completeWork 阶段流程在上一篇文章 深入 React 源码 render 阶段的 beginWork 流程 讲到了函数 performUnitOfWork,该函数通过 beginWork 来创建当前的子节点直到子节点为空,深度遍历的"递"阶段完成,进入"归"阶段,而"归"阶段就始于 completeUnitOfWork(unitOfWork) 函数,这个函数会因上层函数 workLoopSync 被循环调用。function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress)
}
}
// 执行当前工作单元
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate
// 处理 current 并返回 child
let next = beginWork(current, unitOfWork, subtreeRenderLanes)
unitOfWork.memoizedProps = unitOfWork.pendingProps
if (next === null) {
// 没有子节点
completeUnitOfWork(unitOfWork)
} else {
// 处理下一个节点
workInProgress = next
}
}
由此可以看出,当 next === null 时,即当前 Fiber 节点没有子节点时,将调用 completeUnitOfWork 函数completeUnitOfWork 做的事情:从当前节点由下而上循环执行 completeWork,遍历兄弟节点和父节点;当存在兄弟节点时,会结束当前调用,触发兄弟节点的 performUnitOfWork 循环;当遍历到父节点时,则进入下一轮循环收集 EffectList(EffectList 是一条用于收集存在 EffectTag 的 Fiber 节点的单向链表,该 EffectList 收集完成后会在 commit 阶段使用。)function completeUnitOfWork(unitOfWork) {
// 完成当前节点的 work,然后移动到兄弟节点,当没有兄弟节点时,返回到父节点
let completedWork = unitOfWork
do {
// 记录当前节点
const current = completedWork.alternate
// 获取父节点 Fiber
const returnFiber = completedWork.return
// workInProgress 节点没有错误抛出,走正常的 complete 流程
if ((completedWork.flags & Incomplete) === NoFlags) {
let next
// ...
// 执行 completeWork,并把返回值赋值给 next
next = completeWork(current, completedWork, subtreeRenderLanes)
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next
return
}
// 重置子节点的优先级
resetChildLanes(completedWork)
if (returnFiber !== null && (returnFiber.flags & Incomplete) === NoFlags) {
// 父节点没有挂载 firstEffect
if (returnFiber.firstEffect === null) {
// 将当前节点的 effectList 合并到到父节点的 effectList
returnFiber.firstEffect = completedWork.firstEffect
}
// 父节点的 lastEffect 有值
if (completedWork.lastEffect !== null) {
if (returnFiber.lastEffect !== null) {
// 串联 EffectList 链
returnFiber.lastEffect.nextEffect = completedWork.firstEffect
}
returnFiber.lastEffect = completedWork.lastEffect
}
const flags = completedWork.flags
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork
} else {
returnFiber.firstEffect = completedWork
}
returnFiber.lastEffect = completedWork
}
}
} else {
// 执行到 else 这里说明之前的更新有错误
const next = unwindWork(completedWork, subtreeRenderLanes)
// ...
}
// 获取兄弟节点
const siblingFiber = completedWork.sibling
// 存在兄弟节点,当前节点结束 completeWork,return 终止循环
if (siblingFiber !== null) {
workInProgress = siblingFiber
return
}
// 不存在兄弟节点,则向上回到父节点
completedWork = returnFiber
// 将 workInProgress 节点指向父节点
workInProgress = completedWork
} while (completedWork !== null)
// 到达根节点,完成整个树的工作
if (workInProgressRootExitStatus === RootIncomplete) {
// 标记完成
workInProgressRootExitStatus = RootCompleted
}
}
fiber.firstEffect表示挂载到当前 Fiber 节点的 EffectList 的第一个 Fiber 节点,同理 fiber.lastEffect 则表示最后一个。completeWorkcompleteWork 主要做的事情:mount 阶段:创建 DOM 节点,并把 Fiber 子节点的第一层子节点插入到刚创建的 DOM 节点后面,最后给 DOM 节点设置属性和初始化事件监听器update 阶段:调用 updateHostComponent 处理 props,主要为更新旧的 DOM 节点,计算出需要更新的 DOM 节点属性,并给当前节点打上 Update 的 EffectTag。function completeWork(current, workInProgress, renderLanes) {
const newProps = workInProgress.pendingProps
// 根据 workInProgress.tag 进入不同节点处理逻辑函数
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case ContextConsumer:
case MemoComponent:
bubbleProperties(workInProgress)
return null
case ClassComponent: {
const Component = workInProgress.type
if (isLegacyContextProvider(Component)) {
popLegacyContext(workInProgress)
}
bubbleProperties(workInProgress)
return null
}
// 原生 DOM 组件
case HostComponent: {
popHostContext(workInProgress)
const rootContainerInstance = getRootHostContainer()
const type = workInProgress.type
// 更新(当 current 存在且 workInProgress 节点对应的 DOM 实例存在时走更新逻辑)
if (current !== null && workInProgress.stateNode != null) {
updateHostComponent(current, workInProgress, type, newProps, rootContainerInstance)
if (current.ref !== workInProgress.ref) {
markRef(workInProgress)
}
} else {
// 挂载
if (!newProps) {
// This can happen when we abort work.
bubbleProperties(workInProgress)
return null
}
// 为 DOM 节点的创建做准备
const currentHostContext = getHostContext()
// 1. 创建 DOM 节点
const instance = createInstance(type, newProps, rootContainerInstance, currentHostContext, workInProgress)
// 2. 把 Fiber 子节点的第一层子节点插入到当前 DOM 后面
appendAllChildren(instance, workInProgress, false, false)
workInProgress.stateNode = instance
// 3. 初始化 DOM 属性和事件监听器
if (finalizeInitialChildren(instance, type, newProps, rootContainerInstance, currentHostContext)) {
markUpdate(workInProgress)
}
if (workInProgress.ref !== null) {
markRef(workInProgress)
}
}
// bubbleProperties 数在这个过程中负责将子树的事件属性聚合到父节点,以便在根元素上进行统一处理。
bubbleProperties(workInProgress)
return null
}
// case ...
// case ...
}
}
mount 阶段createInstance作用:使用 createElement 创建 DOM 节点export function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
let parentNamespace
parentNamespace = hostContext
// 创建 DOM 元素
const domElement = createElement(type, props, rootContainerInstance, parentNamespace)
// 在 DOM 对象上创建指向 fiber 节点对象的属性(指针)
precacheFiberNode(internalInstanceHandle, domElement)
// 在 DOM 对象上创建指向 props 的属性(指针)
updateFiberProps(domElement, props)
return domElement
}
appendAllChildren作用:把 Fiber 子节点的第一层子节点插入到当前 DOM 后面appendAllChildren = function (parent, workInProgress, needsVisibilityToggle, isHidden) {
// 找到当前节点的子 Fiber 节点
let node = workInProgress.child
// 存在子节点则向下遍历
while (node !== null) {
// 子节点是原生 DOM 节点,则直接执行插入操作
if (node.tag === HostComponent || node.tag === HostText) {
appendInitialChild(parent, node.stateNode)
} else if (enableFundamentalAPI && node.tag === FundamentalComponent) {
appendInitialChild(parent, node.stateNode.instance)
} else if (node.tag === HostPortal) {
// do nothing
} else if (node.child !== null) {
// 继续查找子节点
node.child.return = node
node = node.child
continue
}
if (node === workInProgress) {
return
}
// 不存在兄弟节点,向上回溯
while (node.sibling === null) {
if (node.return === null || node.return === workInProgress) {
return
}
node = node.return
}
node.sibling.return = node.return
node = node.sibling
}
}
// 调用原生的 appendChild 方法
export function appendInitialChild(parentInstance, child) {
parentInstance.appendChild(child)
}
finalizeInitialChildren作用:初始化 DOM 属性和事件监听器export function finalizeInitialChildren(domElement, type, props, rootContainerInstance, hostContext) {
setInitialProperties(domElement, type, props, rootContainerInstance)
return shouldAutoFocusHostComponent(type, props)
}
function setInitialDOMProperties(tag, domElement, rootContainerElement, nextProps, isCustomComponentTag) {
for (const propKey in nextProps) {
if (!nextProps.hasOwnProperty(propKey)) {
continue
}
const nextProp = nextProps[propKey]
if (propKey === STYLE) {
// 设置行内样式
setValueForStyles(domElement, nextProp)
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
// 设置 innerHTML
const nextHtml = nextProp ? nextProp[HTML] : undefined
if (nextHtml != null) {
setInnerHTML(domElement, nextHtml)
}
} else if (propKey === CHILDREN) {
if (typeof nextProp === 'string') {
const canSetTextContent = tag !== 'textarea' || nextProp !== ''
if (canSetTextContent) {
setTextContent(domElement, nextProp)
}
} else if (typeof nextProp === 'number') {
setTextContent(domElement, '' + nextProp)
}
} else if (propKey === SUPPRESS_CONTENT_EDITABLE_WARNING || propKey === SUPPRESS_HYDRATION_WARNING) {
// Noop
} else if (propKey === AUTOFOCUS) {
// do nothing
} else if (registrationNameDependencies.hasOwnProperty(propKey)) {
// 绑定事件
if (nextProp != null) {
if (!enableEagerRootListeners) {
ensureListeningTo(rootContainerElement, propKey, domElement)
} else if (propKey === 'onScroll') {
listenToNonDelegatedEvent('scroll', domElement)
}
}
} else if (nextProp != null) {
// 设置属性
setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag)
}
}
}
update 阶段updateHostComponent我们以 HostComponent 为例,讲讲它的处理逻辑。updateHostComponent 函数作用是更新旧的 DOM 节点,计算出需要更新的 DOM 节点属性,并给当前节点打上 Update 的 EffectTag。updateHostComponent = function (current, workInProgress, type, newProps, rootContainerInstance) {
const oldProps = current.memoizedProps
// props 前后没有发生变化,则直接返回
if (oldProps === newProps) {
return
}
const instance = workInProgress.stateNode
const currentHostContext = getHostContext()
// 计算出需要更新的 DOM 节点属性
const updatePayload = prepareUpdate(instance, type, oldProps, newProps, rootContainerInstance, currentHostContext)
// 新属性被挂载到 workInProgress.updateQueue 中,以便在 commit 阶段进行统一处理
workInProgress.updateQueue = updatePayload
if (updatePayload) {
// 标记 workInProgress 节点有更新(标记上 Update 的 EffectTag)
markUpdate(workInProgress)
}
}
function markUpdate(workInProgress) {
// Tag the fiber with an update effect. This turns a Placement into
// a PlacementAndUpdate.
workInProgress.flags |= Update
}
completeWork 流程图当所有节点都完成 completeWork 时,更新工作就完成了,然后 workInProgress 树会进入 commit 阶段,这个后续会继续写文补充这个流程。
lucky0-0
浅谈对 React Fiber 的理解
Fiber 出现的背景首先要知道的是,JavaScript 引擎和页面渲染引擎两个线程是互斥的,当其中一个线程执行时,另一个线程只能挂起等待。在这样的机制下,如果 JavaScript 线程长时间地占用了主线程,那么渲染层面的更新就不得不长时间地等待,界面长时间不更新,会导致页面响应度变差,用户可能会感觉到卡顿。而这正是 React 15 的 Stack Reconciler 所面临的问题,即是 JavaScript 对主线程的超时占用问题。Stack Reconciler 是一个同步的递归过程,使用的是 JavaScript 引擎自身的函数调用栈,它会一直执行到栈空为止,所以当 React 在渲染组件时,从开始到渲染完成整个过程是一气呵成的。如果渲染的组件比较庞大,js 执行会占据主线程较长时间,会导致页面响应度变差。而且所有的任务都是按照先后顺序,没有区分优先级,这样就会导致优先级比较高的任务无法被优先执行。Fiber 是什么Fiber 的中文翻译叫纤程,与进程、线程同为程序执行过程,Fiber 就是比线程还要纤细的一个过程。纤程意在对渲染过程实现进行更加精细的控制。从架构角度来看,Fiber 是对 React 核心算法(即调和过程)的重写。从编码角度来看,Fiber 是 React 内部所定义的一种数据结构,它是 Fiber 树结构的节点单位,也就是 React 16 新架构下的"虚拟 DOM"。一个 fiber 就是一个 JavaScript 对象,Fiber 的数据结构如下:type Fiber = {
// 用于标记fiber的WorkTag类型,主要表示当前fiber代表的组件类型如FunctionComponent、ClassComponent等
tag: WorkTag,
// ReactElement里面的key
key: null | string,
// ReactElement.type,调用`createElement`的第一个参数
elementType: any,
// The resolved function/class/ associated with this fiber.
// 表示当前代表的节点类型
type: any,
// 表示当前FiberNode对应的element组件实例
stateNode: any,
// 指向他在Fiber节点树中的`parent`,用来在处理完这个节点之后向上返回
return: Fiber | null,
// 指向自己的第一个子节点
child: Fiber | null,
// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
sibling: Fiber | null,
index: number,
ref: null | (((handle: mixed) => void) & { _stringRef: ?string }) | RefObject,
// 当前处理过程中的组件props对象
pendingProps: any,
// 上一次渲染完成之后的props
memoizedProps: any,
// 该Fiber对应的组件产生的Update会存放在这个队列里面
updateQueue: UpdateQueue<any> | null,
// 上一次渲染的时候的state
memoizedState: any,
// 一个列表,存放这个Fiber依赖的context
firstContextDependency: ContextDependency<mixed> | null,
mode: TypeOfMode,
// Effect
// 用来记录Side Effect
effectTag: SideEffectTag,
// 单链表用来快速查找下一个side effect
nextEffect: Fiber | null,
// 子树中第一个side effect
firstEffect: Fiber | null,
// 子树中最后一个side effect
lastEffect: Fiber | null,
// 代表任务在未来的哪个时间点应该被完成,之后版本改名为 lanes
expirationTime: ExpirationTime,
// 快速确定子树中是否有不在等待的变化
childExpirationTime: ExpirationTime,
// fiber的版本池,即记录fiber更新过程,便于恢复
alternate: Fiber | null,
}
在 2020 年 5 月,以 expirationTime 属性为代表的优先级模型被 lanes 取代。Fiber 如何解决问题的Fiber 把一个渲染任务分解为多个渲染任务,而不是一次性完成,把每一个分割得很细的任务视作一个"执行单元",React 就会检查现在还剩多少时间,如果没有时间就将控制权让出去,故任务会被分散到多个帧里面,中间可以返回至主进程控制执行其他任务,最终实现更流畅的用户体验。即是实现了"增量渲染",实现了可中断与恢复,恢复后也可以复用之前的中间状态,并给不同的任务赋予不同的优先级,其中每个任务更新单元为 React Element 对应的 Fiber 节点。Fiber 实现原理实现的方式是requestIdleCallback这一 API,但 React 团队 polyfill 了这个 API,使其对比原生的浏览器兼容性更好且拓展了特性。window.requestIdleCallback()方法将在浏览器的空闲时段内调用的函数排队。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间 timeout,则有可能为了在超时前执行函数而打乱执行顺序。requestIdleCallback回调的执行的前提条件是当前浏览器处于空闲状态。即requestIdleCallback的作用是在浏览器一帧的剩余空闲时间内执行优先度相对较低的任务。首先 React 中任务切割为多个步骤,分批完成。在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间再进行页面的渲染。等浏览器忙完之后有剩余时间,再继续之前 React 未完成的任务,是一种合作式调度。简而言之,由浏览器给我们分配执行时间片,我们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。React 16 的Reconciler基于 Fiber 节点实现,被称为 Fiber Reconciler。作为静态的数据结构来说,每个 Fiber 节点对应一个 React element,保存了该组件的类型(函数组件/类组件/原生组件等等)、对应的 DOM 节点等信息。作为动态的工作单元来说,每个 Fiber 节点保存了本次更新中该组件改变的状态、要执行的工作。每个 Fiber 节点有个对应的 React element,多个 Fiber 节点是如何连接形成树呢?靠如下三个属性:// 指向父级Fiber节点
this.return = null
// 指向子Fiber节点
this.child = null
// 指向右边第一个兄弟Fiber节点
this.sibling = null
Fiber 架构核心Fiber 架构可以分为三层:Scheduler 调度器 —— 调度任务的优先级,高优任务优先进入 ReconcilerReconciler 协调器 —— 负责找出变化的组件Renderer 渲染器 —— 负责将变化的组件渲染到页面上相比 React15,React16 多了Scheduler(调度器),调度器的作用是调度更新的优先级。在新的架构模式下,工作流如下:每个更新任务都会被赋予一个优先级。当更新任务抵达调度器时,高优先级的更新任务(记为 A)会更快地被调度进 Reconciler 层;此时若有新的更新任务(记为 B)抵达调度器,调度器会检查它的优先级,若发现 B 的优先级高于当前任务 A,那么当前处于 Reconciler 层的 A 任务就会被中断,调度器会将 B 任务推入 Reconciler 层。当 B 任务完成渲染后,新一轮的调度开始,之前被中断的 A 任务将会被重新推入 Reconciler 层,继续它的渲染之旅,即“可恢复”。Fiber 架构的核心即是"可中断"、"可恢复"、"优先级"Scheduler 调度器这个需要上面提到的requestIdleCallback,React 团队实现了功能更完备的 requestIdleCallback polyfill,这就是 Scheduler。除了在空闲时触发回调的功能外,Scheduler 还提供了多种调度优先级供任务设置。Reconciler 协调器在 React 15 中是递归处理虚拟 DOM 的,React 16 则是变成了可以中断的循环过程,每次循环都会调用shouldYield判断当前是否有剩余时间。function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// workInProgress表示当前工作进度的树。
workInProgress = performUnitOfWork(workInProgress)
}
}
React 16 是如何解决中断更新时 DOM 渲染不完全的问题呢?在 React 16 中,Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟 DOM 打上的标记。export const Placement = /* */ 0b0000000000010
export const Update = /* */ 0b0000000000100
export const PlacementAndUpdate = /* */ 0b0000000000110
export const Deletion = /* */ 0b0000000001000
Placement表示插入操作PlacementAndUpdate表示替换操作Update表示更新操作Deletion表示删除操作整个Scheduler与Reconciler的工作都在内存中进行,所以即使反复中断,用户也不会看见更新不完全的 DOM。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。Renderer 渲染器Renderer根据Reconciler为虚拟 DOM 打的标记,同步执行对应的 DOM 操作。Fiber 架构对生命周期的影响render 阶段:纯净且没有副作用,可能会被 React 暂停、终止或重新启动。pre-commit 阶段:可以读取 DOM。commit 阶段:可以使用 DOM,运行副作用,安排更新。其中 pre-commit 和 commit 从大阶段上来看都属于 commit 阶段。在 render 阶段,React 主要是在内存中做计算,明确 DOM 树的更新点;而 commit 阶段,则负责把 render 阶段生成的更新真正地执行掉。新老两种架构对 React 生命周期的影响主要在 render 这个阶段,这个影响是通过增加 Scheduler 层和改写 Reconciler 层来实现的。在 render 阶段,一个庞大的更新任务被分解为了一个个的工作单元,这些工作单元有着不同的优先级,React 可以根据优先级的高低去实现工作单元的打断和恢复。之前写过一篇文章关于为什么 React 一些旧生命周期函数打算废弃的原因:谈谈对 React 新旧生命周期的理解而这次从 Firber 机制 render 阶段的角度看这三个生命周期,这三个生命周期的共同特点是都处于 render 阶段:componentWillMount
componentWillUpdate
componentWillReceiveProps
由于 render 阶段是允许暂停、终止和重启的,这就导致 render 阶段的生命周期都有可能被重复执行,故也是废弃他们的原因之一。Fiber 更新过程虚拟 DOM 更新过程分为 2 个阶段:render/reconciliation 协调阶段(可中断/异步):通过 Diff 算法找出所有节点变更,例如节点新增、删除、属性变更等等, 获得需要更新的节点信息,对应早期版本的 Diff 过程。commit 提交阶段(不可中断/同步):将需要更新的节点一次过批量更新,对应早期版本的 patch 过程。协调阶段在协调阶段会进行 Diff 计算,会生成一棵 Fiber 树。该阶段开始于performSyncWorkOnRoot或performConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress)
}
}
// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
}
它们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。workInProgress代表当前已创建的 workInProgress fiber。performUnitOfWork方法将触发对 beginWork 的调用,进而实现对新 Fiber 节点的创建。若 beginWork 所创建的 Fiber 节点不为空,则 performUniOfWork 会用这个新的 Fiber 节点来更新 workInProgress 的值,为下一次循环做准备。通过循环调用 performUnitOfWork 来触发 beginWork,新的 Fiber 节点就会被不断地创建。当 workInProgress 终于为空时,说明没有新的节点可以创建了,也就意味着已经完成对整棵 Fiber 树的构建。我们知道 Fiber Reconciler 是从 Stack Reconciler 重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork的工作可以分为两部分:"递"和"归"。"递阶段"首先从 rootFiber 开始向下深度优先遍历。为遍历到的每个 Fiber 节点调用beginWork方法。function beginWork(
current: Fiber | null, // 当前组件对应的Fiber节点在上一次更新时的Fiber节点
workInProgress: Fiber, // 当前组件对应的Fiber节点
renderExpirationTime: ExpirationTime // 优先级相关
): Fiber | null {
// ...省略函数体
}
该方法会根据传入的 Fiber 节点创建子 Fiber 节点,并将这两个 Fiber 节点连接起来。当遍历到叶子节点(即没有子组件的组件)时就会进入"归"阶段。"归阶段"在"归"阶段会调用completeWork处理 Fiber 节点。completeWork 将根据 workInProgress 节点的 tag 属性的不同,进入不同的 DOM 节点的创建、处理逻辑。completeWork 内部有 3 个关键动作:创建 DOM 节点(CreateInstance)将 DOM 节点插入到 DOM 树中(AppendAllChildren)为 DOM 节点设置属性(FinalizeInitialChildren)当某个 Fiber 节点执行完completeWork,如果其存在兄弟 Fiber 节点(即fiber.sibling !== null),会进入其兄弟 Fiber 的"递"阶段。如果不存在兄弟 Fiber,会进入父级 Fiber 的"归"阶段。"递"和"归"阶段会交错执行直到"归"到 rootFiber。至此,协调阶段的工作就结束了。commit 提交阶段commit 阶段的主要工作(即 Renderer 的工作流程)分为三部分:before mutation 阶段,这个阶段 DOM 节点还没有被渲染到界面上去,过程中会触发 getSnapshotBeforeUpdate,也会处理 useEffect 钩子相关的调度逻辑。mutation 阶段,这个阶段负责 DOM 节点的渲染。在渲染过程中,会遍历 effectList,根据 flags(effectTag)的不同,执行不同的 DOM 操作。layout 阶段,这个阶段处理 DOM 渲染完毕之后的收尾逻辑。比如调用 componentDidMount/componentDidUpdate,调用 useLayoutEffect 钩子函数的回调等。除了这些之外,它还会把 fiberRoot 的 current 指针指向 workInProgress Fiber 树。
lucky0-0
【React 原理(一)】实现 createElement 和 render 方法
实现 createElement 方法这个方法平时开发我们并不会用到,因为它是经 babel 编译后的代码,我们新建一个 React 项目,index.js 最简单的代码结构如下:import React from 'react'
import ReactDOM from 'react-dom'
ReactDOM.render(<h1 className='title'>Hello React</h1>, document.getElementById('root'))
这里就 jsx 会变编译成真正的 DOM ,把 html 代码拿到 babel 官网编译于是我们就看到了 React.createElement() 方法,但这只是调用这个方法,它具体做了什么返回什么我们还不知道,我们可以打印这个函数运行的结果:console.log(
React.createElement(
'h1',
{
className: 'title',
},
'Hello React'
)
)
返回的这个对象就是虚拟 DOM 了。我们来分析它返回的对象参数,首先第一个是?typeof: REACT_ELEMENT_TYPE这个是 React 元素对象的标识属性REACT_ELEMENT_TYPE 的值是一个 Symbol 类型,代表了一个独一无二的值。如果浏览器不支持 Symbol类型,值就是一个二进制值。为什么是 Symbol?主要防止 XSS 攻击伪造一个假的 React 组件。因为 JSON 中是不会存在 Symbol 类型的。key:这个比如循环中会用到这个key值props:传入的属性值,比如 id, className, style, children 等ref: DOM 的引用剩下的是私有属性(本篇不展开讨论)在本篇我们会用自己简单的方式实现这两个方法,而不是根据源码,所以实现上的方法只要能实现它的基本功能即可;有个基本概念在,以后再循序渐进学习源码。而 createElement 中有三个参数,更确切说是 n 个参数:type:表示要渲染的元素类型。这里可以传入一个元素 Tag 名称,也可以传入一个组件(如div span 等,也可以是是函数组件和类组件)props:创建React元素所需要的props。childrens(可选参数):要渲染元素的子元素,这里可以向后传入n个参数。可以为文本字符串,也可以为数组初步 createElement 方法:// 创建 JSX 对象
function createElement(type, props, ...childrens) {
return {
type,
props: {
...props,
children: childrens.length <= 1 ? childrens[0] || '' : childrens,
},
}
参数中 props 和 childrens 是并列关系,然后返回的 props 对象,里面包含了 children,所以我们需要再 props 里面添加 children 参数,然后根据 children 参数为一个或多个的可能在进行取值处理。调用该方法:console.log(
createElement(
'h1',
{
className: 'title',
},
'Hello React'
)
)
除去其它本篇我们不讨论的属性,目前算是实现了一半;我们观察原来 React 自身方法输出的结果有 key, ref, 同输出的 props 也是并列关系,于是我们进一步作出处理function createElement(type, props, ...childrens) {
let ref, key
if ('ref' in props) {
ref = props['ref']
props['ref'] = undefined
}
if ('key' in props) {
key = props['key']
props['key'] = undefined
}
return {
type,
props: {
...props,
children: childrens.length <= 1 ? childrens[0] || '' : childrens,
},
ref,
key,
}
}
同样的方式调用结果如下:如果添加多一些属性,我们来看看结果console.log(
createElement(
'div',
{ id: 'box', className: 'box', style: { color: 'red' }, key: '20' },
'this is text',
createElement('h2', { className: 'title' }, 'hello'),
createElement('div', { className: 'content' }, 'Hi')
)
)
用了这种比较粗鲁的方式添加,设置为 undefined 在实现 render 方法的时候我们会根据这个忽略props内部的 key 和 props 属性,这里就实现了最基本的 createElement 方法了。实现 render 方法render 方法的第一个参数接收的是 createElement 返回的对象,也就是虚拟DOM;第二个参数则是挂载的目标DOM。同样的做法,我们用 babel 编译来看:执行后,就被挂在到页面了实现代码如下:/*
* 功能:把创建的对象生成对应的DOM元素,最后插入到页面中
* objJSX: createElement 返回的 JSX 对象
* container:挂载的容器,如 document.getElementById('root')
*/
function render(objJSX, container) {
let { type, props } = objJSX
let newElement = document.createElement(type)
for (let attr in props) { // 遍历传入的 props 属性
if (!props.hasOwnProperty(attr)) break // 不是私有的直接结束遍历
let value = props[attr] // >如果当前属性没有值,直接不处理即可
if (value == undefined) continue // NULL OR UNDEFINED
// 对几个特殊属性单独设置
switch (attr.toUpperCase()) {
case 'ID':
newElement.setAttribute('id', value)
break
case 'CLASSNAME':
newElement.setAttribute('class', value)
break
case 'STYLE': // 传入的行内样式 style 是个对象,故需遍历赋值
for (let styleAttr in value) {
if (value.hasOwnProperty(styleAttr)) {
newElement['style'][styleAttr] = value[styleAttr]
}
}
break
case 'CHILDREN':
/*
* 可能是一个值:可能是字符串也可能是一个JSX对象
* 可能是一个数组:数组中的每一项可能是字符串也可能是JSX对象
*/
// 首先把一个值也变为数组,这样后期统一操作数组即可
!(value instanceof Array) ? (value = [value]) : null
value.forEach((item, index) => {
// 验证ITEM是什么类型的:如果是字符串就是创建文本节点,如果是对象,我们需要再次执行RENDER方法,把创建的元素放到最开始创建的大盒子中
if (typeof item === 'string') {
let text = document.createTextNode(item)
newElement.appendChild(text)
} else {
render(item, newElement)
}
})
break
default:
newElement.setAttribute(attr, value)
}
}
container.appendChild(newElement)
}
lucky0-0
深入 React Context 源码与实现原理
前置知识本文假设你对 context 基础用法和 React fiber 渲染流程有一定的了解,因为这些知识不会介绍详细。本文基于 React v18.2.0Context APIReact 渲染流程React 渲染分为 render 阶段和 commit 阶段,其中 render 阶段分为两步(深度优先遍历):beginWork(进入节点的过程向下遍历,协调子元素)completeUnitOfWork(离开节点的过程向上回溯)区别 render 和 beginWork为了避免与上面的阶段混淆,以下 render 都代指开发者层面的 render,即指类组件执行 render 方法或函数组件执行如果一个组件发生更新,当前组件到 fiber root 上的父级链上的所有 fiber,都会执行 beginWork,但执行 beginWork,不代表触发了组件的 render(fiber 会检查组件是否需要进行渲染,不需要则会跳过复用旧的 fiber 节点)所以 render 不等于 beginWork如果组件 render 执行了,则一定经历了 beginWork 流程,触发了 beginWork综上 beginWork 的工作是进入节点时协调子元素,如果 fiber 类型是类组件或者函数组件,则需检测比较组件是否需要执行 render,不需要则会跳过复用旧的 fiber 节点React.createContext 原理const MyContext = React.createContext(defaultValue)
创建一个 Context 对象。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效源码位置:packages/react/src/ReactContext.jscreateContext 函数的核心逻辑是返回一个 context 对象,其中包括三个重要属性:Provider 和 Consumer 两个组件(React Element 对象)属性_currentValue :保存 context 的值,用来保存传递给 Provider 的 value 属性)下列是精简去除类型定义和引入的源码,后面源码举例都这么处理,为了方便直观的看:const REACT_PROVIDER_TYPE = Symbol.for('react.provider')
const REACT_CONTEXT_TYPE = Symbol.for('react.context')
export function createContext(defaultValue) {
const context = {
$$typeof: REACT_CONTEXT_TYPE, // 本质就是 Consumer Element 类型
_currentValue: defaultValue, // 保存 context 的值
_currentValue2: defaultValue, // 为了支持多个并发渲染器,适配不同的平台
_threadCount: 0, // 跟踪当前有多少个并发渲染器
Provider: null,
Consumer: null,
}
// 添加 Provider 属性,本质就是 Provider Element 类型
context.Provider = {
$$typeof: REACT_PROVIDER_TYPE,
_context: context,
}
// 添加 Consumer 属性
context.Consumer = context
return context
}
JSX 语法在进入 render 时会被编译成 React Element 对象Context.Provider 原理<MyContext.Provider value={/* 某个值 */}>
先来了解 Provider 的特性:每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化Provider 接收一个 value 属性,传递给消费组件。一个 Provider 可以和多个消费组件有对应关系。只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效多个相同的 Provider 也可以嵌套使用,里层的会覆盖外层的数据。当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染,可跳过 shouldComponentUpdate 强制更新如果一个组件发生更新,那么当前组件到 fiber root 上的父级链上的所有 fiber,更新优先级都会升高,都会触发 beginWork,但不一定会 render当初次 Fiber 树渲染,进入 beginWork 方法,其中对应的节点处理函数是 updateContextProvider:function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextProvider:
return updateContextProvider(current, workInProgress, renderLanes)
}
}
进入 updateContextProvider 方法:function updateContextProvider(current, workInProgress, renderLanes) {
const providerType = workInProgress.type
const context = providerType._context
const newProps = workInProgress.pendingProps
const oldProps = workInProgress.memoizedProps
// 新的 value 值
const newValue = newProps.value
// 获取 Provider 上的 value
pushProvider(workInProgress, context, newValue)
// 更新阶段
if (oldProps !== null) {
const oldValue = oldProps.value
// 使用 Object.is 来比较新旧值是否发生变化
if (is(oldValue, newValue)) {
// context 值没有变更,则提前退出
if (
oldProps.children === newProps.children &&
!hasLegacyContextChanged()
) {
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
)
}
} else {
// context 值发生改变,深度优先遍历查找 consumer 消费组件,标记更新
propagateContextChange(workInProgress, context, renderLanes)
}
}
// 继续向下调和子代 fiber
const newChildren = newProps.children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}
// 使用栈存储 context._currentValue 值,设置 context._currentValue 为最新值
function pushProvider(providerFiber, context, nextValue) {
// 压栈
push(valueCursor, context._currentValue, providerFiber)
// 修改 context 的值
context._currentValue = nextValue
}
首次执行时,保存 workInProgress.pendingProps.value 值作为最新值,然后调用 pushProvider 方法设置context._currentValue 值pushProvider:存储 context 值的函数,利用栈先进后出的特性,先把 context._currentValue 压栈;与后面流程的 popProvider(出栈)函数相对应。更新阶段时通过浅比较(Object.is)来判断新旧 context 值是否发生改变,没发生改变则调用 bailoutOnAlreadyFinishedWork 进入 bailout,复用当前 Fiber 节点,改变则调用propagateContextChange方法我们总结下 Context.Provider 的 Fiber 更新方法 —— updateContextProvider的核心逻辑:将 Provider 的 value 属性赋值给 context._currentValue(压栈)通过 Object.is 浅比较 context 新旧值是否发生变化发生变化时,调用 propagateContextChange 走更新的流程,深度优先遍历查找消费组件来标记更新propagateContextChange 逻辑:深度优先遍历所有的子代 fiber ,然后找到里面具有 dependencies 的属性,对比 dependencies 中的 context 和当前 Provider 的 context 是否是同一个,如果是同一个,会提高 fiber 的更新优先级,让 fiber 在接下来的调和过程中,处于一个高优先级待更新的状态,而高优先级的 fiber 都会 beginWork消费 Context 原理由上文知识我们简略粗暴的说:Provider 一顿操作核心就是修改 context._currentValue 的值,那么消费 Context 值的原理也就是想方设法读取 context._currentValue 的值了。Context.Consumer(函数组件)<MyContext.Consumer>
{value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>
一个 React 组件可以订阅 context 的变更,此组件可以让你在函数式组件中可以订阅 context。这种方法需要一个函数作为子元素(function as a child)。这个函数接收当前的 context 值,并返回一个 React 节点。传递给函数的 value 值等价于组件树上方离这个 context 最近的 Provider 提供的 value 值当 context 值更新时,Fiber 树渲染时,进入 beginWork 方法,beginWork 中对于 ContextConsumer 的节点处理函数是 updateContextConsumer:function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ContextConsumer:
return updateContextConsumer(current, workInProgress, renderLanes)
}
}
updateContextConsumer的核心逻辑:调用 prepareToReadContext 和 readContext 读取最新的 context 值。通过 render props 函数,传入最新的 context value 值,得到最新的 children 。调和 childrenfunction updateContextConsumer(current, workInProgress, renderLanes) {
// 拿到 context
let context = workInProgress.type
context = context._context
const newProps = workInProgress.pendingProps
// 获取 Consumer 组件的 render props children
const render = newProps.children
// 读取 context 前的准备工作
prepareToReadContext(workInProgress, renderLanes)
// 读取最新 context._currentValue 值
const newValue = readContext(context)
let newChildren
// 最新的 children element
newChildren = render(newValue)
// 进入主流程,调和 children
reconcileChildren(current, workInProgress, newChildren, renderLanes)
return workInProgress.child
}
useContext(函数组件)const value = useContext(MyContext)
接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。看如下代码,useContext Hook 挂载阶段和更新阶段,本质都是调用 readContext 函数,readContext 函数会返回 context._currentValue。而且也是调用了 prepareToReadContext 和 readContextfunction beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
prepareToReadContext(workInProgress, renderLanes)
// 处理各种hooks逻辑
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
)
// ...
}
renderWithHooks 函数是调用函数组件的主要函数function renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
) {
// ...
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount // 挂载阶段
: HooksDispatcherOnUpdate // 更新阶段
}
// 确保 Hooks 只能在函数组件内部或自定义 Hooks 中使用,提供正确的调度程序
function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}
function useContext(Context) {
const dispatcher = resolveDispatcher()
return dispatcher.useContext(Context)
}
const HooksDispatcherOnMount = {
useContext: readContext,
// ...
}
const HooksDispatcherOnUpdate = {
useContext: readContext,
// ...
}
Class.contextType(类组件)class MyClass extends React.Component {
componentDidMount() {
let value = this.context
/* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
}
componentDidUpdate() {
let value = this.context
/* ... */
}
componentWillUnmount() {
let value = this.context
/* ... */
}
render() {
let value = this.context
/* 基于 MyContext 组件的值进行渲染 */
}
}
MyClass.contextType = MyContext
挂载在 class 上的 contextType 属性可以赋值为由 React.createContext() 创建的 Context 对象。此属性可以让你使用 this.context 来获取最近 Context 上的值。你可以在任何生命周期中访问到它,包括 render 函数中。类组件会判断类组件上是否有静态属性 contextType如果有则调用 readContext 方法,并赋值给类实例的 context 属性,所以我们才可以使用 this.context 获取 context 值function beginWork(current, workInProgress, renderLanes) {
switch (workInProgress.tag) {
case ClassComponent:
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
)
}
}
function updateClassComponent(
current,
workInProgress,
Component,
nextProps,
renderLanes,
) {
// ...
prepareToReadContext(workInProgress, renderLanes)
mountClassInstance(workInProgress, Component, nextProps, renderLanes)
// ...
}
function mountClassInstance(workInProgress, ctor, newProps, renderLanes) {
// ...
const instance = workInProgress.stateNode
// 判断类组件上是否有静态属性 contextType
const contextType = ctor.contextType
// 有则调用 readContext
if (typeof contextType === 'object' && contextType !== null) {
// 赋值给类实例的 context 属性
instance.context = readContext(contextType)
}
}
综上,以上三种方式只是 React 根据不同使用场景封装的 API,它们在消费/订阅 context 的共同操作:先调用 prepareToReadContext 进行准备工作再调用 readContext 方法读取 context 值(readContext 方法返回 context._currentValue 最新值)上文提到 propagateContextChange ,如果组件订阅了 context,不管是函数组件还是类组件,都会将 fiber.lanes 设置为 renderLanes。在 beginWork 阶段,发现 fiber.lanes 等于 renderLanes,则走 beginWork 的逻辑,强制组件更新prepareToReadContext 和 readContext 逻辑prepareToReadContext 的核心逻辑:设置全局变量 currentlyRenderingFiber 为当前工作的 fiber,并重置lastContextDependency 等全局变量function prepareToReadContext(workInProgress, renderLanes) {
// 设置全局变量 currentlyRenderingFiber 为当前工作的 fiber, 为 readContext 做准备
currentlyRenderingFiber = workInProgress
// 用于构造 dependencies 列表
lastContextDependency = null
// 将全局变量 lastFullyObservedContext (保存的是 context 对象) 重置为 null
lastFullyObservedContext = null
const dependencies = workInProgress.dependencies
if (dependencies !== null) {
const firstContext = dependencies.firstContext
if (firstContext !== null) {
if (includesSomeLane(dependencies.lanes, renderLanes)) {
// Context list has a pending update. Mark that this fiber performed work.
markWorkInProgressReceivedUpdate()
}
// 重置 fiber context 依赖
dependencies.firstContext = null
}
}
}
readContext 的核心逻辑:收集组件依赖的所有不同的 context,如果组件订阅了 context,则将 context 添加到 fiber.dependencies 链表中返回context._currentValue, 并构造一个contextItem添加到workInProgress.dependencies 链表之后。function readContext(context) {
return readContextForConsumer(currentlyRenderingFiber, context)
}
function readContextForConsumer(consumer, context) {
// ReactDOM 中 isPrimaryRenderer 为 true,则一直返回 context._currentValue
const value = isPrimaryRenderer
? context._currentValue
: context._currentValue2
// 相等说明是同一个 Context,不处理为了防止重复添加依赖
if (lastFullyObservedContext === context) {
// Nothing to do. We already observe everything in this context.
} else {
const contextItem = {
context: context,
memoizedValue: value,
next: null,
}
// 构造一个 contextItem, 加入到 workInProgress.dependencies 链表之后
if (lastContextDependency === null) {
lastContextDependency = contextItem
// dependencies 属性用于判定是否依赖了 ContextProvider 中的值
consumer.dependencies = {
lanes: NoLanes,
firstContext: contextItem,
}
} else {
// 将 context 添加到 fiber.dependencies 链表末尾
lastContextDependency = lastContextDependency.next = contextItem
}
}
// 返回 context._currentValue
return value
}
Context 原理八连问上面源码实际上还是讲解不够完整的,在这推荐一篇文章:【React 源码系列】React Context 原理,如何合理设计共享状态,个人认为相对讲得很清晰了。想知道自己对原理的理解,除了输出就是回答解决一些提问了,这里列举了一些原理相关的问题,写下简略的解答,看看自己是否了解。Provider 如何传递 context?通过将 Provider 的 value 属性值赋值给 context._currentValue没有 Provider 包裹,为什么读不到最新的 context 值?render() {
return (
<>
<TestContext.Provider value={10}>
{/* 可读到 context 值最新值 10 */}
<Test />
</TestContext.Provider>
{/* 只能读到 context 初始值(createContext 函数的参数 defaultValue) */}
<Test />,
</>
)
}
消费 context 时是读取 context._currentValue 值,理论上其它组件也是读取该最新值的。Provider 其中一个特性是只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。所以没有被 Provider 包裹的组件,是只能读到默认值的。React 在深度优先遍历 fiber 树时,最外层 Provider 开始 beginWork,会先将 context._currentValue 的旧值保存起来,赋新的值给 context._currentValue(所以在里层的组件都能读到最新值),在离开 Provider 节点时会调用 completeUnitOfWork 完成工作,在此会将 context._currentValue 恢复成旧值,到遍历第二个 <Test /> 节点时就读的是 context 的默认值(不被 Provider 包裹的组件 render 时 beginWork 的时候就读到旧值了)。相同 Provider 嵌套使用,里层的会覆盖外层的数据是怎么实现的?render() {
return (
<>
<TestContext.Provider value={10}>
<Test1 />
<TestContext.Provider value={100}>
<Test2 />
</TestContext.Provider>
</TestContext.Provider>
</>
)
}
在这场景下, <Test1 /> 和 <Test2 /> 组件读取的值分别是 10 和 100。为了实现嵌套的机制,React 利用的是栈的特性(后入先出),通过 pushProvider 和 popProvider。Fiber 深度优先遍历时:最外层 Provider 将 value 值 10 压入栈 pushProvider,此时栈顶是 10遍历里层 Provider 时将 value 值 100 压入栈 pushProvider,此时栈顶是 100,即context._currentValue 的值为 100消费组件 <Test2 />读取时,在其所在 Provider 范围内先读取栈顶的值,所以读取的是 100;里层的 Provider 完成遍历工作离开时,弹出栈顶 popProvider的值 100,此时栈顶的值是 10, 即 context._currentValue 的值为 10,<Test1 /> 里面读到的值也就为 10 了。由于 React 调和过程就是 Fiber 树深度优先遍历的过程, 向下遍历(beginWork)和向上回溯(completeWork)恰好符合栈的特性(入栈和出栈),Context 的嵌套读取就是利用了这个特性。三种消费 context 的原理useContext:本质上调用 readContext 方法Context.Consumer:本质上是类型为 REACT_CONTEXT_TYPE 的 React Element 对象,context 本身就存在 Consumer 里面,本质也是调用 readContextClass.contextType:通过静态属性 contextType 建立联系 ,在类组件实例化的时候被使用,本质上也是调用 readContext三种方式只是 React 根据不同使用场景封装的 API,本质都是调用了 readContext 方法读取 context._currentValue 值context 的存取发生在 React 渲染的哪些阶段context 的存取就是发生在 beginWork 阶段,在 beginWork 阶段,如果当前组件订阅了 context,则从 context 中读取 _currentValue 值消费 context 的组件,context 改变为什么会订阅更新?当 Provider 的 context value 值更新时,会调用 updateContextProvider 方法,里面的 propagateContextChange 方法会对 fiber 子树向下深度优先遍历所有的 fiber 节点,目的是为了找到消费组件标记更新。如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,就会被标记更新。而消费组件调用的 readContext 方法则会把 fiber.dependencies 和 context 对象建立关联,fiber.dependencies 用于判断是否依赖了 ContextProvider 中的值context 值更新时消费 context 的 fiber 和父级链都会提高更新优先级,向上遍历时,会设置消费节点的父路径上所有节点的 fiber.childLanes 属性,(childLanes 属性用于判断子节点是否需要更新)需要更新则子节点就会进入更新逻辑(开始 beginWork)。消费 context 的组件是如何跳过 PureComponent、shouldComponentUpdate 强制 render?类组件更新流程中,强制更新会跳过 PureComponent 和 shouldComponentUpdate 等优化策略,在外部代码层面,我们可调用 this.forceUpdate(),就会给类组件打上强制更新的 tag。而在内部实现上, context 的 value 改变时,要想订阅 context 的类组件更新,相应的也得打上强制更新的 tag当 context 值发生变化时,会调用 propagateContextChange 对 Fiber 子树向下深度优先遍历所有的 fiber 节点,如果 fiber.dependencies 中存在一个 context 和当前 Provider 的 context 相等,那说明这个组件订阅了当前的 Provider 的 context,如果 fiber 节点是类组件, 则会创建一个 update 对象,并将 update.tag 标记为 ForceUpdate;而处理 update 时,发现 tag 为 ForceUpdate 的话,会将全局变量 hasForceUpdate 设置为 true, 这决定了类组件会强制更新。在 updateClassComponent 中会调用 updateClassInstance 判断类组件是否应该更新。在 updateClassInstance 中会判断全局变量 hasForceUpdate 或者组件的 shouldComponentUpdate 的返回值是否为 true, true 则表示要强制更新。简述 Context 原理Context 的实现原理:创建 Context:createContext 返回一个 context 对象,对象包括 Provider 和 Consumer 两个组件属性,并创建 _currentValue 属性用来保存 context 的值Provider 负责传递 context 值,并使用栈的特性存储修改 context 值消费 Context:消费组件节点调用 readContext 读取 context._currentValue 获取最新值Provider 更新 Context:ContextProvider 节点深度优先遍历子代 fiber,消费 context 的 fiber 和父级链都会提升更新优先级;对于类组件的 fiber ,会被 forceUpdate 处理。接下来所有消费的 fiber,都会执行 beginWork
lucky0-0
深入 React 的 setState 机制
setState 经典问题setState(updater, [callback])
React 通过 this.setState() 来更新 state,当使用 this.setState()的时候 ,React 会调用 render 方法来重新渲染 UI。setState 的几种用法就不用我说了,来看看网上讨论 setState 比较多的问题:批量更新import React, { Component } from 'react'
class App extends Component {
state = {
count: 1,
}
handleClick = () => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
}
render() {
return (
<>
<button onClick={this.handleClick}>加1</button>
<div>{this.state.count}</div>
</>
)
}
}
export default App
点击按钮触发事件,打印的都是 1,页面显示 count 的值为 2。这就是常常说的 setState 批量更新,对同一个值进行多次 setState , setState 的批量更新策略会对其进行覆盖,取最后一次的执行结果。所以每次 setState 之后立即打印值都是初始值 1,而最后页面显示的值则为最后一次的执行结果,也就是 2。setTimeoutimport React, { Component } from 'react'
class App extends Component {
state = {
count: 1,
}
handleClick = () => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
setTimeout(() => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 3
})
}
render() {
return (
<>
<button onClick={this.handleClick}>加1</button>
<div>{this.state.count}</div>
</>
)
}
}
export default App
点击按钮触发事件,发现 setTimeout 里面的 count 值打印值为 3,页面显示 count 的值为 3。setTimeout 里面 setState 之后能马上能到最新值。在 setTimeout 里面,setState 是同步的;经过前面两次的 setState 批量更新,count 值已经更新为 2。在 setTimeout 里面的首先拿到新的 count 值 2,再一次 setState,然后能实时拿到 count 的值为 3。DOM 原生事件import React, { Component } from 'react'
class App extends Component {
state = {
count: 1,
}
componentDidMount() {
document.getElementById('btn').addEventListener('click', this.handleClick)
}
handleClick = () => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 2
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 3
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 4
}
render() {
return (
<>
<button id='btn'>触发原生事件</button>
<div>{this.state.count}</div>
</>
)
}
}
export default App
点击按钮,会发现每次 setState 打印出来的值都是实时拿到的,不会进行批量更新。在 DOM 原生事件里面,setState 也是同步的。setState 同步异步问题这里讨论的同步和异步并不是指 setState 是否异步执行,使用了什么异步代码,而是指调用 setState 之后 this.state 能否立即更新。React 中的事件都是合成事件,都是由 React 内部封装好的。React 本身执行的过程和代码都是同步的,只是合成事件和钩子函数的调用顺序在更新之前,导致在合成事件和钩子函数中没法立马拿到更新后的值,就是我们所说的"异步"了。由上面也可以得知 setState 在原生事件和 setTimeout 中都是同步的。setState 源码层面源码选择的 React 版本为15.6.2setState 函数源码里面,setState 函数的代码React 组件继承自React.Component,而 setState 是React.Component的方法ReactComponent.prototype.setState = function (partialState, callback) {
this.updater.enqueueSetState(this, partialState)
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState')
}
}
可以看到它直接调用了 this.updater.enqueueSetState 这个方法。enqueueSetStateenqueueSetState: function(publicInstance, partialState) {
// 拿到对应的组件实例
var internalInstance = getInternalInstanceReadyForUpdate(
publicInstance,
'setState',
);
// queue 对应一个组件实例的 state 数组
var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
queue.push(partialState); // 将 partialState 放入待更新 state 队列
// 处理当前的组件实例
enqueueUpdate(internalInstance);
}
_pendingStateQueue表示待更新队列enqueueSetState 做了两件事:将新的 state 放进组件的状态队列里;用 enqueueUpdate 来处理将要更新的实例对象。接下来看看 enqueueUpdate 做了什么:function enqueueUpdate(component) {
ensureInjected()
// isBatchingUpdates 标识着当前是否处于批量更新过程
if (!batchingStrategy.isBatchingUpdates) {
// 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
batchingStrategy.batchedUpdates(enqueueUpdate, component)
return
}
// 需要批量更新,则先把组件塞入 dirtyComponents 队列
dirtyComponents.push(component)
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1
}
}
batchingStrategy 表示批量更新策略,isBatchingUpdates表示当前是否处于批量更新过程,默认是 false。enqueueUpdate做的事情:判断组件是否处于批量更新模式,如果是,即isBatchingUpdates为 true 时,不进行 state 的更新操作,而是将需要更新的组件添加到dirtyComponents数组中;如果不是处于批量更新模式,则对所有队列中的更新执行batchedUpdates方法当中 batchingStrategy该对象的isBatchingUpdates属性直接决定了是马上要走更新流程,还是应该进入队列等待;所以大概可以得知batchingStrategy用于管控批量更新的对象。来看看它的源码:/**
* batchingStrategy源码
**/
var ReactDefaultBatchingStrategy = {
isBatchingUpdates: false, // 初始值为 false 表示当前并未进行任何批量更新操作
// 发起更新动作的方法
batchedUpdates: function (callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates
ReactDefaultBatchingStrategy.isBatchingUpdates = true
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e)
} else {
// 启动事务,将 callback 放进事务里执行
return transaction.perform(callback, null, a, b, c, d, e)
}
},
}
每当 React 调用 batchedUpdate 去执行更新动作时,会先把isBatchingUpdates置为 true,表明正处于批量更新过程中。看完批量更新整体的管理机制,发现还有一个操作是transaction.perform,这就引出 React 中的 Transaction(事务)机制。Transaction(事务)机制Transaction 是创建一个黑盒,该黑盒能够封装任何的方法。因此,那些需要在函数运行前、后运行的方法可以通过此方法封装(即使函数运行中有异常抛出,这些固定的方法仍可运行)。在 React 中源码有关于 Transaction 的注释如下: * <pre>
* wrappers (injected at creation time)
* + +
* | |
* +-----------------|--------|--------------+
* | v | |
* | +---------------+ | |
* | +--| wrapper1 |---|----+ |
* | | +---------------+ v | |
* | | +-------------+ | |
* | | +----| wrapper2 |--------+ |
* | | | +-------------+ | | |
* | | | | | |
* | v v v v | wrapper
* | +---+ +---+ +---------+ +---+ +---+ | invariants
* perform(anyMethod) | | | | | | | | | | | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | +---+ +---+ +---------+ +---+ +---+ |
* | initialize close |
* +-----------------------------------------+
* </pre>
根据以上注释,可以看出:一个 Transaction 就是将需要执行的 method 使用 wrapper(一组 initialize 及 close 方法称为一个 wrapper) 封装起来,再通过 Transaction 提供的 perform 方法执行。在 perform 之前,先执行所有 wrapper 中的 initialize 方法;perform 完成之后(即 method 执行后)再执行所有的 close 方法,而且 Transaction 支持多个 wrapper 叠加。这就是 React 中的事务机制。batchingStrategy 批量更新策略再看回batchingStrategy批量更新策略,ReactDefaultBatchingStrategy 其实就是一个批量更新策略事务,它的 wrapper 有两个:FLUSH_BATCHED_UPDATES 和 RESET_BATCHED_UPDATES。isBatchingUpdates在 close 方法被复位为 false,如下代码:var RESET_BATCHED_UPDATES = {
initialize: emptyFunction,
close: function () {
ReactDefaultBatchingStrategy.isBatchingUpdates = false
},
}
// flushBatchedUpdates 将所有的临时 state 合并并计算出最新的 props 及 state
var FLUSH_BATCHED_UPDATES = {
initialize: emptyFunction,
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates),
}
var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES]
React 钩子函数都说 React 钩子函数也是异步更新,则必须一开始 isBatchingUpdates 为 ture,但默认 isBatchingUpdates 为 false,它是在哪里被设置为 true 的呢?来看下面代码:// ReactMount.js
_renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {
// 实例化组件
var componentInstance = instantiateReactComponent(nextElement);
// 调用 batchedUpdates 方法
ReactUpdates.batchedUpdates(
batchedMountComponentIntoNode,
componentInstance,
container,
shouldReuseMarkup,
context
);
}
这段代码是在首次渲染组件时会执行的一个方法,可以看到它内部调用了一次 batchedUpdates 方法(将 isBatchingUpdates 设为 true),这是因为在组件的渲染过程中,会按照顺序调用各个生命周期(钩子)函数。如果在函数里面调用 setState,则看下列代码:if (!batchingStrategy.isBatchingUpdates) {
// 立即更新组件
batchingStrategy.batchedUpdates(enqueueUpdate, component)
return
}
// 批量更新,则先把组件塞入 dirtyComponents 队列
dirtyComponents.push(component)
则所有的更新都能够进入 dirtyComponents 里去,即 setState 走的异步更新React 合成事件当我们在组件上绑定了事件之后,事件中也有可能会触发 setState。为了确保每一次 setState 都有效,React 同样会在此处手动开启批量更新。看下面代码:// ReactEventListener.js
dispatchEvent: function (topLevelType, nativeEvent) {
try {
// 处理事件:batchedUpdates会将 isBatchingUpdates设为true
ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
} finally {
TopLevelCallbackBookKeeping.release(bookKeeping);
}
}
isBatchingUpdates 这个变量,在 React 的生命周期函数以及合成事件执行前,已经被 React 改为 true,这时我们所做的 setState 操作自然不会立即生效。当函数执行完毕后,事务的 close 方法会再把 isBatchingUpdates 改为 false。就像最上面的例子,整个过程模拟大概是:handleClick = () => {
// isBatchingUpdates = true
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
// isBatchingUpdates = false
}
而如果有 setTimeout 介入后handleClick = () => {
// isBatchingUpdates = true
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
setTimeout(() => {
// setTimeout异步执行,此时 isBatchingUpdates 已经被重置为 false
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 3
})
// isBatchingUpdates = false
}
isBatchingUpdates是在同步代码中变化的,而 setTimeout 的逻辑是异步执行的。当 this.setState 调用真正发生的时候,isBatchingUpdates 早已经被重置为 false,这就使得 setTimeout 里面的 setState 具备了立刻发起同步更新的能力。batchedUpdates 方法看到这里大概就可以了解 setState 的同步异步机制了,接下来让我们进一步体会,可以把 React 的batchedUpdates拿来试试,在该版本中此方法名称被置为unstable_batchedUpdates 即不稳定的方法。import React, { Component } from 'react'
import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'
class App extends Component {
state = {
count: 1,
}
handleClick = () => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 1
setTimeout(() => {
batchedUpdates(() => {
this.setState({
count: this.state.count + 1,
})
console.log(this.state.count) // 2
})
})
}
render() {
return (
<>
<button onClick={this.handleClick}>加1</button>
<div>{this.state.count}</div>
</>
)
}
}
export default App
如果调用batchedUpdates方法,则 isBatchingUpdates变量会被设置为 true,由上述得为 true 走的是批量更新策略,则 setTimeout 里面的方法也变成异步更新了,所以最终打印值为 2,与本文第一道题结果一样。总结setState 同步异步的表现会因调用场景的不同而不同:在 React 钩子函数及合成事件中,它表现为异步;而在 setTimeout/setInterval 函数,DOM 原生事件中,它都表现为同步。这是由 React 事务机制和批量更新机制的工作方式来决定的。在 React16 中,由于引入了 Fiber 机制,源码多少有点不同,但大同小异,之后我也会写 React16 原理的文章,敬请关注!
lucky0-0
升级 React Router v6 指南
v5 升级 v6 指南<Switch>全部换成<Routes>v5<BrowserRouter>
<Menu />
<Switch>
<Route component={Home} path="/home"></Route>
<Route component={List} path="/list"></Route>
<Route component={Detail} path="/detail"></Route>
<Route component={Category} path="/category"></Route>
</Switch>
</BrowserRouter>
// Category.tsx
<Switch>
<Route component={CategoryA} path="/category/a"></Route>
<Route component={CategoryB} path="/category/b"></Route>
</Switch>
Switch 组件作用:渲染第一个被 location 匹配到的并且作为子元素的 <Route> 或者 <Redirect>,它仅仅只会渲染一个路径v6<BrowserRouter>
<Menu />
<Routes>
<Route element={<Home />} path="/home"></Route>
<Route element={<List />} path="/list"></Route>
<Route element={<Detail />} path="/detail"></Route>
<Route element={<Category />} path="/category">
{/* children 写法嵌套子路由,path是相对路径 */}
<Route element={<CategoryA />} path="a"></Route>
<Route element={<CategoryB />} path="b"></Route>
</Route>
</Routes>
</BrowserRouter>
与 Switch 相比,Routes 的主要优点是:Routes 内的所有 <Route> 和 <Link>的 path 支持相对路由(如果以/开头的则是绝对路由)。这使得 <Route path> 和 <Link to> 中的代码更精简、更可预测路由基于最佳 path 匹配的,而不是按顺序遍历选择的路由可以嵌套在同一个地方而不必分散在不同的组件中注意:不能认为 Routes 作为 Switch 的替代品。Switch 功能是匹配唯一的 Route 组件但它本身是可选的,可使用Route组件而不使用Switch组件。但只要使用Route组件则 v6 的Routes组件是必选的, Routes 必须套在最外层才可以使用Route组件,否则会报错。Route 组件属性Route 的 render 或 component 改为 element// v5
<Route component={Home} path="/home"></Route>
// v6
<Route element={<Home />} path="/home"></Route>
简化path格式,只支持两种动态占位符:id 动态参数* 通配符,只能在 path 的末尾使用,如 users/*v6 path的正确写法:/groups
/groups/admin
/users/:id
/users/:id/messages
/files/*
/files/:id/*
v6 path错误的写法/users/:id? // ? 不满足上面两种格式
/tweets/:id(\d+) // 有正则表达式,不满足
/files/*/cat.jpg
/files-*
路由匹配的区分大小写开启 caseSensitivecaseSensitive,用于正则匹配 path 时是否开启 ignore 模式,即匹配时是否忽略大小写<Routes caseSensitive>
所有路径匹配都会忽略 URL 上的尾部斜杠新增 Outlet 组件作用:通常用于渲染子路由,类似插槽的作用,用于匹配子路由的 elementexport default function Category() {
return (
<div>
<div>
<Link to="a">跳转 CategoryA</Link>
</div>
<div>
<Link to="b">跳转 CategoryB</Link>
</div>
{/* 自动匹配子路由的渲染 */}
<Outlet />
</div>
)
}
Link 组件属性to 属性有无 / 与当前 URL 的区别在 v5 中,如果 to 没有以 / 开头的话会充满不确定性,这取决于当前的 URL。比如当前 URL 是/category, <Link to="a"> 会渲染成 <a href="/a">; 而当前 URL 如果是 /category/,那么又会渲染成 <a href="/category/a">。在 v6 中,无论当前 URL 是 /category 还是 /category/, <Link to="a"> 都会渲染成 <a href='/category/a'>,即忽略 URL 上的尾部斜杠统一规则处理。to 属性支持相对位置与'..' 和'.'等写法<ul>
<li>当前页面:CategoryA</li>
<li>当前url:/category/a</li>
<li>
{/* /list */}
<Link to="../../list">跳转到list页面</Link>
</li>
<li>
{/* /category/b */}
<Link to="../b">跳转到category/b页面</Link>
</li>
<li>
{/* /category/a */}
<Link to=".">跳转到当前路由</Link>
</li>
</ul>
直接传 state 属性// v5:
<Link to={{ pathname: "/home", state: state }} />
// v6:
<Link to="/home" state={state} />
新增 target 属性type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top' | (string & {})
NavLink<NavLink exact> 属性名改为了 <NavLink end>移除activeStyle、activeClassName属性<NavLink
to="/messages"
- style={{ color: 'blue' }}
- activeStyle={{ color: 'green' }}
+ style={({ isActive }) => ({ color: isActive ? 'green' : 'blue' })}
>
Messages
</NavLink>
移除Redirect重定向组件移除的主要原因是不利于 SEO// v5
<Redirect from="/404" to="/home" />
// v6 使用 Navigate 组件替代
<Route path="/404" element={<Navigate to="/home" replace />} />
新增 useNavigate 代替 useHistory函数组件可以使用useHistory获取history对象,用来做页面跳转导航// v5
import { useHistory } from 'react-router-dom'
export default function Menu() {
const history = useHistory()
return (
<div>
<div
onClick={() => {
history.push('/list')
}}
>
编程式路由跳转list页面
</div>
</div>
)
}
// v6
import { useNavigate } from 'react-router-dom'
export default function Menu() {
const navigate = useNavigate()
return (
<div>
<div
onClick={() => {
navigate('/list') // 等价于 history.push
}}
>
编程式路由跳转list页面
</div>
</div>
)
}
下面再列举其它其它的区别用法//v5
history.replace('/list')
// v6
navigate('/list', { replace: true })
// v5
history.go(1)
history.go(-1)
// v6
navigate(1)
navigate(-1)
新增 useRoutes 代替 react-router-configuseRoutes 根据路由表生成对应的路由规则useRoutes使用必须在<Router>里面react-router-config:用于集中管理路由配置import { useRoutes } from 'react-router-dom'
import Home from './components/Home'
import List from './components/List'
function App() {
const element = useRoutes([
{ path: '/home', element: <Home /> },
{ path: '/list', element: <List /> },
])
return element
}
export default App
新增 useSearchParamsv6 提供 useSearchParams 返回一个数组来获取和设置 url 参数import { useSearchParams } from 'react-router-dom'
export default function Detail() {
const [searchParams, setSearchParams] = useSearchParams()
console.log('getParams', searchParams.get('name'))
return (
<div
onClick={() => {
setSearchParams({ name: 'jacky' })
}}
>
当前页面:Detail 点我设置url查询参数为name=jacky
</div>
)
}
不支持 <Prompt>在老版本中,Prompt组件可以实现页面关闭的拦截,但它在 v6 版本还暂不支持,如果想 v5 升级 v6 就要考虑清楚了。// v5
<Prompt
when={formIsHalfFilledOut}
message="Are you sure you want to leave?"
/>
总结v5 和 v6 在使用层面的区别总结:<Switch> 全部换成 <Routes>Route 新特性变更新增 Outlet 组件用于渲染匹配到的子路由移除Redirect重定向组件,因为不利于 SEO新增 useNavigate 替代 useHistory新增 useRoutes 代替 react-router-config新增 useSearchParams 来获取和设置 url 参数
lucky0-0
手写模拟实现 React Hooks
useStateimport React from 'react'
import ReactDOM from 'react-dom'
let memorizedState
const useState = initialState => {
memorizedState = memorizedState || initialState // 初始化
const setState = newState => {
memorizedState = newState
render() // setState 之后触发重新渲染
}
return [memorizedState, setState]
}
const App = () => {
const [count1, setCount1] = useState(0)
return (
<div>
<div>
<h2>useState: {count1}</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
</div>
</div>
)
}
const render = () => {
ReactDOM.render(<App />, document.getElementById('root'))
}
render()
但到这里会有一个问题,就是当增加第二个 useState 的时候会发现改变两个 state 都是改同一个值,同步变化,于是,我们通过使用数组的方式下标来方式来区分。import React from 'react'
import ReactDOM from 'react-dom'
let memorizedState = [] // 通过数组形式存储有关使用hook的值
let index = 0 // 通过下标记录 state 的值
const useState = initialState => {
let currentIndex = index
memorizedState[currentIndex] = memorizedState[index] || initialState
const setState = newState => {
memorizedState[currentIndex] = newState
render() // setState 之后触发重新渲染
}
return [memorizedState[index++], setState]
}
const App = () => {
const [count1, setCount1] = useState(0)
const [count2, setCount2] = useState(10)
return (
<div>
<div>
<h2>
useState: {count1}--{count2}
</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
<button
onClick={() => {
setCount2(count2 + 10)
}}
>
添加count2
</button>
</div>
</div>
)
}
const render = () => {
index = 0
ReactDOM.render(<App />, document.getElementById('root'))
}
render()
这样就实现效果了:分析:第一次页面渲染的时候,根据 useState 顺序,声明了 count1 和 count2 两个 state,并按下标顺序依次存入数组中当调用setState的时候,更新 count1/count2 的值,触发重新渲染的时候,index 被重置为 0。然后又重新按 useState 的声明顺序,依次拿出最新 state 的值;由此也可见为什么当我们使用 hook 时,要注意点 hook 不能在循环、判断语句内部使用,要声明在组件顶部。memorizedState这个数组我们下面实现部分 hook 都会用到,现在memorizedState数组长度为 2,依次存放着两个使用useState后返回的 state 值;0: 0
1: 10
每次更改数据后,调用render方法,App函数组件重新渲染,又重新调用useState,但外部变量memorizedState之前已经依次下标记录下了 state 的值,故重新渲染是直接赋值之前的 state 值做初始值。知道这个做法,下面的useEffect,useCallback,useMemo也是这个原理。当然实际源码,useState是用链表记录顺序的,这里我们只是模拟效果。useReduceruseReducer 接受 Reducer 函数和状态的初始值作为参数,返回一个数组。数组的第一个成员是状态的当前值,第二个成员是发送 action 的 dispatch 函数。let reducerState
const useReducer = (reducer, initialArg, init) => {
let initialState
if (init !== undefined) {
initialState = init(initialArg) // 初始化函数赋值
} else {
initialState = initialArg
}
const dispatch = action => {
reducerState = reducer(reducerState, action)
render()
}
reducerState = reducerState || initialState
return [reducerState, dispatch]
}
const init = initialNum => {
return { num: initialNum }
}
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { num: state.num + 1 }
case 'decrement':
return { num: state.num - 1 }
default:
throw new Error()
}
}
const App = () => {
const [state, dispatch] = useReducer(reducer, 20, init)
return (
<div>
<div>
<h2>useReducer:{state.num}</h2>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
</div>
</div>
)
}
useEffect对于 useEffect 钩子,当没有依赖值的时候,很容易想到雏形代码:const useEffect = (callback, dependencies) => {
if (!dependencies) {
// 没有添加依赖项则每次执行,添加依赖项为空数组
callback()
}
}
但如果有依赖 state 值,即是我们使用 useState 后返回的值,这部分我们就需要用上方定义的数组 memorizedState 来记录let memorizedState = [] // 存放 hook
let index = 0 // hook 数组下标位置
/**
* useState 实现
*/
const useState = initialState => {
let currentIndex = index
memorizedState[currentIndex] = memorizedState[index] || initialState
const setState = newState => {
memorizedState[currentIndex] = newState
render() // setState 之后触发重新渲染
}
return [memorizedState[index++], setState]
}
/**
* useEffect 实现
*/
const useEffect = (callback, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let lastDependencies = memorizedState[index] // 依赖项数组
let hasChanged = !dependencies.every(
(item, index) => item === lastDependencies[index]
) // 循环遍历依赖项是否与上次的值相同
if (hasChanged) {
// 依赖项有改变就执行 callback 函数
memorizedState[index++] = dependencies
setTimeout(callback) // 设置宏任务,在组件render之后再执行
} else {
index++ // 每个hook占据一个下标位置,防止顺序错乱
}
} else {
// 第一次执行
memorizedState[index++] = dependencies
setTimeout(callback)
}
}
const App = () => {
const [count1, setCount1] = useState(0)
const [count2, setCount2] = useState(10)
useEffect(() => {
console.log('useEffect1')
}, [count1, count2])
useEffect(() => {
console.log('useEffect2')
}, [count1])
return (
<div>
<div>
<h2>
useState: {count1}--{count2}
</h2>
<button
onClick={() => {
setCount1(count1 + 1)
}}
>
添加count1
</button>
<button
onClick={() => {
setCount2(count2 + 10)
}}
>
添加count2
</button>
</div>
</div>
)
}
const render = () => {
index = 0
ReactDOM.render(<App />, document.getElementById('root'))
}
render()
程序第一次执行完毕后,memorizedState 数组值如下0: 0
1: 10
2: [0, 10]
3: [0]
上述代码回调函数执行,本来我们可以用callback()执行即可,但因为useEffect在渲染时是异步执行,并且要等到浏览器将所有变化渲染到屏幕后才会被执行;因为是异步且等页面渲染完毕才执行,根据对 JS 事件循环的理解,我们想要它异步执行任务,就在此创建一个宏任务setTimeout(callback)让它进入宏任务队列等待执行,当然这其中具体的渲染过程我这里就不细说了。还有一个 hook 是useLayoutEffect,除了执行回调的两处地方代码实现不同,其他代码相同,callback这里我用微任务Promise.resolve().then(callback),把函数执行加入微任务队列。因为useLayoutEffect在渲染时是同步执行,会在所有的 DOM 变更之后同步调用,一般可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 将被同步刷新。怎么证明呢?如果你在useLayoutEffect加了死循环,然后重新打开网页,你会发现看不到页面渲染的内容就进入死循环了;而如果是useEffect的话,会看到页面渲染完成后才进入死循环。useLayoutEffect(() => {
while (true) {}
}, [])
useCallbackuseCallback和useMemo会在组件第一次渲染的时候执行,之后会在其依赖的变量发生改变时再次执行;并且这两个 hooks 都返回缓存的值,useMemo 返回缓存计算数据的值,useCallback返回缓存函数的引用。const useCallback = (callback, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let [lastCallback, lastDependencies] = memorizedState[index]
let hasChanged = !dependencies.every(
(item, index) => item === lastDependencies[index]
) // 判断依赖值是否发生改变
if (hasChanged) {
memorizedState[index++] = [callback, dependencies]
return callback
} else {
index++
return lastCallback // 依赖值不变,返回上次缓存的函数
}
} else {
// 第一次执行
memorizedState[index++] = [callback, dependencies]
return callback
}
}
useMemo而useMemo实现与useCallback也很类似,只不过它返回的函数执行后的计算返回值,直接把函数执行了。const useMemo = (memoFn, dependencies) => {
if (memorizedState[index]) {
// 不是第一次执行
let [lastMemo, lastDependencies] = memorizedState[index]
let hasChanged = !dependencies.every(
(item, index) => item === lastDependencies[index]
)
if (hasChanged) {
memorizedState[index++] = [memoFn(), dependencies]
return memoFn()
} else {
index++
return lastMemo
}
} else {
// 第一次执行
memorizedState[index++] = [memoFn(), dependencies]
return memoFn()
}
}
useContext代码出乎意料的少吧...const useContext = context => {
return context._currentValue
}
useRefuseRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。let lastRef
const useRef = value => {
lastRef = lastRef || { current: value }
return lastRef
}
后面几个例子我就没展示出 Demo 了,附上 Github 地址:上述hooks实现和案例
lucky0-0
一文带你深入 React Hooks 原理与源码
renderWithHooks 函数在这篇文章 深入 React 源码 render 阶段的 beginWork 流程 提到,当 React 渲染时,判断当前为函数组件,会执行 updateFunctionComponent// 函数组件
case FunctionComponent: {
return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes)
}
而 updateFunctionComponent 会调用 renderWithHooks 方法,这个方法就是 Hooks 执行的入口了renderWithHooks 方法核心就是确定 ReactCurrentDispatcher,执行函数组件函数function renderWithHooks(
current, // 当前函数组件对应的初始化 fiber
workInProgress, // 当前正在工作的 fiber 对象
Component, // 函数组件本身
props, // 函数组件第一个参数 props
secondArg, // 函数组件其他参数
nextRenderLanes // 下次渲染优先级
) {
renderLanes = nextRenderLanes
// 正在处理的函数组件对应 Fiber
currentlyRenderingFiber = workInProgress
// 置空
workInProgress.memoizedState = null // memoizedState 用于保存 hooks 链表信息
workInProgress.updateQueue = null // 存放副作用存储的链表
workInProgress.lanes = NoLanes
// 首次挂载和更新阶段分配不同的调度器
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate
// 执行函数组件
let children = Component(props, secondArg)
if (didScheduleRenderPhaseUpdateDuringThisPass) {
// 防止太多次数的重新渲染
}
// 还原调度器
ReactCurrentDispatcher.current = ContextOnlyDispatcher
// 重置全局变量
renderLanes = NoLanes
currentlyRenderingFiber = null
currentHook = null
workInProgressHook = null
return children
}
通过上面代码可以总结, renderWithHooks 方法主要工作是:将 workInProgress 赋值给 currentlyRenderingFiber,置空 workInProgress 树的 memoizedState 和 updateQueue判断当前是挂载还是更新阶段,挂载则赋予 ReactCurrentDispatcher.current值为 HooksDispatcherOnMount,更新阶段则赋予值为 HooksDispatcherOnUpdate执行函数组件 Component(props, secondArg)重置全局变量为 null。如 currentHook、workInProgressHook,以及ReactCurrentDispatcher.current 重置为 ContextOnlyDispatcher,防止在错误时机使用 Hook// 首次渲染挂载
const HooksDispatcherOnMount = {
readContext,
useCallback: mountCallback,
useContext: readContext,
useEffect: mountEffect,
useImperativeHandle: mountImperativeHandle,
useLayoutEffect: mountLayoutEffect,
useMemo: mountMemo,
useReducer: mountReducer,
useRef: mountRef,
useState: mountState,
useDebugValue: mountDebugValue,
useDeferredValue: mountDeferredValue,
useTransition: mountTransition,
useMutableSource: mountMutableSource,
useOpaqueIdentifier: mountOpaqueIdentifier,
}
// 更新
const HooksDispatcherOnUpdate = {
readContext,
useCallback: updateCallback,
useContext: readContext,
useEffect: updateEffect,
useImperativeHandle: updateImperativeHandle,
useLayoutEffect: updateLayoutEffect,
useMemo: updateMemo,
useReducer: updateReducer,
useRef: updateRef,
useState: updateState,
useDebugValue: updateDebugValue,
useDeferredValue: updateDeferredValue,
useTransition: updateTransition,
useMutableSource: updateMutableSource,
useOpaqueIdentifier: updateOpaqueIdentifier,
}
也就是说,通过给 ReactCurrentDispatcher 赋值不同的变量来调用 hooks 不同阶段的处理函数。比如使用 useState 的时候,首次挂载实际调用的是HooksDispatcherOnMount.useState,即 mountState 方法;更新时调用的是HooksDispatcherOnUpdate.useState,即updateState 方法。function resolveDispatcher() {
const dispatcher = ReactCurrentDispatcher.current
return dispatcher
}
function useState(initialState) {
const dispatcher = resolveDispatcher()
return dispatcher.useState(initialState)
}
ReactCurrentDispatcher 在函数组件使用时有HooksDispatcherOnMount和HooksDispatcherOnUpdate两种值,当执行完函数组件,就会被重置为 ContextOnlyDispatcher。如果开发时调用了这个形态下的 hooks 会抛出错误,用于防止开发者在函数组件外部调用 hooksexport const ContextOnlyDispatcher = {
readContext,
useCallback: throwInvalidHookError,
useContext: throwInvalidHookError,
useEffect: throwInvalidHookError,
useImperativeHandle: throwInvalidHookError,
useLayoutEffect: throwInvalidHookError,
useMemo: throwInvalidHookError,
useReducer: throwInvalidHookError,
useRef: throwInvalidHookError,
useState: throwInvalidHookError,
useDebugValue: throwInvalidHookError,
useDeferredValue: throwInvalidHookError,
useTransition: throwInvalidHookError,
useMutableSource: throwInvalidHookError,
useOpaqueIdentifier: throwInvalidHookError,
}
function throwInvalidHookError() {
invariant(
false,
'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' +
' one of the following reasons:\n' +
'1. You might have mismatching versions of React and the renderer (such as React DOM)\n' +
'2. You might be breaking the Rules of Hooks\n' +
'3. You might have more than one copy of React in the same app\n' +
'See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.'
)
}
Hooks 原理useStatemountState讲完公共的入口函数,接下来来单独看下每个 Hooks 的源码。上文说到 useState,先来看看挂载时调用的 mountState。function mountState(initialState) {
// 创建 hook 对象,添加到 workInProgressHook 链表
const hook = mountWorkInProgressHook()
// useState 第一个参数为函数,则执行函数进行初始化赋值
if (typeof initialState === 'function') {
initialState = initialState()
}
hook.memoizedState = hook.baseState = initialState
const queue = (hook.queue = {
pending: null,
dispatch: null, // 负责更新 state 的函数
lastRenderedReducer: basicStateReducer, // 自带 reducer 默认值
lastRenderedState: initialState,
})
// 负责更新 state
const dispatch = (queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue))
return [hook.memoizedState, dispatch]
}
这里可以知道,hook.memoizedState 的值就是我们外部使用时 state 的值再来看下 mountWorkInProgressHook 函数,作用是新建一个 hook 对象并添加到 hooks 链表,返回 workInProgressHook,workInProgressHook 可以按 hook 调用顺序指向最新的 hookfunction mountWorkInProgressHook() {
// 创建一个 hook 对象
const hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
}
// 第一个 Hook
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = hook
} else {
// 链表形式串联
workInProgressHook = workInProgressHook.next = hook
}
return workInProgressHook
}
dispatchAction 函数的作用是触发更新function dispatchAction(fiber, queue, action) {
const eventTime = requestEventTime()
const lane = requestUpdateLane(fiber)
// 创建一个 update 对象,记录此次更新的信息
const update = {
lane,
action,
eagerReducer: null,
eagerState: null,
next: null,
}
// 待更新队列
const pending = queue.pending
// 第一次更新
if (pending === null) {
update.next = update // 链表为空,则指向自己本身
} else {
update.next = pending.next
pending.next = update
}
queue.pending = update // 指向最新的 update
const alternate = fiber.alternate
// 判断当前 fiber 是否处在渲染阶段
if (fiber === currentlyRenderingFiber || (alternate !== null && alternate === currentlyRenderingFiber)) {
didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true
} else {
// 当前 fiber 没有处在渲染阶段
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
const lastRenderedReducer = queue.lastRenderedReducer
if (lastRenderedReducer !== null) {
try {
const currentState = queue.lastRenderedState
// 调用 lastRenderedReducer 获取最新的 state
const eagerState = lastRenderedReducer(currentState, action)
update.eagerReducer = lastRenderedReducer
update.eagerState = eagerState
// is 方法原理是 Object.is 进行浅比较
if (is(eagerState, currentState)) {
return
}
} catch (error) {
} finally {
}
}
}
// 调度更新
scheduleUpdateOnFiber(fiber, lane, eventTime)
}
}
触发更新都会调用到 scheduleUpdateOnFiber 方法,它是整个更新任务的开始。这个方法之前文章已经过了下源码,具体可见:深入 React 源码 render 阶段的 beginWork 流程 - scheduleUpdateOnFiber总体做的事情:创建一个 update 对象记录更新信息,加入到待更新的 pending 队列判断当前 fiber 是否处于渲染阶段,是则不需要更新当前函数组件;不是则需要执行更新执行更新的操作:获取最新 state,与上一次的 state 做浅比较,如果相等则不需要更新;不相等则往下执行 scheduleUpdateOnFiber 进行调度更新。Hooks 数据结构,存储 hooks 状态和更新相关的信息:const hook = {
memoizedState: null, // 存储了特定 hook 的缓存状态。对于不同的 hook 其值会有所不同
baseState: null, // 存储了 hook 的初始状态
baseQueue: null, // 初始队列
queue: null, // 需要更新的队列
next: null, // 下一个 hook
}
updateState讲完了 useState 的挂载,接下来来看看更新的函数 updateStatefunction updateState(initialState) {
return updateReducer(basicStateReducer, initialState)
}
function basicStateReducer(state, action) {
return typeof action === 'function' ? action(state) : action
}
const HooksDispatcherOnUpdate = {
useReducer: updateReducer,
useState: updateState,
}
我们可以看到 useState 的更新时调用的是 updateReducer 方法,底层复用了 useReducer 的更新方法,所以可以说 useState 其实就是有默认 reducer 参数(basicStateReducer) 的 useReducer。markSkippedUpdateLanes:React 的调度和渲染过程中,可能会有一些低优先级的更新被跳过,以便更快地处理高优先级的更新。而该函数的主要目的就是将这些被跳过的更新标记起来,以便在后续的渲染过程中重新处理它们updateReducer 主要工作是处理组件的 state 更新,大致如下:合并并处理所有的更新遍历队列的 update 对象,使用 action 和 reducer 计算出最新的状态,并返回最新的 state 和更新 state 的函数function updateReducer(reducer, initialArg, init) {
// 获取当前 fiber 对象的 hook 信息,即 workInProgressHook
const hook = updateWorkInProgressHook()
const queue = hook.queue
queue.lastRenderedReducer = reducer
const current = currentHook
const baseQueue = current.baseQueue
const pendingQueue = queue.pending
if (pendingQueue !== null) {
// ... 将 pending queue 合并到 base queue
}
// 处理更新队列
if (baseQueue !== null) {
const first = baseQueue.next
let newState = current.baseState
let newBaseState = null
let newBaseQueueLast = null
let update = first
// 循环计算新状态
do {
const updateLane = update.lane
// updateLane 的优先级不在当前渲染阶段的优先级范围内时
if (!isSubsetOfLanes(renderLanes, updateLane)) {
// ...
markSkippedUpdateLanes(updateLane) // 将被跳过的更新标记起来,以便在后续的渲染过程中重新处理它们
} else {
// ...
if (update.eagerReducer === reducer) {
newState = update.eagerState
} else {
// 计算新状态
const action = update.action
newState = reducer(newState, action) // 执行 reducer,得到最新 states
}
}
update = update.next
} while (update !== null && update !== first)
// ...
if (!is(newState, hook.memoizedState)) {
// 标记在当前渲染周期中接收到了更新
markWorkInProgressReceivedUpdate() // 该函数只是将 didReceiveUpdate 全局变量设置为 true
}
// 更新 memoizedState 的值
hook.memoizedState = newState
hook.baseState = newBaseState
hook.baseQueue = newBaseQueueLast
queue.lastRenderedState = newState
}
// 返回最新的 state 和更新 state 的函数
const dispatch = queue.dispatch
return [hook.memoizedState, dispatch]
}
上述代码只保留一些关键的逻辑,再来看下 updateWorkInProgressHook 的作用:构建 hooks 链表并按顺序复用上一次的 hook 状态信息,确保 currentHook 和workInProgressHook 有正确的指向。这里要知道一个源码细节帮助理解,就是上面我们提到的,当组件更新的时候,currentHook 和 workInProgressHook 都会被重置为 nullfunction updateWorkInProgressHook() {
let nextCurrentHook
// 当前 fiber 的第一个 Hook
if (currentHook === null) {
const current = currentlyRenderingFiber.alternate
if (current !== null) {
nextCurrentHook = current.memoizedState
} else {
nextCurrentHook = null
}
} else {
nextCurrentHook = currentHook.next
}
let nextWorkInProgressHook
// 当前 fiber 的第一个 Hook
if (workInProgressHook === null) {
nextWorkInProgressHook = currentlyRenderingFiber.memoizedState
} else {
nextWorkInProgressHook = workInProgressHook.next
}
if (nextWorkInProgressHook !== null) {
workInProgressHook = nextWorkInProgressHook
nextWorkInProgressHook = workInProgressHook.next
currentHook = nextCurrentHook
} else {
currentHook = nextCurrentHook
// 生成新的 hook 对象,复用 currentHook
const newHook = {
memoizedState: currentHook.memoizedState, // 上次渲染时所用的 state
baseState: currentHook.baseState, // 一次更新中,产生的最新 state
baseQueue: currentHook.baseQueue, // 最新的 update 队列
queue: currentHook.queue, // 当前 update 队列
next: null,
}
// 第一个 Hook
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = workInProgressHook = newHook
} else {
workInProgressHook = workInProgressHook.next = newHook
}
}
return workInProgressHook
}
useReducermountReducermountReducer 的实现思路与 mountState 大致相同,区别主要在于 mountState 有默认 reducer 参数 basicStateReducer,而 mountReducer 的 reducer 则需依赖外部传入。function mountReducer(reducer, initialArg, init) {
const hook = mountWorkInProgressHook() // 创建 Hook
let initialState
if (init !== undefined) {
initialState = init(initialArg) // 处理初始 state 的函数(可选)
} else {
initialState = initialArg // 第二个参数,指定初始值
}
// 初始值赋值给 hook.memoizedState
hook.memoizedState = hook.baseState = initialState
// 创建更新队列,将 reducer 和 初始化后的 state 传入
const queue = (hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: reducer,
lastRenderedState: initialState,
})
// 负责更新 state
const dispatch = (queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue))
return [hook.memoizedState, dispatch]
}
updateReducer同上,useState 部分已经解析了,此处略过。useEffectmountEffectuseEffect 挂载时调用的是 mountEffect 方法,该方法是返回 mountEffectImpl 的调用结果。mountEffectImpl 则是核心函数,它确保组件挂载时,副作用会被正确地执行和管理,主要工作:创建 hook 对象,添加到 workInProgressHook 链表创建 effect 并添加到 当前 fiber 的 updateQueue 的链表上,并将该 effect 赋值给 hook.memoizedState 属性function useEffect(create, deps) {
const dispatcher = resolveDispatcher()
return dispatcher.useEffect(create, deps)
}
function mountEffect(create, deps) {
return mountEffectImpl(
// UpdateEffect 表示更新 Update;PassiveEffect 表示副作用类型为 useEffect
UpdateEffect | PassiveEffect,
HookPassive,
create, // useEffect 第一个参数,即副作用函数
deps
)
}
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
// 1. 创建 hook 对象,添加到 workInProgressHook 链表
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
// 给当前 fiber 打上 "UpdateEffect | PassiveEffect" 标记
currentlyRenderingFiber.flags |= fiberFlags
// 2. 创建 effect 并添加到 当前 fiber 的 updateQueue 的链表上,最后将该 effect 赋值给 hook.memoizedState 属性
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined, // 首次挂载 destroy 参数传 undefined
nextDeps
)
}
useState 源码的 hook.memoizedState 存的是 state 值,而 useEffect 存的是 pushEffect 函数的返回结果,即 effect 对象(链表)pushEffect 函数主要工作:创建 effect,并添加到 updateQueue 链表,最后返回 effectfunction pushEffect(tag, create, destroy, deps) {
// 1. 创建 effect
const effect = {
tag, // 用于区分 useEffect 和 useLayoutEffect
create,
destroy, // mountEffectImpl 该参数传的是 undefined
deps,
next: null,
}
// 2. 将 effect 添加到 updateQueue 链表
let componentUpdateQueue = currentlyRenderingFiber.updateQueue
// fiber 节点不存在时则初始化 updateQueue
if (componentUpdateQueue === null) {
// 创建新的 updateQueue
componentUpdateQueue = createFunctionComponentUpdateQueue() // 返回 { lastEffect: null }
currentlyRenderingFiber.updateQueue = componentUpdateQueue
componentUpdateQueue.lastEffect = effect.next = effect
} else {
const lastEffect = componentUpdateQueue.lastEffect
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect
} else {
// 构成单向环形链表:lastEffect 为最新的 effect,lastEffect.next 为第一个 effect
const firstEffect = lastEffect.next
lastEffect.next = effect
effect.next = firstEffect
componentUpdateQueue.lastEffect = effect
}
}
return effect
}
updateEffect更新时调用的核心方法是 updateEffectImpl,主要工作是:获取当前 fiber 对象的 hook 信息对比新旧依赖项,如果依赖项不变则创建 effect 到链表中(但最终并不会执行, tag 不含 HookHasEffect);不相等则继续执行,最终也会创建 effect 到链表中(tag 含 HookHasEffect)依赖项变化时:给当前 fiber 打上 PassiveEffect,表示存在需要执行的 useEffect;并在创建 effect 时传入 HookHasEffect,表示有副作用,commit 阶段则会执行 effect 的回调和销毁函数function updateEffect(create, deps) {
return updateEffectImpl(UpdateEffect | PassiveEffect, HookPassive, create, deps)
}
// 这里传进来的 fiberFlags 值为 UpdateEffect | PassiveEffect(给 fiber 使用的标记)
// 这里传进来的 hookFlags 表示 Passive EffectTag (标识是 useEffect 还是 useLayoutEffect)
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
let destroy = undefined
if (currentHook !== null) {
// 上一次的 effect 对象
const prevEffect = currentHook.memoizedState
destroy = prevEffect.destroy
// 有依赖项
if (nextDeps !== null) {
// 上一次的依赖数组
const prevDeps = prevEffect.deps
// 新旧依赖相等
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 相等也把 effect 加入链表,确保顺序一致
pushEffect(hookFlags, create, destroy, nextDeps) // effect tag 少了 HookHasEffect,后续处理看做无更新
return
}
}
}
// 给当前 fiber 打上 "UpdateEffect | PassiveEffect" 标记
currentlyRenderingFiber.flags |= fiberFlags
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags, // HookHasEffect 表示有副作用,这里 hookFlags 传值为 Passive,两者合计表示 useEffect 有副作用
create, // 回调函数
destroy, // 销毁函数
nextDeps // 当前最新的依赖项
)
}
// 用于判断两个依赖数组的值是否相等
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) {
return false
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// Object.is 判断是否相等
if (is(nextDeps[i], prevDeps[i])) {
continue
}
return false
}
return true
}
useLayoutEffectuseEffect 和 useLayoutEffect 底层复用的都是同个函数,只不过第一个和第二个传参不一样,依靠 fiber.flags 和 effect.tag 实现对 effect 的识别。两者最大的区别是 useEffect为异步执行,而 useLayoutEffect 为同步执行。fiber.flags 不同effect.tag 不同function mountLayoutEffect(create, deps) {
return mountEffectImpl(UpdateEffect, HookLayout, create, deps)
}
function updateLayoutEffect(create, deps) {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps)
}
useRefuseRef 的实现是最容易读懂的。mountRef 就是生成一个对象并返回,结构为 { current: 值 },current 属性来保存初始化的值updateRef 则直接返回缓存在 hook.memoizedState 的 ref 对象function mountRef(initialValue) {
const hook = mountWorkInProgressHook()
const ref = { current: initialValue } // 创建ref对象
hook.memoizedState = ref // ref 对象赋值给 memoizedState,保存的是内存地址
return ref // 返回 ref 对象,当 current 属性发生变化时,组件不会重新渲染
}
function updateRef(initialValue) {
const hook = updateWorkInProgressHook()
// 直接返回缓存在 hook.memoizedState 的 ref 对象
return hook.memoizedState
}
useCallbackmountCallback:将 useCallback 的回调函数与依赖项以数组形式保存到 hook.memoizedStateupdateCallback:判断新旧依赖项是否相等,相等则取出上一次的hook.memoizedState 的缓存值,返回上一次回调函数的引用;不相等则返回新传入的回调函数function mountCallback(callback, deps) {
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
// memoizedState 缓存 useCallback 的两个参数的引用数组
hook.memoizedState = [callback, nextDeps]
return callback
}
function updateCallback(callback, deps) {
const hook = updateWorkInProgressHook()
// 获取更新时的依赖项
const nextDeps = deps === undefined ? null : deps
// 取出上一次的数组值
const prevState = hook.memoizedState
if (prevState !== null) {
if (nextDeps !== null) {
// 取出第二个参数依赖项数组
const prevDeps = prevState[1]
// 依赖项不变则取出上一次的回调函数
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0] // 返回缓存的函数
}
}
}
// 依赖项发生变化,返回新传入的函数
hook.memoizedState = [callback, nextDeps]
return callback
}
useMemomountMemo:将 useMemo 的缓存计算结果与依赖项以数组形式保存到 hook.memoizedStateupdateMemo:判断新旧依赖项是否相等,相等则取出上一次的hook.memoizedState 的缓存值,返回上一次计算结果;不相等则返回执行回调函数,返回回调函数的计算结果。function mountMemo(nextCreate, deps) {
const hook = mountWorkInProgressHook()
const nextDeps = deps === undefined ? null : deps
const nextValue = nextCreate() // 执行函数返回计算结果
// memoizedState 缓存 useMemo 的两个参数的引用数组
hook.memoizedState = [nextValue, nextDeps]
return nextValue
}
function updateMemo(nextCreate, deps) {
const hook = updateWorkInProgressHook()
// 获取更新时的依赖项
const nextDeps = deps === undefined ? null : deps
const prevState = hook.memoizedState
if (prevState !== null) {
if (nextDeps !== null) {
// 取出第二个参数依赖项数组
const prevDeps = prevState[1]
// 依赖项不变则取出上一次的计算结果
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0] // 返回缓存的结果值
}
}
}
// 依赖项发生变化
const nextValue = nextCreate() // 重新执行函数返回最新计算结果
hook.memoizedState = [nextValue, nextDeps]
return nextValue
}
看上面代码,可以看出 useMemo 和 useCallback 实现思路几乎一致,区别在于 useMemo 需要会将外部传入的函数 nextCreate() 直接执行,这与两者的用法相关。总结hooks 实现是基于 fiber 的,通常链表形式串起来。每个 fiber 节点的 memoizedState 保存了对应的数据,不同的 hooks 通过使用该对应数据完成对应的逻辑功能。useState/useReducer:memoizedState 等于 state 的值useEffect:memoizedState 保存的是 effect 链表(包含 useEffect 第一个参数回调与第二个参数依赖项数组的值)useRef:memoizedState 等于 { current: 当前值 }useCallback:保存两个参数值,memoizedState 等于 [callback, deps],缓存的是函数useMemo:保存两个参数值,memoizedState 等于 [callback(), deps],缓存的是函数执行计算结果
lucky0-0
一文解读 React 17 与 React 18 的更新变化
React 17 更新首先,官方发布日志称react17最大的特点就是无新特性,这个版本主要目标是让React能渐进式升级,它允许多版本混用共存,可以说是为更远的未来版本做准备了。去除事件池在React17之前,如果使用异步的方式来获取事件e对象,会发现合成事件对象被销毁,如下:function App() {
const handleClick = (e: React.MouseEvent) => {
console.log('直接打印e', e.target) // <button>React事件池</button>
// v17以下在异步方法拿不到事件e,必须先调用 e.persist()
// e.persist()
// 异步方式获取事件e
setTimeout(() => {
console.log('setTimeout打印e', e.target) // null
})
}
return (
<div className="App">
<button onClick={handleClick}>React事件池</button>
</div>
)
}
如果你需要在事件处理函数运行之后获取事件对象的属性,你需要调用 e.persist(),它会将当前的合成事件从事件池中删除,并允许保留对事件的引用。事件池:合成事件对象会被放入池中统一管理。这意味着合成事件对象可以被复用,当所有事件处理函数被调用之后,其所有属性都会被回收释放置空。事件池的好处是在较旧浏览器中重用了不同事件的事件对象以提高性能,但它对现代浏览器的性能优化微乎其微,反而给开发者带来困惑,因此去除了事件池,因此也没有了事件复用机制。function App() {
// v17 去除了 React 事件池,异步方式使用e不再需要 e.persist()
const handleClick = (e: React.MouseEvent) => {
console.log('直接打印e', e.target) // <button>React事件池</button>
setTimeout(() => {
console.log('setTimeout打印e', e.target) // <button>React事件池</button>
})
}
return (
<div className="App">
<button onClick={handleClick}>React事件池</button>
</div>
)
}
事件委托到根节点reactv17前,React 将事件委托到 document 上,在react17中,则委托到根节点const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);
import { useState, useEffect } from 'react'
function App() {
const [isShowText, setIsShowText] = useState(false)
const handleShowText = (e: React.MouseEvent) => {
// e.stopPropagation() // v16无效
// e.nativeEvent.stopImmediatePropagation() // 阻止监听同一事件的其他事件监听器被调用
setIsShowText(true)
}
useEffect(() => {
document.addEventListener('click', () => {
setIsShowText(false)
})
}, [])
return (
<div className="App">
<button onClick={handleShowText}>事件委托变更</button>
{isShowText && <div>展示文字</div>}
</div>
)
}
如上代码,在react16和v17版本,点击按钮时,都不会显示文字。这是因为react的合成事件是基于事件委托的,有事件冒泡,先执行React事件,再执行document上挂载的事件。v16:出于对冒泡的了解,我们直接在按钮事件上加e.stopPropagation(),这样就不会冒泡到document,isShowText 也不会被置为false了。但由于v16版本的事件委托是绑在document上的,它的事件源跟document就是同级了,而不是上下级,所以e.stopPropagation()并没有起作用。如果要阻止冒泡,可以使用原生的e.nativeEvent.stopImmediatePropagation()阻止同级冒泡,这样文字就可以显示了。v17:由于事件委托到根目录root节点,与document属于上下级关系,所以可以直接使用e.stopPropagation()阻止stopImmediatePropagation() 方法可以阻止监听同一事件的其他事件监听器被调用这种更新不仅方便了局部使用 React 的项目,还可以用于项目的渐进式升级,解决不同版本的 React 组件嵌套使用时,e.stopPropagation()无法正常工作的问题更贴近原生浏览器事件对事件系统进行了一些较小的更改:onScroll 事件不再冒泡,以防止出现常见的混淆React 的 onFocus 和 onBlur 事件已在底层切换为原生的 focusin 和 focusout 事件。它们更接近 React 现有行为,有时还会提供额外的信息。blur、focus 和 focusin、focusout 的区别:blur、focus 不支持冒泡,focusin、focusout 支持冒泡捕获事件(例如,onClickCapture)现在使用的是实际浏览器中的捕获监听器。这些更改会使 React 与浏览器行为更接近,并提高了互操作性。尽管 React 17 底层已将 onFocus 事件从 focus 切换为 focusin,但请注意,这并未影响冒泡行为。在 React 中,onFocus 事件总是冒泡的,在 React 17 中会继续保持,因为通常它是一个更有用的默认值全新的 JSX 转换器总结下来就是两点:用 jsx() 函数替换 React.createElement()运行时自动引入 jsx() 函数,无需手写引入react在v16中,我们写一个React组件,总要引入import React from 'react'
这是因为在浏览器中无法直接使用 jsx,所以要借助工具如@babel/preset-react将 jsx 语法转换为 React.createElement 的 js 代码,所以需要显式引入 React,才能正常调用 createElement。通过React.createElement() 创建元素是比较频繁的操作,本身也存在一些问题,无法做到性能优化,具体可见官方优化的 动机v17之后,React 与 Babel 官方进行合作,直接通过将 react/jsx-runtime 对 jsx 语法进行了新的转换而不依赖 React.createElement,因此v17使用 jsx 语法可以不引入 React,应用程序依然能正常运行。function App() {
return <h1>Hello World</h1>;
}
// 新的 jsx 转换为
// 由编译器引入(禁止自己引入!)
import { jsx as _jsx } from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}
如何升级至新的 JSX 转换更新至支持新转换的 React 版本(v17)如果你还在使用v16,也可升级至 React v16.14.0 的版本,官方在该版本也支持这个特性。修改配置@babel/preset-react编译增加 runtime: 'automatic'配置// 如果你使用的是 @babel/preset-react
{
"presets": [
["@babel/preset-react", {
"runtime": "automatic"
}]
]
}
// 如果你使用的是 @babel/plugin-transform-react-jsx
{
"plugins": [
["@babel/plugin-transform-react-jsx", {
"runtime": "automatic"
}]
]
}
修改 tsconfig.json 配置,具体配置可见TS官方文档{
"compilerOptions": {
// "jsx": "react",
"jsx": "react-jsx",
},
}
从 Babel 8 开始,"automatic" 会将两个插件默认集成在 rumtime 中副作用清理时机useEffect(() => {
// This is the effect itself.
return () => {
// This is its cleanup.
}
})
v17前,组件被卸载时,useEffect的清理函数都是同步运行的;对于大型应用程序来说,同步会减缓屏幕的过渡(如切换标签)v17后,useEffect 副作用清理函数是异步执行的,如果要卸载组件,则清理会在屏幕更新后运行此外,v17 将在运行任何新副作用之前执行所有副作用的清理函数(针对所有组件),v16 只对组件内的副作用保证这种顺序。不过需要注意useEffect(() => {
someRef.current.someSetupMethod();
return () => {
someRef.current.someCleanupMethod();
};
});
问题在于 someRef.current 是可变的且因为异步的,在运行清除函数时,它可能已经设置为 null。// 用一个变量量在 ref 每次变化时,将 someRef.current 保存起来,放到副作用清理回调函数的闭包中,来保证不可变性。
useEffect(() => {
const instance = someRef.current
instance.someSetupMethod()
return () => {
instance.someCleanupMethod()
}
})
或者用 useLayoutEffectuseLayoutEffect(() => {
someRef.current.someSetupMethod();
return () => {
someRef.current.someCleanupMethod();
};
});
useLayoutEffect 可以保证回调函数同步执行,这样就能确保 ref 此时还是最后的值。返回一致的 undefined 错误在v17以前,组件返回undefined始终是一个错误。但是有漏网之鱼,React 只对类组件和函数组件执行此操作,但并不会检查 forwardRef 和 memo 组件的返回值。function Button() {
return; // Error: Nothing was returned from render
}
function Button() {
// We forgot to write return, so this component returns undefined.
// React surfaces this as an error instead of ignoring it.
<button />;
}
在 v17 中修复了这个问题,forwardRef 和 memo 组件的行为会与常规函数组件和类组件保持一致,在返回 undefined 时会报错let Button = forwardRef(() => {
// We forgot to write return, so this component returns undefined.
// React 17 surfaces this as an error instead of ignoring it.
<button />;
});
let Button = memo(() => {
// We forgot to write return, so this component returns undefined.
// React 17 surfaces this as an error instead of ignoring it.
<button />;
});
原生组件栈v16中错误调用栈的缺点:缺少源码位置追溯,在控制台无法点击跳转到到出错的地方无法适用于生产环境整体来说不如原生的 JavaScript 调用栈,不同于常规压缩后的 JavaScript 调用栈,它们可以通过 sourcemap 的形式自动恢复到原始函数的位置,而使用 React 组件栈,在生产环境下必须在调用栈信息和 bundle 大小间进行选择。在v17使用了不同的机制生成组件调用栈,直接从 JavaScript 原生错误栈生成的,所以在生产环境也能按sourcemap 还原回来,且支持点击跳到源码位置。想详细了解的可见该 PR移除私有导出 APIv17 删除了一些私有 API,主要是 React Native for Web 使用的另外,还删除了ReactTestUtils.SimulateNative工具方法,因为其行为与语义不符,如果你想要一种简便的方式来触发测试中原生浏览器的事件,可直接使用 React Testing Library启发式更新算法更新引用 React17新特性:启发式更新算法React16的expirationTimes模型只能区分是否>=expirationTimes决定节点是否更新。React17的lanes模型可以选定一个更新区间,并且动态的向区间中增减优先级,可以处理更细粒度的更新。这个我目前也不是太清楚具体算法,先不展开了有兴趣的可去查阅相关资料React 18 更新并发模式v18的新特性是使用现代浏览器的特性构建的,彻底放弃对 IE 的支持。v17 和 v18 的区别就是:从同步不可中断更新变成了异步可中断更新,v17可以通过一些试验性的API开启并发模式,而v18则全面开启并发模式。并发模式可帮助应用保持响应,并根据用户的设备性能和网速进行适当的调整,该模式通过使渲染可中断来修复阻塞渲染限制。在 Concurrent 模式中,React 可以同时更新多个状态。这里参考下文区分几个概念:并发模式是实现并发更新的基本前提v18 中,以是否使用并发特性作为是否开启并发更新的依据。并发特性指开启并发模式后才能使用的特性,比如:useDeferredValue/useTransition可阅读参考 Concurrent Mode(并发模式)更新 render APIv18 使用 ReactDOM.createRoot() 创建一个新的根元素进行渲染,使用该 API,会自动启用并发模式。如果你升级到v18,但没有使用ReactDOM.createRoot()代替ReactDOM.render()时,控制台会打印错误日志要提醒你使用React18,该警告也意味此项变更没有造成breaking change,而可以并存,当然尽量是不建议。// v17
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
// v18
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<App />)
自动批处理批处理是指 React 将多个状态更新,聚合到一次 render 中执行,以提升性能在v17的批处理只会在事件处理函数中实现,而在Promise链、异步代码、原生事件处理函数中失效。而v18则所有的更新都会自动进行批处理。// v17
const handleBatching = () => {
// re-render 一次,这就是批处理的作用
setCount((c) => c + 1)
setFlag((f) => !f)
}
// re-render两次
setTimeout(() => {
setCount((c) => c + 1)
setFlag((f) => !f)
}, 0)
// v18
const handleBatching = () => {
// re-render 一次
setCount((c) => c + 1)
setFlag((f) => !f)
}
// 自动批处理:re-render 一次
setTimeout(() => {
setCount((c) => c + 1)
setFlag((f) => !f)
}, 0)
如果在某些场景不想使用批处理,可以使用 flushSync退出批处理,强制同步执行更新。flushSync 会以函数为作用域,函数内部的多个 setState 仍然是批量更新const handleAutoBatching = () => {
// 退出批处理
flushSync(() => {
setCount((c) => c + 1)
})
flushSync(() => {
setFlag((f) => !f)
})
}
Suspense 支持 SSRSSR 一次页面渲染的流程:服务器获取页面所需数据将组件渲染成 HTML 形式作为响应返回客户端加载资源(hydrate)执行 JS,并生成页面最终内容上述流程是串行执行的,v18前的 SSR 有一个问题就是它不允许组件"等待数据",必须收集好所有的数据,才能开始向客户端发送HTML。如果其中有一步比较慢,都会影响整体的渲染速度。v18 中使用并发渲染特性扩展了Suspense的功能,使其支持流式 SSR,将 React 组件分解成更小的块,允许服务端一点一点返回页面,尽早发送 HTML和选择性的 hydrate, 从而可以使SSR更快的加载页面<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
具体可参考文章 React 18 中新的 Suspense SSR 架构startTransitionTransitions 是 React 18 引入的一个全新的并发特性。它允许你将标记更新作为一个 transitions(过渡),这会告诉 React 它们可以被中断执行,并避免回到已经可见内容的 Suspense 降级方案。本质上是用于一些不是很急迫的更新上,用来进行并发控制在v18之前,所有的更新任务都被视为急迫的任务,而Concurrent Mode 模式能将渲染中断,可以让高优先级的任务先更新渲染。React 的状态更新可以分为两类:紧急更新:比如点击按钮、搜索框打字是需要立即响应的行为,如果没有立即响应给用户的体验就是感觉卡顿延迟过渡/非紧急更新:将 UI 从一个视图过渡到另一个视图。一些延迟可以接受的更新操作,不需要立即响应startTransition API 允许将更新标记为非紧急事件处理,被startTransition包裹的会延迟更新的state,期间可能被其他紧急渲染所抢占。因为 React 会在高优先级更新渲染完成之后,才会渲染低优先级任务的更新React 无法自动识别哪些更新是优先级更高的。比如用户的键盘输入操作后,setInputValue会立即更新用户的输入到界面上,是紧急更新。而setSearchQuery是根据用户输入,查询相应的内容,是非紧急的。const [inputValue, setInputValue] = useState()
const onChange = (e)=>{
setInputValue(e.target.value) // 更新用户输入值(用户打字交互的优先级应该要更高)
setSearchQuery(e.target.value) // 更新搜索列表(可能有点延迟,影响)
}
return (
<input value={inputValue} onChange={onChange} />
)
React无法自动识别,所以它提供了 startTransition让我们手动指定哪些更新是紧急的,哪些是非紧急的,从而让我们改善用户交互体验。// 紧急的更新
setInputValue(e.target.value)
// 开启并发更新
startTransition(() => {
setSearchQuery(input) // 非紧急的更新
})
这里有个比较好的在线例子,可以直接感受到 startTransition的优化useTransition当有过渡任务(非紧急更新)时,我们可能需要告诉用户什么时候当前处于 pending(过渡) 状态,因此v18提供了一个带有isPending标志的 Hook useTransition来跟踪 transition 状态,用于过渡期。useTransition 执行返回一个数组。数组有两个状态值:isPending: 指处于过渡状态,正在加载中startTransition: 通过回调函数将状态更新包装起来告诉 React这是一个过渡任务,是一个低优先级的更新function TransitionTest() {
const [isPending, startTransition] = useTransition()
const [count, setCount] = useState(0)
function handleClick() {
startTransition(() => {
setCount((c) => c + 1)
})
}
return (
<div>
{isPending && <div>spinner...</div>}
<button onClick={handleClick}>{count}</button>
</div>
)
}
直观感觉这有点像 setTimeout,而防抖节流其实本质也是setTimeout,区别是防抖节流是控制了执行频率,让渲染次数减少了,而 v18的 transition 则没有减少渲染的次数。useDeferredValueuseDeferredValue 和 useTransition 一样,都是标记了一次非紧急更新。useTransition是处理一段逻辑,而useDeferredValue是产生一个新状态,它是延时状态,这个新的状态则叫 DeferredValue。所以使用useDeferredValue可以推迟状态的渲染useDeferredValue 接受一个值,并返回该值的新副本,该副本将推迟到紧急更新之后。如果当前渲染是一个紧急更新的结果,比如用户输入,React 将返回之前的值,然后在紧急渲染完成后渲染新的值。function Typeahead() {
const query = useSearchQuery('');
const deferredQuery = useDeferredValue(query);
// Memoizing 告诉 React 仅当 deferredQuery 改变,
// 而不是 query 改变的时候才重新渲染
const suggestions = useMemo(() =>
<SearchSuggestions query={deferredQuery} />,
[deferredQuery]
);
return (
<>
<SearchInput query={query} />
<Suspense fallback="Loading results...">
{suggestions}
</Suspense>
</>
);
}
这样一看,useDeferredValue直观就是延迟显示状态,那用防抖节流有什么区别呢?如果使用防抖节流,比如延迟300ms显示则意味着所有用户都要延时,在渲染内容较少、用户CPU性能较好的情况下也是会延迟300ms,而且你要根据实际情况来调整延迟的合适值;但是useDeferredValue是否延迟取决于计算机的性能。感兴趣可以看下这篇文章:usedeferredvalue-in-react-18在线例子useIduseId支持同一个组件在客户端和服务端生成相同的唯一的 ID,避免 hydration 的不匹配,原理就是每个 id 代表该组件在组件树中的层级结构。function Checkbox() {
const id = useId()
return (
<>
<label htmlFor={id}>Do you like React?</label>
<input id={id} type="checkbox" name="react" />
</>
)
}
这里涉及到 SSR 部分知识,这里不展开了,可以阅读该篇文章理解:为了生成唯一id,React18专门引入了新Hook:useId提供给第三方库的 Hook这两个 Hook 日常开发基本用不到,简单带过useSyncExternalStoreuseSyncExternalStore 一般是第三方状态管理库使用如 Redux。它通过强制的同步状态更新,使得外部 store 可以支持并发读取。它实现了对外部数据源订阅时不再需要 useEffectconst state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
useInsertionEffectuseInsertionEffect 仅限于 css-in-js 库使用。它允许 css-in-js 库解决在渲染中注入样式的性能问题。 执行时机在 useLayoutEffect 之前,只是此时不能使用ref和调度更新,一般用于提前注入样式。useInsertionEffect(() => {
console.log('useInsertionEffect 执行')
}, [])
lucky0-0
深入 React 合成事件机制源码与原理
前言本文源码基于 React v17.0.2,React 事件的基础知识就不回顾了,主要从 React 原理切入,从事件注册到事件执行的整个链路。合成事件是什么React 基于浏览器事件机制实现了一套自身的机制,即浏览器原生事件的跨浏览器包装器。为什么需要合成事件兼容所有浏览器,更好的跨平台方便 React 统一进行事件管理,更好地控制事件的执行链路React 17 事件特性v17 与 v16 相比:v16 React 将事件委托到 document 上;v17 则委托到根节点。去除事件池onScroll 事件不再冒泡,以防止出现常见的混淆React 的 onFocus 和 onBlur 事件已在底层切换为原生的 focusin 和 focusout 事件。它们更接近 React 现有行为,有时还会提供额外的信息。捕获事件(例如 onClickCapture)现在使用的是实际浏览器中的捕获监听器。事件注册事件注册是在顶层自执行的,在 React 自身引入文件的时候调用的。注册事件(React 将同种类型的事件放在一个插件中):import * as BeforeInputEventPlugin from './plugins/BeforeInputEventPlugin'
import * as ChangeEventPlugin from './plugins/ChangeEventPlugin'
import * as EnterLeaveEventPlugin from './plugins/EnterLeaveEventPlugin'
import * as SelectEventPlugin from './plugins/SelectEventPlugin'
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin'
// 原生 DOM 事件名称与 React 事件名称映射关系。其中一个作用就是给 allNativeEvents 注入所有原生事件名,下文会用到
SimpleEventPlugin.registerEvents()
EnterLeaveEventPlugin.registerEvents()
ChangeEventPlugin.registerEvents()
SelectEventPlugin.registerEvents()
BeforeInputEventPlugin.registerEvents()
registerEvents 用于初始化原生事件(其中一个作用是为 allNativeEvents集合注入原生事件名)事件插件SimpleEventPlugin:处理常见的 DOM 事件EnterLeaveEventPlugin:处理鼠标进入离开时事件ChangeEventPlugin:处理表单元素上的 onChange 事件SelectEventPlugin:负责处理表单元素上的 onSelect 事件BeforeInputEventPlugin:用于处理 input、textarea 或者 contentEditable 元素上的 onBeforeInput 事件事件绑定源码细节在 React 初始化渲染的时候,ReactDOM.render 会调用函数 listenToAllSupportedEvents 来绑定事件function createRootImpl(container, tag, options) {
// 在根容器上监听支持的事件
const rootContainerElement = container.nodeType === COMMENT_NODE ? container.parentNode : container
listenToAllSupportedEvents(rootContainerElement)
}
代码位置:packages/react-dom/src/events/DOMPluginEventSystem.js,只列出 listenToAllSupportedEvents 的核心代码:function listenToAllSupportedEvents(rootContainerElement) {
if (!rootContainerElement[listeningMarker]) {
// allNativeEvents 是一个 Set 集合,保存所有浏览器原生事件名
allNativeEvents.forEach((domEventName) => {
// 判断是否支持冒泡的事件,不支持的话无需事件委托到根节点
if (!nonDelegatedEvents.has(domEventName)) {
// 冒泡阶段绑定事件
listenToNativeEvent(domEventName, false, rootContainerElement, null)
}
// 捕获阶段绑定事件
listenToNativeEvent(domEventName, true, rootContainerElement, null)
})
}
}
listenToAllSupportedEvents 的核心逻辑:通过 listenToNativeEvent 来绑定浏览器事件,且绑定在 rootContainerElement 根节点。如果是支持冒泡的事件,则捕获阶段和冒泡阶段都绑定事件;不支持冒泡的事件,则只绑定捕获阶段的事件。allNativeEvents:是一个 Set 集合,保存了 80 个浏览器原生 DOM 事件nonDelegatedEvents:Set 集合,保存浏览器原生事件中不会冒泡的事件,如 load,scroll,媒体事件 canplay,play 等等接下来看看 listenToNativeEvent 的实现:创建事件委托的回调函数根据事件是捕获阶段还是冒泡阶段,调用不同的事件绑定函数function listenToNativeEvent(
domEventName,
isCapturePhaseListener,
rootContainerElement,
targetElement,
eventSystemFlags
) {
// 绑定事件
addTrappedEventListener(target, domEventName, eventSystemFlags, isCapturePhaseListener)
}
function addTrappedEventListener(
targetContainer,
domEventName,
eventSystemFlags,
isCapturePhaseListener,
isDeferredListenerForLegacyFBSupport
) {
// 创建事件委托的回调函数(其实是事件派发器)
let listener = createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags)
// 根据事件是捕获阶段还是冒泡阶段,调用不同的绑定函数
if (isCapturePhaseListener) {
unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener)
} else {
unsubscribeListener = addEventBubbleListener(targetContainer, domEventName, listener)
}
}
// 表示在冒泡阶段触发事件处理函数
function addEventBubbleListener(target, eventType, listener) {
// 第三个参数为 false(冒泡阶段)
target.addEventListener(eventType, listener, false) // 需要注意这里的 listener 是事件派发器,并不是我们自己使用时写的事件回调
return listener
}
// 表示在捕获阶段触发事件处理函数
function addEventCaptureListener(target, eventType, listener) {
// 第三个参数为 true(捕获阶段)
target.addEventListener(eventType, listener, true)
return listener
}
再来看看上面的函数 createEventListenerWrapperWithPriority 的实现:export function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
// 根据事件名获取事件的优先级
const eventPriority = getEventPriorityForPluginSystem(domEventName)
let listenerWrapper
// 根据事件优先级返回对应的事件监听函数
switch (eventPriority) {
// 离散事件
case DiscreteEvent: // 优先级最高
listenerWrapper = dispatchDiscreteEvent
break
// 用户交互阻塞渲染的事件
case UserBlockingEvent: // 优先级适中
listenerWrapper = dispatchUserBlockingUpdate
break
// 其它事件
case ContinuousEvent: // 优先级最低
default:
listenerWrapper = dispatchEvent
break
}
// 返回事件回调函数 listener
return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer)
}
由上述代码可以看出,不同的 DOM 事件调用 getEventPriorityForPluginSystem 会返回不同的优先级,优先级包括:DiscreteEvent:离散事件。如 click、keydown、focusin 等,这些事件的触发不是连续的,可以快速响应,优先级最高UserBlockingEvent:用户交互阻塞渲染的事件。如 drag、scroll 等,优先级适中ContinuousEvent 与 default:连续事件和默认事件。连续事件如 playing、load 等,优先级最低而前两个对应的 dispatchDiscreteEvent 和 dispatchUserBlockingUpdate 其实都是对 dispatchEvent 的封装,所以下文我们重点看 dispatchEvent 函数就行了。listenToNativeEvent看完上面的代码,让我们来抽离出最核心的代码,把函数调用代码去掉抽离整合如下:function listenToNativeEvent(domEventName, isCapturePhaseListener, target) {
const listener = dispatchEvent.bind(null, domEventName, eventSystemFlags, targetContainer)
if (isCapturePhaseListener) {
target.addEventListener(eventType, listener, true)
} else {
target.addEventListener(eventType, listener, false)
}
}
核心流程React 初始化时,会在根节点上绑定原生事件支持冒泡的事件,React 会同时绑定捕获阶段和冒泡阶段的事件;不支持冒泡的事件,React 则只绑定捕获阶段的事件React 将事件分为三种优先级类型,在绑定事件处理函数时会使用不同的回调函数,但底层都是调用 dispatchEvent 函数我们也可以知道,事件在根节点中代理后是一直在触发的,只是没有绑定对应的回调函数。问:React 事件都是在顶层进行代理派发执行的,对不支持冒泡的事件,React 如何触发在根节点 React 只绑定了不支持冒泡事件的捕获阶段,而实际上 React 会对不支持冒泡的事件(除了 scroll)进行特殊处理,这个过程发生在 DOM 实例的创建阶段(completeWork),React 会直接把事件绑定在具体的元素上function setInitialProperties(domElement, tag, rawProps, rootContainerElement) {
switch (tag) {
case 'img':
case 'image':
case 'link':
// We listen to these events in case to ensure emulated bubble
// listeners still fire for error and load events.
listenToNonDelegatedEvent('error', domElement) // 传入事件名与具体的 DOM 元素
listenToNonDelegatedEvent('load', domElement)
props = rawProps
break
// ...
}
}
// 绑定非代理事件
function listenToNonDelegatedEvent(domEventName, targetElement) {
const isCapturePhaseListener = false // 非捕获阶段(冒泡阶段) useCapture: false
const listenerSet = getEventListenerSet(targetElement)
const listenerSetKey = getListenerSetKey(domEventName, isCapturePhaseListener)
if (!listenerSet.has(listenerSetKey)) {
// 绑定事件
addTrappedEventListener(
targetElement, // 绑定到具体 DOM 元素
domEventName,
IS_NON_DELEGATED, // 非代理事件
isCapturePhaseListener
)
listenerSet.add(listenerSetKey)
}
}
事件触发dispatchEvent 调用链路dispatchEvent 函数执行时调用的关键函数如下:1. `attemptToDispatchEvent`
2. `dispatchEventForPluginEventSystem`
3. `dispatchEventsForPlugins`
4. `extractEvents`(`SimpleEventPlugin.extractEvents`...)
5. `processDispatchQueue`
当 DOM 事件触发之后, 会进入到 dispatchEvent这个回调函数,里面会调用 attemptToDispatchEvent 这个方法,作用是尝试调度事件function dispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
// ...
// 尝试派发事件
const blockedOn = attemptToDispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent)
// 尝试派发事件成功,则 return, 下方的代码无需执行
if (blockedOn === null) {
// ...
return
}
// 派发事件
dispatchEventForPluginEventSystem(domEventName, eventSystemFlags, nativeEvent, null, targetContainer)
}
attemptToDispatchEvent 函数的逻辑:获取触发事件的 DOM 元素根据该 DOM 元素对应的 fiber 节点通过事件插件系统,派发事件function attemptToDispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
// 获取触发事件的 DOM 元素(即获取 nativeEvent.target 属性)
const nativeEventTarget = getEventTarget(nativeEvent) // nativeEvent 是原生事件对象
// 根据该 DOM 元素对应的 fiber 节点
let targetInst = getClosestInstanceFromNode(nativeEventTarget)
// ...
// 通过插件系统,派发事件
dispatchEventForPluginEventSystem(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer)
}
dispatchEventForPluginEventSystem 会收集 Fiber 节点上的事件,并派发事件(批量更新 batchUpdate)function dispatchEventForPluginEventSystem(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
// 打开批处理
batchedEventUpdates(() =>
dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, ancestorInst, targetContainer)
)
}
function dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
// 获取触发事件的 DOM 元素(即获取 nativeEvent.target 属性)
const nativeEventTarget = getEventTarget(nativeEvent)
// 初始化事件派发队列,用于储存 listener
const dispatchQueue = []
// 1. 创建合成事件,并收集同类型事件添加到 dispatchQueue 中
extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer
)
// 2. 根据事件派发队列执行事件派发
processDispatchQueue(dispatchQueue, eventSystemFlags)
}
合成事件extractEvents 函数会进行事件合成,遍历 Fiber 链表,把收集同类型事件加入到 dispatchQueue 队列。function extractEvents(dispatchQueue, domEventName, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags, targetContainer) {
SimpleEventPlugin.extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);
const shouldProcessPolyfillPlugins =
(eventSystemFlags & SHOULD_NOT_PROCESS_POLYFILL_EVENT_PLUGINS) === 0;
if (shouldProcessPolyfillPlugins) {
EnterLeaveEventPlugin.extractEvents(...)
ChangeEventPlugin.extractEvents(...)
SelectEventPlugin.extractEvents(...)
BeforeInputEventPlugin.extractEvents(...)
}
}
在合成事件中,会根据 domEventName 来决定使用哪种类型的合成事件。SimpleEventPlugin 提供了 React 事件系统的基本功能,以 SimpleEventPlugin.extractEvents 为例,看看这个函数内部的关键代码:根据原生事件名,得到对应的 React 事件名,并根据不同原生事件名取不同的合成事件构造函数(如 SyntheticEventCtor)从当前 Fiber 节点出发,分别在捕获阶段和冒泡阶段收集节点上所有监听该事件的 listener往事件派发队列 dispatchQueue 添加事件(在此注入合成事件实例和收集的同类型事件数组)// 根据原生事件名得到 React 事件名
const reactName = topLevelEventsToReactNames.get(domEventName)
// 合成事件实例
let SyntheticEventCtor = SyntheticEvent
// switch (domEventName) // 不同事件名
// SyntheticEventCtor = xxx // 赋予相应的合成事件构造函数
// 收集节点上所有监听该事件的 listener,向上遍历直到根节点
const listeners = accumulateSinglePhaseListeners(
targetInst,
reactName,
nativeEvent.type,
inCapturePhase,
accumulateTargetOnly
)
if (listeners.length > 0) {
const event = new SyntheticEventCtor(reactName, reactEventType, null, nativeEvent, nativeEventTarget)
// 往事件派发队列添加事件(注入合成事件实例与同类型事件监听数组)
dispatchQueue.push({ event, listeners })
}
问:如何收集 DOM 节点上的事件?答:<div onClick={() => { console.log('click) }}></div>
React 会给该 div Fiber 节点的 props 上添加 onClick 属性;Fiber 节点中有一个属性 return,通过它可以找到它对应的父节点 Fiber ,这样就可以依次向上遍历父节点的 props 属性有无 onClick 属性,有则添加收集起来,所谓收集也就是从 props 中取出来。抽离整理 SyntheticEventCtor的关键实现:export const SyntheticEvent = createSyntheticEvent(EventInterface)
// 不同事件类型不同的 Interface
function createSyntheticEvent(Interface) {
function SyntheticBaseEvent(reactName, reactEventType, targetInst, nativeEvent, nativeEventTarget) {
this.isPropagationStopped = functionThatReturnsFalse
// ...
// 在合成事件构造函数的原型上添加
Object.assign(SyntheticBaseEvent.prototype, {
// 阻止默认事件
preventDefault: function () {
if (event.preventDefault) {
event.preventDefault()
}
this.isDefaultPrevented = functionThatReturnsTrue
},
// 阻止冒泡
stopPropagation: function () {
if (event.stopPropagation) {
event.stopPropagation()
}
this.isPropagationStopped = functionThatReturnsTrue
},
// 17 版本去除了事件池,persist 和 isPersistent 都没有用了,但为了向下兼容保留
persist: function () {
// Modern event system doesn't use pooling.
},
isPersistent: functionThatReturnsTrue,
})
}
return SyntheticBaseEvent
}
function functionThatReturnsTrue() {
return true
}
这里就可以清晰知道,我们平时在 React 事件使用的 e.preventDefault 和 e.stopPropagation 都是 React 重写封装的,而且是写在合成对象构造函数原型上,且同类型的事件会复用同一个合成事件实例对象。事件派发processDispatchQueue函数:遍历 dispatchQueue 数组 ,调用 processDispatchQueueItemsInOrder 函数派发事件function processDispatchQueue(dispatchQueue, eventSystemFlags) {
// 是否是捕获阶段,关系到后面执行的顺序
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0
// 循环收集的事件数组
for (let i = 0; i < dispatchQueue.length; i++) {
const { event, listeners } = dispatchQueue[i]
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase)
}
}
processDispatchQueueItemsInOrder:收集 dispatchListeners 时,是从当前 Fiber 节点遍历至根节点,所以可理解顺序遍历就是冒泡的顺序根据事件阶段(冒泡或捕获)来决定是顺序还是倒序遍历合成事件中的 listeners。捕获阶段是从上往下调用 Fiber 树中绑定的回调函数,所以倒序遍历;而冒泡阶段是从下往上调用 Fiber 中的回调函数,所以是顺序遍历最后执行 executeDispatch 真正派发了事件,在 Fiber 节点上绑定的 listener 也就被执行了。function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {
let previousInstance
if (inCapturePhase) {
// 捕获阶段,倒序遍历
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const { instance, currentTarget, listener } = dispatchListeners[i]
// 判断当前是否已停止冒泡了,是则直接 return
// 如果 e.stopPropagation() 方法被调用过,则会一直返回 true,否则默认一直返回 false
if (instance !== previousInstance && event.isPropagationStopped()) {
return
}
executeDispatch(event, listener, currentTarget)
previousInstance = instance
}
} else {
// 冒泡阶段,顺序遍历
for (let i = 0; i < dispatchListeners.length; i++) {
const { instance, currentTarget, listener } = dispatchListeners[i]
if (instance !== previousInstance && event.isPropagationStopped()) {
return
}
executeDispatch(event, listener, currentTarget)
previousInstance = instance
}
}
}
function executeDispatch(event, listener, currentTarget) {
const type = event.type || 'unknown-event'
event.currentTarget = currentTarget
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event)
event.currentTarget = null // 重置
}
由上面可知,React 模拟原生事件捕获与冒泡的执行顺序,本质是靠向上搜集事件后,控制事件的遍历顺序去模拟的。核心流程在触发事件之前,React 会根据当前实际触发事件的 DOM 元素找到其 Fiber 节点,向上收集同类型事件添加到事件队列中。根据事件阶段(冒泡/捕获),来决定(顺序/倒序)遍历执行事件函数。当调用 React 阻止冒泡方法时,就是把变量 isPropagationStopped 设置为一个返回 true 的函数,后续派发事件时只要代码判断时则执行函数结果为 true 则表示阻止冒泡,就不再走下面逻辑。React 事件原理概述接下来读完全文,来总结一下 React 事件机制:React 代码执行时,顶层会自动执行事件的注册,初始化事件插件。React 首次渲染时,会在根节点上绑定所有原生事件。支持冒泡的事件,React 会同时绑定捕获阶段和冒泡阶段的事件;不支持冒泡的事件,会将事件绑定在具体 DOM 元素上。事件触发前会从目标元素的 Fiber 节点向上收集同类型事件队列,构造合成对象,同类型的事件会复用同一个合成事件实例对象。根据监听的事件阶段,决定顺序还是倒序遍历执行事件处理函数(模拟事件的冒泡捕获机制)。
lucky0-0
深入 React 系列
进阶学习 React 系列
lucky0-0
雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)
前言这一期文章的内容有点杂,实现了通过雪花算法生成id、通用的附件方案,邮箱验证、修改密码功能。有很多兄弟对这个项目会开发哪些功能感到好奇,在后面我列了一下后续项目的功能清单。雪花算法数据库主键id生成方案数据库主键id有几种常用的方案:自增方案数据库可以设置主键id自增,但是后续数据量大的情况下,如果使用水平分表方式优化,可能会生成重复ID。并且id是连续的,别人可以轻松猜到下一条数据id。uuidUUID 生成的是一个无序且唯一的字符串,这种数据正常来说很适合做数据库id,但是它也有自己的缺点。存储空间占用:UUID通常以128位的形式表示,相比于较短的整型或字符串标识符,需要更多的存储空间。这可能在大规模数据集合和索引中导致存储成本和性能方面的负担。可读性差:UUID是由数字和字母组成的字符串,对人类来说不易读写和记忆。当需要手动查询或分析数据库时,可读性差可能造成不便。索引效率下降:UUID具有随机性,生成的值没有明显的顺序性或局部性。这导致在使用UUID作为主键或索引时,插入新记录的效率会下降,因为新记录通常被插入到已有索引的各个位置,而不是一个连续的位置。查询性能影响:在某些情况下,由于UUID的无序性,查询效率可能受到影响。特别是基于范围的查询、排序和连接操作可能不如使用递增整数类型的标识符高效。数据库碎片化:由于UUID的随机性,插入新记录时可能导致数据库表的碎片化。碎片化会增加数据库的存储空间占用和查询性能下降。利用redis原子性生成自增id这个和上面自增id方案差不多,虽然解决了水平分表可能带来的问题,但是它也有自己的缺陷。生成的id也是连续的,和自增id一样,下一条数据的id很容易被别人猜到。当并发请求生成自增ID较高时,单个Redis实例可能成为性能瓶颈。雪花算法目前在分布式系统中常用的生成数据库主键算法,它没有上面方案的缺点,性能也比较高。雪花算法介绍雪花算法(Snowflake Algorithm)是一种用于生成全局唯一标识符(Unique Identifier)的算法,最初由Twitter开发并开源。它主要用于分布式系统中,以解决在分布式环境下生成唯一ID的需求。雪花算法原理就是生成一个的64位比特位的 long 类型的唯一 id。最高1位固定值0,没有意义。接下来41位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用69年。再接下10位存储机器码,包括5位 datacenterId 和5位 workerId。最多可以部署2^10=1024台机器。最后12位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成2^12=4096个不重复 id。node版本算法实现网上有很多java版本的实现,我找了一篇,仿造用node实现了一下,具体实现看代码中的注释。export class SnowFlake {
// 系统上线的时间戳,我这里设置为 2023-06-22 00:00:00 的时间戳
epoch = BigInt(1687392000000);
// 数据中心的位数
dataCenterIdBits = 5;
// 机器id的位数
workerIdBits = 5;
// 自增序列号的位数
sequenceBits = 12;
// 最大的数据中心id 这段位运算可以理解为2^5-1 = 31
maxDataCenterId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
// 最大的机器id 这段位运算可以理解为2^5-1 = 31
maxWorkerId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
// 时间戳偏移位数
timestampShift = BigInt(
this.dataCenterIdBits + this.workerIdBits + this.sequenceBits
);
// 数据中心偏移位数
dataCenterIdShift = BigInt(this.workerIdBits + this.sequenceBits);
// 机器id偏移位数
workerIdShift = BigInt(this.sequenceBits);
// 自增序列号的掩码
sequenceMask = (BigInt(1) << BigInt(this.sequenceBits)) - BigInt(1);
// 记录上次生成id的时间戳
lastTimestamp = BigInt(-1);
// 数据中心id
dataCenterId = BigInt(0);
// 机器id
workerId = BigInt(0);
// 自增序列号
sequence = BigInt(0);
constructor(dataCenterId: number, workerId: number) {
// 校验数据中心 ID 和工作节点 ID 的范围
if (dataCenterId > this.maxDataCenterId || dataCenterId < 0) {
throw new Error(
`Data center ID must be between 0 and ${this.maxDataCenterId}`
);
}
if (workerId > this.maxWorkerId || workerId < 0) {
throw new Error(`Worker ID must be between 0 and ${this.maxWorkerId}`);
}
this.dataCenterId = BigInt(dataCenterId);
this.workerId = BigInt(workerId);
}
nextId() {
let timestamp = BigInt(Date.now());
// 如果上一次生成id的时间戳比下一次生成的还大,说明服务器时间有问题,出现了回退,这时候再生成id,可能会生成重复的id,所以直接抛出异常。
if (timestamp < this.lastTimestamp) {
// 时钟回拨,抛出异常并拒绝生成 ID
throw new Error('Clock moved backwards. Refusing to generate ID.');
}
// 如果当前时间戳和上一次的时间戳相等,序列号加一
if (timestamp === this.lastTimestamp) {
// 同一毫秒内生成多个 ID,递增序列号,防止冲突
this.sequence = (this.sequence + BigInt(1)) & this.sequenceMask;
if (this.sequence === BigInt(0)) {
// 序列号溢出,等待下一毫秒
timestamp = this.waitNextMillis(this.lastTimestamp);
}
} else {
// 不同毫秒,重置序列号
this.sequence = BigInt(0);
}
this.lastTimestamp = timestamp;
// 组合各部分生成最终的 ID,可以理解为把64位二进制转换位十进制数字
const id =
((timestamp - this.epoch) << this.timestampShift) |
(this.dataCenterId << this.dataCenterIdShift) |
(this.workerId << this.workerIdShift) |
this.sequence;
return id.toString();
}
waitNextMillis(lastTimestamp) {
let timestamp = BigInt(Date.now());
while (timestamp <= lastTimestamp) {
// 主动等待,直到当前时间超过上次记录的时间戳
timestamp = BigInt(Date.now());
}
return timestamp;
}
}代码里使用了BitInt,这个node10.4.0才支持。测试算法可以看到生成的id都是递增的,使用这个类有个需要注意的地方,不要在使用的地方每次都去new,应该给他做成单例,所有地方都用同一个实例,不然可能会生成出重复id。pm2中使用雪花算法这个其实才是我这篇文章中关于雪花算法的重点,因为上面那些东西网上都能搜索到,而在pm2中使用雪花算法生成id,我没看到类似的文章。pm2中使用雪花算法的问题是啥,因为pm2启动服务是多进程的,也就是有多个SnowFlake实例,如果并发高的情况下,很可能会生成重复的id。怎么解决这个问题呢,上文中机器id派上用场了,我们只要保证每个实例的机器id不一样就行了。从网上了找了一些资料,没有找到答案。突然想到我以前用pm2 list查看每个进程前面有个id。只要在服务里获取这个id就行了,最后发现pm2启动服务的时候,会把当前实例id注入到当前环境变量pm_id中,我们只要在new SnowFlake的时候把当前pm_id当成机器id传进去就行了。项目中引用SnowFlake新建src/utils/snow.flake.ts文件,把上面代码复制进去,为了让每一个地方使用同一个SnowFlake实例,我们在这个文件中提前new好,然后再导出new好的实例。(js中实现单例模式真是超级简单)import { env } from 'process';
export class SnowFlake {
// 系统上线的时间戳,我这里设置为 2023-06-22 00:00:00 的时间戳
epoch = BigInt(1687392000000);
// 数据中心的位数
dataCenterIdBits = 5;
// 机器id的位数
workerIdBits = 5;
// 自增序列号的位数
sequenceBits = 12;
// 最大的数据中心id 这段位运算可以理解为2^5-1 = 31
maxDataCenterId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
// 最大的机器id 这段位运算可以理解为2^5-1 = 31
maxWorkerId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
// 时间戳偏移位数
timestampShift = BigInt(
this.dataCenterIdBits + this.workerIdBits + this.sequenceBits
);
// 数据中心偏移位数
dataCenterIdShift = BigInt(this.workerIdBits + this.sequenceBits);
// 机器id偏移位数
workerIdShift = BigInt(this.sequenceBits);
// 自增序列号的掩码
sequenceMask = (BigInt(1) << BigInt(this.sequenceBits)) - BigInt(1);
// 记录上次生成id的时间戳
lastTimestamp = BigInt(-1);
// 数据中心id
dataCenterId = BigInt(0);
// 机器id
workerId = BigInt(0);
// 自增序列号
sequence = BigInt(0);
constructor(dataCenterId: number, workerId: number) {
// 校验数据中心 ID 和工作节点 ID 的范围
if (dataCenterId > this.maxDataCenterId || dataCenterId < 0) {
throw new Error(
`Data center ID must be between 0 and ${this.maxDataCenterId}`
);
}
if (workerId > this.maxWorkerId || workerId < 0) {
throw new Error(`Worker ID must be between 0 and ${this.maxWorkerId}`);
}
this.dataCenterId = BigInt(dataCenterId);
this.workerId = BigInt(workerId);
}
nextId() {
let timestamp = BigInt(Date.now());
// 如果上一次生成id的时间戳比下一次生成的还大,说明服务器时间有问题,出现了回退,这时候再生成id,可能会生成重复的id,所以直接抛出异常。
if (timestamp < this.lastTimestamp) {
// 时钟回拨,抛出异常并拒绝生成 ID
throw new Error('Clock moved backwards. Refusing to generate ID.');
}
// 如果当前时间戳和上一次的时间戳相等,序列号加一
if (timestamp === this.lastTimestamp) {
// 同一毫秒内生成多个 ID,递增序列号,防止冲突
this.sequence = (this.sequence + BigInt(1)) & this.sequenceMask;
if (this.sequence === BigInt(0)) {
// 序列号溢出,等待下一毫秒
timestamp = this.waitNextMillis(this.lastTimestamp);
}
} else {
// 不同毫秒,重置序列号
this.sequence = BigInt(0);
}
this.lastTimestamp = timestamp;
// 组合各部分生成最终的 ID,可以理解为把64位二进制转换位十进制数字
const id =
((timestamp - this.epoch) << this.timestampShift) |
(this.dataCenterId << this.dataCenterIdShift) |
(this.workerId << this.workerIdShift) |
this.sequence;
return id.toString();
}
waitNextMillis(lastTimestamp) {
let timestamp = BigInt(Date.now());
while (timestamp <= lastTimestamp) {
// 主动等待,直到当前时间超过上次记录的时间戳
timestamp = BigInt(Date.now());
}
return timestamp;
}
}
// 如果有pm_id,把pm_id当机器id传进去
export const snowFlake = new SnowFlake(0, +env.pm_id || 0);改造base.entity把base.entity里面id自增给删除,同时把数据库类型设置为bigint,然后字段类型设置为string,typeorm会自动把数据库中的bigint转换为string。这里相信大多数前端都遇到过一个问题,如果你的后端使用雪花算法生成id,然后以long类型返回给前端,前端会因为精度问题,会把最后几位变成0。这种情况有两个解决方案,第一个是前端解决,在请求拦截器中判断然后转成字符串。第二种方案是后端转。第一种方案性能很差,相信大部分兄弟都是使用第二种方案。使用typeorm插入数据拦截器注入id上面我们把id自增给删除了,所以需要我们自己手动传id,我们不可能每次new实体的时候,掉snowFlake.nextId()方法生成一个id然后赋值给当前实体id,这样做其实也可以,但是有点麻烦。好在typeorm支持插入实体拦截器,所有的插入都会执行这个拦截器。我们只需要在这个拦截器中,把id注入进去就行了。创建src/typeorm-event-subscriber.ts文件import { EventSubscriberModel } from '@midwayjs/typeorm';
import { EntitySubscriberInterface, InsertEvent } from 'typeorm';
import { snowFlake } from './utils/snow.flake';
@EventSubscriberModel()
export class EverythingSubscriber implements EntitySubscriberInterface {
beforeInsert(event: InsertEvent<any>) {
if (!event.entity.id) {
event.entity.id = snowFlake.nextId();
}
}
}把这个拦截器配置到typeorm中这样每次插入数据的时候,都会自动注入雪花算法生成的id。附件方案介绍后台管理系统肯定少不了附件上传功能,下面给大家分享一个使用起来比较简单的方案。文件服务器要做附件,肯定要先有一个文件服务,常用的有腾讯的cos,阿里的oss,七牛云也可以。不过这些都是收费的,大家如果自己有服务器,可以自己使用minio搭建一个。使用镜像启动minio先拉minio镜像docker pull minio/minio启动minio镜像,9000对应的是控制台前端服务,9001是接口调用的上传下载文件服务。账号是minio,密码是minio@123。docker run -p 9000:9000 -p 9001:9001 --name minio \
-d --restart=always \
-e MINIO_ACCESS_KEY=minio \
-e MINIO_SECRET_KEY=minio@123 \
-v /usr/local/minio/data:/data \
-v /usr/local/minio/config:/root/.minio \
minio/minio server /data --console-address ":9000" --address ":9001"启动完成后,访问http://localhost:9000,出现下面的界面输入上面设置的帐号密码登录进去,创建一个桶。改变桶的权限,不然可以上传文件,但是别人无法访问,所以给桶设置public权限。我们上传一个文件测试一下上传成功后,可以通过桶名称和文件名访问,注意这里的端口号是上面设置的文件服务端口号。http://localhost:7101/fluxy-admin/242091682079921_.pic_hd.jpg后端服务对接minio实现思路整个流程很简单,前端上传文件,后端接受到文件,然后调用minio的接口上传到minio服务器,把文件访问地址返回给前端。文件服务可能会在后面很多地方使用,比如用户头像,这种一对一的方案还好(一个用户只有一个头像),上传成功后,把图片地址存到头像字段中。一对多的情况下就不能这样用了,比如一个单据有多个附件,这种就需要加关联关系表了,下面给大家分享一个不要加关联关系吧。我们建一个文件表,把单据id存到文件表里,这样就不用加关联关系表了,虽然用雪花算法可以保证不同业务单据id是唯一的,但是如果后面按业务模块拆分微服务单独部署了,机器id可能是一样的,就有可能生成重复的单据id,所以我们再加一个业务字段来保证当前业务下id是不会重复的。因为是先上传文件再保存单据,然后把单据id存到文件表里。假设文件上传了,但是用户又取消了创建单据,这样文件表和文件服务器就会有很多脏数据,这时候我们可以写一个定时任务定期去清理没有单据id的文件。引入upload组件后端服务引入upload组件,这里miday官方文档写的很清楚,这里我就不说了。封装minio服务安装minio依赖pnpm i minio新建src/autoload/minio.ts文件import { Config, IMidwayContainer, Singleton } from '@midwayjs/core';
import { ApplicationContext, Autoload, Init } from '@midwayjs/decorator';
import * as Minio from 'minio';
import { MinioConfig } from '../interface';
export type MinioClient = Minio.Client;
@Autoload()
@Singleton()
export class MinioAutoLoad {
@ApplicationContext()
applicationContext: IMidwayContainer;
@Config('minio')
minioConfig: MinioConfig;
@Init()
async init() {
const minioClient = new Minio.Client(this.minioConfig);
this.applicationContext.registerObject('minioClient', minioClient);
}
}这里用到了@Singleton单例装饰器和@Autoload自动执行装饰器,只要在方法上使用@Init()装饰器,下面的init方法就会在项目启动后自动执行。init方法中,先根据配置new了一个Minio.Client实例,然后注入到上下文中,这样就可以在代码中直接使用这个服务了。码中使用上面注入的minioClient实例在src/config/config.default.ts添加minio配置新建file服务使用下面命令创建file服务node ./script/create-module filefile实体文件。pkName就是业务单据类型,pkValue就是单据id。// src/module/file/entity/file.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_file')
export class FileEntity extends BaseEntity {
@Column({ comment: '文件名' })
fileName?: string;
@Column({ comment: '文件路径' })
filePath?: string;
@Column({ comment: '外健名称', nullable: true })
pkName: string;
@Column({ comment: '外健值', nullable: true })
pkValue?: string;
}file service文件。封装了三个方法,一个功能的上传方法、设置pkName和pkValue方法、清理脏数据。import { Config, Inject, Provide } from '@midwayjs/decorator';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { FileEntity } from '../entity/file';
import { UploadFileInfo } from '@midwayjs/upload';
import { MinioClient } from '../../../autoload/minio';
import { MinioConfig } from '../../../interface';
@Provide()
export class FileService extends BaseService<FileEntity> {
@InjectEntityModel(FileEntity)
fileModel: Repository<FileEntity>;
@Inject()
minioClient: MinioClient;
@Config('minio')
minioConfig: MinioConfig;
@InjectDataSource()
defaultDataSource: DataSource;
getModel(): Repository<FileEntity> {
return this.fileModel;
}
// 上传方法
async upload(file: UploadFileInfo<string>) {
// 生成文件名。因为文件名可能重复,这里手动拼了时间戳。
const fileName = `${new Date().getTime()}_${file.filename}`;
// 这里使用了typeorm的事务,如果文件信息存表失败的情况下,就不用上传到minio服务器了,如果后面上传文件失败了,前面插入的数据,也会自动会滚。保证了不会有脏数据。
const data = await this.defaultDataSource.transaction(async manager => {
const fileEntity = new FileEntity();
fileEntity.fileName = fileName;
fileEntity.filePath = `/file/${this.minioConfig.bucketName}/${fileName}`;
await manager.save(FileEntity, fileEntity);
await this.minioClient.fPutObject(
this.minioConfig.bucketName,
fileName,
file.data
);
return fileEntity;
});
return data;
}
// 上传单据时,把单据id注入进去
async setPKValue(id: string, pkValue: string, pkName: string) {
const entity = await this.getById(id);
if (!entity) return;
entity.pkValue = pkValue;
entity.pkName = pkName;
await this.fileModel.save(entity);
return entity;
}
// 清理脏数据,清理前一天的数据
async clearEmptyPKValueFiles() {
const curDate = new Date();
curDate.setDate(curDate.getDate() - 1);
const records = await this.fileModel
.createQueryBuilder()
.where('createDate < :date', { date: curDate })
.andWhere('pkValue is null')
.getMany();
this.defaultDataSource.transaction(async manager => {
await manager.remove(FileEntity, records);
await Promise.all(
records.map(record =>
this.minioClient.removeObject(
this.minioConfig.bucketName,
record.fileName
)
)
);
});
}
}file controller。使用upload组件上传文件后,upload会把文件暂时存在服务器临时目录下,files里面存的有临时文件地址。import { Controller, Inject, Post, Provide, Files } from '@midwayjs/core';
import { FileService } from '../service/file';
import { NotLogin } from '../../../decorator/not.login';
import { ApiBody } from '@midwayjs/swagger';
@Provide()
@Controller('/file')
export class FileController {
@Inject()
fileService: FileService;
@Inject()
minioClient;
@Post('/upload')
@ApiBody({ description: 'file' })
@NotLogin()
async upload(@Files() files) {
if (files.length) {
return await this.fileService.upload(files[0]);
}
return {};
}
}实战,实现上传头像功能前端头像上传封装前端头像上传组件,这里使用了antd-img-crop头像剪裁组件。因为这个组件要在FormItem组件下使用,所以参数里有value,onChange参数。// src/pages/user/avatar.tsx
import React from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Upload } from 'antd';
import type { UploadChangeParam } from 'antd/es/upload';
import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface';
import ImgCrop from 'antd-img-crop';
import { antdUtils } from '@/utils/antd';
const beforeUpload = (file: RcFile) => {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
antdUtils.message?.error('文件类型错误');
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
antdUtils.message?.error('文件大小不能超过2M');
}
if (!(isJpgOrPng && isLt2M)) {
return Upload.LIST_IGNORE;
}
return true;
};
interface PropsType {
value?: UploadFile[];
onChange?: (value: UploadFile[]) => void;
}
const Avatar: React.FC<PropsType> = ({
value,
onChange,
}) => {
const handleChange: UploadProps['onChange'] = (info: UploadChangeParam<UploadFile>) => {
if (onChange) {
onChange(info.fileList);
}
};
const onPreview = async (file: UploadFile) => {
const src = file.url || file?.response?.filePath;
if (src) {
const imgWindow = window.open(src);
if (imgWindow) {
const image = new Image();
image.src = src;
imgWindow.document.write(image.outerHTML);
} else {
window.location.href = src;
}
}
};
return (
<ImgCrop showGrid rotationSlider showReset>
<Upload
name="avatar"
listType="picture-card"
className="avatar-uploader"
action="/api/file/upload"
onChange={handleChange}
fileList={value}
beforeUpload={beforeUpload}
onPreview={onPreview}
>
{(value?.length || 0) < 1 && <PlusOutlined />}
</Upload>
</ImgCrop>
);
};
export default Avatar;在表单中使用这个组件表单提交后,把附件id传到后台编辑时给默认值这里的代码平平无奇,没啥可说的。后端头像上传功能实现改造创建用户的方法,如果添加用户时上传了头像,根据当前文件id更新pkName和pkValue字段数据。pkValue时当前用户id,pkName是类型,建议用表名_字段名。编辑用户时稍微复杂一点改造分页查询方法,把当前用户表和file表关联查询,把查询出来的file信息映射到user的avatarEntity字段上。这里可以使用typeorm的关联关系,查询用户的时候会自动把头像信息查出来,这种对于新手很友好,上手简单,基本不用学习sql。但是这种方式会自动给创建外键,现在很多公司都不推荐使用外键了,所以我这里都使用join自己去查。使用定时任务清除脏数据midway内置了任务队列组件。安装组件pnpm i @midwayjs/bull@3在src/configuration.ts文件中导入组件因为任务队列依赖redis,所以需要在配置中配置redis信息创建src/queue/clear.file.ts文件// src/queue/clear.file.ts
import { Processor, IProcessor } from '@midwayjs/bull';
import { Inject } from '@midwayjs/core';
import { FileService } from '../module/file/service/file';
// 每天凌晨00:00:00定时执行下面清理文件的方法
@Processor('clear_file', {
repeat: {
cron: '0 0 0 * * *',
},
})
export class ClearFileProcessor implements IProcessor {
@Inject()
fileService: FileService;
async execute() {
// 调用文件服务里清理文件方法
this.fileService.clearEmptyPKValueFiles();
}
}
邮箱验证前言一般后台管理系统不会开放注册功能,很多都是管理员给员工开通帐号,开通完帐号后,随机生成一个密码,然后通过邮箱发送给当前用户,员工收到邮件就可以登录系统了。这里面有个问题,万一管理员手滑了,邮箱写错了发送给了别人,别人知道了帐号密码就能登录系统了。所以我们在添加用户的时候,先给员工发一个验证码,然后员工把验证码发给管理员,管理员填写验证码才能添加用户,这样就防止手滑写错邮箱的问题了。开通个人邮箱服务想发送邮件,需要先开启邮箱服务。下面我以qq邮箱为例开通邮件服务。这里开启后会给密钥记得要存起来,后面会用到。封装发邮件的公共服务创建src/common/mail.service.ts文件,使用nodemailer这个库去发送邮件。// src/common/mail.service.ts
import { Config, Provide, Singleton } from '@midwayjs/core';
import * as nodemailer from 'nodemailer';
import { MailConfig } from '../interface';
interface MailInfo {
// 目标邮箱
to: string;
// 标题
subject: string;
// 文本
text?: string;
// 富文本,如果文本和富文本同时设置,富文本生效。
html?: string;
}
@Provide()
@Singleton()
export class MailService {
@Config('mail')
mailConfig: MailConfig;
async sendMail(mailInfo: MailInfo) {
const transporter = nodemailer.createTransport(this.mailConfig);
// 定义transport对象并发送邮件
const info = await transporter.sendMail({
from: this.mailConfig.auth.user, // 发送方邮箱的账号
...mailInfo,
});
return info;
}
}
在配置文件中添加邮箱服务器配置host:邮箱服务器地址,qq邮箱是smtp.qq.comport:邮箱服务器端口号,qq邮箱是465secure:表示使用安全连接auth.user:服务器的邮箱账号auth.pass:上面生成的密钥实战,实现添加用户时邮箱验证前端实现封装一个带有邮箱输入框和计时器的表单组件,代码很简单,我就不详细说了。import { useRequest } from '@/hooks/use-request';
import { Button, Form, Input } from 'antd';
import React, { ChangeEventHandler, useEffect, useRef, useState } from "react";
import userService from './service';
interface PropsType {
value?: string;
onChange?: ChangeEventHandler;
disabled?: boolean;
}
const EmailInput: React.FC<PropsType> = ({
value,
onChange,
disabled,
}) => {
const [timer, setTimer] = useState<number>(0);
const form = Form.useFormInstance();
const intervalTimerRef = useRef<number>();
const { runAsync } = useRequest(userService.sendEmailCaptcha, { manual: true });
const sendEmailCaptcha = async () => {
const values = await form.validateFields(['email']);
setTimer(180);
await runAsync(values.email);
intervalTimerRef.current = window.setInterval(() => {
setTimer(prev => {
if (prev - 1 === 0) {
window.clearInterval(intervalTimerRef.current);
}
return prev - 1;
});
}, 1000);
}
useEffect(() => {
return () => {
if (intervalTimerRef.current) {
window.clearInterval(intervalTimerRef.current);
}
}
}, []);
return (
<div className='flex items-center gap-[12px]'>
<Input disabled={disabled} onChange={onChange} value={value} className='flex-1' />
{!disabled && (
<Button
disabled={timer > 0}
onClick={sendEmailCaptcha}>
{timer > 0 ? `重新发送(${timer}秒)` : '发送邮箱验证码'}
</Button>
)}
</div>
)
}
export default EmailInput;在表单中添加刚加的组件和添加一个邮箱验证码输入框效果展示后端实现在user controler中添加一个发送邮箱验证码的接口效果改造添加用户方法,先校验邮箱验证码对不对,然后随机生成一个密码加盐保存到数据库中,最后把帐号和密码发送给对应的用户。修改密码前言可以看到上面生成密码很难记,不可能用户每次登录都看一下邮箱,所以增加了修改密码的功能。 正好我前两天在掘金重置密码,看到掘金重置密码发送邮件的弹框有点意思,这里就拿来用用。(如果不能用,可以联系我删除。)掘金的效果图,注意看上面的图片邮箱输入框获取到焦点时 这种交互挺有意思的,为掘金的设计人员点个赞。前端代码实现代码实现很简单,搞两张图片,加一个标记输入框是否获取到焦点的变量,获取到焦点显示一张图片,失去焦点显示另外一张图片。在做一个修改密码的页面,用户在邮件中点击链接重定向到这个页面修改密码,url上会带两个参数一个是邮箱,另外一个是邮箱验证码,这两个参数是后端发送邮件时注入到url上的。后端实现在auth controller中添加一个发送重置邮箱验证码的接口,因为这个接口不需要登录也能调用,所以加了NotLogin装饰器。邮件内容展示添加修改密码接口。实现比较简单,先校验邮箱和邮箱验证码,然后再把前端传过来的密码加盐更新到当前用户。这里有个小细节,用户修改完密码,需要把当前用户以前颁发过的token和refreshToken全部删除掉,然后让用户重新登录。这样子的话,需要在登录的时候把当前颁发的token和refreshToken存起来。redis的smembers方法获取某个key的数组值,redis的sadd方法往某个key里面添加一项。登录的时候,把当前token和refreshToken存起来docker-compose中增加minio服务和邮箱服务配置上篇写部署的时候,我其实已经把minio文件服务部署上去了。后端服务增加邮箱服务器配置把路由方式从hash模式改成history模式很简单把createHashRouter方法改成createBrowserRouter就行了还需要改一下nginx配置,不然进入一个功能后,然后手动刷新一下页面就会404了。因为如果当前路由为https://fluxyadmin.cn/dashboard这个,刷新一下,相当于向nginx请求这个dashboard这个资源,这个资源当然不存在,所以我们需要改一下nginx配置,在请求不到资源的时候尝试加载根目录下的index.html文件。后续项目功能清单有不少兄弟对这个项目会实现哪些功能很好奇,这里和大家说一下。前端动态菜单、动态路由、按钮权限。后端接口权限项目实战、封装常用组件、可能会集成工作流。实战项目暂定人事管理系统。前端低代码平台使用前端低代码平台重构前面项目的前端部分后端低代码平台使用后端低代码平台重构前面做的实战项目后端接口部分低代码平台对接chatgpt,实现用户说个需求,就能实现一个功能或系统搭建框架文档平台编写框架脚手架这些功能大部分我在公司里都已经实现过,所以大家不用担心做不出来。总结这篇文章我写的很糟心,这种实现业务功能的文章,其实没啥可写的,写了也没啥亮点。但是我这个专栏,主打的是从零开始,不可能绕过一些功能点不写的,因为有些刚入门的同学在跟着项目做,如果我漏了一些功能没写,然后后面突然冒出来一个功能,他们可能会感到疑惑。为了照顾这些人,我会把我这个框架做的功能都给写出来,无论大小,简单的功能我就写思路,复杂一点的会把核心代码贴出来。
lucky0-0
使用这个库实现接口鉴权,太简单了。(上)——从零开始搭建一个高颜值后台管理系统全栈框架(十二)
前言前面我们实现了按钮权限控制,但是只在前端控制按钮显示和隐藏并没有多大用处,别人只要知道接口,可以通过一些请求工具直接调你的接口,所以后端在接受到请求的时候首先判断用户有没有权限,有权限则通过,无权限则拒绝访问。接口鉴权这里我推荐使用casbin这个库,使用起来真的很简单,并且支持多个平台,node、java、go、php这些常用的后端语言都支持。我们公司的项目(java)接口鉴权这一块的功能是一个后端大佬自己从零开始开发的,我接触过casbin之后,向我们后端推荐了这个库,现在我们公司项目已经使用这个库了。Casbin概述Casbin 是一个强大的、高效的开源访问控制框架,其权限管理机制支持多种访问控制模型。支持很多种语言Casbin能做什么支持自定义请求的格式,默认的请求格式为{subject, object, action}。具有访问控制模型model和策略policy两个核心概念。支持RBAC中的多层角色继承,不止主体可以有角色,资源也可以具有角色。支持内置的超级用户 例如:root 或 administrator。超级用户可以执行任何操作而无需显式的权限声明。支持多种内置的操作符,如 keyMatch,方便对路径式的资源进行管理,如 /foo/bar 可以映射到 /foo*Casbin不能做什么身份认证 authentication(即验证用户的用户名和密码),Casbin 只负责访问控制。应该有其他专门的组件负责身份认证,然后由 Casbin 进行访问控制,二者是相互配合的关系。管理用户列表或角色列表。 Casbin 认为由项目自身来管理用户、角色列表更为合适, 用户通常有他们的密码,但是 Casbin 的设计思想并不是把它作为一个存储密码的容器。 而是存储RBAC方案中用户和角色之间的映射关系。性能测试上面是官网给出的性能测试数据,可以看出性能是没有问题的。入门前言上面的介绍大家可能看的云里雾里,下面带着大家实战一下,让大家更深入的了解casbin的用法。初始化一个midway项目找一个合适的目录,执行下面命令创建midway项目。npm init midway安装casbin依赖pnpm i casbin --save创建casbin模型描述文件在项目src目录下创建basic_model.conf文件,文件内容如下:[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act后面讲解这些内容的含义创建casbin策略文件在项目src目录下创建basic_policy.csv文件,文件内容如下:p, alice, data1, read
p, bob, data2, write后面讲解这些内容的含义在home.controler中使用casbin方法// src/controller/home.controller.ts
import { App, Controller, Get } from '@midwayjs/core';
import { newEnforcer } from 'casbin';
import * as koa from '@midwayjs/koa';
import { join } from 'path';
@Controller('/')
export class HomeController {
@App()
app: koa.Application;
@Get('/')
async home(): Promise<boolean> {
// this.app.getBaseDir() 获取当前项目基本目录,开发环境是src,打包过后是dist
// 模型文件路径
const casbinModelPath = join(this.app.getBaseDir(), '/basic_model.conf');
// 策略文件路径
const casbinPolicyPath = join(this.app.getBaseDir(), '/basic_policy.csv');
// new一个casbin实例,有两个参数,一个是模型描述文件的路径,一个是策略文件的路径
const e = await newEnforcer(casbinModelPath, casbinPolicyPath);
// 这里判断bob这个人是否有data2的写权限
// 从策略文件中可以看到bob是拥有data2的write权限的,所以这里应该返回为true
// 策略文件里的内容
// p, alice, data1, read
// p, bob, data2, write
const result = await e.enforce('bob', 'data2', 'write');
console.log(true);
return result;
}
}启动项目测试在终端中使用npm run dev启动项目,项目启动成功后,访问http://127.0.0.1:7001/,可以看到和我们上面猜测的一样返回了true。改一下代码,bob没有data2的read权限,这里应该返回fase。小结这里我们简单的入了门,知道了如何在midway项目中使用casbin库。model是什么上面我们创建了一个basic_model.conf文件,可能大家对里面的内容有点迷惑,这里给大家解答一下。model config至少包含四个部分,[request_definition], [policy_definition], [policy_effect], [matchers]。request_definition描述[request_definition] 是访问请求的定义。 它定义了 e.Enforce(...) 函数中的参数。[request_definition]
r = sub, obj, act上面 sub, obj, act 表示经典三元组: 访问实体 (Subject),访问资源 (Object) 和访问方法 (Action)。 但是, 你可以自定义你自己的请求表单, 如果不需要指定特定资源,则可以这样定义 sub、act ,或者如果有两个访问实体, 则为 sub、sub2、obj、act。小结这里没啥好说的,文档已经很清楚了。policy_definition描述[policy_definition] 是策略的定义。 它界定了该策略的含义。 例如,我们有以下模式:[policy_definition]
p = sub, obj, act
p2 = sub, act这些是我们对policy规则的具体描述p, alice, data1, read
p2, bob, write-all-objectspolicy部分的每一行称之为一个策略规则, 每条策略规则通常以形如p, p2的policy type开头。 如果存在多个policy定义,那么我们会根据前文提到的policy type与具体的某条定义匹配。 上面的policy的绑定关系将会在matcher中使用, 罗列如下:(alice, data1, read) -> (p.sub, p.obj, p.act)
(bob, write-all-objects) -> (p2.sub, p2.act)小结看完上面描述,大家可能还有疑惑。这里我举个🌰。我们刚才的例子中basic_model.conf文件里[policy_definition] 的配置是下面这样的:[policy_definition]
p = sub, obj, act然后我们的csv策略描述文件里的数据格式是这样的:p, alice, data1, read
p, bob, data2, write这个p和上面的p是对应的,bob相当于sub,data2相当于obj,write相当于act。为啥要定义这个呢,因为有时候需要支持多种策略定义,后面说到RBAC模型的时候,再详细解释。policy_effect描述[policy_effect] 部分是对policy生效范围的定义, 原语定义了当多个policy rule同时匹配访问请求request时,该如何对多个决策结果进行集成以实现统一决策。小结官方文档看完后,大家可能更迷惑,这里说一下我的理解。[policy_effect]
e = some(where (p.eft == allow))开始我一直不理解p.eft从哪来的,仔细看完文档后,才发现策略定义里面把这个省略了,最后一个参数就是eft,默认值都是allow。策略定义中[policy_definition]
p = sub, obj, act, eftcsv中p, alice, data1, read, allow
p, bob, data2, write, allow这样改造后大家应该理解了吧。前面的some,表示如果匹配到了多个,只要有一个是allow就返回true。举个🌰:csv中添加一条数据:p, alice, data1, read, allow,
p, bob, data2, write, allow,
p, bob, data2, write, deny,如果我们拿'bob', 'data2', 'write'去匹配,会匹配出两条数据,一个结果是allow,一个结果是deny,因为判断那里写了,只要有一个allow就返回true,所以匹配结果是true。matchers描述[matchers] 是策略匹配器的定义。 匹配器是表达式。 它确定了如何根据请求评估策略规则。[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act上面的这个匹配器是最简单的,它表示请求的三元组:主题、对象、行为都应该匹配策略规则中的表达式。在匹配器中,你可以使用算术运算符如 +, -, * , / ,也可以使用逻辑运算符如:&&,||,!。内置匹配器函数上面匹配表达式中除了使用简单的比较以外,还可以使用函数。casbin内置了一些常用的函数。keyMatch2这个函数我们后面会用到,用来匹配动态参数接口。自定义函数如果上面函数不满足你的需求,还可以自定义函数。举个🌰上面规则表示只要策略中的sub的其中一个值包含传过来的字符串,就返回true。测试一下const result = await e.enforce('b', 'data2', 'write');因为bob包含b,所以肯定返回trueconst result = await e.enforce('bb', 'data2', 'write');因为上面策略中的sub的值没有包含bb的,所以肯定返回false。小结匹配器支持自定义函数,让这个库有更大的扩展空间。RBAC模型实战前言下面我们用这个库实现接口鉴权功能,这个可以使用casbin内置的RBAC模型,这个模型实现了用户、角色、资源(接口)的权限控制。model可以从github上复制内置的RBAC模型配置,关于RBAC官方文档有讲解配置的含义。[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.actpolicy可以从github上复制内置的RBAC策略示例数据,关于RBAC官方文档有讲解配置的含义。p, alice, data1, read
p, bob, data2, write
p, data2_admin, data2, read
p, data2_admin, data2, write
g, alice, data2_admin
上面数据表示alice用户拥有data2_admin角色,data2_admin角色有data2资源的read权限和data2资源的write权限,所以alice用户有data2资源的read权限和data2资源的write权限,上面两行表示用户alice有date1资源的read权限,bob用户有data2的write权限。测试和我们猜测的一样,返回true升级如果把资源替换成接口呢,改造一个csv文件。p, admin, /api/book, get
p, admin, /api/book, post
p, admin, /api/book, delete
p, user, /api/book, get
g, 张三, user
g, 李四, adminadmin角色拥有/api/book这个接口的get,post,delete权限,user角色只有get权限。张三是user角色,李四是admin角色。const result = await e.enforce('张三', '/api/book', 'get');const result = await e.enforce('张三', '/api/book', 'post');假设我们现在有个根据id获取单个book的接口,根据restful规范,接口应该设计成/api/book/:id,从前端拿到的请求url是/api/book/1这样的,那我们怎么匹配这种情况呢。改造csv,给user角色添加一个/api/book/:id接口权限p, admin, /api/book, get
p, admin, /api/book, post
p, admin, /api/book, delete
p, user, /api/book, get
p, user, /api/book/:id, get
g, 张三, user
g, 李四, adminconst result = await e.enforce('张三', '/api/book/1', 'get');这样肯定返回false,因为model匹配那里写的是==,明显/api/book/1不等于/api/book/:id。这时候就需要用到内置函数keyMatch2了。改造model文件从数据库中加载策略前言上面我们都是从csv中加载的策略,有人会说,谁的管理系统会把用户、角色、接口这些信息存到csv中,一般都是存到数据库中。casbin支持从数据库中加载策略数据,并且已经有人写好了库,可以直接使用。实战安装依赖pnpm i typeorm-adapter --save
pnpm i mysql2 --save创建数据库这个不会自动创建数据库,需要我们自己建一个数据库,使用工具连接数据库创建casbin-demo数据库。不用自己建表,typeorm-adapter会自动帮我们建表。数据库方面的知识可以看下我这篇文章juejin.cn/post/723673…。通过typeorm创建casbin实例在项目启动的时候,创建一个单例service,全局每个地方都可以使用。import { Singleton, Autoload, Init, App } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import { Enforcer, newEnforcer } from 'casbin';
import { join } from 'path';
import TypeORMAdapter from 'typeorm-adapter';
@Autoload()
@Singleton()
export class CasbinService {
@App()
app: koa.Application;
enforcer: Enforcer;
@Init()
async init() {
const casbinModelPath = join(this.app.getBaseDir(), '/basic_model.conf');
const adapter = await TypeORMAdapter.newAdapter({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: '12345678',
database: 'casbin-demo',
});
// 这里创建casbin实例,第二个参数由以前的csv改成了从数据库中加载
const e = await newEnforcer(casbinModelPath, adapter);
// 从数据库中加载策略
await e.loadPolicy();
this.enforcer = e;
}
}
启动项目后,发现数据库中自动创建了一个表。把csv中的数据存迁移到数据库中改造home.controler代码import { App, Controller, Get, Inject } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import { CasbinService } from '../casbin';
@Controller('/')
export class HomeController {
@App()
app: koa.Application;
@Inject()
casbinService: CasbinService;
@Get('/')
async home(): Promise<boolean> {
const result = await this.casbinService.enforcer.enforce(
'张三',
'/api/book/1',
'get'
);
return result;
}
}测试一下总结上面带着大家简单的入了一下门,至于怎么把系统中的用户、角色、接口信息转换成策略表的数据格式存到数据库中,我会在下一篇文章中以实战的方式分享给大家
lucky0-0
低代码在线加载远程组件——低代码知识点详解(四)
前言在开发企业级低代码平台会有这样一种场景,平台内置的组件不能满足客户的需求,客户希望能够自定义组件,但是我们源码不能给客户,这时候就需要一种方案加载客户自定义的组件。下面我们来实现一下加载远程组件。往期回顾《保姆级》低代码知识点详解(一) 低代码事件绑定和组件联动——低代码知识点详解(二) 低代码动态属性和在线执行脚本——低代码知识点详解(三)模拟用户开发一个远程组件初始化项目使用pnpm workspace创建两个项目,一个是组件项目,另外一个是调试项目。找个空白文件夹,执行下面命令:npm init在根目录下创建pnpm-workspace.yaml文件,把下面内容复制进去packages:
- 'packages/**'创建packages文件夹,然后创建lib文件夹,存放组件项目。组件使用rollup工具来打包,在lib文件夹下创建rollup.config.js打包配置文件,把下面代码复制进去。// packages/lib/rollup.config.js
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import { defineConfig } from 'rollup';
import clear from 'rollup-plugin-clear';
import dts from 'rollup-plugin-dts';
import { terser } from 'rollup-plugin-terser';
import typescript from 'rollup-plugin-typescript2';
export default defineConfig([{
input: './src/index.tsx',
output:
[
{
format: 'umd',
name: 'dbfuButton',
file: './dist/bundle.umd.js'
}, {
format: 'amd',
file: './dist/bundle.amd.js'
},
{
format: 'cjs',
file: './dist/bundle.cjs.js'
},
{
format: "es",
file: "./dist/bundle.es.js"
},
],
plugins: [
terser(),
resolve(),
commonjs(),
typescript(),
clear({
targets: ['dist']
}),
],
external: ['react', 'react-dom']
},
{
input: './src/index.tsx',
plugins: [dts()],
output: {
format: 'esm',
file: './dist/index.d.ts',
},
}
]);在lib文件夹下创建src/index.tsx组件// packages/lib/src/index.tsx
import React from 'react';
function RemoteComponent() {
return (
<div>test</div>
)
}
export default RemoteComponent;创建package.json文件// packages/lib/package.json
{
"name": "dbfu-remote-component",
"version": "1.0.0",
"description": "",
"author": {
"name": "dbfu"
},
"scripts": {
"build": "rollup -c ./rollup.config.js",
"dev": "rollup -c ./rollup.config.js -w"
},
"devDependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-proposal-class-properties": "^7.18.6",
"@babel/plugin-proposal-decorators": "^7.20.13",
"@babel/plugin-syntax-jsx": "^7.18.6",
"@babel/plugin-transform-react-jsx": "^7.20.13",
"@babel/plugin-transform-runtime": "^7.19.6",
"@babel/preset-env": "^7.20.2",
"@babel/preset-react": "^7.18.6",
"@rollup/plugin-babel": "^6.0.3",
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/react": "^18.0.28",
"rollup": "^3.15.0",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-clear": "^2.0.7",
"rollup-plugin-dts": "^5.3.0",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-typescript2": "^0.34.1",
"tslib": "^2.5.0",
"typescript": "^5.0.2"
},
"type": "module",
"main": "dist/bundle.es.js",
"module": "dist/bundle.es.js",
"typings": "dist/index.d.ts",
"dependencies": {
"@emotion/css": "^11.11.2",
"postcss": "^8.4.31",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"dist"
]
}安装依赖pnpm i进入lib文件夹下执行下面命令,打包组件npm run build打包成功后,会多一个dist文件夹初始化调试项目进入packages目录,使用vite创建项目。npm create vite example安装上面组件的依赖pnpm i dbfu-remote-component安装完后,package.json文件里会多一行这样的代码,表示从本地加载组件。引入组件测试一下
// packages/example/src/App.tsx
import DbfuRemoteComponent from 'dbfu-remote-component'
function App() {
return (
<DbfuRemoteComponent />
)
}
export default App在example文件夹下执行下面命令,启动项目npm run dev访问http://127.0.0.1:5173/,可以看到下面页面把lib中组件文本改成hello,重新执行一下npm run build命令后,可以看到文件变成了hello这里如果不想每次改东西后重新build一下,可以执行npm run dev命令,改完后实时生效。测试完组件后,在lib文件夹下执行npm publish把组件推到npm库中。这里还有一些配置,我省略了,大家可以看下我以前写的一篇文章,文章里有介绍如何发布一下npm包。发布成功后,可以在npm库中搜索到我们刚上传的组件。在线加载远程组件介绍上面我们模拟用户开发了一个组件,正常我们可以在低代码项目里安装组件,然后使用组件,但是前面说了,我们是一个低代码平台,不可能把低代码的源码给客户,让客户引入自己刚写的组件。为了解决上面问题,我们可以这样做,在物料区支持在线添加组件,渲染组件的时候根据添加的组件配置信息动态加载然后渲染。这里涉及到一个问题,知道npm上的组件名,怎么渲染这个组件,下面我们来实现一下。实战针对上面的问题,我们可以使用React.lazy方法,这个api就是用来异步加载组件的。举个例子上面的例子实现了异步加载Test组件,可以把上面代码改造成下面这样,2s后显示test。React.lazy必须配合React.Suspense。看下组件打包后的内容把代码压缩关了,重新打一下包,看一下打包出来bundle.umd.js文件内容动态加载组件我们拿到这段js文本,使用new Function()动态执行这段文本,把module,exports和require这些变量注入进去,就可以拿到RemoteComponent组件了。上面代码如果没有module这些变量,会把RemoteComponent挂到window上,我们可以从window上取,但是这样会污染window,所以还是使用第一种方式。下面根据上面的思路,写个demo验证一下根据打印可以看到,我们获取到了组件把Test改造一下hello渲染出来了,上面的方法是对的现在组件代码是写死的,我们怎么获取到组件打包后的代码呢,npm支持通过url获取打包后的js文件。url格式是这样的https://cdn.jsdelivr.net/npm/{组件名}@{版本号}/{文件路径}根据上面格式,访问一下刚才写的组件,可以访问到。https://cdn.jsdelivr.net/npm/dbfu-remote-component@1.0.1/dist/bundle.umd.js改造一下Test代码,使用fetch获取js文本可以正常显示到此远程加载组件核心功能实现了,下面把方案迁移到低代码平台中。低代码中加载远程组件在Item-Type添加一个远程组件组件类型物料区添加一个远程组件组件渲染的时候,使用刚才方案加载组件说明一下,正常这里应该是在线配置组件,不应该在代码里配置,因为这个是demo,不想做那么麻烦,大家先明白这样做可以解决问题就行了,后面实战的时候再慢慢完善。配置属性像普通组件一样,远程组件也可以动态配置属性,组件对外暴露text属性。给远程组件加一个text属性配置因为刚才发了新版本,版本号要改一下。效果展示样式远程组件如果想写样式,这里我推荐使用css in js方案,主要可以解决样式冲突问题。css in js库有很多,这里我推荐@emotion/css库。给远程组件文字颜色设置red因为这里自动生成的css类名是动态的,所以可以保证样式不会冲突。脚手架如果用户想自定义插件,需要自己搭建一套组件框架,这显然很麻烦,所以我们给用户提供一个脚手架能够快速创建一个组件项目。实现也简单,把刚才创建的项目当成模版,然后用户执行初始化命令后,把模版代码复制到当前目录下。找个空白目录执行下面命令npm init修改package.json新建src/index.js#!/usr/bin/env node
const { input } = require('@inquirer/prompts');
const path = require('path');
const fs = require('fs');
const { copyFolder } = require('./utils');
async function main() {
// 交互式输入组件名称和描述
const name = await input({ message: '请输入组件名称' }).catch(() => '');
if (!name) return;
const description = await input({ message: '请输入组件描述' }).catch(() => '');
if (!description) return;
// 获取当前文件夹地址
const curPath = process.cwd();
// 把模板复制到当前文件夹
copyFolder(path.resolve(__dirname, './template'), path.resolve(curPath, name))
let package = fs.readFileSync(path.resolve(curPath, `./${name}/package.json`));
// 把组件名称和组件描述写入package.json
if (package) {
package = JSON.parse(package);
package.name = name;
package.description = description;
}
const componentName = getComponentName(name);
let filePath = path.resolve(curPath, `./${name}/packages/lib/package.json`);
// 修改package.json
fs.writeFileSync(filePath, JSON.stringify(package, null, 2));
// 修改index.tsx
filePath = path.resolve(curPath, `./${name}/packages/lib/src/index.tsx`);
const componentCode = fs.readFileSync(filePath).toString();
const newComponentCode = componentCode.replace(/ComponentName/g, componentName);
fs.writeFileSync(filePath, newComponentCode);
// 修改example
filePath = path.resolve(curPath, `./${name}/packages/example/src/App.tsx`);
const testComponentCode = fs.readFileSync(filePath).toString();
const newTestComponentCode = testComponentCode
.replace(/ComponentName/g, componentName)
.replace(/ComponentPath/g, name);
fs.writeFileSync(filePath, newTestComponentCode);
// 修改example的package.json里的依赖
filePath = path.resolve(curPath, `./${name}/packages/example/package.json`);
const packageComponentCode = fs.readFileSync(filePath).toString();
const newPackageComponentCode = packageComponentCode
.replace(/ComponentPath/g, name);
fs.writeFileSync(filePath, newPackageComponentCode);
console.log(`\nDone.`);
}
main();这里使用@inquirer/prompts库来获取用户输入
// src/utils.js
const fs = require('fs');
const path = require('path');
// 复制文件夹
function copyFolder(sourceDir, targetDir) {
// 创建目标文件夹
fs.mkdirSync(targetDir);
// 读取源文件夹中的所有文件和子文件夹
const files = fs.readdirSync(sourceDir);
// 遍历源文件夹中的内容
files.forEach((file) => {
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
const stats = fs.statSync(sourcePath);
// 判断是否为文件夹
if (stats.isDirectory()) {
// 如果是文件夹,则递归调用复制文件夹函数
copyFolder(sourcePath, targetPath);
} else {
// 如果是文件,则直接复制到目标文件夹
fs.copyFileSync(sourcePath, targetPath);
}
});
}
/**
* 获取组件名称
*
* @param name 组件名称
* @returns 组件标准名称
*/
function getComponentName(name) {
// 把dbfu-button转换成DbfuButton
if (name?.includes('-')) {
return name.split('-').map(char => getComponentName(char)).join('');
}
// 把button转换成Button
return name.split('').map((char, index) => index === 0 ? char.toUpperCase() : char).join('');
}
module.exports = { copyFolder, getComponentName }实现后,把库推到npm库。测试安装依赖npm i -g create-lowcode-component安装成功后,找一个空白目录执行下面命令测试一下create-lowcode-component查看生成的项目最后这一篇我们实现了用户自定义组件、低代码加载远程组件和快速创建组件项目的脚手架。下一篇我们把事件处理给升级一下,允许一个事件可以绑定多个动作,并且实现使用可视化的方式来编排动作,这个功能目前很多低代码平台都不支持,算是我开发的低代码平台的一个亮点,把页面和逻辑拆开了。
lucky0-0
从零开始搭建一个高颜值后台管理系统全栈框架(七)
前言掘金有很多关于vue的动态路由文章,关于react的动态路由实现方案比较少。有不少文章写的也不是真正的动态路由,只是动态菜单。本地维护路由表,然后把角色配置在路由上,路由跳转时拿到路由的角色信息,然后判断当前用户有没有这个角,这种方案并不是动态路由,后台管理系统的角色都是动态的,这样是肯定不行的。我理解的动态路由是后台返回当前用户拥有权限的菜单,前端根据菜单动态创建路由),不用本地配置路由表,这才是动态菜单。 这篇我给大家分享一下基于react-router v6版本的动态路由方案。为了让大家对动态路由了解的更清楚,这次单独初始化一个新项目,带着大家一点一点实现动态路由,下一篇把这个方案集成到我们系统中。初始化项目在合适的目录下执行下面命令,快速创建一个react vite项目npm create vite然后在项目里安装react-router-dom依赖pnpm i react-router-dom实战创建三个测试页面在src目录下新建pages文件夹,在pages下创建三个假页面。使用react-routerreact-router v6版本支持按配置的方式创建路由了,这种方式定义路由简单了很多。改造src/App.tsx文件import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import Page1 from './pages/page1'
import Page2 from './pages/page2'
import Page3 from './pages/page3'
const router = createBrowserRouter([{
path: '/page1',
Component: Page1,
}, {
path: '/page2',
Component: Page2,
}, {
path: '/page3',
Component: Page3,
}])
function App() {
return (
<RouterProvider router={router} />
)
}
export default Appv5版本实现上面功能,需要这样写。<Switch>
<Route path="/page1">
<Page1 />
</Route>
<Route path="/page2">
<Page2 />
</Route>
<Route path="/page3">
<Page3/>
</Route>
</Switch>
个人感觉还是v6这种配置式的方便一点。重定向上面例子中我们想一进来就重定向到/page1路由,v6版本没有redirect组件了,我们可以使用Navigate组件实现重定向。import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
import Page1 from './pages/page1'
import Page2 from './pages/page2'
import Page3 from './pages/page3'
const router = createBrowserRouter([{
path: '/page1',
Component: Page1,
}, {
path: '/page2',
Component: Page2,
}, {
path: '/page3',
Component: Page3,
}, {
path: '/',
element: <Navigate to="/page1" />,
}])
function App() {
return (
<RouterProvider router={router} />
)
}
export default App自定义404页面现在路由如果没有匹配到,会加载react-router默认404页面,需要改成我们自己的404页面。只需要在路由后面加一个通配符的路由就行了,表示其他路由没匹配到,才配置这个路由。我测试了一下这个路由可以加在任何位置,不像以前版本,必须放在最后。使用Link组件实现路由跳转我们可以使用react-router-dom中的Link标签实现路由跳转,这里有个需要主意的点,Link必须在RouterProvider中某个路由中使用,不然会报错。因为Link组件内部使用到了RouterProvider中的context,我们需要改造一下,新增一个layout布局组件,在这个里面使用Link。这里需要用到路由嵌套,v6版本路由嵌套定义也很简单,使用children就行了。import { createBrowserRouter, RouterProvider, Navigate, Link } from 'react-router-dom'
import Page1 from './pages/page1'
import Page2 from './pages/page2'
import Page3 from './pages/page3'
import NotFound from './NotFound'
import Layout from './Layout'
const router = createBrowserRouter([{
path: '/',
Component: Layout,
children: [{
path: '/page1',
Component: Page1,
}, {
path: '/page2',
Component: Page2,
}, {
path: '/page3',
Component: Page3,
}, {
path: '/',
element: <Navigate to="/page1" />,
}],
}, {
path: '*',
Component: NotFound,
}])
function App() {
return (
<RouterProvider router={router} />
)
}
export default Applayout组件import { Link, Outlet } from 'react-router-dom';
function Layout() {
return (
<div style={{ display: 'flex', gap: 20 }}>
<ul>
<li><Link to="/page1">page1</Link></li>
<li><Link to="/page2">page2</Link></li>
<li><Link to="/page3">page3</Link></li>
</ul>
<Outlet />
</div>
)
}
export default Layout;这里用了Outlet组件,表示路由组件的占位,匹配到哪个路由,哪个路由组件就会渲染到这里。根据路由按需加载借助react的lazy和Suspense可以轻松的实现按需加载。路由按需加载是优化首屏时间的一个重要方法,相当于把一个大的包,拆成了一个个小包,只有访问某个页面的时候,才去加载对应的js,减少了首屏js的体积。使用lazy动态导入组件Suspense包起来,再加上一个loading。没做按需加载之前打出来的包只有一个js文件做了按需加载后,可以看到多了3个js文件,因为我们只配了三个页面路由是按需加载的。实现动态菜单写两个获取菜单方法来模拟从后端获取数据,一个是获取管理员菜单,另外一个是获取普通用户的菜单数据。改造layout组件,调用接口获取菜单,接口没返回前先显示loading。管理员角色普通用户路由鉴权上面虽然实现了动态菜单,但是如果用户手动改url访问/page3,还是能访问的,下面我们来实现路由鉴权。reactr-router6版本出了一个hooks,可以获取到当前匹配的路由,我们获取到当前路由,然后拿当前路由去后端响应的菜单中去检查,如果不在的话就表示没权限。import { useEffect, useState } from 'react';
import { Link, Outlet, useMatches } from 'react-router-dom';
import { getUserMenus } from './service';
function Layout() {
const [menus, setMenus] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// 获取匹配到的路由
const matches = useMatches();
useEffect(() => {
getUserMenus().then((adminMenus: any) => {
setMenus(adminMenus);
setLoading(false);
})
}, []);
if (loading) {
return (
<div>loading...</div>
)
}
// 匹配的路由返回的是个数组,默认最后一个就是当前路由。
if (matches.length && !menus.some(menu => matches[matches.length - 1].pathname === menu.route)) {
return (
<div>403</div>
)
}
return (
<div style={{ display: 'flex', gap: 20 }}>
<ul>
{menus.map(menu => (
<li key={menu.route}><Link to={menu.route}>{menu.name}</Link></li>
))}
{/* <li><Link to="/page1">page1</Link></li>
<li><Link to="/page2">page2</Link></li>
<li><Link to="/page3">page3</Link></li> */}
</ul>
<Outlet />
</div>
)
}
export default Layout;
动态路由方案虽然上面使用路由鉴权的方式实现了路由拦截,但是需要本地维护路由表,远程还要维护一套菜单表,能不能只维护菜单,不用维护本地路由表呢,有两个我知道的方案。方案一这里说一下我在我们公司搞的方案,项目搞的早,当时react还不支持lazy和Suspense,只能写一个脚本,每次启动项目或打包的时候,执行这个脚本动态从后端接口拉取全量的路由,然后在本地生成路由配置文件,这样就不用手动维护了,弊端就是太依赖后端接口了。方案二借助vite的import.meta.glob方法动态导入组件,webpack可以使用require.context。可以看到我们使用import.meta.glob方法是可以获取到pages文件夹下一级目录下的tsx文件,然后我们就可以使用了。lazy(() => import('./pages/page1/index.tsx'))方法差不多啊,为啥要用import.meta.glob。因为import参数不支持变量,只能是写死的路径。因为在编译代码的时候,根据import里面的路径找到文件然后编译代码,里面如果是变量,在编译代码的过程中,vite不知道具体的值。这种写法是支持的,因为vite会根据import.meta.glob匹配到的文件打成一个个小包,所以不存在不知道文件路径的问题。这里不建议使用*.tsx匹配全部文件,因为会匹配所有tsx文件并打成一个个小包,如果我们一个路由引入了多个组件,一次可能会请求很多个组件,多次请求会对服务器造成压力,所以我们这里可以约定一个路由的这里看到了会把test.tsx打成了独立的包把test组件和index打在了一个包里。实现动态路由万事俱备,我们只需要根据后台返回的菜单,动态创建路由就行了。给菜单数据里面加一个组件地址的字段,因为都是./pages开头,所以把这个给内置了,配的时候不用配./pages了。改造App.tsx文件,获取菜单后动态添加路由。上面把菜单数据存到上下文中,方便layout文件中使用。使用动态路由方案后,就不用自己在单独写路由鉴权了,因为没有权限的会匹配不到,然后显示404。拓展在组件外路由跳转react-router v6中history.push这种方式跳转路由已经废弃了,只能使用useNavigate去跳转路由了。因为这个是hooks,只能在组件中使用,那在组件外怎么跳转路由呢,比如axios响应拦截器中。我们可以把创建的router导出,这样就可以在任意地方引入使用它的navigate方法了。路由按需加载时,使用nprogress库作加载进度条reactr-router没有提供文件按需加载开始和结束的回调,不过我们可以用Suspense的fallback属性来实现这个功能。文件加载的时候会显示fallback设置的组件,当文件加载成功后,这个组件便会卸载,我们可以在这个组件开始的时候,执行nprogress的start方法,卸载后执行end方法。loading组件实现,100毫秒内不显示loading和进度条,不然会出现闪烁的情况。我把网速设置成了3G慢一点,不然加载速度太快了,看不到进度条。vue中实现动态路由前言路由这一块vue比react强大不少,react-router的api和使用方式,也慢慢像vue靠拢。借助上面react实现动态路由的思路,我们把vue动态路由也简单实现一下。创建三个测试页面main文件中使用vue-router插件import { createRouter, createWebHistory } from 'vue-router'
import { createApp } from 'vue'
import NotFound from './404.vue'
import App from './App.vue'
const router = createRouter({
history: createWebHistory(),
// 如果路由匹配不上,就显示404
routes: [
{ path: '/:pathMatch(.*)', component: NotFound },
{ path: '/', redirect: '/page1' },
],
})
createApp(App).use(router).mount('#app')App.vue文件中使用动态路由,进页面2秒后,动态添加三个路由。<script>
const components = import.meta.glob('./pages/*/index.vue');
export default {
name: 'App',
data() {
return {
loading: true
}
},
created() {
window.setTimeout(() => {
this.$router.addRoute({ path: '/page1', component: components['./pages/page1/index.vue'] });
this.$router.addRoute({ path: '/page2', component: components['./pages/page2/index.vue'] });
this.$router.addRoute({ path: '/page3', component: components['./pages/page3/index.vue'] });
// 必须要刷新一下,不然添加完不会显示
this.$router.replace(this.$router.currentRoute.value.fullPath)
this.loading = false;
}, 2000);
}
}
</script>
<template>
<ul>
<li>
<router-link to="/page1">page1</router-link>
</li>
<li>
<router-link to="/page2">page2</router-link>
</li>
<li>
<router-link to="/page3">page3</router-link>
</li>
</ul>
<div v-if="loading">loading...</div>
<div v-else>
<router-view></router-view>
</div>
</template>总结篇幅有限,这一篇先让大家了解一下动态路由方案,下一篇会把这套方案实践到我们项目中去,实现真正的动态菜单和动态路由。
lucky0-0
【解读 ahooks 源码系列】 (开篇)如何获取和监听 DOM 元素
前言由于在工作中自定义 Hook 场景写的较多,当实现某个通用场景功能时,可能没想过有已实现好的 Hook 封装或者压根没想去从 Hooks 库里面找,但是社区好的实现使用起来是可以提高开发效率和减少 bug 率的。公司项目中有依赖库 ahooks,但我用的次数不多,于是有了想详细了解 ahooks 的打算,更主要是为了更加熟练抽离与实现一些场景 Hook,学习如何更好的自定义 Hook,便有开始阅读 ahooks 源码的打算了。学习 ahooks 源码的好处在我看来,学习 ahooks 常见 Hooks 封装有以下好处:熟悉如何根据需求去提炼相应的 Hooks,将通用逻辑进行封装讲解源码实现思路,提炼核心实现,通过学习源码学习自定义 Hooks 最佳实践深入学习特定的场景 Hooks,项目开发中一点通,使用时更得心应手关于源码系列本系列文章基于 ahooks 版本 v3.7.4,后续会相继输出 ahooks 源码解读的系列文章。按照 ahooks 官网的分类,我目前先从 DOM 篇开始看起,DOM 篇包括的 Hooks 如下:useEventListener:优雅的使用 addEventListener。useClickAway:监听目标元素外的点击事件。useDocumentVisibility:优雅的使用 addEventListener。useDrop & useDrag:处理元素拖拽的 Hook。useEventTarget:常见表单控件(通过 e.target.value 获取表单值) 的 onChange 跟 value 逻辑封装,支持自定义值转换和重置功能。useExternal:动态注入 JS 或 CSS 资源,useExternal 可以保证资源全局唯一。useTitle:用于设置页面标题。useFavicon:设置页面的 favicon。useFullscreen:管理 DOM 全屏的 Hook。useHover:监听 DOM 元素是否有鼠标悬停。useMutationObserver:一个监听指定的 DOM 树发生变化的 Hook。useInViewport:观察元素是否在可见区域,以及元素可见比例。useKeyPress:监听键盘按键,支持组合键,支持按键别名。useLongPress:监听目标元素的长按事件。useMouse:监听鼠标位置。useResponsive:获取响应式信息。useScroll:监听元素的滚动位置。useSize:监听 DOM 节点尺寸变化的 Hook。useFocusWithin:监听当前焦点是否在某个区域之内,同 css 属性: focus-within。由于内容较多,DOM 篇会分成几篇文章输出,这样每篇读起来既不太耗时也能快速过一遍。文章会在解读源码的基础上,也会把涉及到的 JS 基础知识拎出来,在学源码的过程也能查漏补缺基础。回到本文正题,在看 DOM 篇分类下的 Hooks 时,我发现 getTargetElement 方法和 useEffectWithTarget 内部 Hook 使用较多,所以在讲源码之前先来了解这两个 Hook。如何获取 DOM 元素三种类型的 target在 DOM 类 Hooks 使用规范中提到:ahooks 大部分 DOM 类 Hooks 都会接收 target 参数,表示要处理的元素。target 支持三种类型 React.MutableRefObject、HTMLElement、() => HTMLElement。React.MutableRefObjectexport default () => {
const ref = useRef(null)
const isHovering = useHover(ref)
return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>
}HTMLElementexport default () => {
const isHovering = useHover(document.getElementById('test'))
return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}支持 () => HTMLElement,一般适用在 SSR 场景export default () => {
const isHovering = useHover(() => document.getElementById('test'))
return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}getTargetElement为了兼容以上三种类型入参,ahooks 封装了 getTargetElement - 获取目标 DOM 元素 方法。我们来看看代码做了什么:判断是否为浏览器环境,不是则返回 undefined判断目标元素是否为空,为空则返回函数参数指定的默认元素核心:export function getTargetElement<T extends TargetType>(target: BasicTarget<T>, defaultElement?: T) {
// 判断是否为浏览器环境
if (!isBrowser) {
return undefined;
}
// 目标元素为空则返回函数参数指定的默认元素
if (!target) {
return defaultElement;
}
let targetElement: TargetValue<T>;
// 支持函数执行返回
if (isFunction(target)) {
targetElement = target();
} else if ('current' in target) {
// 兼容 React.MutableRefObject 类型,返回 .current 属性的值
targetElement = target.current;
} else {
// 普通 DOM 元素
targetElement = target;
}
return targetElement;
}对应的 TS 类型:type TargetValue<T> = T | undefined | null
type TargetType = HTMLElement | Element | Window | Document
export type BasicTarget<T extends TargetType = Element> =
| (() => TargetValue<T>)
| TargetValue<T>
| MutableRefObject<TargetValue<T>>监听 DOM 元素target 支持动态变化ahooks 的 DOM 类 Hooks 使用规范第二条点指出:DOM 类 Hooks 的 target 是支持动态变化的,如下:export default () => {
const [boolean, { toggle }] = useBoolean()
const ref = useRef(null)
const ref2 = useRef(null)
const isHovering = useHover(boolean ? ref : ref2)
return (
<>
<div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>
<div ref={ref2}>{isHovering ? 'hover' : 'leaveHover'}</div>
</>
)
}useEffectWithTarget为了满足上述条件, ahooks 内部则封装 useEffectWithTarget(packages/hooks/src/utils/useEffectWithTarget.ts),来看这个文件的代码:import { useEffect } from 'react'
import createEffectWithTarget from './createEffectWithTarget'
const useEffectWithTarget = createEffectWithTarget(useEffect)
export default useEffectWithTarget看到它实际用了 createEffectWithTarget方法,传入的参数是 useEffect(packages/hooks/src/utils/createEffectWithTarget.ts)createEffectWithTarget 接受参数 useEffect 或 useLayoutEffect,返回 useEffectWithTarget 函数 useEffectWithTarget 函数接收三个参数:前两个参数是 effect 和 deps(与 useEffect 参数一致),第三个参数则兼容了 DOM 元素的三种类型,可传 普通 DOM/ref 类型/函数类型useEffectWithTarget 实现思路:使用 useEffect/useLayoutEffect 监听,内部不传第二个参数依赖项,每次更新都会执行该副作用函数通过 hasInitRef 判断是否是第一次执行,是则初始化:记录最后一次目标元素列表和依赖项,执行 effect 函数由于该 useEffectType 函数体每次更新都会执行,所以每次都拿到最新的 targets 和 deps,所以后续执行可与第 2 点记录的最后一次的ref值进行比对非首次执行:则判断元素列表长度或目标元素或者依赖发生变化,变化了则执行更新流程:执行上一次返回的卸载函数,更新最新值,重新执行 effect组件卸载:执行 unLoadRef.current?.() 卸载函数,重置 hasInitRefconst createEffectWithTarget = (
useEffectType: typeof useEffect | typeof useLayoutEffect,
) => {
/**
*
* @param effect
* @param deps
* @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
*/
const useEffectWithTarget = (
effect: EffectCallback,
deps: DependencyList,
target: BasicTarget<any> | BasicTarget<any>[],
) => {
// 判断是否已初始化
const hasInitRef = useRef(false)
const lastElementRef = useRef<(Element | null)[]>([]) // 最后一次
const lastDepsRef = useRef<DependencyList>([])
const unLoadRef = useRef<any>()
// useEffectType:代表 useEffect 或 useLayoutEffect,每次更新都会执行该函数
useEffectType(() => {
const targets = Array.isArray(target) ? target : [target]
const els = targets.map((item) => getTargetElement(item)) // 获取 DOM 元素列表
// 首次执行:初始化
if (!hasInitRef.current) {
hasInitRef.current = true
lastElementRef.current = els // 最后一次执行的相应的 target 元素
lastDepsRef.current = deps // 最后一次执行的相应的依赖
unLoadRef.current = effect() // 执行外部传入的 effect 函数,返回卸载函数
return
}
// 非首次执行:判断元素列表长度或目标元素或者依赖发生变化
if (
els.length !== lastElementRef.current.length ||
!depsAreSame(els, lastElementRef.current) ||
!depsAreSame(deps, lastDepsRef.current)
) {
// 依赖发生变更了,相当于走 useEffect 更新流程
unLoadRef.current?.()
lastElementRef.current = els
lastDepsRef.current = deps
unLoadRef.current = effect() // 再次执行 effect,赋值卸载函数给 unLoadRef
}
}) // 没有传第二个参数,则每次都会执行
// 卸载操作 Hook
useUnmount(() => {
unLoadRef.current?.() // 执行卸载操作
// for react-refresh
hasInitRef.current = false
})
}
return useEffectWithTarget
}depsAreSame 实现:import type { DependencyList } from 'react'
export default function depsAreSame(
oldDeps: DependencyList,
deps: DependencyList,
): boolean {
if (oldDeps === deps) return true // 浅比较
for (let i = 0; i < oldDeps.length; i++) {
if (!Object.is(oldDeps[i], deps[i])) return false
}
return true
}这样使用起来跟 useEffect 的区别就是有第三个参数——监听的 DOM 元素
lucky0-0
实现前端项目发布后,用户无刷新升级。——从零开始搭建一个高颜值后台管理系统全栈框架(十五)
背景现在前端改东西发布后,如果用户没刷新的情况下,切换页面,可能会报错。原因这是因为我们文件做了路由按需加载,也就是说只有切页面的时候,才去加载对应代码,这样做的好处是第一次加载的资源比较少,白屏时间短。但是这样做会引发另外一个问题,假如用户一直在使用系统,这时候我们改了某个页面的bug,用户在没打开过那个页面的情况下,(如果用户打开过,因为前端会把加载过的代码缓存在内存中,不去从服务器加载js,这种情况不会报错。)我们发布后,因为这当前文件的hash已经变了,老的那个js文件已经不存在了,所以这时候用户再切到那个页面就会报错。解决这个问题的方案很多,这里我把我了解的方案,给大家分享一下,最后一种方案很牛,一定要看到最后。方案多版本描述上面说了因为新发布的代码会把老的文件覆盖,那我们每次打包的时候,把老的代码给保留下来就行了,这个方案我没有实际操作过,但是理论上是可行的。实现思路在CI中,拉取上一个版本的镜像,然后使用cp命令把镜像里的上个版本的代码复制出来,然后和当前打包出来的文件夹合并一下,然后打出一个新的镜像。# 从镜像中复制文件到外面
docker cp <容器ID或名称>:<容器内路径> <宿主机路径>小结这个方案需要有运维的知识,对docker要有一定的了解。前端一直不刷新,改的bug会一直不生效,需要通知用户手动刷新。同时需要后端接口也支持多版本,不然前后端版本对不上,可能会报错。后端给前端推送消息,刷新页面实现思路后端写一个接口,使用websocket通知所有客户端,前端接收到对应的消息后,调用浏览器刷新页面方法刷新页面。发布成功后在CI中调用这个接口就行了。小结这个方案有个问题,万一用户正在输入某个表单,这时候前端发布了,然后浏览器自动刷新,这时候用户心态可能会炸。如果给用户一个选项,可以稍后刷新,用户做完操作后,可能会忘记刷新,然后有可能会出现上面的报错。监听文件404,刷新页面描述这是个简单粗暴的方法,全局监听js资源加载404,如果404了,就刷新页面。实现window.addEventListener('error', function(event) {
const target = event.target;
if (target.tagName === 'SCRIPT' && target.src) {
// 刷新页面
window.location.reload();
}
}, true);
总结简单粗暴,我目前在公司里使用的就是这个方案。轮询检查index.html,查询js有没有变动描述前端写一个定时器,定时请求index.html文件内容,判断当前内容和上一次的是不是一样,如果不一样说明前端发布了新版本,然后和上面一样刷新就行了。总结具体实现可以参考这篇文章。前端重新部署如何通知用户刷新网页?终极方案-用户无感升级说明这个方案我参考了这篇文章。不用刷新!用户无感升级,解决前端部署最后的问题实现思路既然文件加载失败了,我们只要发现文件加载失败了,找到正确的资源文件名重新加载就行了。当文件加载失败时,怎么找到对应的资源文件名呢,这里用到了manifest.json文件,打包资源的文件映射,有了它我们知道了文件名就能找到打包后的文件路径。实现修改vite打包设置,开启manifest.json输出manifest.json文件内容file字段就是打包后的字段名改造对外导出的路由配置文件,这一块可以看一下前面关于动态路由的文章。具体代码:// src/config/routes.tsx
export const modules = import.meta.glob('../pages/**/index.tsx');
export const componentPaths = Object.keys(modules).map((path: string) => path.replace('../pages', ''));
let manifest: any;
export const components = Object.keys(modules).reduce<Record<string, () => Promise<any>>>((prev, path: string) => {
const formatPath = path.replace('../pages', '');
prev[formatPath] = async () => {
try {
// 这里其实就是动态加载js,如果报错了说明js资源不存在
return await modules[path]() as any;
} catch {
// 如果manifest已经存在了,就不用再请求了
if (manifest) {
try {
// 有可能manifest是过期的,所以可能还会加载失败
return await import('/' + manifest[`src/pages${formatPath}`]?.file);
} catch {
// 如果失败,重新获取一下manifest.json,拿到最新的路径
manifest = await (await fetch('/manifest.json')).json() as any;
return await import('/' + manifest[`src/pages${formatPath}`]?.file);
}
} else {
// 如果manifest.json为空,请求manifest.json,并根据最新的路径加载对应js
manifest = await (await fetch('/manifest.json')).json() as any;
return await import('/' + manifest[`src/pages${formatPath}`]?.file);
}
}
}
return prev;
}, {});
以前为了解决404问题,nginx配置了当请求的资源存在的时候返回index.html,导致现在js虽然不存在了,请求也不会报错,返回了index.html的内容。修改ngxin配置,如果js和css文件匹配不到则返回404测试验证修改菜单管理页面第一列标题打开网站,随便打开一个页面,这时候不要打开菜单管理页面。等发布完成后,不要刷新,打开菜单管理页面。根据下图可以发现,新的内容已经生效了开始js是404,后面请求了manifest.json后,加载了新的资源。最后上面方案有个小问题,如果开始加载过菜单管理页面后,后面改的东西就不会生效了,这时候可以和前面方案结合一下,使用轮询检查manifest.json内容有没有变化,如果有变化通知用户刷新。到此框架这一块的功能基本已经实现完了,后面会开始开发低代码平台,在公司做了几年低代码,把我的一些心得分享给大家。我的目标是做一个企业级的低代码平台,也会像前面这些文章一样,带着大家一点一点做,让大家看完后自己也能实现一个低代码平台,敬请期待吧。
lucky0-0
使用低代码实战开发页面(下)——低代码知识点详解(7)
前言前面已经简单的实现了CRUD中查询和新增功能,这一篇我们来实现一下编辑和删除功能,不过我在做demo的时候,发现了一些问题,框架做了一些改造,所以这一期拖的时间有点长。体外话有些倔友对低代码存在的意思表示好奇,这里结合公司里的低代码平台说明一下。往期回顾《保姆级》低代码知识点详解(一) 低代码事件绑定和组件联动——低代码知识点详解(二) 低代码动态属性和在线执行脚本——低代码知识点详解(三) 低代码在线加载远程组件——低代码知识点详解(四) 低代码可视化逻辑编排——低代码知识点详解(五) 使用低代码实战开发页面(上)——低代码知识点详解(六)页面持久化前面有人和我说,希望刷新后配置还在,这就需要把数据存到localstorage中。因为我们是使用zustand库做状态存储的,而zustand库实现数据持久化是非常简单的,只需要使用一个中间件就行了,看下面变量持久化例子。import {create} from 'zustand';
import {createJSONStorage, persist} from 'zustand/middleware';
export interface Variable {
/**
* 变量名
*/
name: string;
/**
* 默认值
*/
defaultValue: string;
/**
* 备注
*/
remark: string;
}
interface State {
variables: Variable[];
}
interface Action {
/**
* 添加组件
* @param component 组件属性
* @param parentId 上级组件id
* @returns
*/
setVariables: (variables: Variable[]) => void;
}
export const useVariablesStore = create(
persist<State & Action>(
(set) => ({
variables: [],
setVariables: (variables) => set({variables}),
}),
{
name: 'variables',
storage: createJSONStorage(() => localStorage),
}
)
);
components持久化和这个一样,代码就不展示了。实现删除组件功能如果一不小心拖错了组件,希望可以给删除掉,下面来实现删除组件功能。删除按钮加在选中组件遮罩的左上角。动态算出删除按钮的问题渲染删除按钮通过绝对定位把按钮设置到左上角实现删除组件方法递归查找出当前组件的父组件,然后从父组件children中移除当前要删除的组件。效果展示通过当前组件选择父组件当组件嵌套比较多的时候,因为容器组件可能被遮挡,就无法选中了。现在我们来实现一下,选中一个组件后,可以切换到它的上级组件。获取当前组件所有上级,然后使用antd中Dropdown组件渲染。动态隐藏组件这个功能主要是用来实现组件联动的。有三个按钮,两个按钮来控制另外一个按钮的显示和隐藏,下面我们来实现一下这个功能。因为这个功能所有组件都是通用的,所以给提出来,变成公共配置。如下图:所有组件默认是显示的,可以直接切换隐藏,也可以绑定变量来控制隐藏。先封装一个带变量选择的Switch切换框,和前面封装input类似,代码如下:// src/editor/common/setting-form-item/switch.tsx
import { SettingOutlined } from '@ant-design/icons';
import { Switch } from 'antd';
import { useState } from 'react';
import SelectVariableModal from '../select-variable-modal';
interface Value {
type: 'static' | 'variable';
value: any;
}
interface Props {
value?: Value,
onChange?: (value: Value) => void;
}
const SettingFormItemSwitch: React.FC<Props> = ({ value, onChange }) => {
const [visible, setVisible] = useState(false);
function valueChange(checked: any) {
onChange && onChange({
type: 'static',
value: checked,
});
}
function select(record: any) {
onChange && onChange({
type: 'variable',
value: record.name,
});
setVisible(false);
}
return (
<div className='flex gap-[8px]'>
<Switch
disabled={value?.type === 'variable'}
checked={(value?.type === 'static' || !value) ? value?.value : ''}
onChange={valueChange}
checkedChildren="隐藏"
unCheckedChildren="显示"
/>
<SettingOutlined
onClick={() => { setVisible(true) }}
className='cursor-pointer'
style={{ color: value?.type === 'variable' ? 'blue' : '' }}
/>
<SelectVariableModal
open={visible}
onCancel={() => { setVisible(false) }}
onSelect={select}
/>
</div>
)
}
export default SettingFormItemSwitch;
在渲染的时候,根据配置判断是否渲染组件,如果是隐藏,则返回null,不渲染组件。效果展示表格支持添加操作列因为编辑和删除是表格行上的操作,所以表格单元格需要支持自定义操作。因为这个配置比较麻烦,所以组件需要自定义setter,在渲染setter的地方判断一下,如果是组件就去渲染,如果是数组使用公共属性组件去渲染。table-column配置代码如下:// src/editor/components/table-column/setter.tsx
import { EditOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Drawer, Form, Input, Space, Tooltip } from 'antd';
import { useRef, useState } from 'react';
import { arrayMove } from "react-sortable-hoc";
import CommonSetter from '../../common/common-setter';
import SortableList from '../../common/sortable-list';
import FlowEvent from '../../layouts/flow-event';
const setter = [
{
name: 'type',
label: '类型',
type: 'select',
options: [
{
label: '文本',
value: 'text',
},
{
label: '日期',
value: 'date',
},
{
label: '操作',
value: 'option',
},
],
},
{
name: 'title',
label: '标题',
type: 'input',
},
{
name: 'dataIndex',
label: '字段',
type: 'input',
},
];
function OptionsFormItem({ value = [], onChange }: any) {
const [open, setOpen] = useState(false);
const [curItem, setCurItem] = useState<any>({});
const flowEventRef = useRef<any>();
function changeHandle(val: string, item: any) {
item.label = val;
onChange([...value]);
}
function sortEndHanlde({ oldIndex, newIndex }: { oldIndex: number; newIndex: number; }) {
onChange(arrayMove(value, oldIndex, newIndex));
}
function save() {
const val = flowEventRef.current?.save();
curItem.event = val;
onChange([...value]);
setOpen(false);
setCurItem({});
}
return (
<div>
<div className="h-[32px] flex items-center bg-[#f7f8fa] px-[16px] rounded-2px font-semibold">操作</div>
<div className='w-[100%] p-[20px]'>
<SortableList
items={value || []}
hiddenEdit
onDelete={(item: any) => {
const index = value.findIndex((v: any) => v.dataIndex === item.dataIndex);
value.splice(index, 1);
onChange([...value]);
}}
itemRender={(item: any) => (
<Space>
<div
className='w-[140px] text-[14px] text-[rgb(0,0,0)]'
style={{ fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif,'Apple Color Emoji','Segoe UI Emoji','Segoe UI Symbol','Noto Color Emoji'" }}
>
<Input onChange={(e) => { changeHandle(e.target.value, item) }} value={item.label} />
</div>
<Tooltip title="设置点击事件">
<EditOutlined
onClick={() => {
setCurItem(item);
setOpen(true);
}}
className="cursor-pointer"
/>
</Tooltip>
</Space>
)}
useDragHandle
onSortEnd={sortEndHanlde}
/>
</div>
<div
className='px-[20px]'
>
<Button
icon={(
<PlusOutlined />
)}
block
type='dashed'
onClick={() => {
onChange([...value, { key: new Date().getTime(), label: '' }]);
}}
>
添加
</Button>
</div>
<Drawer
title="设置事件流"
width="100vw"
open={open}
zIndex={1005}
onClose={() => { setOpen(false); }}
extra={(
<Button
type='primary'
onClick={save}
>
保存
</Button>
)}
push={false}
destroyOnClose
styles={{ body: { padding: 0 } }}
>
<FlowEvent flowData={curItem?.event} ref={flowEventRef} />
</Drawer>
</div>
)
}
function Setter() {
const form = Form.useFormInstance();
const type = Form.useWatch('type', form);
return (
<>
<CommonSetter setters={setter} />
{type === 'option' && (
<Form.Item noStyle name="options">
<OptionsFormItem />
</Form.Item>
)}
</>
)
}
export default Setter;
为了支持添加的操作可以拖拽排序,使用了react-sortable-hoc组件,原生组件用起来比较难受,我这里做了个简单的封装,方便后面其他地方使用。// src/editor/common/sortable-list.tsx
import { DeleteOutlined, EditOutlined, HolderOutlined } from '@ant-design/icons';
import { Space } from 'antd';
import { SortableContainer, SortableElement, SortableHandle } from "react-sortable-hoc";
const DragHandle = SortableHandle(() => <HolderOutlined className='text-[14px] cursor-move' />);
const SortableItem = SortableElement<any>(({
item,
onDelete,
itemRender,
customItemRender,
hiddenEdit,
onEdit,
hiddenDelete,
hiddenDrag,
itemIndex,
deleteHandle,
editHandle,
}: any) => {
function renderDelete() {
if (deleteHandle) {
if (deleteHandle(item, itemIndex)) {
return (
<DeleteOutlined onClick={onDelete} className="hover:text-[red] cursor-pointer" />
)
}
} else if (!hiddenDelete) {
return (
<DeleteOutlined onClick={onDelete} className="hover:text-[red] cursor-pointer" />
)
}
}
function renderEdit() {
if (editHandle) {
if (editHandle(item, itemIndex)) {
return (
<EditOutlined className="cursor-pointer" onClick={() => { onEdit(item, itemIndex) }} />
)
}
} else if (!hiddenEdit) {
return (
<EditOutlined className="cursor-pointer" onClick={() => { onEdit(item, itemIndex) }} />
)
}
}
return (
<div key={item.key} style={{ lineHeight: 1, fontSize: 14 }} className="py-[4px]">
{customItemRender ? customItemRender(item, itemIndex) : (
<Space style={{ width: '100%' }}>
{renderEdit()}
{itemRender && itemRender(item, itemIndex)}
{renderDelete()}
{!hiddenDrag && <DragHandle />}
</Space>
)}
</div>
)
});
const SortableList = SortableContainer<any>(({
items,
onDelete,
itemRender,
hiddenEdit,
hiddenDelete,
onEdit,
hiddenDrag,
customItemRender,
deleteHandle,
editHandle,
}: any) => {
return (
<div>
{items.map((value: any, index: number) => (
<SortableItem
key={value.key}
index={index}
item={value}
itemIndex={index}
onDelete={() => {
onDelete(value, index);
}}
itemRender={itemRender}
hiddenEdit={hiddenEdit}
hiddenDelete={hiddenDelete}
onEdit={onEdit}
hiddenDrag={hiddenDrag}
customItemRender={customItemRender}
deleteHandle={deleteHandle}
editHandle={editHandle}
/>
))}
</div>
);
});
export default SortableList;效果展示改造表格渲染列的方法,增加操作类型判断。上面代码里_execEventFlow方法,是封装的一个公共执行事件流的方法,需要传入事件流配置,和额外参数就行了,参数后面再说。预览一下新增请求接口动作实现删除功能,需要按钮点击事件绑定删除接口,所以现在我们来实现一下请求接口。先看一下配置效果实现请求接口的配置代码,没啥好说的,省略了。实现请求接口方法,这里随着动作越来越多,prod文件里的代码也越来越多了,所以我这里给单独拆出来了一个文件(src/editor/utils/action.ts),存放动作实现的方法。这里讲解一下eventData和initEventData这两个参数。eventData是当前事件的参数,就是事件流中间事件产生的参数。initEventData是初始事件的参数,也就是开始事件的参数。开始事件的eventData和initEventData是同一个。这里record对应的就是eventData和initEventData上面配置中{initEventData.id}是为了把当前点击行的id传给后端删除当前数据。增加确认框动作为了防止误删除,在删除数据的时候,加一个确认操作。配置和实现比较简单,就不详细说了。删除功能演示表单支持设置默认值因为要实现编辑功能,表单需要支持设置默认值。表单校验一般的表格都会有一些校验规则,这里先实现一个简单的必输校验。form-item配置里添加校验属性使用antd的form-item组件rules属性实现必输功能给表单添加校验通过事件这样可以在校验通过事件中拿到表单的值,调用接口去新增数据或更新数据。最后低代码基础知识详解就到这里了,后面会在fluxy-admin中完善前面实现的低代码功能,最终实现一个企业级低代码平台。
lucky0-0
【解读 ahooks 源码系列】Dev篇——useTrackedEffect 和 useWhyDid
前言本文是 ahooks 源码(v3.7.4)系列的第六篇——Dev 篇,该篇主要是协助开发调优的 Hook,只有两个往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin本文主要解读 useTrackedEffect、useWhyDidYouUpdate 的源码实现useTrackedEffect追踪是哪个依赖变化触发了 useEffect 的执行。官方文档基本用法查看每次 effect 执行时发生变化的依赖项官方在线 Demoimport React, { useState } from 'react';
import { useTrackedEffect } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);
useTrackedEffect(
(changes) => {
console.log('Index of changed dependencies: ', changes);
},
[count, count2],
);
return (
<div>
<p>Please open the browser console to view the output!</p>
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>count + 1</button>
</div>
<div style={{ marginTop: 16 }}>
<p>Count2: {count2}</p>
<button onClick={() => setCount2((c) => c + 1)}>count + 1</button>
</div>
</div>
);
};核心实现实现原理:通过 uesRef 记录上一次依赖的值,在当前执行的时候,判断当前依赖值和上次依赖值之间有无变化changes:变化的依赖 index 数组previousDeps:上一个依赖currentDeps:当前依赖useTrackedEffect(
effect: (changes: [], previousDeps: [], currentDeps: []) => (void | (() => void | undefined)),
deps?: deps,
)源码实现const useTrackedEffect = (effect: Effect, deps?: DependencyList) => {
const previousDepsRef = useRef<DependencyList>(); // 记录上次依赖
useEffect(() => {
// 判断依赖前后的 changes
const changes = diffTwoDeps(previousDepsRef.current, deps);
const previousDeps = previousDepsRef.current; // 赋值上次依赖
previousDepsRef.current = deps;
return effect(changes, previousDeps, deps);
}, deps);
};diffTwoDeps 方法实现:对前后两个 deps 依赖项列表使用 Object.is 进行严格相等性检查如果定义了 deps1,则遍历 deps1 并将每个元素与来自 deps2 的对应索引元素进行比较(因为这个函数只在这个钩子中使用,所以假设两个 deps 列表的长度总是相同的)const diffTwoDeps = (deps1?: DependencyList, deps2?: DependencyList) => {
// 对前后两个 deps 依赖项列表使用 Object.is 进行严格相等性检查
return deps1
? deps1
.map((_ele, idx) => (!Object.is(deps1[idx], deps2?.[idx]) ? idx : -1))
.filter((ele) => ele >= 0) // 过滤相等值
: deps2
? deps2.map((_ele, idx) => idx)
: [];
};完整源码useWhyDidYouUpdate帮助开发者排查是那个属性改变导致了组件的 rerender。官方文档基本用法官方在线 Demo打开控制台,可以看到改变的属性。import { useWhyDidYouUpdate } from 'ahooks';
import React, { useState } from 'react';
const Demo: React.FC<{ count: number }> = (props) => {
const [randomNum, setRandomNum] = useState(Math.random());
useWhyDidYouUpdate('useWhyDidYouUpdateComponent', { ...props, randomNum });
return (
<div>
<div>
<span>number: {props.count}</span>
</div>
<div>
randomNum: {randomNum}
<button onClick={() => setRandomNum(Math.random)} style={{ marginLeft: 8 }}>
🎲
</button>
</div>
</div>
);
};
export default () => {
const [count, setCount] = useState(0);
return (
<div>
<Demo count={count} />
<div>
<button onClick={() => setCount((prevCount) => prevCount - 1)}>count -</button>
<button onClick={() => setCount((prevCount) => prevCount + 1)} style={{ marginLeft: 8 }}>
count +
</button>
</div>
<p style={{ marginTop: 8 }}>Please open the browser console to view the output!</p>
</div>
);
};使用场景检查哪些 props 发生改变协助找出无效渲染:useWhyDidYouUpdate 会告诉我们监听数据中所有变化的数据,不管它是不是无效的更新,但还需要我们自己来区分识别哪些是无效更新的属性,从而进行优化。实现思路使用 useRef 声明 prevProps 变量(确保拿到最新值),用来保存上一次的 props每次 useEffect 更新都置空 changedProps 对象,并将新旧 props 对象的属性提取出来,生成属性数组 allKeys遍历 allKeys 数组,去对比新旧属性值。如果不同,则记录到 changedProps 对象中如果 changedProps 有长度,则输出改变的内容,并更新 prevProps核心实现实现原理:通过 useEffect 拿到上一次 props 值 和当前 props 值 进行遍历比较,如果值发送改变则输出// componentName:观测组件的名称
// props:需要观测的数据(当前组件 state 或者传入的 props 等可能导致 rerender 的数据)
export default function useWhyDidYouUpdate(componentName: string, props: IProps) {
const prevProps = useRef<IProps>({});
useEffect(() => {
if (prevProps.current) {
// 获取所有的需要观测的数据
const allKeys = Object.keys({ ...prevProps.current, ...props });
const changedProps: IProps = {}; // 发生改变的属性值
allKeys.forEach((key) => {
// 通过 Object.is 判断是否进行更新
if (!Object.is(prevProps.current[key], props[key])) {
changedProps[key] = {
from: prevProps.current[key],
to: props[key],
};
}
});
// 遍历改变的属性,有值则输出日志
if (Object.keys(changedProps).length) {
console.log('[why-did-you-update]', componentName, changedProps);
}
}
prevProps.current = props;
});
}
lucky0-0
【解读 ahooks 源码系列】 Scene 篇(二)
前言本文是 ahooks 源码(v3.7.4)系列的第十三篇——【解读 ahooks 源码系列】 Scene 篇(二)往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState【解读 ahooks 源码系列】Effect 篇(一):useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect【解读 ahooks 源码系列】Effect 篇(二):useDeepCompareEffect、useDeepCompareLayoutEffect、useInterval、useTimeout、useRafInterval、useRafTimeout、useLockFn、useUpdate、useThrottleEffect【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(一):useMount、useUnmount、useUnmountedRef、useCounter、useNetwork、useSelections、useHistoryTravel本文主要解读 useTextSelection、useCountdown、useDynamicList、useWebSocket 的源码实现useTextSelection实时获取用户当前选取的文本内容及位置。官方文档基本用法官方在线 Demoimport React from 'react';
import { useTextSelection } from 'ahooks';
export default () => {
const { text } = useTextSelection();
return (
<div>
<p>You can select text all page.</p>
<p>Result:{text}</p>
</div>
);
};核心实现Element.getBoundingClientRect():返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。Window.getSelection:返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置。Selection.removeAllRanges():从当前 selection 对象中移除所有的 range 对象,取消所有的选择只留下 anchorNode 和 focusNode 属性并将其设置为 nullSelection.getRangeAt:返回一个包含当前选区内容的区域对象。实现思路:通过监听 mousedown 和 mouseup 事件,获取选中文本内容使用 window.getSelection() 方法,而获取位置信息使用 getBoundingClientRect 方法mousedown 回调:清空之前的信息(state/range)、判断选中范围是否在目标区域mouseup 回调:获取选中区域文本与位置信息,更新到 stateconst initRect: Rect = {
top: NaN,
left: NaN,
bottom: NaN,
right: NaN,
height: NaN,
width: NaN,
};
const initState: State = {
text: '',
...initRect,
};
function useTextSelection(target?: BasicTarget<Document | Element>): State {
const [state, setState] = useState(initState);
const stateRef = useRef(state);
const isInRangeRef = useRef(false);
stateRef.current = state;
useEffectWithTarget(
() => {
// 获取目标元素
const el = getTargetElement(target, document);
if (!el) {
return;
}
const mouseupHandler = () => {
let selObj: Selection | null = null;
let text = '';
let rect = initRect;
if (!window.getSelection) return;
// 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
selObj = window.getSelection();
// 转为字符串
text = selObj ? selObj.toString() : '';
if (text && isInRangeRef.current) {
// 获取文本位置信息并设置
rect = getRectFromSelection(selObj);
setState({ ...state, text, ...rect });
}
};
// 任意点击都需要清空之前的 range
const mousedownHandler = (e) => {
if (!window.getSelection) return;
if (stateRef.current.text) {
setState({ ...initState });
}
isInRangeRef.current = false;
// 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
const selObj = window.getSelection();
if (!selObj) return;
selObj.removeAllRanges();
isInRangeRef.current = el.contains(e.target);
};
el.addEventListener('mouseup', mouseupHandler);
document.addEventListener('mousedown', mousedownHandler);
return () => {
el.removeEventListener('mouseup', mouseupHandler);
document.removeEventListener('mousedown', mousedownHandler);
};
},
[],
target,
);
return state;
}获取文本位置信息函数:function getRectFromSelection(selection: Selection | null): Rect {
if (!selection) {
return initRect;
}
// rangeCount:返回选区 (selection) 中 range 对象数量的只读属性
if (selection.rangeCount < 1) {
return initRect;
}
// 返回一个包含当前选区内容的区域对象
const range = selection.getRangeAt(0);
const { height, width, top, left, right, bottom } = range.getBoundingClientRect();
return {
height,
width,
top,
left,
right,
bottom,
};
}完整源码useCountdown一个用于管理倒计时的 Hook。官方文档基本用法官方在线 Demoimport React from 'react';
import { useCountDown } from 'ahooks';
export default () => {
const [countdown, formattedRes] = useCountDown({
targetDate: '2022-12-31 24:00:00',
});
const { days, hours, minutes, seconds, milliseconds } = formattedRes;
return (
<>
<p>
There are {days} days {hours} hours {minutes} minutes {seconds} seconds {milliseconds}{' '}
milliseconds until 2022-12-31 24:00:00
</p>
</>
);
};核心实现实现思路:通过定时器 setInterval 进行设置倒计时;当剩余时间为负值时,停止倒计时,执行结束回调。const useCountdown = (options: Options = {}) => {
const { leftTime, targetDate, interval = 1000, onEnd } = options || {};
const target = useMemo<TDate>(() => {
// 如果传了 leftTime,则采用 leftTime,忽略 targetDate
if ('leftTime' in options) {
return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined;
} else {
return targetDate;
}
}, [leftTime, targetDate]);
const [timeLeft, setTimeLeft] = useState(() => calcLeft(target));
// 最新引用的倒计时结束回调
const onEndRef = useLatest(onEnd);
useEffect(() => {
if (!target) {
// for stop
setTimeLeft(0);
return;
}
// 立即执行一次
setTimeLeft(calcLeft(target));
const timer = setInterval(() => {
const targetLeft = calcLeft(target);
setTimeLeft(targetLeft);
// 为0代表倒计时结束
if (targetLeft === 0) {
clearInterval(timer); // 清除定时器
onEndRef.current?.(); // 执行回调
}
}, interval);
return () => clearInterval(timer);
}, [target, interval]);
// 返回格式化后的倒计时
const formattedRes = useMemo(() => parseMs(timeLeft), [timeLeft]);
// [倒计时时间戳(毫秒), 格式化后的倒计时]
return [timeLeft, formattedRes] as const;
};来看下 calcLeft 和 parseMs 函数:// 计算目标时间和当前时间相差的毫秒数
const calcLeft = (target?: TDate) => {
if (!target) {
return 0;
}
// https://stackoverflow.com/questions/4310953/invalid-date-in-safari
// 剩余时间 = 目标时间 - 当前时间
const left = dayjs(target).valueOf() - Date.now();
// 剩余时间小于0,则返回0表示结束
return left < 0 ? 0 : left;
};
// 格式化倒计时
const parseMs = (milliseconds: number): FormattedRes => {
return {
days: Math.floor(milliseconds / 86400000),
hours: Math.floor(milliseconds / 3600000) % 24,
minutes: Math.floor(milliseconds / 60000) % 60,
seconds: Math.floor(milliseconds / 1000) % 60,
milliseconds: Math.floor(milliseconds) % 1000,
};
};完整源码useDynamicList一个帮助你管理动态列表状态,并能生成唯一 key 的 Hook。官方文档基本用法官方在线 Demoimport { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { useDynamicList } from 'ahooks';
import { Input } from 'antd';
import React from 'react';
export default () => {
const { list, remove, getKey, insert, replace } = useDynamicList(['David', 'Jack']);
const Row = (index: number, item: any) => (
<div key={getKey(index)} style={{ marginBottom: 16 }}>
<Input
style={{ width: 300 }}
placeholder="Please enter name"
onChange={(e) => replace(index, e.target.value)}
value={item}
/>
{list.length > 1 && (
<MinusCircleOutlined
style={{ marginLeft: 8 }}
onClick={() => {
remove(index);
}}
/>
)}
<PlusCircleOutlined
style={{ marginLeft: 8 }}
onClick={() => {
insert(index + 1, '');
}}
/>
</div>
);
return (
<>
{list.map((ele, index) => Row(index, ele))}
<div>{JSON.stringify([list])}</div>
</>
);
};核心实现实现思路:对数组的常见 API 进行封装维护一个 list 列表数组和 keyList,每次操作元素都要设置 list 和 keyList维护的 list 列表:// 当前的列表
const [list, setList] = useState(() => {
initialList.forEach((_, index) => {
setKey(index);
});
return initialList;
});比如我们要进行插入操作时,使用 js 的 splice 方法进行插入,赋值新的 list 值,同时调用 setKey 方法// 在指定位置插入元素
const insert = useCallback((index: number, item: T) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 0, item);
setKey(index);
return temp;
});
}, []);setKey 方法里面使用 counterRef 维持自增 key(唯一标识符),并把该标识符插入到 keyList,确保每个列表项都有一个唯一的标识符,进行增删等等元素操作就不会出现问题。const counterRef = useRef(-1); // 存储最后一个key
// 包含了列表中每个项的唯一标识符
const keyList = useRef<number[]>([]);
// 用于更新 keyList,确保 keyList 始终包含最新的唯一标识符列表
const setKey = useCallback((index: number) => {
counterRef.current += 1; // 每次设置都保持自增 +1
keyList.current.splice(index, 0, counterRef.current);
}, []);其它的封装实现都大同小异:// 重新设置 list 的值
const resetList = useCallback((newList: T[]) => {
keyList.current = [];
setList(() => {
newList.forEach((_, index) => {
setKey(index);
});
return newList;
});
}, []);
// 获得某个元素的 uuid
const getKey = useCallback((index: number) => keyList.current[index], []);
// 获得某个 key 的 index
const getIndex = useCallback(
(key: number) => keyList.current.findIndex((ele) => ele === key),
[],
);
// 在指定位置插入多个元素
const merge = useCallback((index: number, items: T[]) => {
setList((l) => {
const temp = [...l];
items.forEach((_, i) => {
setKey(index + i);
});
temp.splice(index, 0, ...items);
return temp;
});
}, []);
// 替换指定元素
const replace = useCallback((index: number, item: T) => {
setList((l) => {
const temp = [...l];
temp[index] = item;
return temp;
});
}, []);
// 删除指定元素
const remove = useCallback((index: number) => {
setList((l) => {
const temp = [...l];
temp.splice(index, 1);
// remove keys if necessary
try {
keyList.current.splice(index, 1);
} catch (e) {
console.error(e);
}
return temp;
});
}, []);
// 移动元素
const move = useCallback((oldIndex: number, newIndex: number) => {
if (oldIndex === newIndex) {
return;
}
setList((l) => {
const newList = [...l];
const temp = newList.filter((_, index: number) => index !== oldIndex);
temp.splice(newIndex, 0, newList[oldIndex]);
// move keys if necessary
try {
const keyTemp = keyList.current.filter((_, index: number) => index !== oldIndex);
keyTemp.splice(newIndex, 0, keyList.current[oldIndex]);
keyList.current = keyTemp;
} catch (e) {
console.error(e);
}
return temp;
});
}, []);
// 在列表末尾添加元素
const push = useCallback((item: T) => {
setList((l) => {
setKey(l.length);
return l.concat([item]);
});
}, []);
// 移除末尾元素
const pop = useCallback(() => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(0, keyList.current.length - 1);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(0, l.length - 1));
}, []);
// 在列表起始位置添加元素
const unshift = useCallback((item: T) => {
setList((l) => {
setKey(0);
return [item].concat(l);
});
}, []);
// 移除起始位置元素
const shift = useCallback(() => {
// remove keys if necessary
try {
keyList.current = keyList.current.slice(1, keyList.current.length);
} catch (e) {
console.error(e);
}
setList((l) => l.slice(1, l.length));
}, []);
// 校准排序
const sortList = useCallback(
(result: T[]) =>
result
.map((item, index) => ({ key: index, item })) // add index into obj
.sort((a, b) => getIndex(a.key) - getIndex(b.key)) // sort based on the index of table
.filter((item) => !!item.item) // remove undefined(s)
.map((item) => item.item), // retrive the data
[],
);完整源码useWebSocket用于处理 WebSocket 的 Hook。官方文档基本用法官方在线 Demoimport React, { useRef, useMemo } from 'react';
import { useWebSocket } from 'ahooks';
enum ReadyState {
Connecting = 0,
Open = 1,
Closing = 2,
Closed = 3,
}
export default () => {
const messageHistory = useRef<any[]>([]);
const { readyState, sendMessage, latestMessage, disconnect, connect } = useWebSocket(
'wss://demo.piesocket.com/v3/channel_1?api_key=VCXCEuvhGcBDP7XhiJJUDvR1e1D3eiVjgZ9VRiaV¬ify_self',
);
messageHistory.current = useMemo(
() => messageHistory.current.concat(latestMessage),
[latestMessage],
);
return (
<div>
{/* send message */}
<button
onClick={() => sendMessage && sendMessage(`${Date.now()}`)}
disabled={readyState !== ReadyState.Open}
style={{ marginRight: 8 }}
>
✉️ send
</button>
{/* disconnect */}
<button
onClick={() => disconnect && disconnect()}
disabled={readyState !== ReadyState.Open}
style={{ marginRight: 8 }}
>
❌ disconnect
</button>
{/* connect */}
<button onClick={() => connect && connect()} disabled={readyState === ReadyState.Open}>
{readyState === ReadyState.Connecting ? 'connecting' : '📞 connect'}
</button>
<div style={{ marginTop: 8 }}>readyState: {readyState}</div>
<div style={{ marginTop: 8 }}>
<p>received message: </p>
{messageHistory.current.map((message, index) => (
<p key={index} style={{ wordWrap: 'break-word' }}>
{message?.data}
</p>
))}
</div>
</div>
);
};WebSocket 基础知识WebSocket 对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。WebSocket() 构造函数使用 WebSocket() 构造函数来构造一个 WebSocket。var aWebSocket = new WebSocket(url [, protocols]);url:要连接的 URL,即 WebSocket 服务器将响应的 URL。protocols:一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket 子协议readyState 常量WebSocket.CONNECTING(正在连接中):0WebSocket.OPEN(已经连接并且可以通讯):1WebSocket.CLOSING(连接正在关闭):2WebSocket.CLOSED(连接已关闭或者没有连接成功):3属性WebSocket.readyState:当前的连接状态WebSocket.onopen:用于指定连接成功后的回调函数WebSocket.onclose:用于指定连接关闭后的回调函数WebSocket.onerror:用于指定连接失败后的回调函数WebSocket.onmessage:用于指定当从服务器接受到信息时的回调函数方法WebSocket.close():关闭当前链接WebSocket.send(data):对要传输的数据进行排队核心实现如果没有传 manual 为 true指定手动连接的话,进来会默认自动连接。const reconnectTimesRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const websocketRef = useRef<WebSocket>(); // webSocket 实例
// 当前 webSocket 连接状态
const [readyState, setReadyState] = useState<ReadyState>(ReadyState.Closed);
useEffect(() => {
if (!manual) {
connect();
}
}, [socketUrl, manual]);
// 手动连接 webSocket,如果当前已有连接,则关闭后重新连接
const connect = () => {
reconnectTimesRef.current = 0; // 重置 websocket 重连次数
connectWs();
};
const connectWs = () => {
// 如当前处于重连逻辑处理,则清除重连定时器
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
// 关闭之前的 websocket 连接
if (websocketRef.current) {
websocketRef.current.close();
}
const ws = new WebSocket(socketUrl, protocols);
setReadyState(ReadyState.Connecting);
// 监听连接失败后的回调函数
ws.onerror = (event) => {
if (unmountedRef.current) {
return;
}
reconnect(); // 错误则进行重连 websocket
onErrorRef.current?.(event, ws); // 执行错误回调
setReadyState(ws.readyState || ReadyState.Closed);
};
// 监听连接成功后的回调函数
ws.onopen = (event) => {
if (unmountedRef.current) {
return;
}
onOpenRef.current?.(event, ws); // 执行连接成功回调
reconnectTimesRef.current = 0; // 连接成功后重置重连次数
setReadyState(ws.readyState || ReadyState.Open);
};
// 监听从服务器接受到信息时的回调函数
ws.onmessage = (message: WebSocketEventMap['message']) => {
if (unmountedRef.current) {
return;
}
onMessageRef.current?.(message, ws); // 执行收到消息回调
setLatestMessage(message); // 设置最新的 message
};
// 监听连接关闭后的回调函数
ws.onclose = (event) => {
if (unmountedRef.current) {
return;
}
reconnect(); // 重连
onCloseRef.current?.(event, ws);
setReadyState(ws.readyState || ReadyState.Closed);
};
websocketRef.current = ws; // 保存 websocket 实例
};重连与断开连接实现重连:const reconnect = () => {
// 没有超过重试次数 && 没有连接成功
if (
reconnectTimesRef.current < reconnectLimit &&
websocketRef.current?.readyState !== ReadyState.Open
) {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
// 指定重试时间间隔后重连
reconnectTimerRef.current = setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
connectWs();
reconnectTimesRef.current++;
}, reconnectInterval);
}
};断开连接:// 手动断开 webSocket 连接
const disconnect = () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimesRef.current = reconnectLimit;
websocketRef.current?.close();
};
// 组件销毁时,则断开
useUnmount(() => {
unmountedRef.current = true; // 标识设置为已卸载
disconnect();
});发送消息const sendMessage: WebSocket['send'] = (message) => {
// 连接成功状态才可发送
if (readyState === ReadyState.Open) {
websocketRef.current?.send(message);
} else {
throw new Error('WebSocket disconnected');
}
};
lucky0-0
低代码可视化逻辑编排——低代码知识点详解(五)
前言前面我们实现了组件事件绑定动作,但是一个事件只能绑定一个动作,大大限制了开发复杂功能的能力。这一篇来实现一个事件可以绑定多个动作,并且通过可视化的方式设置,让流程配置清晰明了。思路来源于虚幻游戏引擎的蓝图功能,这一篇先简单实现一下,主要是给大家分享一下实现思路。可视化库使用antv的G6开源库,功能强大,使用起来也简单。往期回顾《保姆级》低代码知识点详解(一)低代码事件绑定和组件联动——低代码知识点详解(二)低代码动态属性和在线执行脚本——低代码知识点详解(三)低代码在线加载远程组件——低代码知识点详解(四)案例分析上一篇文章后,留了一张图,下面给大家分析一下这张图的意义。触发某个事件后,执行组件方法,然后动作执行成功后调一个接口,接口执行成功或失败后执行动作。从上图可以得知:每个组件的每个事件都可以绑定一个事件流,当触发组件事件的时候,执行这个事件流。事件流设计页面,有4种类型节点,第一个是开始节点,第二个是动作节点、第三个是条件节点、第四个是事件节点,开始节点也算是事件节点。开始节点:没有意义,表示入口。动作节点:可以绑定动作,可以连接事件节点和条件节点条件节点:可以配置多个条件分支,每个条件分支可以绑定一个动作节点,只能连接事件节点。事件节点:每个动作节点都会有事件节点,只能连接动作节点。效果展示上图可以使用antv的G6库来实现,因为官方给的demo中有类似的案例,可以参考使用。经过一番改造,终于实现了需求。动作节点配置条件节点配置核心功能讲解前言这一块内容比较多,我挑几个核心功能和大家分享一下。数据结构节点数据结构export interface Node {
/**
* 节点id
*/
id: string;
/**
* 节点描述
*/
label: string;
/**
* 节点类型
*/
type: string;
/**
* 节点绑定的下拉菜单
*/
menus: Menu[];
/**
* 节点子节点
*/
children?: Node[];
/**
* 节点额外配置
*/
config?: any;
/**
* 节点条件结果
*/
conditionResult?: boolean;
/**
* 节点事件key
*/
eventKey?: string;
}下拉菜单export interface Menu {
/**
* 菜单key
*/
key: string;
/**
* 菜单描述
*/
label: string;
/**
* 即将生成的节点类型
*/
nodeType?: string;
/**
* 将生成的节点名称
*/
nodeName?: string;
/**
* 如果节点为条件节点,每个菜单表示一个条件,conditionId对应定义的条件id
*/
conditionId?: string;
}初始数据export const data: Node = {
id: 'root',
label: '开始',
type: 'start',
menus: [
{
key: 'action',
label: '动作',
nodeType: 'action',
nodeName: '动作',
},
{
key: 'condition',
label: '条件',
nodeType: 'condition',
nodeName: '条件',
},
],
};实现画布居中偏上G6提供了垂直水平居中方法 graph.fitCenter(),但是只支持垂直水平居中,根据上图我们只需要水平居中,所以在画布渲染后,需要用translate方法向上移动一下位置。自定义带添加图标节点import { COLLAPSE_ICON, EXPAND_ICON } from '../icons';
export const actionNode: any = {
options: {
style: {
fill: '#F9F0FF',
stroke: '#B37FEB',
radius: 8,
lineWidth: 1,
},
stateStyles: {
hover: {},
selected: {},
},
labelCfg: {
style: {
fill: '#000000',
fontSize: 14,
fontWeight: 400,
fillOpacity: '0.7',
},
},
size: [120, 40],
},
afterDraw(cfg: any, group: any) {
const styles = this.getShapeStyle(cfg);
const h = styles.height;
const w = styles.width;
const keyShape: any = group.addShape('rect', {
attrs: {
x: 0,
y: 0,
...styles,
},
});
group.addShape('marker', {
attrs: {
x: w / 2 - 20,
y: 0,
r: 6,
stroke: '#ff4d4f',
cursor: 'pointer',
symbol: COLLAPSE_ICON,
},
name: 'remove-item',
});
if (cfg.menus?.length) {
group.addShape('marker', {
attrs: {
x: 0,
y: h / 2 + 7,
r: 6,
stroke: '#73d13d',
cursor: 'pointer',
symbol: EXPAND_ICON,
},
name: 'add-item',
});
}
if (cfg.label) {
group.addShape('text', {
// attrs: style
attrs: {
x: 0, // 居中
y: 0,
textAlign: 'center',
textBaseline: 'middle',
text: cfg.label,
fill: '#000000',
fontSize: 12,
fontWeight: 400,
fillOpacity: '0.7',
},
name: 'text-shape',
});
}
return keyShape;
},
update(cfg: any, node: any) {
const styles = this.getShapeStyle(cfg);
const h = styles.height;
const group = node.getContainer();
const child = group.find((item: any) => {
return item.get('name') === 'add-item';
});
const text = group.find((item: any) => {
return item.get('name') === 'text-shape';
});
if (text) {
text.attr({ text: cfg.label })
}
if (!child && cfg.menus?.length) {
group.addShape('marker', {
attrs: {
x: 0,
y: h / 2 + 7,
r: 6,
stroke: '#73d13d',
cursor: 'pointer',
symbol: EXPAND_ICON,
},
name: 'add-item',
});
}
},
};
根据是否有菜单动态添加加号图标这里复杂一点的是算坐标,y为0的表示节点中心,所以需要下移半个节点高度(h/2),再加上图标的高度(+7),多加1是为了好看点。连接线上加文本如图所示,如果线的源节点是条件节点,获取条件名称,添加到线上。实现下拉菜单监听添加按钮点击事件,获取当前节点位置,根据当前节点的位置和菜单配置渲染下拉菜单。// src/editor/layouts/flow-event/context-menu.tsx
import React from 'react';
import { Dropdown } from 'antd';
interface Props {
position: {
top?: number;
left?: number;
};
onSelect: (item: any) => void;
items: { label: string, key: string }[];
open: boolean;
}
const ContextMenu: React.FC<Props> = (props) => {
const { position, onSelect, items, open } = props;
return (
<div
style={{
position: 'absolute',
top: position?.top,
left: position?.left,
}}
>
<Dropdown
menu={{
items: items.map(item => ({ label: item.label, key: item.key })),
onClick: onSelect
}}
open={open}
>
<a onClick={(e) => e.preventDefault()}></a>
</Dropdown>
</div>
);
}
export default ContextMenu;执行事件流遍历节点,判断是否是动作节点,如果是动作节点并且条件结果不为false,则根据不同动作类型执行不同动作;如果是条件节点,执行条件脚本,把结果注入到子节点conditionResult属性中。显示提示动作实现组件方法动作实现执行脚本动作实现最后这一篇我们实现了事件可视化配置,下一篇会封装一些常用组件,并且使用低代码做一个完整功能实战一下。
lucky0-0
前端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(一)
效果展示登录页首页表格例子国际化暗黑模式首页暗黑模式表格页面适配移动端前言个人介绍不知不觉已经30岁了,做前端开发也已经5年了,这期间在掘金学到了很多知识,现在也要对外输出知识了,帮助大家一起成长。我这5年期间主要都是在做后台管理系统,目前在公司充当前端架构师的角色,主导研发公司的低代码平台。这些年积攒了一些后台管理系统和低代码开发心得,现在想通过从零开始做一个后台管理系统向大家分享这些经验和心得。项目介绍在公司一直使用的是umi+dva那一套,最近刚好学习了vite和zustand,打算这个项目就用vite和zustand练练手。项目的ui借鉴了mui的其中一个模版项目,berry-react-material-admin,这个模版项目没有开源,是收费的,我觉得ui很好看,就借鉴了一下。该项目的最终目标是实现一个企业级后台管理系统全栈框架,后面也会加入一些低代码功能,想法是把chatgpt和低代码结合,通过用户描述自己的需求就能生成一个功能或系统。关于chatgpt和低代码结合这一块,我已经做了技术验证,完全是可行的。项目前端使用vite4+react18+react-router6+antd5+windicss+zustand技术栈,后端使用midwayjs+typeorm+mysql+redis,这里后端使用midway,没有使用nest,是因为midway国内阿里团队开发的,文档很完善也很详细。如果遇到问题,可以加入midway交流群,群里有很多大佬积极的为别人解决问题,作者也经常在群里帮别人解决问题,所以不用担心遇到解决不了的问题。这个项目预计用一年的时间来做,陆陆续续会出一些文章,目标是让看文章的人也能开发一个属于自己的后台管理系统。项目核心技术介绍状态管理库介绍项目使用zustand框架作为状态管理库,状态管理库我在jotai,valtio,zustand这三个犹豫了很久,看了很多他们三个的文章,这三个使用起来差不多,可以根据个人喜好选择,我最终还是选择了zustand。 zustand的入门文档推荐看这两篇文章:谈谈复杂应用的状态管理(上):为什么是 Zustand 谈谈复杂应用的状态管理(下):基于 Zustand 的渐进式状态管理实践我的项目实战目前我在项目中使用zustand存储全局公共属性,比如国际化的当前语言、菜单的展开收起状态、主题,后面实现登录功能后,也会把用户信息存进去。下面代码中devtools和persist是zustand中间件,devtools借助google redux插件方便调试,persist可以把数据持久化到localStorage中。菜单收起和展开功能实现使用reduxgoogle插件查看数据持久化到localStorage中的数据暗黑模式项目样式使用的是windicss框架,用这个框架实现暗黑主题切换很简单,只需要在样式前面加上:dark,然后动态设置body的class样式,就能快速实现暗黑主题切换。官方文档。项目实战暗黑主题下背景颜色是rgb(33,41,70),亮色主题下是rgb(94,53,177)react-router v6react-router v6支持了配置的方式,不用写那么多组件了。import { RouterProvider, createHashRouter } from 'react-router-dom';
function App() {
const router = createHashRouter(
[
{
path: '/user/login',
Component: Login,
}, {
path: '/',
Component: BasicLayout,
children: routeConfig,
}, {
path: '*',
Component: Result404,
}
]
);
return (
<RouterProvider router={router} />
)
}
上面配置了登录页面和layout,还有404页面,如果页面路由都没匹配上才会显示404页面。layout代码路由配置因为做了路由懒加载,所以切换页面的时候,加了一个loading,用的是nprogress组件。icon使用icon方案使用的是我前面分享过的一个方案开发中使用iconfont太麻烦了,于是我写了这款vscode插件,支持react和vue。国际化国际化方案使用的是i18n-next这个库,使用起来特别简单。使用这个库的原因是它支持动态加载国际化内容,目前国际化内容是放在本地的,后面会把国际化信息存到后端,方便业务翻译。我前面也分享过一篇国际化的文章,可以快速的做国际化。开发过程中,因为国际化太麻烦,我写了这款vscode国际化插件。我的项目实战初始化i18n-next,并导出国际化t方法。业务代码中使用切换语言国际化效果移动端适配移动端适配主要使用了windicss和antd的Row、Col组件。如果想实现在小屏下隐藏某个元素,只需要这样写就行了,超级简单。借助react-use库封装一个快速判断是小屏还是大屏的hooks,可以在代码中使用判断。使用Row、Col组件在不同屏幕大小下显示不同数量。总结一个简单的后台管理系统前端框架搭建起来,后面我会慢慢完善其他功能的。下期写如何使用midway搭建后端框架。我建了一个专栏,后面关于后台管理系统开发的文章都会放在专栏里,方便大家阅读。
lucky0-0
集成低代码平台并实现版本控制——从零开始搭建一个高颜值后台管理系统全栈框架(十六)
前言现在市面上开源的低代码平台很多,但这些开源的平台都只包含了低代码部分,如果有人想用这些低代码平台开发功能,必须自己先实现一些基础功能,比如登录注册、菜单管理、权限管理等,实现完这些基础功能后还要自己去对接低代码功能,反正用起来比较麻烦。而我想做的就是一套开箱即用的低代码平台,可以帮助个人开发者快速做出一个产品,可以帮助产品经理快速做出一个原型去做市场验证,可以帮助接私活的兄弟快速完成任务。后面会带着大家一点点完成这个全栈项目,让大家看完后,可以自己也做出一个低代码平台,虽然现在很多人排斥低代码平台,但是你做过低代码平台,了解低代码的运行原理,简历上还是会有一些加分的。这一篇的目标是把我们前面做的低代码功能给迁移到fluxy-admin平台中,并且把低代码创建的页面对接到系统菜单中,通过菜单可以访问低代码创建的页面。还会实现低代码页面版本管理,支持版本发布、回滚等功能。没看过我前面写的低代码基础知识入门的朋友,建议先看一下。低代码入门知识详解题外话这里说一下我为啥把自己的开发记录分享出来一是因为我是一个自制力比较差的人,很多事情都是半途而废,说实话去年写fluxy-admin基础功能的时候,有段时间停了很久,差点没坚持下去,但是我看到评论区有些人催更,还有些点赞鼓励,我咬着牙坚持下去,最终给完结了。二是因为授人以鱼不如授人以渔,我做出来一个平台给大家用,不如教大家自己做一个低代码平台,有些刚入门的同学,跟着我的教程一点点做完,也算有了自己的实战项目了。实战前期准备创建目录在pages下面创建low-code文件夹,后面低代码相关的页面都放这里面。再创建一个page文件夹,表示低代码页面配置。在page文件夹下创建list和new文件夹分别表示低代码页面列表页和低代码配置页面。创建菜单先创建一个低代码平台目录,接着再创建一个低代码页面管理菜单,这个页面主要用来管理低代码创建出来的页面,然后再创建一个低代码页面配置菜单,用来创建低代码页面。低代码迁移把前面做的低代码demo给迁移到项目中,虽然前面做的很粗糙,但是基本功能都有,后面在这个基础上慢慢优化。前期先以组件的形式放在项目里,后面会把低代码做成独立组件发布成npm包,这样做的好处是低代码和基础平台节藕,方便其他平台使用。很多开源项目都是这样出来的,先内部沉淀,随着功能越来越丰富,把一些基础功能拆出去开源。在src/components文件夹下创建low-code文件夹,把lowcode-demo项目里的editor文件夹复制到low-code文件夹中。在src/pages/low-code/page/new/index.tsx引入低代码编辑器// src/pages/low-code/page/new/index.tsx
import Layout from '@/components/low-code/editor/layouts';
import { KeepAliveTabContext } from '@/layouts/tabs-context';
import { useContext } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { useNavigate } from 'react-router-dom';
const LowCodePageNew = () => {
const navigate = useNavigate();
const keepAliveTab = useContext(KeepAliveTabContext);
const onBack = () => {
// 因为打开当前页面会打开一个页签,后退的时候需要关闭这个页签
keepAliveTab.closeTab();
navigate('/low-code/page');
}
return (
<div className='w-full bg-container h-full fixed top-0 bottom-0 z-1000 right-0 left-0 bg-white'>
<DndProvider backend={HTML5Backend}>
<Layout onBack={onBack} />
</DndProvider>
</div>
)
}
export default LowCodePageNew;
在列表页面先加一个创建页面按钮,可以跳到编辑页面。// src/pages/low-code/page/list/index.tsx
import { Button } from 'antd';
import { useNavigate } from 'react-router-dom';
const LowCodePageList = () => {
const navigate = useNavigate();
return (
<div>
<Button
onClick={() => {
navigate('/low-code/page/new-page');
}}
type='primary'
>
创建页面
</Button>
</div>
)
}
export default LowCodePageList;
因为需要支持暗黑模式,调整了一些元素背景颜色,这一步很简单所以省略了。效果展示保存为系统菜单前言假如我们用低代码创建出了一个页面后,怎么和系统菜单绑定呢,下面我们来实现一下。实现思路在工具栏加一个保存按钮,点击保存时,弹出一个表单,这个表单和新建菜单的表单差不多,只不过这里不能选择类型,写死菜单类型为4(低代码页面),并且可以选择上级菜单。填完菜单信息后,点保存按钮调用后端新建菜单接口并把填的菜单信息和低代码数据传给接口,后端接口判断一下菜单类型如果为低代码页面类型,则把传过来低代码内容保存成文件上传到文件服务器,文件名为当前菜单id。这里为什么把低代码内容保存成文件,而不是直接存到数据库中,有以下好处:前端请求的时候,因为是文件服务和后端服务是分开的,可以减少后端服务压力。前端请求的时候,静态文件可以更好的使用浏览器缓存,虽然接口也可以配置缓存,但是还要单独配置,比较麻烦。前端请求的时候,文件走cdn很简单,现在云服务器运营商的对象存储都支持cdn。前端实现给工具栏中添加一个保存按钮,对外暴露保存事件。new/index.tsx中监听保存事件,然后弹出弹框填写菜单信息,最后保存到后端。填写菜单信息的弹框可以把以前开发过的添加菜单的弹框代码复制过来改一改,需要注意的时候,最后保存的时候,需要把菜单类型写死为低代码页面类型。// src/pages/low-code/page/new/new-page-modal.tsx
import { antdIcons } from '@/assets/antd-icons';
import { useVariablesStore } from '@/components/low-code/editor/stores/variable';
import { useRequest } from '@/hooks/use-request';
import { MenuType } from '@/pages/menu/interface';
import menuService, { Menu } from '@/pages/menu/service';
import roleService from '@/pages/role/service';
import { antdUtils } from '@/utils/antd';
import { Form, Input, InputNumber, Modal, Select, Switch, TreeSelect } from 'antd';
import type { DataNode } from 'antd/es/tree';
import React, { useEffect, useState } from 'react';
import { useComponentsStore } from '../../../../components/low-code/editor/stores/components';
interface Props {
open: boolean;
setOpen: (open: boolean) => void;
}
const NewPageModal: React.FC<Props> = ({
open,
setOpen,
}) => {
const [treeData, setTreeData] = useState<DataNode[]>([]);
const { components } = useComponentsStore();
const { variables } = useVariablesStore();
const {
runAsync,
loading: saveLoading,
} = useRequest(menuService.addMenu, { manual: true });
const [form] = Form.useForm();
const formatTree = (roots: Menu[] = [], group: Record<string, Menu[]>): DataNode[] => {
return roots.map((node) => {
return {
value: node.id,
label: node.name,
key: node.id,
title: node.name,
children: formatTree(group[node.id] || [], group),
} as DataNode;
});
};
const getData = async () => {
const [error, data] = await roleService.getAllMenus();
if (!error) {
const group = data.reduce<Record<string, Menu[]>>((prev, cur) => {
if (!cur.parentId) {
return prev;
}
if (prev[cur.parentId]) {
prev[cur.parentId].push(cur);
} else {
prev[cur.parentId] = [cur];
}
return prev;
}, {});
const roots = data.filter((o) => !o.parentId);
const newTreeData = formatTree(roots, group);
setTreeData(newTreeData);
}
};
const save = async (values: any) => {
// 写死菜单类型为低代码页面
values.type = MenuType.LowCodePage;
// 把组件和变量转成字符串,传给后端
values.pageSetting = JSON.stringify({ components, variables });
const [error] = await runAsync(values);
if (!error) {
antdUtils.message?.success('分配成功');
setOpen(false);
}
};
useEffect(() => {
getData();
}, []);
return (
<Modal
title="保存页面"
open={open}
onCancel={() => { setOpen(false); }}
width={640}
onOk={() => { form.submit(); }}
confirmLoading={saveLoading}
>
<Form
form={form}
onFinish={save}
labelCol={{ flex: '0 0 100px' }}
wrapperCol={{ span: 16 }}
initialValues={{ show: true }}
>
<Form.Item
rules={[{
required: true,
message: '不能为空',
}]}
label="上级菜单"
name="parentId"
>
<TreeSelect treeData={treeData} />
</Form.Item>
<Form.Item
rules={[{
required: true,
message: '不能为空',
}]}
label="名称"
name="name"
>
<Input />
</Form.Item>
<Form.Item label="图标" name="icon">
<Select>
{Object.keys(antdIcons).map((key) => (
<Select.Option key={key}>{React.createElement(antdIcons[key])}</Select.Option>
))}
</Select >
</Form.Item>
<Form.Item
tooltip="以/开头,不用手动拼接上级路由。参数格式/:id"
label="路由"
name="route"
rules={[{
pattern: /^\//,
message: '必须以/开头',
}, {
required: true,
message: '不能为空',
}]}
>
<Input />
</Form.Item>
<Form.Item valuePropName="checked" label="是否显示" name="show">
<Switch />
</Form.Item>
<Form.Item label="排序号" name="orderNumber">
<InputNumber />
</Form.Item>
</Form>
</Modal >
)
}
export default NewPageModal;
后端实现首先给菜单实体添加一个低代码页面当前版本号字段再改造一下新建菜单的逻辑,如果菜单类型为低代码页面,默认版本为v1.0.0,然后把低代码页面配置信息保存成json文件上传到minio文件服务器。渲染低代码页面实现思路在动态创建路由的时候,判断菜单类型如果为低代码页面类型,就去加载低代码渲染组件,把当前页面id和版本号传过去,低代码渲染组件中,根据页面id和版本号,请求低代码页面信息,拿到信息后渲染低代码页面。改造动态路由添加低代码渲染器组件// src/components/low-code/renderer/index.tsx
import ProdStage from '@/components/low-code/editor/layouts/stage/prod';
import { Spin } from 'antd';
import { useEffect, useRef, useState } from 'react';
const LowCodeRenderer = ({ pageId, version }: { pageId?: string, version?: string }) => {
const [loading, setLoading] = useState(true);
const [components, setComponents] = useState([]);
// 存放已经加载过的组件配置
const loadedComponents = useRef(new Map());
const loadComponents = async () => {
const url = `/file/low-code/${pageId}/${version}.json`;
// 已经加载过,直接返回
if (loadedComponents.current.has(url)) {
setComponents(loadedComponents.current.get(url));
return;
}
// 获取组件配置
const data = await window.fetch(`/file/low-code/${pageId}/${version}.json`)
.then(res => res.json());
loadedComponents.current.set(url, data.components);
setComponents(data.components);
}
const init = async () => {
setLoading(true);
await loadComponents();
setLoading(false);
}
useEffect(() => {
if (pageId && version) {
init();
}
}, [
pageId,
version,
]);
if (loading || !pageId || !version) {
return (
<Spin />
)
}
return (
<ProdStage components={components} />
)
}
export default LowCodeRenderer;效果展示这里我使用的是超级管理员账号,默认拥有所有菜单权限,正常用户需要分配才能看到菜单。到这里我们已经给低代码页面对接到了系统中,可以正常的创建和展示低代码页面。实现多版本前言如果我们使用低代码开发了一个页面,测试完成后,上线了,有一天突然加了一个需求,然后在当前版本去修改,修改测试完发布到线上,突然出现了一个很严重的bug,需要回滚到上一个版本,这时候就需要多版本了,下面我们来实现一下。后端实现加一个版本实体// src/module/menu/entity/menu.version.ts
import { Column, Entity } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_menu_version')
export class MenuVersionEntity extends BaseEntity {
@Column({ comment: '菜单id' })
menuId?: string;
@Column({ comment: '版本号' })
version?: string;
@Column({ comment: '版本描述' })
description?: string;
}
新增低代码页面的时候,初始化一个默认版本增加查询低代码页面接口更新版本就是用新的页面配置覆盖老的创建新版本实现发布功能发布功能很简单,就是把菜单上的版本号改一下就行了。前端实现列表页实现列表页面使用了表格嵌套,外面是菜单,里面一层是版本。代码实现当前版本不能编辑和发布,但是都可以复制编辑页这里的代码比较多就不一一截图了,都是业务代码很简单。核心就是编辑页保存支持更新、保存成新版本和新页面。复制页和编辑差不多,只不过不能更新,可以保存为新版本和新页面。最后这一篇主要是把低代码迁移进来了,实现的功能比较基础,后面我们一点点去完善。
lucky0-0
通过RBAC模型实现前后端动态菜单和动态路由——从零开始搭建一个高颜值后台管理系统全栈框架(八)
前言上一篇已经把动态路由实现方案写出来,这一篇主要就是在项目中实战了,实现一个企业级菜单路由权限方案。友情提醒:因为上一篇原理已经说过,所以这一篇有很多代码。RBAC是什么?我这个菜单路由权限控制方案是基于RBAC模型实现的,下面给大家介绍一下什么是RBAC。RBAC(Role-Based Access Control)模型是一种用于访问控制的权限管理模型。在 RBAC 模型中,权限的分配和管理是基于角色进行的。RBAC 模型包含以下几个核心概念:用户(User):用户是实际使用系统的人员或实体。每个用户都可以关联到一个或多个角色。角色(Role):角色代表了一组具有相似权限需求的用户。每个用户可以被分配一个或多个角色,并通过角色来确定其拥有的权限。权限(Permission):权限指定了对系统资源进行操作的能力。它们定义了用户在系统中可以执行的动作或访问的资源范围。系统中的菜单、接口、按钮都可以抽象为资源。在 RBAC 模型中,管理员为每个角色分配适当的权限,然后将角色与用户关联起来,从而控制用户对系统资源的访问。这种角色与权限之间的层次结构和关系,使得权限管理更加灵活和可维护。RBAC 模型的优点包括简化权限管理、减少错误和滥用风险、提高系统安全性和可伸缩性等。目前很多后台管理系统都是基于这个模型实现资源访问控制,RBAC1模型、RBAC2模型、RBAC3模型也都是基于这个改造和升级的。前端实现动态菜单、动态路由前言按正常步骤来说应该先讲菜单、角色、用户配置功能,但是这一块代码比较多,并且大家可能只对前端实现动态路由感兴趣,所以给这一块放到最前面来讲,对后面配置不感兴趣的可以直接跳过。查询当前用户菜单数据接口实现改造获取用户信息方法,把菜单信息也返回,这里的菜单数据是打平的,前端自己构造成树形结构。用户信息数据结构菜单数据结构前端实现新增router组件// src/router.tsx
import { RouteObject, RouterProvider, createBrowserRouter } from 'react-router-dom';
import Login from './pages/login';
import BasicLayout from './layouts';
import { App } from 'antd';
import { useEffect } from 'react';
import { antdUtils } from './utils/antd';
import ResetPassword from './pages/login/reset-password';
export const router = createBrowserRouter(
[
{
path: '/user/login',
Component: Login,
},
{
path: '/user/reset-password',
Component: ResetPassword,
},
{
path: '*',
Component: BasicLayout,
children: []
},
]
);
function findNodeByPath(routes: RouteObject[], path: string) {
for (let i = 0; i < routes.length; i += 1) {
const element = routes[i];
if (element.path === path) return element;
findNodeByPath(element.children || [], path);
}
}
export const addRoutes = (parentPath: string, routes: RouteObject[]) => {
if (!parentPath) {
router.routes.push(...routes as any);
return;
}
const curNode = findNodeByPath(router.routes, parentPath);
if (curNode?.children) {
curNode?.children.push(...routes);
} else if (curNode) {
curNode.children = routes;
}
}
export const replaceRoutes = (parentPath: string, routes: RouteObject[]) => {
if (!parentPath) {
router.routes.push(...routes as any);
return;
}
const curNode = findNodeByPath(router.routes, parentPath);
if (curNode) {
curNode.children = routes;
}
}
const Router = () => {
const { notification, message, modal } = App.useApp();
useEffect(() => {
antdUtils.setMessageInstance(message);
antdUtils.setNotificationInstance(notification);
antdUtils.setModalInstance(modal);
}, [notification, message, modal]);
return (
<RouterProvider router={router} />
)
};
export default Router;模仿vue的router封装一个动态添加和替换路由的方法,下面我使用的是替换路由方法,因为退出登录时不用清除已添加的路由了。在layout组件中动态添加路由上面代码是获取到用户信息后执行的。先把后端返回的打平的菜单数据构造成树形结构,这里把一维数组转换为属性结构,使用了一个小技巧,先把数据按照父级id分组,在过去当前子级时,把当前id传进去就行了,不用每次都遍历全部数组获取子级,算是一个小性能优化,空间换时间。动态添加路由有几个需要注意的地方:嵌套路由的情况,类似于列表页和详情页,这时候虽然菜单上配的是详情页是列表页的子级,但是路由不能生成这样的上下级结构,不然渲染详情的时候,会显示列表页的组件内容,所以在构造树形结构时随便生成了一个一维的路由数组。useNavigatehooks会报错,被这个问题卡了很久,后来看react-router源码才发现的。 动态添加完路由必须手动replace一下当前路由,不然不会触发重新匹配,会显示404。往路由里加动态属性可以使用handle实现动态菜单import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Menu } from 'antd';
import type { ItemType } from 'antd/es/menu/hooks/useItems';
import { Link, useMatches } from 'react-router-dom';
import { useGlobalStore } from '@/stores/global';
import { useUserStore } from '@/stores/global/user';
import { antdIcons } from '@/assets/antd-icons';
import { Menu as MenuType } from '@/pages/user/service';
const SlideMenu = () => {
const matches = useMatches();
const [openKeys, setOpenKeys] = useState<string[]>([]);
const [selectKeys, setSelectKeys] = useState<string[]>([]);
const {
collapsed,
} = useGlobalStore();
const {
currentUser,
} = useUserStore();
useEffect(() => {
if (collapsed) {
setOpenKeys([]);
} else {
const [match] = matches || [];
if (match) {
// 获取当前匹配的路由,默认为最后一个
const route = matches.at(-1);
// 从匹配的路由中取出自定义参数
const handle = route?.handle as any;
// 从自定义参数中取出上级path,让菜单自动展开
setOpenKeys(handle?.parentPaths || []);
// 让当前菜单和所有上级菜单高亮显示
setSelectKeys([...(handle?.parentPaths || []), handle?.path] || []);
}
}
}, [
matches,
collapsed,
]);
const getMenuTitle = (menu: MenuType) => {
if (menu?.children?.filter(menu => menu.show)?.length) {
return menu.name;
}
return (
<Link to={menu.path}>{menu.name}</Link>
);
}
const treeMenuData = useCallback((menus: MenuType[]): ItemType[] => {
return (menus)
.map((menu: MenuType) => {
const children = menu?.children?.filter(menu => menu.show) || [];
return {
key: menu.path,
label: getMenuTitle(menu),
icon: menu.icon && antdIcons[menu.icon] && React.createElement(antdIcons[menu.icon]),
children: children.length ? treeMenuData(children || []) : null,
};
})
}, []);
const menuData = useMemo(() => {
return treeMenuData(currentUser?.menus?.filter(menu => menu.show) || []);
}, [currentUser]);
return (
<Menu
className='bg-primary color-transition'
mode="inline"
selectedKeys={selectKeys}
style={{ height: '100%', borderRight: 0 }}
items={menuData}
inlineCollapsed={collapsed}
openKeys={openKeys}
onOpenChange={setOpenKeys}
/>
)
}
export default SlideMenu;这里把我们上面构造的菜单数据,转换为antd的Menu组件的数据结构。有个需要说明的地方,通过匹配到的路由自动展开对应菜单和高亮显示,上面代码中有注释。详情页面实现方案上篇文章有位兄弟和我讨论了一下关于详情页的路由方案,我的实现方案是把详情页设置为隐藏,这样在菜单中就看不到了,使用代码可以正常的跳转。他觉得详情页还需要配置到后端,有点麻烦,我个人觉得无论在前端配还是在线配都需要配一遍,并且配在远程,还可以控制权限,比如想让某个角色只拥有列表页权限,没有详情页权限。实现菜单、角色、用户配置功能菜单增删改查后端接口实现菜单模型// src/module/menu/entity/menu.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_menu')
export class MenuEntity extends BaseEntity {
@Column({ comment: '上级id', nullable: true })
parentId?: string;
@Column({ comment: '名称' })
name?: string;
@Column({ comment: '图标', nullable: true })
icon?: string;
@Column({ comment: '类型,1:目录 2:菜单' })
type?: number;
@Column({ comment: '路由' })
route?: string;
@Column({ comment: '本地组件地址', nullable: true })
filePath?: string;
@Column({ comment: '排序号' })
orderNumber?: number;
@Column({ comment: 'url', nullable: true })
url?: string;
@Column({ comment: '是否在菜单中显示' })
show?: boolean;
}
菜单Service实现// src/module/menu/service/menu.ts
import { Provide } from '@midwayjs/decorator';
import { DataSource, FindOptionsOrder, IsNull } from 'typeorm';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { MenuEntity } from '../entity/menu';
import { R } from '../../../common/base.error.util';
import { MenuInterfaceEntity } from '../entity/menu.interface';
import { MenuDTO } from '../dto/menu';
@Provide()
export class MenuService extends BaseService<MenuEntity> {
@InjectEntityModel(MenuEntity)
menuModel: Repository<MenuEntity>;
@InjectEntityModel(MenuInterfaceEntity)
menuInterfaceModel: Repository<MenuInterfaceEntity>;
@InjectDataSource()
defaultDataSource: DataSource;
getModel(): Repository<MenuEntity> {
return this.menuModel;
}
async createMenu(data: MenuDTO) {
if ((await this.menuModel.countBy({ route: data.route })) > 0) {
throw R.error('路由不能重复');
}
return await this.create(data.toEntity());
}
async page(
page: number,
pageSize: number,
where?: FindOptionsWhere<MenuEntity>
) {
if (where) {
where.parentId = IsNull();
} else {
where = { parentId: IsNull() };
}
const order: FindOptionsOrder<MenuEntity> = { orderNumber: 'ASC' };
const [data, total] = await this.menuModel.findAndCount({
where,
order,
skip: page * pageSize,
take: pageSize,
});
if (!data.length) return { data: [], total: 0 };
const ids = data.map((o: MenuEntity) => o.id);
const countMap = await this.menuModel
.createQueryBuilder('menu')
.select('COUNT(menu.parentId)', 'count')
.addSelect('menu.parentId', 'id')
.where('menu.parentId IN (:...ids)', { ids })
.groupBy('menu.parentId')
.getRawMany();
const result = data.map((item: MenuEntity) => {
const count =
countMap.find((o: { id: string; count: number }) => o.id === item.id)
?.count || 0;
return {
...item,
hasChild: Number(count) > 0,
};
});
return { data: result, total };
}
async getChildren(parentId?: string) {
if (!parentId) {
throw R.validateError('父节点id不能为空');
}
const data = await this.menuModel.find({
where: { parentId: parentId },
order: { orderNumber: 'ASC' },
});
if (!data.length) return [];
const ids = data.map((o: any) => o.id);
const countMap = await this.menuModel
.createQueryBuilder('menu')
.select('COUNT(menu.parentId)', 'count')
.addSelect('menu.parentId', 'id')
.where('menu.parentId IN (:...ids)', { ids })
.groupBy('menu.parentId')
.getRawMany();
const result = data.map((item: any) => {
const count = countMap.find(o => o.id === item.id)?.count || 0;
return {
...item,
hasChild: Number(count) > 0,
};
});
return result;
}
async removeMenu(id: string) {
await this.menuModel
.createQueryBuilder()
.delete()
.where('id = :id', { id })
.orWhere('parentId = :id', { id })
.execute();
}
}普通的增删改查,没啥好说的。加了一个获取下级getChildren接口,因为前端展示树形菜单时做了按需加载,动态展示下一级,算是最简单的性能优化。菜单增删改查前端页面实现列表页// src/pages/menu/index.tsx
import React, { useEffect, useState, useMemo } from 'react';
import { Button, Divider, Table, Tag, Space, TablePaginationConfig, Popconfirm } from 'antd';
import { antdUtils } from '@/utils/antd';
import { antdIcons } from '@/assets/antd-icons';
import { useRequest } from '@/hooks/use-request';
import NewAndEditForm, { MenuType } from './new-edit-form';
import menuService, { Menu } from './service';
const MenuPage: React.FC = () => {
const [dataSource, setDataSource] = useState<Menu[]>([]);
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: 1,
pageSize: 10,
});
const [createVisible, setCreateVisible] = useState(false);
const [parentId, setParentId] = useState<string>('');
const [expandedRowKeys, setExpandedRowKeys] = useState<readonly React.Key[]>([]);
const [curRowData, setCurRowData] = useState<Menu>();
const [editData, setEditData] = useState<null | Menu>(null);
const { loading, runAsync: getMenusByPage } = useRequest(menuService.getMenusByPage, { manual: true });
const getMenus = async () => {
const { current, pageSize } = pagination || {};
const [error, data] = await getMenusByPage({
current,
pageSize,
});
if (!error) {
setDataSource(
data.data.map((item: any) => ({
...item,
children: item.hasChild ? [] : null,
})),
);
setPagination(prev => ({
...prev,
total: data.total,
}));
}
};
const cancelHandle = () => {
setCreateVisible(false);
setEditData(null);
};
const saveHandle = () => {
setCreateVisible(false);
setEditData(null);
if (!curRowData) {
getMenus();
setExpandedRowKeys([]);
} else {
curRowData._loaded_ = false;
expandHandle(true, curRowData);
}
}
const expandHandle = async (expanded: boolean, record: (Menu)) => {
if (expanded && !record._loaded_) {
const [error, children] = await menuService.getChildren(record.id);
if (!error) {
record._loaded_ = true;
record.children = (children || []).map((o: Menu) => ({
...o,
children: o.hasChild ? [] : null,
}));
setDataSource([...dataSource]);
}
}
};
const tabChangeHandle = (tablePagination: TablePaginationConfig) => {
setPagination(tablePagination);
}
useEffect(() => {
getMenus();
}, [
pagination.size,
pagination.current,
]);
const columns: any[] = useMemo(
() => [
{
title: '名称',
dataIndex: 'name',
width: 300,
},
{
title: '类型',
dataIndex: 'type',
align: 'center',
width: 100,
render: (value: number) => (
<Tag color="processing">{value === MenuType.DIRECTORY ? '目录' : '菜单'}</Tag>
),
},
{
title: '图标',
align: 'center',
width: 100,
dataIndex: 'icon',
render: value => antdIcons[value] && React.createElement(antdIcons[value])
},
{
title: '路由',
dataIndex: 'router',
},
{
title: 'url',
dataIndex: 'url',
},
{
title: '文件地址',
dataIndex: 'filePath',
},
{
title: '排序号',
dataIndex: 'orderNumber',
width: 100,
},
{
title: '操作',
dataIndex: 'id',
align: 'center',
width: 200,
render: (value: string, record: Menu) => {
return (
<Space
split={(
<Divider type='vertical' />
)}
>
<a
onClick={() => {
setParentId(value);
setCreateVisible(true);
setCurRowData(record);
}}
>
添加
</a>
<a
onClick={() => {
setEditData(record);
setCreateVisible(true);
}}
>
编辑
</a>
<Popconfirm
title="是否删除?"
onConfirm={async () => {
const [error] = await menuService.removeMenu(value);
if (!error) {
antdUtils.message?.success('删除成功');
getMenus();
setExpandedRowKeys([]);
}
}}
placement='topRight'
>
<a>删除</a>
</Popconfirm>
</Space>
);
},
},
],
[],
);
return (
<div>
<Button
className="mb-[12px]"
type="primary"
onClick={() => {
setCreateVisible(true);
}}
>
新建
</Button>
<Table
columns={columns}
dataSource={dataSource}
rowKey="id"
loading={loading}
pagination={pagination}
onChange={tabChangeHandle}
tableLayout="fixed"
expandable={{
rowExpandable: () => true,
onExpand: expandHandle,
expandedRowKeys,
onExpandedRowsChange: (rowKeys) => {
setExpandedRowKeys(rowKeys);
},
}}
/>
<NewAndEditForm
onSave={saveHandle}
onCancel={cancelHandle}
visible={createVisible}
parentId={parentId}
editData={editData}
/>
</div>
);
};
export default MenuPage;上面功能,主要使用了antd的Tree组件,展开下一级时,判断是否已经加载过了,如果没有加载则调接口查询下一级。动态显示@ant-design/icons里图标,写了一个简单脚本把@ant-design/icons的所有图标生成出来,创建一个name和组件的map,然后使用createElement根据name动态渲染图标组件。这样做有个缺点,就是打包的时候会把没用的图标也打包进去,会导致包的体积变大,这个后面在做打包优化的时候再详细讲解怎么去优化。上面代码中我没有使用useCallBack和useMemo,我建议业务代码中能不用这两个hooks做优化就不用,真出了性能问题的时候,再去优化,要相信react的diff算法。antdIcons文件新增和编辑表单组件// src/pages/menu/new-edit-form.tsx
import React, { useEffect, useState } from 'react'
import { Modal, Form, Input, Switch, Radio, InputNumber, Select } from 'antd'
import { componentPaths } from '@/config/routes';
import { antdIcons } from '@/assets/antd-icons';
import menuService, { Menu } from './service';
import { antdUtils } from '@/utils/antd';
interface CreateMemuProps {
visible: boolean;
onCancel: (flag?: boolean) => void;
parentId?: string;
onSave: () => void;
editData?: Menu | null;
}
export enum MenuType {
DIRECTORY = 1,
MENU,
BUTTON,
}
const CreateMenu: React.FC<CreateMemuProps> = (props) => {
const { visible, onCancel, parentId, onSave, editData } = props;
const [saveLoading, setSaveLoading] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
if (visible) {
if (editData) {
form.setFieldsValue(editData);
}
} else {
form.resetFields();
}
}, [visible]);
const save = async (values: any) => {
setSaveLoading(true);
values.parentId = parentId || null;
values.show = values.type === MenuType.DIRECTORY ? true : values.show;
if (editData) {
values.parentId = editData.parentId;
const [error] = await menuService.updateMenu({ ...editData, ...values });
if (!error) {
antdUtils.message?.success("更新成功");
onSave()
}
} else {
const [error] = await menuService.addMenu(values);
if (!error) {
antdUtils.message?.success("新增成功");
onSave()
}
}
setSaveLoading(false);
}
return (
<Modal
open={visible}
title="新建"
onOk={() => {
form.submit();
}}
confirmLoading={saveLoading}
width={640}
onCancel={() => {
form.resetFields();
onCancel();
}}
destroyOnClose
>
<Form
form={form}
onFinish={save}
labelCol={{ flex: '0 0 100px' }}
wrapperCol={{ span: 16 }}
initialValues={{
show: true,
type: MenuType.DIRECTORY,
}}
>
<Form.Item label="类型" name="type">
<Radio.Group
optionType="button"
buttonStyle="solid"
>
<Radio value={MenuType.DIRECTORY}>目录</Radio>
<Radio value={MenuType.MENU}>菜单</Radio>
</Radio.Group>
</Form.Item>
<Form.Item label="名称" name="name">
<Input />
</Form.Item>
<Form.Item label="图标" name="icon">
<Select>
{Object.keys(antdIcons).map((key) => (
<Select.Option key={key}>{React.createElement(antdIcons[key])}</Select.Option>
))}
</Select >
</Form.Item>
<Form.Item
tooltip="以/开头,不用手动拼接上级路由。参数格式/:id"
label="路由"
name="route"
rules={[{
pattern: /^\//,
message: '必须以/开头',
}]}
>
<Input />
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => (
form.getFieldValue("type") === 2 && (
<Form.Item label="文件地址" name="filePath">
<Select
options={componentPaths.map(path => ({
label: path,
value: path,
}))}
/>
</Form.Item>
)
)}
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => (
form.getFieldValue("type") === 2 && (
<Form.Item valuePropName="checked" label="是否显示" name="show">
<Switch />
</Form.Item>
)
)}
</Form.Item>
<Form.Item label="排序号" name="orderNumber">
<InputNumber />
</Form.Item>
</Form>
</Modal>
)
}
export default CreateMenu;
菜单分为两种类型,目录和菜单:目录:不能点击,可以展开。菜单:可以点击,会渲染对应的组件。在下拉框中展示图标使用下拉框展示组件地址,这里借助了上篇文章中说的import.meta.glob获取匹配到的文件地址,单独定义了一个文件,后面动态添加路由也有使用这个文件中的components属性。// src/config/routes.tsx
export const modules = import.meta.glob('../pages/**/index.tsx');
export const componentPaths = Object.keys(modules).map((path: string) => path.replace('../pages', ''));
export const components = Object.keys(modules).reduce<Record<string, () => Promise<any>>>((prev, path: string) => {
prev[path.replace('../pages', '')] = modules[path];
return prev;
}, {});角色增删改查后端接口实现角色模型import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_role')
export class RoleEntity extends BaseEntity {
@Column({ comment: '名称' })
name?: string;
@Column({ comment: '代码' })
code?: string;
}角色和菜单关联模型import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_role_menu')
export class RoleMenuEntity extends BaseEntity {
@Column({ comment: '角色id' })
roleId?: string;
@Column({ comment: '菜单id' })
menuId?: string;
}角色service实现import { Provide } from '@midwayjs/decorator';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { MenuInterfaceEntity } from '../../menu/entity/menu.interface';
import { RolePageDTO } from '../dto/role.page';
import { RoleEntity } from '../entity/role';
import { RoleMenuEntity } from '../entity/role.menu';
import {
createQueryBuilder,
likeQueryByQueryBuilder,
} from '../../../utils/typeorm.utils';
import { RoleDTO } from '../dto/role';
import { R } from '../../../common/base.error.util';
@Provide()
export class RoleService extends BaseService<RoleEntity> {
@InjectEntityModel(RoleEntity)
roleModel: Repository<RoleEntity>;
@InjectEntityModel(RoleMenuEntity)
roleMenuModel: Repository<RoleMenuEntity>;
@InjectEntityModel(MenuInterfaceEntity)
menuInterfaceModel: Repository<MenuInterfaceEntity>;
@InjectDataSource()
defaultDataSource: DataSource;
getModel(): Repository<RoleEntity> {
return this.roleModel;
}
async createRole(data: RoleDTO) {
if ((await this.roleModel.countBy({ code: data.code })) > 0) {
throw R.error('代码不能重复');
}
this.defaultDataSource.transaction(async manager => {
const entity = data.toEntity();
await manager.save(RoleEntity, entity);
const roleMenus = data.menuIds.map(menuId => {
const roleMenu = new RoleMenuEntity();
roleMenu.menuId = menuId;
roleMenu.roleId = entity.id;
return roleMenu;
});
if (roleMenus.length) {
// 批量插入
await manager
.createQueryBuilder()
.insert()
.into(RoleMenuEntity)
.values(roleMenus)
.execute();
}
});
}
async editRole(data: RoleDTO) {
await this.defaultDataSource.transaction(async manager => {
const entity = data.toEntity();
await manager.save(RoleEntity, entity);
if (Array.isArray(data.menuIds)) {
await manager
.createQueryBuilder()
.delete()
.from(RoleMenuEntity)
.where('roleId = :roleId', { roleId: data.id })
.execute();
const roleMenus = data.menuIds.map(menuId => {
const roleMenu = new RoleMenuEntity();
roleMenu.menuId = menuId;
roleMenu.roleId = entity.id;
return roleMenu;
});
if (roleMenus.length) {
// 批量插入
await manager
.createQueryBuilder()
.insert()
.into(RoleMenuEntity)
.values(roleMenus)
.execute();
}
}
});
}
async removeRole(id: string) {
await this.defaultDataSource.transaction(async manager => {
await manager
.createQueryBuilder()
.delete()
.from(RoleEntity)
.where('id = :id', { id })
.execute();
await manager
.createQueryBuilder()
.delete()
.from(RoleMenuEntity)
.where('roleId = :id', { id })
.execute();
});
}
async getRoleListByPage(rolePageDTO: RolePageDTO) {
const { name, code, page, size } = rolePageDTO;
let queryBuilder = createQueryBuilder<RoleEntity>(this.roleModel);
queryBuilder = likeQueryByQueryBuilder(queryBuilder, {
code,
name,
});
const [data, total] = await queryBuilder
.orderBy('createDate', 'DESC')
.skip(page * size)
.take(size)
.getManyAndCount();
return {
total,
data,
};
}
async getMenusByRoleId(roleId: string) {
const curRoleMenus = await this.roleMenuModel.find({
where: { roleId: roleId },
});
return curRoleMenus;
}
async allocMenu(roleId: string, menuIds: string[]) {
const curRoleMenus = await this.roleMenuModel.findBy({
roleId,
});
const roleMenus = [];
menuIds.forEach((menuId: string) => {
const roleMenu = new RoleMenuEntity();
roleMenu.menuId = menuId;
roleMenu.roleId = roleId;
roleMenus.push(roleMenu);
});
await this.defaultDataSource.transaction(async transaction => {
await Promise.all([transaction.remove(RoleMenuEntity, curRoleMenus)]);
await Promise.all([transaction.save(RoleMenuEntity, roleMenus)]);
});
}
}
给角色分配菜单使用的方法是把以前分配的菜单全部删了,然后再根据前端传过来的创建。这样做简单,但是性能可能会有问题,后面会改成增量更新。角色增删改查前端页面实现列表页import { t } from '@/utils/i18n';
import {
Space,
Table,
Form,
Row,
Col,
Input,
Button,
Modal,
FormInstance,
Divider,
Popconfirm,
} from 'antd';
import { useAntdTable } from 'ahooks';
import { useRef, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import NewAndEditForm from './new-edit-form';
import roleService, { Role } from './service';
import dayjs from 'dayjs';
import { antdUtils } from '@/utils/antd';
import RoleMenu from './role-menu';
const UserPage = () => {
const [form] = Form.useForm();
const {
tableProps,
search: { submit, reset },
} = useAntdTable(roleService.getRoleListByPage, { form });
const [editData, setEditData] = useState<Role | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
const [roleMenuVisible, setRoleMenuVisible] = useState(false);
const [curRoleId, setCurRoleId] = useState<string | null>();
const formRef = useRef<FormInstance>(null);
const columns: any[] = [
{
title: '名称',
dataIndex: 'name',
},
{
title: '代码',
dataIndex: 'code',
valueType: 'text',
},
{
title: '创建时间',
dataIndex: 'createDate',
hideInForm: true,
search: false,
valueType: 'dateTime',
width: 190,
render: (value: Date) => {
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
},
{
title: '操作',
dataIndex: 'id',
hideInForm: true,
width: 240,
align: 'center',
search: false,
render: (id: string, record: Role) => (
<Space
split={(
<Divider type='vertical' />
)}
>
<a
onClick={async () => {
setCurRoleId(id);
setRoleMenuVisible(true);
}}
>
分配菜单
</a>
<a
onClick={() => {
setEditData(record);
setFormOpen(true);
}}
>
编辑
</a>
<Popconfirm
title="确认删除?"
onConfirm={async () => {
const [error] = await roleService.removeRole(id);
if (!error) {
antdUtils.message?.success('删除成功!');
submit();
}
}}
placement="topRight"
>
<a className="select-none">
删除
</a>
</Popconfirm>
</Space>
),
},
];
const [formOpen, setFormOpen] = useState(false);
const openForm = () => {
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditData(null);
};
const saveHandle = () => {
submit();
setFormOpen(false);
setEditData(null);
};
return (
<div>
<Form
onFinish={submit}
form={form}
size='large'
className='dark:bg-[rgb(33,41,70)] bg-white p-[24px] rounded-lg'
>
<Row gutter={24}>
<Col className='w-[100%]' lg={24} xl={8}>
<Form.Item name='code' label="代码">
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Form.Item name='name' label="名称">
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Space>
<Button onClick={submit} type='primary'>
{t('YHapJMTT' /* 搜索 */)}
</Button>
<Button onClick={reset}>{t('uCkoPyVp' /* 清除 */)}</Button>
</Space>
</Col>
</Row>
</Form>
<div className='mt-[16px] dark:bg-[rgb(33,41,70)] bg-white rounded-lg px-[12px]'>
<div className='py-[16px] '>
<Button
onClick={openForm}
type='primary'
size='large'
icon={<PlusOutlined />}
>
{t('morEPEyc' /* 新增 */)}
</Button>
</div>
<Table
rowKey='id'
scroll={{ x: true }}
columns={columns}
className='bg-transparent'
{...tableProps}
/>
</div>
<Modal
title={editData ? t('wXpnewYo' /* 编辑 */) : t('VjwnJLPY' /* 新建 */)}
open={formOpen}
onOk={() => {
formRef.current?.submit();
}}
destroyOnClose
width={640}
onCancel={closeForm}
confirmLoading={saveLoading}
>
<NewAndEditForm
ref={formRef}
editData={editData}
onSave={saveHandle}
open={formOpen}
setSaveLoading={setSaveLoading}
/>
</Modal>
<RoleMenu
onCancel={() => {
setCurRoleId(null); setRoleMenuVisible(false);
}}
roleId={curRoleId}
visible={roleMenuVisible}
/>
</div>
);
};
export default UserPage;普通的增删改查操作表单组件import { t } from '@/utils/i18n';
import { Form, Input, FormInstance } from 'antd'
import { forwardRef, useImperativeHandle, ForwardRefRenderFunction, useState } from 'react'
import roleService, { Role } from './service';
import { antdUtils } from '@/utils/antd';
import { useRequest } from '@/hooks/use-request';
import RoleMenu from './role-menu';
interface PropsType {
open: boolean;
editData?: Role | null;
onSave: () => void;
setSaveLoading: (loading: boolean) => void;
}
const NewAndEditForm: ForwardRefRenderFunction<FormInstance, PropsType> = ({
editData,
onSave,
setSaveLoading,
}, ref) => {
const [form] = Form.useForm();
const { runAsync: updateUser } = useRequest(roleService.updateRole, { manual: true });
const { runAsync: addUser } = useRequest(roleService.addRole, { manual: true });
const [roleMenuVisible, setRoleMenuVisible] = useState(false);
const [menuIds, setMenuIds] = useState<string[]>();
useImperativeHandle(ref, () => form, [form]);
const finishHandle = async (values: Role) => {
setSaveLoading(true);
if (editData) {
const [error] = await updateUser({ ...editData, ...values, menuIds });
setSaveLoading(false);
if (error) {
return;
}
antdUtils.message?.success(t("NfOSPWDa" /* 更新成功! */));
} else {
const [error] = await addUser({ ...values, menuIds });
setSaveLoading(false);
if (error) {
return;
}
antdUtils.message?.success(t("JANFdKFM" /* 创建成功! */));
}
onSave();
}
return (
<Form
labelCol={{ sm: { span: 24 }, md: { span: 5 } }}
wrapperCol={{ sm: { span: 24 }, md: { span: 16 } }}
form={form}
onFinish={finishHandle}
initialValues={editData || {}}
name='addAndEdit'
>
<Form.Item
label="代码"
name="code"
rules={[{
required: true,
message: t("jwGPaPNq" /* 不能为空 */),
}]}
>
<Input disabled={!!editData} />
</Form.Item>
<Form.Item
label="名称"
name="name"
rules={[{
required: true,
message: t("iricpuxB" /* 不能为空 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label="分配菜单"
name="menus"
>
<a onClick={() => { setRoleMenuVisible(true) }}>选择菜单</a>
</Form.Item>
<RoleMenu
onSave={(menuIds: string[]) => {
setMenuIds(menuIds);
setRoleMenuVisible(false);
}}
visible={roleMenuVisible}
onCancel={() => {
setRoleMenuVisible(false);
}}
roleId={editData?.id}
/>
</Form>
)
}
export default forwardRef(NewAndEditForm);分配菜单的组件import React, { useEffect, useState } from 'react';
import { Modal, Spin, Tree, Radio } from 'antd';
import { antdUtils } from '@/utils/antd';
import roleService from './service';
import { Menu } from '../menu/service';
import { DataNode } from 'antd/es/tree';
interface RoleMenuProps {
visible: boolean;
onCancel: () => void;
roleId?: string | null;
onSave?: (checkedKeys: string[]) => void;
}
const RoleMenu: React.FC<RoleMenuProps> = (props) => {
const { visible, onCancel, roleId, onSave } = props;
const [treeData, setTreeData] = useState<DataNode[]>([]);
const [getDataLoading, setGetDataLoading] = useState(false);
const [checkedKeys, setCheckedKeys] = useState<string[]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const [selectType, setSelectType] = useState('allChildren');
const getAllChildrenKeys = (children: any[], keys: string[]): void => {
(children || []).forEach((node) => {
keys.push(node.key);
getAllChildrenKeys(node.children, keys);
});
};
const getFirstChildrenKeys = (children: any[], keys: string[]): void => {
(children || []).forEach((node) => {
keys.push(node.key);
});
};
const onCheck = (_: any, { checked, node }: any) => {
const keys = [node.key];
if (selectType === 'allChildren') {
getAllChildrenKeys(node.children, keys);
} else if (selectType === 'firstChildren') {
getFirstChildrenKeys(node.children, keys);
}
if (checked) {
setCheckedKeys((prev) => [...prev, ...keys]);
} else {
setCheckedKeys((prev) => prev.filter((o) => !keys.includes(o)));
}
};
const formatTree = (roots: Menu[] = [], group: Record<string, Menu[]>): DataNode[] => {
return roots.map((node) => {
return {
key: node.id,
title: node.name,
children: formatTree(group[node.id] || [], group),
} as DataNode;
});
};
const getData = async () => {
setGetDataLoading(true);
const [error, data] = await roleService.getAllMenus();
if (!error) {
const group = data.reduce<Record<string, Menu[]>>((prev, cur) => {
if (!cur.parentId) {
return prev;
}
if (prev[cur.parentId]) {
prev[cur.parentId].push(cur);
} else {
prev[cur.parentId] = [cur];
}
return prev;
}, {});
const roots = data.filter((o) => !o.parentId);
const newTreeData = formatTree(roots, group);
setTreeData(newTreeData);
}
setGetDataLoading(false);
};
const getCheckedKeys = async () => {
if (!roleId) return;
const [error, data] = await roleService.getRoleMenus(roleId);
if (!error) {
setCheckedKeys(data);
}
};
const save = async () => {
if (onSave) {
onSave(checkedKeys);
return;
}
if (!roleId) return;
setSaveLoading(true);
const [error] = await roleService.setRoleMenus(checkedKeys, roleId)
setSaveLoading(false);
if (!error) {
antdUtils.message?.success('分配成功');
onCancel();
}
};
useEffect(() => {
if (visible) {
getData();
getCheckedKeys();
} else {
setCheckedKeys([]);
}
}, [visible]);
return (
<Modal
open={visible}
title="分配菜单"
onCancel={() => {
onCancel();
}}
width={640}
onOk={save}
confirmLoading={saveLoading}
bodyStyle={{ height: 400, overflowY: 'auto', padding: '20px 0' }}
>
{getDataLoading ? (
<Spin />
) : (
<div>
<label>选择类型:</label>
<Radio.Group
onChange={(e) => setSelectType(e.target.value)}
defaultValue="allChildren"
optionType="button"
buttonStyle="solid"
>
<Radio value="allChildren">所有子级</Radio>
<Radio value="current">当前</Radio>
<Radio value="firstChildren">一级子级</Radio>
</Radio.Group>
<div className="mt-16px">
<Tree
checkable
onCheck={onCheck}
treeData={treeData}
checkedKeys={checkedKeys}
checkStrictly
className='py-[10px]'
/>
</div>
</div>
)}
</Modal>
);
};
export default RoleMenu;选择类型分为三种情况:所有子级,上下级有联动,选中上级会把下级也勾选。当前:上下级没有联动一级子级:只联动一级,只选中当前一级的下级。用户分配角色改造新建用户接口,新建用户时把用户分配的角色保存起来。编辑用户是,把已分配的角色先删掉,然后再重新分配前端新增选择角色的下拉框效果展示我们可以使用useNavigate()跳转路由使用useParams获取路由参数,使用useSearchParams获取query参数多级菜单配置多级菜单展示没有参数的详情页有参数的详情页欢迎大家访问fluxyadmin.cn体验和测试总结这篇代码偏多,主要原理上一篇已经写过了,所以这一篇主要都是实现。下一篇写按钮权限控制。
lucky0-0
使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十三)
前言上篇文章中给大家介绍了怎么使用casbin,这一篇我们开始实战。实现思路大体思路把对用户、角色、菜单的操作转换为casbin的数据存到数据库中,每次启动项目把这些数据加载到内存中,然后再请求拦截器中使用casbin的方法进行接口验证,如果当前用户角色有这个接口的权限则通过,没有则报错。具体实现思路用户分配角色在新建或编辑用户的时候,根据分配的角色按照casbin中g,用户,角色数据格式存到数据库中。角色分配按钮权限因为接口绑定在按钮权限上,所以在角色分配按钮权限的时候,需要找到当前按钮权限绑定了哪些接口,然后把对应的接口和角色按照casbin中p,角色,接口url,接口请求方式,按钮权限id数据格式存到数据库中。按钮权限绑定接口在新建按钮权限的时候,可以选择当前按钮绑定了哪些接口,后端接收到,然后把他们的关系存起来留后面角色分配按钮权限时使用。小结为啥不在角色那里分配菜单的时候选接口,因为角色分配菜单是面向用户的功能,你让用户选接口,他们也不懂啊,但是用户知道当前页面有哪些按钮,他们可以根据用户角色决定给他们显示哪些按钮。实战前言midway框架已经封装了casbin插件,让他们集成casbin更加简单。不过midway官方的例子只适用于简单的场景,像我们这种比较复杂的场景得自定义一些东西。安装并启用casbin插件安装依赖pnpm i @midwayjs/casbin@3 --save启用插件import { Configuration } from '@midwayjs/core';
import * as casbin from '@midwayjs/casbin';
import { join } from 'path'
@Configuration({
imports: [
// ...
casbin,
],
importConfigs: [
join(__dirname, 'config')
]
})
export class MainConfiguration {
}配置模型把上篇文章中basic_model.conf文件复制到src目录下,内容如下:[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
g2 = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act配置casbin插件参数配置模型文件路径在src/config/config.default.ts文件配置casbin模型文件路径casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
},以前配置文件导出的是对象,现在需要导出一个方法,以便我们拿到appInfo对象,这个对象中存了一些项目信息。配置策略数据库适配器导入创建适配器方法和内置模型import { createAdapter, CasbinRule } from '@midwayjs/casbin-typeorm-adapter';在typeorm entities中把CasbinRule实体配置进去,不然不能使用typeorm的api操作CasbinRule这个表然后在casbin中配置策略适配器casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
policyAdapter: createAdapter({
dataSourceName: 'default',
}),
},编写免鉴权装饰器有的接口可能不需要鉴权,就像有的接口不需要登录一样,所以需要实现一个像免登录一样的免鉴权装饰器,代码实现和免登录鉴权代码差不多。// src/decorator/not.auth.ts
import {
IMidwayContainer,
MidwayWebRouterService,
Singleton,
} from '@midwayjs/core';
import {
ApplicationContext,
attachClassMetadata,
Autoload,
CONTROLLER_KEY,
getClassMetadata,
Init,
Inject,
listModule,
} from '@midwayjs/decorator';
// 提供一个唯一 key
export const NOT_AUTH_KEY = 'decorator:not.auth';
export function NotAuth(): MethodDecorator {
return (target, key, descriptor: PropertyDescriptor) => {
attachClassMetadata(NOT_AUTH_KEY, { methodName: key }, target);
return descriptor;
};
}
@Autoload()
@Singleton()
export class NotAuthDecorator {
@Inject()
webRouterService: MidwayWebRouterService;
@ApplicationContext()
applicationContext: IMidwayContainer;
@Init()
async init() {
const controllerModules = listModule(CONTROLLER_KEY);
const whiteMethods = [];
for (const module of controllerModules) {
const methodNames = getClassMetadata(NOT_AUTH_KEY, module) || [];
const className = module.name[0].toLowerCase() + module.name.slice(1);
whiteMethods.push(
...methodNames.map(method => `${className}.${method.methodName}`)
);
}
const routerTables = await this.webRouterService.getFlattenRouterTable();
const whiteRouters = routerTables.filter(router =>
whiteMethods.includes(router.handlerName)
);
this.applicationContext.registerObject('notAuthRouters', whiteRouters);
}
}获取用户信息的接口不需要接口鉴权,所有用户和角色都有这个接口权限,所以我们可以使用这个装饰器。改造鉴权中间件改造src/middleware/auth.ts鉴权中间件,在token校验通过后,再进行接口鉴权。首先注入casbinEnforcerService实例@Inject()
casbinEnforcerService: CasbinEnforcerService;
过滤掉不需要鉴权的接口,然后调用校验方法,因为前端接口的url上带的有前缀,为了防止以后这个前缀会变,数据库不存这个,所以getUrlExcludeGlobalPrefix方法是把接口url的前缀给删除掉。这里把系统管理员帐号给过滤了,管理员默认有所有接口的权限。...
// 过滤掉不需要鉴权的接口
if (
this.notAuthRouters.some(
o =>
o.requestMethod === routeInfo.requestMethod &&
o.url === routeInfo.url
)
) {
await next();
return;
}
const matched = await this.casbinEnforcerService.enforce(
ctx.userInfo.userId,
getUrlExcludeGlobalPrefix(this.globalPrefix, routeInfo.fullUrl),
routeInfo.requestMethod
);
if (!matched && ctx.userInfo.userId !== '1') {
throw R.forbiddenError('你没有访问该资源的权限');
}
...
编写获取接口列表的接口因为按钮权限那里可以绑定接口,这个手输接口url和请求方式不友好,还有可能出错,我就想能不能获取系统的接口列表呢,看了midway源码还真被我找到了,看下面的源码,源码中有注释。// src/module/api/service/api.ts
import {
CONTROLLER_KEY,
Config,
Inject,
MidwayWebRouterService,
Provide,
RouterInfo,
getClassMetadata,
listModule,
} from '@midwayjs/core';
@Provide()
export class ApiService {
@Config('koa')
koaConfig: any;
@Inject()
webRouterService: MidwayWebRouterService;
@Inject()
notLoginRouters: RouterInfo[];
@Inject()
notAuthRouters: RouterInfo[];
// 按contoller获取接口列表
async getApiList() {
// 获取所有contoller
const controllerModules = listModule(CONTROLLER_KEY);
const list = [];
// 遍历contoller,获取controller的信息存到list数组中
for (const module of controllerModules) {
const controllerInfo = getClassMetadata(CONTROLLER_KEY, module) || [];
list.push({
title:
controllerInfo?.routerOptions?.description || controllerInfo?.prefix,
path: `${this.koaConfig.globalPrefix}${controllerInfo?.prefix}`,
prefix: controllerInfo?.prefix,
type: 'controller',
});
}
// 获取所有接口
let routes = await this.webRouterService.getFlattenRouterTable();
// 把不用登录和鉴权的接口过滤掉
routes = routes
.filter(
route =>
!this.notLoginRouters.some(
r => r.url === route.url && r.requestMethod === route.requestMethod
)
)
.filter(
route =>
!this.notAuthRouters.some(
r => r.url === route.url && r.requestMethod === route.requestMethod
)
);
// 把接口按照controller分组
const routesGroup = routes.reduce((prev, cur) => {
if (prev[cur.prefix]) {
prev[cur.prefix].push(cur);
} else {
prev[cur.prefix] = [cur];
}
return prev;
}, {});
// 返回controller和接口信息
return list
.map(item => {
if (!routesGroup[item.path]?.length) {
return null;
}
return {
...item,
children: routesGroup[item.path]?.map(o => ({
title: o.description || o.url,
path: o.url,
method: o.requestMethod,
type: 'route',
})),
};
})
.filter(o => !!o);
}
}
给controller和接口加上中文描述前端按钮权限表单改造新建按钮权限的时候新加一个绑定接口的表单项,可以选择上面的接口,这里我们使用了antd的TreeSelect组件。格式化数据自定义TreeSelect里面的label效果展示后端按钮权限新增和编辑接口改造新建一个模型存放按钮权限和接口之间的关系// src/module/menu/entity/menu.api.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_menu_api')
export class MenuApiEntity extends BaseEntity {
@Column({ comment: '菜单id' })
menuId?: string;
@Column({ comment: '请求方式' })
method?: string;
@Column({ comment: 'path' })
path?: string;
}根据前端传过来的按钮和接口的关系往menu.api表里添加数据编辑的时候,需要先把当前按钮绑定的接口先清除掉,然后再插入用户分配角色接口改造新建用户的时候,用户会分配角色,这时候遍历当前用户所分配的角色,然后把他们的关系存到casbin_rule表中这里可以使用casbin rbac模型自带的api给用户添加角色,然后调用savePolicy方法把添加的数据保存到casbin_rule表中。注意因为casbin中没有批量给用户添加角色的api,所以只能遍历添加,这里需要注意一下,casbin默认调用一些方法修改策略,都会自动往数据库中同步,同步的方法很暴力,直接把数据库中的所有数据删除,然后把当前数据写进去,所以我们需要关闭自动保存,由我们手动调用savePolicy方法保存。我们可以在项目启动时候,获取casbin实例,关闭自动保存编辑用户的时候,需要先给当前用户分配的角色清掉,然后再插入,casbin也有api可以直接使用。this.casbinEnforcerService.deleteRolesForUser(entity.id);角色分配按钮权限接口改造新建或编辑角色的时候,遍历当前角色分配的api,然后调用casbin的addPermissionForUser方法,看接口名称像是给用户添加接口权限,实际上这个接口既可以给用户添加权限也可以给角色添加权限。 await Promise.all(
apis.map(api => {
return this.casbinEnforcerService.addPermissionForUser(
entity.id,
api.path,
api.method,
api.menuId
);
})
);
await this.casbinEnforcerService.savePolicy();编辑的时候先清楚当前角色已分配的接口,然后再插入。await this.casbinEnforcerService.deletePermissionsForUser(data.id);pm2多进程策略同步上面代码写完,我本地测试了很多遍,没有发现任何问题。上线后,我在测试的时候,发现有时候校验有问题,有时候没问题。聪明的你可能已经发现问题了,线上使用的是pm2启动了4个进程,我们在一个进程中添加了策略,虽然保存到了数据库,但是其他进程没有重新加载,导致他们的策略还是老的。这个解决方案也很简单,和前面解决消息推送一样,我们可以借助redis消息广播给其他进程,其它进程重新从数据库中加载就行了,不过这个不用我们自己写了,midway已经支持了。在配置文件中,引入创建监听器方法。import { createWatcher } from '@midwayjs/casbin-redis-adapter';然后修改casbin属性 casbin: {
modelPath: join(appInfo.appDir, 'src/basic_model.conf'),
policyAdapter: createAdapter({
dataSourceName: 'default',
}),
policyWatcher: createWatcher({
pubClientName: 'node-casbin-official',
subClientName: 'node-casbin-sub',
}),
},给redis添加两个客户端这样我们再调用savePolicy方法时候,会自动发消息给其它进程,其它进程从数据库中重新加载策略,这样就能保证每个进程的策略一致了。解决数据覆盖问题本以为上面问题解决了,就不会再有其它问题了,没想到在测试的过程还是不稳定,总是会出现策略数据覆盖的问题,莫名其妙少一些数据,或多一些数据,很奇怪,经过一段时间测试,发现如果一个进程改了策略,先保存到数据库,然后通知其它进程重新从数据库中加载策略,这时候如果其它进程又改了策略并且新的策略还没加载完,这时候会用当前进程中的数据以覆盖式的保存到数据库,会把前面改的东西覆盖掉。上面已经说过了,savePolicy方法是先清除数据库中的数据,然后再把当前内存的策略保存到数据库中。savePolicy方法源码实现片段,可以看出确实是先删除数据,然后再保存。解决这个问题也简单,我们不用savePolicy方法就行了,我们自己操作casbin_rule表里的数据。但是不用savePolicy方法,就需要我们自己写方法去通知其它进程了,因为我们拿不到casbin中给其他进程发消息的方法。(midway没有把配置的watcher暴露出来)首先把配置文件中监听器删除,因为我们用不到它了。在项目启动的时候,自定义监听器,然后设置给casbin,这样我们就可以在后面使用这个watcher了。// src/autoload/casbin-watcher.ts
import { IMidwayContainer, Inject, Singleton } from '@midwayjs/core';
import { ApplicationContext, Autoload, Init } from '@midwayjs/decorator';
import { createWatcher } from '@midwayjs/casbin-redis-adapter';
import { CasbinEnforcerService } from '@midwayjs/casbin';
@Autoload()
@Singleton()
export class MinioAutoLoad {
@ApplicationContext()
applicationContext: IMidwayContainer;
@Inject()
casbinEnforcerService: CasbinEnforcerService;
@Init()
async init() {
// 创建监听器
const casbinWatcher = await createWatcher({
pubClientName: 'node-casbin-official',
subClientName: 'node-casbin-sub',
})(this.applicationContext);
// 把监听器设置给casbin
this.casbinEnforcerService.setWatcher(casbinWatcher);
// 往请求上下文中注入casbinWatcher实例,在每个service中可以直接使用
this.applicationContext.registerObject('casbinWatcher', casbinWatcher);
}
}把上面的api改造成自己操作casbin_rule模型增删改查数据,这里只举一个例子,其它和这个类似。casbinWatcher测试验证测试流程新建一个user1新用户,给当前用户分配测试角色,给菜单管理这个菜单添加两个按钮权限,查询按钮权限绑定的是查询接口,创建按钮权限绑定的时候新建接口。前端菜单管理中新建按钮使用绑定创建按钮权限,只有分配这个创建按钮权限才能显示。给菜单管理添加两个按钮权限新建按钮权限绑定的是创建菜单接口查询接口登录user1帐号登录user1帐号后,进入菜单管理页面,因为没有给他分配按钮权限,所以会报错,并且也看不见创建按钮给测试角色分配查询按钮权限打开其它浏览器登录admin帐号,给测试角色分配查询按钮权限,自动刷新页面后,可以查到数据了,但是看不到新建按钮。给测试角色分配新建按钮权限新建按钮就出来了删除查询权限这边会收到权限变更的通知使用casbin优雅且简洁的实现接口鉴权(下)——从零开始搭建一个高颜值后台管理系统全栈框架(十二)然后点击知道了,就会因为没有查询接口权限而报错总结到此终于把接口鉴权功能完整的实现了。
lucky0-0
【解读 ahooks 源码系列】State篇(二)
本文是 ahooks 源码(v3.7.4)系列的第九篇——State 篇(二)往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle本文主要解读 useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState 的源码实现useMap管理 Map 类型状态的 Hook。官方文档基本用法官方在线 Demoimport React from 'react';
import { useMap } from 'ahooks';
export default () => {
const [map, { set, setAll, remove, reset, get }] = useMap<string | number, string>([
['msg', 'hello world'],
[123, 'number type'],
]);
return (
<div>
<button type="button" onClick={() => set(String(Date.now()), new Date().toJSON())}>
Add
</button>
<button
type="button"
onClick={() => setAll([['text', 'this is a new Map']])}
style={{ margin: '0 8px' }}
>
Set new Map
</button>
<button type="button" onClick={() => remove('msg')} disabled={!get('msg')}>
Remove 'msg'
</button>
<button type="button" onClick={() => reset()} style={{ margin: '0 8px' }}>
Reset
</button>
<div style={{ marginTop: 16 }}>
<pre>{JSON.stringify(Array.from(map), null, 2)}</pre>
</div>
</div>
);
};APIconst [
map, // Map 对象
{
set, // 添加元素
setAll, // 生成一个新的 Map 对象
remove, // remove
reset, // 重置为默认值
get // 获取元素
}
] = useMap(initialValue?: Iterable<[any, any]>);MapMap 对象保存键值对,并且能够记住键的原始插入顺序。任何值(对象或者基本类型)都可以作为一个键或一个值。详情可以看MDN核心实现由于 React state 是不可变数据,所以需要每次更改都需要创建一个新的 Map 对象。function useMap<K, T>(initialValue?: Iterable<readonly [K, T]>) {
// 获取默认的 Map 参数
const getInitValue = () => {
return initialValue === undefined ? new Map() : new Map(initialValue);
};
const [map, setMap] = useState<Map<K, T>>(() => getInitValue());
// 添加元素
const set = (key: K, entry: T) => {
setMap((prev) => {
const temp = new Map(prev);
temp.set(key, entry);
return temp;
});
};
// 生成一个新的 Map 对象
const setAll = (newMap: Iterable<readonly [K, T]>) => {
setMap(new Map(newMap));
};
// 移除元素
const remove = (key: K) => {
setMap((prev) => {
const temp = new Map(prev);
temp.delete(key);
return temp;
});
};
// 重置为默认值
const reset = () => setMap(getInitValue());
// 获取元素
const get = (key: K) => map.get(key);
return [
map,
{
// useMemoizedFn 持久化导出函数
set: useMemoizedFn(set),
setAll: useMemoizedFn(setAll),
remove: useMemoizedFn(remove),
reset: useMemoizedFn(reset),
get: useMemoizedFn(get),
},
] as const;
}完整源码useSet管理 Set 类型状态的 Hook。官方文档基本用法官方在线 Demoimport React from 'react';
import { useSet } from 'ahooks';
export default () => {
const [set, { add, remove, reset }] = useSet(['Hello']);
return (
<div>
<button type="button" onClick={() => add(String(Date.now()))}>
Add Timestamp
</button>
<button
type="button"
onClick={() => remove('Hello')}
disabled={!set.has('Hello')}
style={{ margin: '0 8px' }}
>
Remove Hello
</button>
<button type="button" onClick={() => reset()}>
Reset
</button>
<div style={{ marginTop: 16 }}>
<pre>{JSON.stringify(Array.from(set), null, 2)}</pre>
</div>
</div>
);
};APIconst [
set, // Set 对象
{
add, // 添加元素
remove, // 移除元素
reset // 重置为默认值
}
] = useSet(initialValue?: Iterable<K>);SetSet 对象允许你存储任何类型的唯一值,无论是原始值或者是对象引用。详情可以看MDN核心实现由于 React state 是不可变数据,所以需要每次更改都需要创建一个新的 Set 对象。function useSet<K>(initialValue?: Iterable<K>) {
// 获取默认值
const getInitValue = () => {
// 通过 new Set() 构造函数,创建一个新的 Set 对象
return initialValue === undefined ? new Set<K>() : new Set(initialValue);
};
const [set, setSet] = useState<Set<K>>(() => getInitValue());
// 添加元素
const add = (key: K) => {
if (set.has(key)) {
return;
}
setSet(prevSet => {
const temp = new Set(prevSet);
temp.add(key); // 在 Set 对象尾部添加一个元素。返回该 Set 对象。
return temp;
});
};
// 移除元素
const remove = (key: K) => {
if (!set.has(key)) {
return;
}
setSet(prevSet => {
const temp = new Set(prevSet);
temp.delete(key);
return temp;
});
};
// 重置为默认值
const reset = () => setSet(getInitValue());
return [
set,
{
add: useMemoizedFn(add),
remove: useMemoizedFn(remove),
reset: useMemoizedFn(reset),
},
] as const;
}完整源码usePrevious保存上一次状态的 Hook。官方文档基本用法官方在线 Demo记录上次的 count 值import { usePrevious } from 'ahooks';
import React, { useState } from 'react';
export default () => {
const [count, setCount] = useState(0);
const previous = usePrevious(count);
return (
<>
<div>counter current value: {count}</div>
<div style={{ marginBottom: 8 }}>counter previous value: {previous}</div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
increase
</button>
<button type="button" style={{ marginLeft: 8 }} onClick={() => setCount((c) => c - 1)}>
decrease
</button>
</>
);
};使用场景实现新旧值的对比来处理一些逻辑实现思路每次状态变更的时候来比较值有没有发生变化:需要维护两个状态: prevRef(保存上一次状态值)和 curRef(当前状态值)state 状态变更的时候,使用 shouldUpdate 参数判断是否发生变化。如果发生变化,先更新 prevRef 的值为上一个 curRef,将 curRef 的值更新为当前最新 state 值shouldUpdate 支持自定义,由开发结合自身场景判断值是否变化,来更新上一次状态核心实现// 默认判断是否需要更新的函数
const defaultShouldUpdate = <T>(a?: T, b?: T) => !Object.is(a, b);
function usePrevious<T>(
// 需要记录变化的值
state: T,
// 自定义判断值是否变化
shouldUpdate: ShouldUpdateFunc<T> = defaultShouldUpdate,
): T | undefined {
const prevRef = useRef<T>(); // 保存上一次状态值
const curRef = useRef<T>(); // 当前状态值
// 自定义 shouldUpdate 函数,判断值是否变化
if (shouldUpdate(curRef.current, state)) {
prevRef.current = curRef.current;
curRef.current = state;
}
return prevRef.current;
}完整源码useRafState只在 requestAnimationFrame callback 时更新 state,一般用于性能优化。用法与 React.useState 一致官方文档基本用法官方在线 Demoimport { useRafState } from 'ahooks';
import React, { useEffect } from 'react';
export default () => {
const [state, setState] = useRafState({
width: 0,
height: 0,
});
useEffect(() => {
const onResize = () => {
setState({
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
});
};
onResize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div>
<p>Try to resize the window </p>
current: {JSON.stringify(state)}
</div>
);
};requestAnimationFramewindow.requestAnimationFrame():告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行与 setTimeout 相比,requestAnimationFrame 最大的优势是由系统来决定回调函数的执行时机,它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引起丢帧现象,也不会导致动画出现卡顿的问题。window.cancelAnimationFrame:取消一个先前通过调用 window.requestAnimationFrame()方法添加到计划中的动画帧请求。使用场景state 操作是比较频繁的实现频繁的动画效果核心实现主要是实现 setRafState 方法,在外部调用 setRafState 方法时,会取消上一次的 setState 回调函数,并执行 requestAnimationFrame 来控制 setState 的执行时机function useRafState<S>(initialState?: S | (() => S)) {
const ref = useRef(0);
const [state, setState] = useState(initialState);
const setRafState = useCallback((value: S | ((prevState: S) => S)) => {
// 先取消上一次的 setRafState 操作
cancelAnimationFrame(ref.current);
ref.current = requestAnimationFrame(() => {
// 在回调执行真正的 setState
setState(value);
});
}, []);
// 页面卸载时取消回调函数
useUnmount(() => {
cancelAnimationFrame(ref.current);
});
return [state, setRafState] as const;
}完整源码useSafeState用法与 React.useState 完全一样,但是在组件卸载后异步回调内的 setState 不再执行,避免因组件卸载后更新状态而导致的内存泄漏。官方文档警告内容如下:Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.升级 React 18 后官方已经移除了该警告,所以后续无需考虑该告警了,也不再需要这个 useSafeState Hook 了,详情可见该文章:React 18 对 Hooks 的影响:一基本用法官方在线 Demoimport { useSafeState } from 'ahooks';
import React, { useEffect, useState } from 'react';
const Child = () => {
const [value, setValue] = useSafeState<string>();
useEffect(() => {
setTimeout(() => {
setValue('data loaded from server');
}, 5000);
}, []);
const text = value || 'Loading...';
return <div>{text}</div>;
};
export default () => {
const [visible, setVisible] = useState(true);
return (
<div>
<button onClick={() => setVisible(false)}>Unmount</button>
{visible && <Child />}
</div>
);
};核心实现内部使用了useUnmountedRef 这个 Hook 来获取当前组件是否已卸载,该 Hook 原理是通过判断有无执行 useEffect 的卸载函数,在其标识为已卸载。useEffect(() => {
return () => {
// 可设置卸载标识
};
}, []);useSafeState 实现原理则是依据 unmountedRef 标识,在外部执行 setCurrentState 的时候,判断如果标识为 true(已卸载),则 return 停止更新function useSafeState<S>(initialState?: S | (() => S)) {
// useUnmountedRef:获取当前组件是否已卸载
const unmountedRef = useUnmountedRef();
const [state, setState] = useState(initialState);
const setCurrentState = useCallback((currentState) => {
// 如果组件卸载了则停止更新
if (unmountedRef.current) return;
setState(currentState);
}, []);
return [state, setCurrentState] as const;
}完整源码useGetState给 React.useState 增加了一个 getter 方法,以获取当前最新值。官方文档基本用法官方在线 Demo计数器每 3 秒打印一次值import React, { useEffect } from 'react';
import { useGetState } from 'ahooks';
export default () => {
const [count, setCount, getCount] = useGetState<number>(0);
useEffect(() => {
const interval = setInterval(() => {
// 在这里使用 count 无法获取到最新值
console.log('interval count', getCount());
}, 3000);
return () => {
clearInterval(interval);
};
}, []);
return <button onClick={() => setCount((count) => count + 1)}>count: {count}</button>;
};核心实现实现原理是使用 useRef 来保存最新的 state 值,暴露一个 getState 直接返回 stateRef.current 即可function useGetState<S>(initialState?: S) {
const [state, setState] = useState(initialState);
// 使用 useRef 保存最新 state
const stateRef = useRef(state);
stateRef.current = state;
// 获取当前最新值
const getState = useCallback(() => stateRef.current, []);
return [state, setState, getState];
}完整源码useResetState提供重置 state 方法的 Hooks,用法与 React.useState 基本一致。官方文档基本用法官方在线 Demoimport React from 'react';
import { useResetState } from 'ahooks';
interface State {
hello: string;
count: number;
}
export default () => {
const [state, setState, resetState] = useResetState<State>({
hello: '',
count: 0,
});
return (
<div>
<pre>{JSON.stringify(state, null, 2)}</pre>
<p>
<button
type="button"
style={{ marginRight: '8px' }}
onClick={() => setState({ hello: 'world', count: 1 })}
>
set hello and count
</button>
<button type="button" onClick={resetState}>
resetState
</button>
</p>
</div>
);
};核心实现实现原理是直接使用初始值作为 setState 的参数。说白了就是语义化(提供 reset 开头命名的函数)和偷懒(少传了个初始值参数)的写法。const useResetState = <S>(
initialState: S | (() => S),
): [S, Dispatch<SetStateAction<S>>, ResetState] => {
const [state, setState] = useState(initialState);
// 重置 state
// useMemoizedFn:持久化函数 Hook
const resetState = useMemoizedFn(() => {
setState(initialState);
});
return [state, setState, resetState];
};
lucky0-0
从零开始搭建一个高颜值后台管理系统全栈框架(十)
背景前面我们已经实现了菜单权限控制,但是我们给某一用户改了权限后,用户在不刷新的情况下,还能操作没有权限的菜单。虽然我们后面会做接口权限控制,用户调用没有权限的接口时会报错,但是这样也不太好,所以我们在改用户权限时,希望后端给前端主动发一条通知,权限变更了,然后刷新一下页面才能继续使用。后端消息推送方案分析轮询后端写一个接口,前端没隔几秒去调用一次,看看有没有新消息。这种方案在websocket没出来以前是最常用的方案。但是缺点也很明显,时间间隔设置太长,消息实时性变差,时间间隔设置太短,会不停的调用接口,服务器压力变大。WebSocket什么是WebSocket下面是chatgpt给出的答案:WebSocket是一种在Web浏览器和服务器之间进行全双工通信的协议,相比于传统的HTTP协议,它具有以下优势和用途:实时性:WebSocket提供了实时的双向数据传输,能够实现高效的实时通信,而不需要通过轮询或长轮询等间接方式。低延迟:由于WebSocket建立在TCP连接上,并且使用更轻量级的协议头部,因此可以减少数据传输的延迟,提供更快速的响应时间。节省带宽:WebSocket使用较少的网络流量,因为它使用更紧凑和有效的数据帧格式,并且可以使用二进制数据传输,而不仅仅是文本数据。更强大的功能:相比于HTTP请求-响应模型,WebSocket支持服务器主动推送数据到客户端,从而能够实现实时更新、即时聊天、多人协作和实时数据展示等功能。兼容性:WebSocket协议被广泛支持,并且现代的Web浏览器都原生支持WebSocket,无需任何额外插件或库。基于以上特点,WebSocket被广泛应用于实时数据传输、在线聊天、多人游戏、实时协作、股票行情、推送通知、实时监控等场景,为Web应用程序提供了更好的用户体验和功能扩展性。总结综上所述,后端消息推送使用websocket是最好的方案,除非要兼容很老的浏览器(IE8:你在说我?)。如果想兼容老浏览器,可以使用socket.io这个库,当浏览器不支持WebSocket的时候会自动降级成轮询方案。我们系统不打算兼容很老的浏览器,所以暂时不使用socket.io库,后面有需要可以替换。实现前端实现实现思路前端我们可以使用ahooks库里面的useWebSocket这个hooks,封装了一些常用的方法,并且支持断线重连,不过不支持心跳检测,这个后面我们自己实现。把后端推送过来的消息,使用zustand库把消息存到全局,在每个组件中都能使用最新的消息。单独写一个组件,根据消息类型处理后端推送过来的消息。定义消息数据接口前后端消息数据结构定义如下:// src/socket/message.ts
export enum SocketMessageType {
/**
* 权限变更
*/
PermissionChange = 'PermissionChange',
/**
* 密码重置
*/
PasswordChange = 'PasswordChange',
/**
* token过期
*/
TokenExpire = 'TokenExpire',
}
export class SockerMessage<T = any> {
/**
* 消息类型
*/
type: SocketMessageType;
/**
* 消息内容
*/
data?: T;
constructor(type: SocketMessageType, data?: T) {
this.type = type;
this.data = data;
}
}使用useWebSocket在layout.tsx文件中引入useWebSocket并使用,useWebSocket使用请看官方文档。这里url注意一下,window.location.host必须要加上,后面的token是为了鉴权。当token变化时重新连接配置vite支持WebSocket代理添加全局store// src/stores/global/message.ts
import {SocketMessageType} from '@/layouts/message-handle';
import {create} from 'zustand';
import {devtools} from 'zustand/middleware';
export interface SocketMessage {
type: SocketMessageType;
data: any;
}
interface State {
latestMessage?: SocketMessage | null;
}
interface Action {
setLatestMessage: (latestMessage: State['latestMessage']) => void;
}
export const useMessageStore = create<State & Action>()(
devtools(
(set) => {
return {
latestMessage: null,
setLatestMessage: (latestMessage: State['latestMessage']) =>
set({
latestMessage,
}),
};
},
{
name: 'messageStore',
}
)
);监听消息如果latestMessage变化,说明有新消息,把新消息存到全局store中。封装消息处理组件这个组件用来监听消息变化,以及处理消息。如果用户权限变更,刷新页面。如果密码修改或token失效退到登录页面。这里用到了策略模式,可以减少if/else的使用。// src/layouts/message-handle/index.tsx
import loginService from '@/pages/login/service';
import { toLoginPage } from '@/router';
import { useGlobalStore } from '@/stores/global';
import { useMessageStore } from '@/stores/global/message';
import { antdUtils } from '@/utils/antd';
import { useEffect } from 'react';
export enum SocketMessageType {
PermissionChange = 'PermissionChange',
PasswordChange = 'PasswordChange',
TokenExpire = 'TokenExpire',
}
const MessageHandle = () => {
const { latestMessage } = useMessageStore();
const { refreshToken, setToken } = useGlobalStore();
const messageHandleMap = {
[SocketMessageType.PermissionChange]: () => {
antdUtils.modal?.warning({
title: '权限变更',
content: '由于你的权限已经变更,需要重新刷新页面。',
onOk: () => {
window.location.reload();
},
})
},
[SocketMessageType.PasswordChange]: () => {
const hiddenModal = antdUtils.modal?.warning({
title: '密码重置',
content: '密码已经重置,需要重新登录。',
onOk: () => {
toLoginPage();
if (hiddenModal) {
hiddenModal.destroy();
}
},
})
},
[SocketMessageType.TokenExpire]: async () => {
// token失效调用刷新token,外面token变化,会自动重连。
const [error, data] = await loginService.rerefshToken(refreshToken);
if (!error) {
setToken(data.token)
} else {
toLoginPage();
}
},
}
useEffect(() => {
if (latestMessage?.type && messageHandleMap[latestMessage?.type]) {
messageHandleMap[latestMessage?.type]();
}
}, [latestMessage])
return null;
}
export default MessageHandle;
在layout中使用这个组件后端实现前言后端midway框架已经集成WebSocket组件,同时也支持socket.io组件,我们常用的功能midway基本都是内置了,用起来很方便。这里向大家再次推荐midway框架,除了star数量比nest少,开发体验和nest差不多,甚至有些地方比nest更好(后面打算出一篇两个框架开发对比文章),框架作者在群里也会积极的回答大家问题,基本上是有求必应。安装ws依赖pnpm i @midwayjs/ws@3 --save
pnpm i @types/ws --save-dev开启组件在imports导入// src/configuration.ts
import { Configuration } from '@midwayjs/core';
import * as ws from '@midwayjs/ws';
@Configuration({
imports: [ws],
// ...
})
export class MainConfiguration {
async onReady() {
// ...
}
}封装公共SocketService封装一个单例SocketService,每个用户连接都保存在connects里面,方便后面发送消息。// src/socket/service.ts
import { Singleton } from '@midwayjs/core';
import { Context } from '@midwayjs/ws';
import { SocketMessage } from './message';
@Singleton()
export class SocketService {
connects = new Map<string, Context[]>();
/**
* 添加连接
* @param userId 用户id
* @param connect 用户socket连接
*/
addConnect(userId: string, connect: Context) {
const curConnects = this.connects.get(userId);
if (curConnects) {
curConnects.push(connect);
} else {
this.connects.set(userId, [connect]);
}
}
/**
* 删除连接
* @param connect 用户socket连接
*/
deleteConnect(connect: Context) {
const connects = [...this.connects.values()];
for (let i = 0; i < connects.length; i += 1) {
const sockets = connects[i];
const index = sockets.indexOf(connect);
if (index >= 0) {
sockets.splice(index, 1);
break;
}
}
}
/**
* 给指定用户发消息
* @param userId 用户id
* @param data 数据
*/
sendMessage<T>(userId: string, data: SocketMessage<T>) {
const clients = this.connects.get(userId);
if (clients?.length) {
clients.forEach(client => {
client.send(JSON.stringify(data));
});
}
}
}消息对象export enum SocketMessageType {
/**
* 权限变更
*/
PermissionChange = 'PermissionChange',
/**
* 密码重置
*/
PasswordChange = 'PasswordChange',
/**
* token过期
*/
TokenExpire = 'TokenExpire',
}
export class SocketMessage<T = any> {
/**
* 消息类型
*/
type: SocketMessageType;
/**
* 消息内容
*/
data?: T;
constructor(type: SocketMessageType, data?: T) {
this.type = type;
this.data = data;
}
}SocketController// src/socket/controller.ts
import {
WSController,
OnWSConnection,
Inject,
OnWSMessage,
OnWSDisConnection,
} from '@midwayjs/core';
import { RedisService } from '@midwayjs/redis';
import { Context } from '@midwayjs/ws';
import * as http from 'http';
import { SocketService } from './service';
import { SocketMessageType, SocketMessage } from './message';
@WSController()
export class SocketConnectController {
@Inject()
ctx: Context;
@Inject()
redisService: RedisService;
@Inject()
socketService: SocketService;
@OnWSConnection()
async onConnectionMethod(socket: Context, request: http.IncomingMessage) {
// 获取url上token参数
const token = new URLSearchParams(request.url.slice(1)).get('token');
if (!token) {
socket.close();
return;
}
const userInfoStr = await this.redisService.get(`token:${token}`);
if (!userInfoStr) {
socket.send(
JSON.stringify({
type: SocketMessageType.TokenExpire,
})
);
socket.close();
return;
}
const userInfo = JSON.parse(userInfoStr);
this.socketService.addConnect(userInfo.userId, socket);
}
@OnWSMessage('message')
async gotMessage(data: Buffer) {
// 接受前端发送过来的消息
try {
const message = JSON.parse(data.toString()) as SocketMessage;
// 如果前端发送过来的消息时ping,那么就返回pong给前端
if (message.type === SocketMessageType.Ping) {
return {
type: SocketMessageType.Pong,
};
}
} catch {
console.error('json parse error');
}
}
@OnWSDisConnection()
async disconnect() {
// 客户端断开连接后,从全局connects移除
this.socketService.deleteConnect(this.ctx);
}
}权限变更通知用户权限变更目前有两个地方,第一个是用户更新角色接口,第二个是角色更新菜单接口。改造更新用户接口改造角色更新接口密码变更通知改造AuthService中resetPassword方法,密码变更成功后,给对应用户发消息重新登录。优化说明虽然ahooks中封装的useWebSocket已经做了断线重连,但是它只监听了onClose事件,但是有些情况,比如服务器挂了,可能来不及给前端发断开消息,后面服务重启了,但是前端不知道,就没办法自动重连。为了解决这个问题,心跳检测方案就出来了。就是前端一直给后端发消息,后端接受到消息后,响应一个消息,如果一段时间前端接收不到后端响应,说明后端服务可能挂了,这时候我们再去重连,如果重连失败,我们隔几秒去重连一次,后面服务启动成功了,就能自动连接上了。心跳检测还有一个作用,后面我们用nginx做websocket代理,nginx针对websocket代理有个优化,前后端一段时间(这个时间可以自己设置)没有发送消息,就会断开,有了心跳检测,相当于前后端一直在交互,就不会断开了。实现思路因为useWebSocket不支持心跳检测,所以我在useWebSocket上面加了一层来实现心跳检测。当websocket连接三秒后发送一个ping类型消息给后端,再加一个定时器,3秒后websocket重连。这时候如果我们接收到了消息,就把定时器给清除掉,也就不会去重连了。useWebSocket内部已经实现了,如果重连失败,会自动重连的,reconnectLimit重连的次数,reconnectInterval重连的时间间隔,我这里设置了6秒一次,最大30次,也就是说三分钟连不上就不再连了。源码前端实现import {useWebSocket} from 'ahooks';
import type {Options, Result} from 'ahooks/lib/useWebSocket';
import {useRef} from 'react';
export function useWebSocketMessage(
socketUrl: string,
options?: Options
): Result {
const timerRef = useRef<number>();
const {
latestMessage,
sendMessage,
connect,
disconnect,
readyState,
webSocketIns,
} = useWebSocket(socketUrl, {
...options,
reconnectLimit: 30,
reconnectInterval: 6000,
onOpen: (event: Event, instance: WebSocket) => {
sendHeartbeat();
options?.onOpen && options.onOpen(event, instance);
},
onMessage: (message: MessageEvent<any>, instance: WebSocket) => {
// 再次发送心跳消息
sendHeartbeat();
options?.onMessage && options.onMessage(message, instance);
},
onClose(event, instance) {
resetHeartbeat();
options?.onClose && options.onClose(event, instance);
},
onError(event, instance) {
resetHeartbeat();
options?.onError && options.onError(event, instance);
},
});
// 清除重连的定时器
function resetHeartbeat() {
if (timerRef.current) {
window.clearTimeout(timerRef.current);
}
}
// 发送心跳消息
function sendHeartbeat() {
resetHeartbeat();
// 三秒之后发送一次心跳消息
setTimeout(() => {
sendMessage && sendMessage(JSON.stringify({type: 'Ping'}));
// 心跳消息发送3s后,还没得到服务器响应,说明服务器可能挂了,需要自动重连。
timerRef.current = window.setTimeout(() => {
disconnect && disconnect();
connect && connect();
}, 3000);
}, 3000);
}
return {
latestMessage,
connect,
sendMessage,
disconnect,
readyState,
webSocketIns,
};
}
后端实现nginx代理websocket配置上面必须加上这段map配置,不然启动会报错,因为用到了connection_upgrade变量。这里遇到一个小坑,正常代理这样配就行了,然后线上怎么都连不上,最后发现给连接的url ws后面加上/就行了,至于为啥加/才可以,我还没找到原因,如果有人知道,可以告诉我一下。留个坑本地开发完,发布到线上,发现有时候消息能正常发送,有时候不行,调试了一段时间后,发现线上是用pm2启动的,启动了4个进程,连接被分散在各个进程上,有时候某个进程没有某个用户的连接,这时候修改密码或修改权限调了这个进程的接口,就会导致消息发不出去。这个处理起来比较麻烦,需要用到redis消息广播,这个我们下篇文章说。暂时把pm2启动进程改为1。总结websocket除了做后端消息推送功能,还可以做很多功能,比如在线聊天,在线客服等。所以作为前端还是有必要了解一下的。
lucky0-0
【解读 ahooks 源码系列】探究如何实现 useRequest
前言本文是 ahooks 源码(v3.7.4)系列的第十四篇——【解读 ahooks 源码系列】探究如何实现 useRequest往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState【解读 ahooks 源码系列】Effect 篇(一):useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect【解读 ahooks 源码系列】Effect 篇(二):useDeepCompareEffect、useDeepCompareLayoutEffect、useInterval、useTimeout、useRafInterval、useRafTimeout、useLockFn、useUpdate、useThrottleEffect【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(一):useMount、useUnmount、useUnmountedRef、useCounter、useNetwork、useSelections、useHistoryTravel【解读 ahooks 源码系列】 Scene 篇(二):useTextSelection、useCountdown、useDynamicList、useWebSocket本文主要解读 useRequest 的源码实现。useRequestuseRequest 是一个强大的异步数据管理的 Hooks,React 项目中的网络请求场景使用 useRequest 就够了。useRequest 通过插件式组织代码,核心代码极其简单,并且可以很方便的扩展出更高级的功能。目前已有能力包括:自动请求/手动请求轮询防抖节流屏幕聚焦重新请求错误重试loading delaySWR(stale-while-revalidate)缓存官方文档基本用法useRequest 的第一个参数是一个异步函数,在组件初次加载时,会自动触发该函数执行。同时自动管理该异步函数的 loading , data , error 等状态。官方在线 Demoimport { useRequest } from 'ahooks';
import Mock from 'mockjs';
import React from 'react';
function getUsername(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(Mock.mock('@name'));
}, 1000);
});
}
export default () => {
const { data, error, loading } = useRequest(getUsername);
if (error) {
return <div>failed to load</div>;
}
if (loading) {
return <div>loading...</div>;
}
return <div>Username: {data}</div>;
};核心实现主调用流程useRequest 负责初始化和处理数据,最后将结果返回useRequest 方法的入口文件 useRequest.ts 封装了 useRequestImplement 这个 Hook,并引入了默认插件。function useRequest<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options?: Options<TData, TParams>,
plugins?: Plugin<TData, TParams>[],
) {
return useRequestImplement<TData, TParams>(service, options, [
...(plugins || []),
// 默认插件
useDebouncePlugin,
useLoadingDelayPlugin,
usePollingPlugin,
useRefreshOnWindowFocusPlugin,
useThrottlePlugin,
useAutoRunPlugin,
useCachePlugin,
useRetryPlugin,
] as Plugin<TData, TParams>[]);
}useRequestImplement.ts:实例化 Fetch 类——fetchInstance,并针对实例的(卸载)生命周期相关进行逻辑处理function useRequestImplement<TData, TParams extends any[]>(
service: Service<TData, TParams>,
options: Options<TData, TParams> = {},
plugins: Plugin<TData, TParams>[] = [],
) {
// manual:初始化时是否自动执行 service,默认为false
const { manual = false, ...rest } = options;
const fetchOptions = {
manual,
...rest,
};
const serviceRef = useLatest(service);
const update = useUpdate();
// fetch 请求实例
// useCreation, useMemo/useCallback 替代品,可以保证被 memo 的值不会被重新计算
const fetchInstance = useCreation(() => {
const initState = plugins.map((p) => p?.onInit?.(fetchOptions)).filter(Boolean);
return new Fetch<TData, TParams>(
serviceRef,
fetchOptions,
update,
Object.assign({}, ...initState),
);
}, []);
fetchInstance.options = fetchOptions;
// run all plugins hooks
fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));
useMount(() => {
if (!manual) {
// useCachePlugin can set fetchInstance.state.params from cache when init
const params = fetchInstance.state.params || options.defaultParams || [];
// @ts-ignore
fetchInstance.run(...params);
}
});
useUnmount(() => {
fetchInstance.cancel();
});
// 向外抛出的方法都是 Fetch 实例的变量方法
return {
loading: fetchInstance.state.loading,
data: fetchInstance.state.data,
error: fetchInstance.state.error,
params: fetchInstance.state.params || [],
cancel: useMemoizedFn(fetchInstance.cancel.bind(fetchInstance)),
refresh: useMemoizedFn(fetchInstance.refresh.bind(fetchInstance)),
refreshAsync: useMemoizedFn(fetchInstance.refreshAsync.bind(fetchInstance)),
run: useMemoizedFn(fetchInstance.run.bind(fetchInstance)),
runAsync: useMemoizedFn(fetchInstance.runAsync.bind(fetchInstance)),
mutate: useMemoizedFn(fetchInstance.mutate.bind(fetchInstance)),
} as Result<TData, TParams>;
}可以看出,我们平时使用 useRequest 暴露出来的方法,几乎都是 fetchInstance 实例暴露出来的,所以源代码核心,也就是 Fetch 类了核心 Fetch 类Fetch 类是整个 Hook 的核心代码,它封装了暴露给外部的具体数据和方法,只需完成整体请求流程的功能即可。先来看看这个类的大致结构class Fetch<TData, TParams extends any[]> {
// 所有插件执行完成后的返回结果数组
pluginImpls: PluginReturn<TData, TParams>[];
count: number = 0;
state: FetchState<TData, TParams> = {
loading: false, // service 是否正在执行
params: undefined, // 当次执行的 service 的参数数组
data: undefined, // service 返回的数据
error: undefined, // service 抛出的异常
};
constructor(
public serviceRef: MutableRefObject<Service<TData, TParams>>,
public options: Options<TData, TParams>,
public subscribe: Subscribe,
public initState: Partial<FetchState<TData, TParams>> = {},
) {
this.state = {
...this.state,
loading: !options.manual,
...initState,
};
}
// 更新状态
setState(s: Partial<FetchState<TData, TParams>> = {}) {
// ...
}
// 插件运行处理
runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
// ...
}
// 与 run 用法一致,但返回的是 Promise,需要自行处理异常
async runAsync(...params: TParams): Promise<TData> {
// ...
}
// 手动触发 service 执行,异常自动处理
run(...params: TParams) {
// ...
}
// 忽略当前 Promise 的响应
cancel() {
// ...
}
// 使用上一次的 params,重新调用 run
refresh() {
// ...
}
// 使用上一次的 params,重新调用 runAsync
refreshAsync() {
// ...
}
// 直接修改 data
mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
// ...
}
}setState该方法保存于请求参数和请求结果相关的信息,而这些是我们使用 useRequest 需要用的信息,如下:const { data, error, loading } = useRequest(getUsername);setState 实现如下,其中 subscribe 在 Fetch 实例化的时候传入的参数为 useUpdate 执行后返回的 update 方法setState(s: Partial<FetchState<TData, TParams>> = {}) {
this.state = {
...this.state,
...s,
};
this.subscribe(); // 强制组件重新渲染
}runPluginHandlerrunPluginHandler 用于特定的生命周期钩子执行插件方法。该方法通过pluginImpls 数组循环找出所有对应 event 的钩子函数去执行并返回结果。pluginImpls 是一个数组,用来存储所有插件 hooks 的返回结果。它的赋值是在 useRequestImplement.ts 实例化 Fetch 类后执行 fetchInstance.pluginImpls = plugins.map((p) => p(fetchInstance, fetchOptions));runPluginHandler(event: keyof PluginReturn<TData, TParams>, ...rest: any[]) {
// @ts-ignore
const r = this.pluginImpls.map((i) => i[event]?.(...rest)).filter(Boolean);
return Object.assign({}, ...r);
}这个运行插件的方法具体怎么理解作用呢?当我们想实现一个 useRequest 插件类实现特定功能时,想在接口请求成功时做一些事情,那在插件类里面要怎么知道呢?一种比较好的方式就是生命周期,我只要在插件类里写下 onSuccess 方法,在这里写逻辑就能轻松实现想要的功能而不用考虑其它。核心思想就是,Fetch 类只需完成请求整体的流程,其它功能交给插件去实现。那如何把请求前后的流程分享给插件,那就是定义固定的生命周期钩子。如插件想要在接口请求成功后做些事情,则这里的处理只需通过插件数组循环找出哪个插件有写 onSuccess 这个钩子,有就找出来把它执行了。来看下 PluginReturn 这个 TS 定义,也就是插件支持的钩子函数了export interface PluginReturn<TData, TParams extends any[]> {
onBefore?: (params: TParams) =>
| ({
stopNow?: boolean;
returnNow?: boolean;
} & Partial<FetchState<TData, TParams>>)
| void;
onRequest?: (
service: Service<TData, TParams>,
params: TParams,
) => {
servicePromise?: Promise<TData>;
};
onSuccess?: (data: TData, params: TParams) => void;
onError?: (e: Error, params: TParams) => void;
onFinally?: (params: TParams, data?: TData, e?: Error) => void;
onCancel?: () => void;
onMutate?: (data: TData) => void;
}runAsync这个是 Fetch 类请求的核心方法,下面介绍的 run/refresh/refreshAsync 底层都是调用该方法,在该方法不同的时机也会调用插件的不同生命周期钩子函数。onBefore 请求前发起请求前,会调用插件的 onBefore 方法async runAsync(...params: TParams): Promise<TData> {
this.count += 1;
const currentCount = this.count;
// onBefore 函数的返回值(如需实现特定功能插件,字段还可继续扩展)
const {
stopNow = false, // 是否停止请求(可选)- useAutoRunPlugin 插件使用
returnNow = false, // 是否立即返回(可选)- useCachePlugin 插件使用
...state
} = this.runPluginHandler('onBefore', params); // 执行每个插件的 onBefore 钩子函数
// stop request
if (stopNow) {
return new Promise(() => {});
}
this.setState({
loading: true, // 请求前把 loading 设置为 true
params,
...state,
});
// 是否立即返回
if (returnNow) {
return Promise.resolve(state.data);
}
// 执行外部传入的 onBefore 回调
this.options.onBefore?.(params);
// ...
}onRequest 请求中请求阶段这个阶段只有 useCachePlugin 执行了 onRequest 方法,执行后返回 service Promise(有可能是缓存的结果),从而达到缓存 Promise 的效果// 替换 service。目前只有 useCachePlugin 插件内部实现该方法,可实现缓存 Promise 的功能
let { servicePromise } = this.runPluginHandler('onRequest', this.serviceRef.current, params);
// 其它插件走这个逻辑
if (!servicePromise) {
servicePromise = this.serviceRef.current(...params);
}
const res = await servicePromise;onSuccess/onError/onFinally 请求结果整个请求过程是包在 try catch 里面的下文代码有个判断 currentCount !== this.count,调用 runAsync 方法的时候默认 currentCount === this.count,区分是否为正常流程的当前请求。当调用取消请求方法的时候,this.count += 1;,此时两者不相等,直接返回空的 Promisetry {
// onRequest ...
// 区分是否为正常流程的当前请求(调用取消请求方法的时候 this.count 会加1)
if (currentCount !== this.count) {
// prevent run.then when request is canceled
return new Promise(() => {});
}
this.setState({
data: res,
error: undefined,
loading: false,
});
// 执行外部传入的 onSuccess 回调
this.options.onSuccess?.(res, params);
// 执行插件 onSuccess 的生命周期钩子
this.runPluginHandler('onSuccess', res, params);
// 执行外部传入的 onFinally 回调
this.options.onFinally?.(params, res, undefined);
// 执行插件 onFinally 的生命周期钩子
if (currentCount === this.count) {
this.runPluginHandler('onFinally', params, res, undefined);
}
return res;
} catch (error) {
if (currentCount !== this.count) {
// prevent run.then when request is canceled
return new Promise(() => {});
}
this.setState({
error,
loading: false,
});
// 执行外部传入的 onError 回调
this.options.onError?.(error, params);
// 执行插件 onError 的生命周期钩子
this.runPluginHandler('onError', error, params);
// 执行外部传入的 onFinally 回调
this.options.onFinally?.(params, undefined, error);
// 执行插件 onFinally 的生命周期钩子
if (currentCount === this.count) {
this.runPluginHandler('onFinally', params, undefined, error);
}
throw error;
}cancel取消请求cancel() {
this.count += 1; // 标识加1,区分是否为正常流程的当前请求
this.setState({
loading: false,
});
// 执行插件 onCancel 的生命周期钩子
this.runPluginHandler('onCancel');
}run实现是直接调用 runAsync 方法,跟 runAsync 区别是 run 会自动 catch 异常且无返回值run(...params: TParams) {
this.runAsync(...params).catch((error) => {
if (!this.options.onError) {
console.error(error);
}
});
}refresh使用上一次的 params,重新调用 runrefresh() {
// @ts-ignore
this.run(...(this.state.params || []));
}refreshAsync与 run 用法一致,但返回的是 Promise,需要自行处理异常refreshAsync() {
// @ts-ignore
return this.runAsync(...(this.state.params || []));
}mutate直接修改 data,onMutate 这个钩子是唯一不算在请求的生命周期里mutate(data?: TData | ((oldData?: TData) => TData | undefined)) {
// data 支持传入函数
const targetData = isFunction(data) ? data(this.state.data) : data;
// 执行插件 onMutate 的生命周期钩子
this.runPluginHandler('onMutate', targetData);
// 更新 state 中的 data
this.setState({
data: targetData,
});
}插件功能实现讲完 useRequest 的核心实现,接下来就来讲 useRequest 支持的多种功能是如何实现的。Loading Delay首先第一个是 Loading Delay通过设置 options.loadingDelay ,可以延迟 loading 变 e 成 true 的时间,有效防止闪烁。用法假如 getUsername 在 300ms 内返回,则 loading 不会变成 true,避免了页面展示 Loading... 的情况。Democonst { loading, data } = useRequest(getUsername, {
loadingDelay: 300
});实现该功能是通过 useLoadingDelayPlugin 插件实现,通过 onBefore 请求前设置 loading 状态来实现暴露给外部使用的 loading 状态实际是 Fetch 实例维护的 state 状态。async runAsync(...params: TParams): Promise<TData> {
const {
stopNow = false,
returnNow = false,
...state // 此时 useLoadingDelayPlugin 返回的 loading 为 false
} = this.runPluginHandler('onBefore', params);
// ...
this.setState({
loading: true,
params,
...state, // 这里覆盖 loading 状态,置为 false
});
}useLoadingDelayPlugin 实现:const useLoadingDelayPlugin: Plugin<any, any[]> = (fetchInstance, { loadingDelay }) => {
const timerRef = useRef<Timeout>();
if (!loadingDelay) {
return {};
}
const cancelTimeout = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
};
return {
onBefore: () => {
cancelTimeout();
// loadingDelay 时间范围内请求未完成,则才将 loading 状态设为 false
timerRef.current = setTimeout(() => {
fetchInstance.setState({
loading: true,
});
}, loadingDelay);
// loading 状态直接返回 false
return {
loading: false,
};
},
// 请求完成或取消清除定时器
onFinally: () => {
cancelTimeout();
},
onCancel: () => {
cancelTimeout();
},
};
};完整源码轮询通过设置 options.pollingInterval,进入轮询模式,useRequest 会定时触发 service 执行。用法每隔 3000ms 请求一次 getUsername。同时你可以通过 cancel 来停止轮询,通过 run/runAsync 来启动轮询,通过 options.pollingErrorRetryCount 轮询错误重试次数Democonst { data, run, cancel } = useRequest(getUsername, {
pollingInterval: 3000, // 轮询间隔,单位为毫秒
pollingErrorRetryCount: 3, // 轮询错误重试次数
});实现由 usePollingPlugin 插件实现。实现原理:在 onFinally 生命周期里调用 refresh 方法,核心的几行代码如下:// 每当完成一次请求,通过定时器在 `pollingInterval` ms 后再次发起请求
onFinally: () => {
// ...
timerRef.current = setTimeout(() => {
fetchInstance.refresh();
}, pollingInterval);
},如何实现 pollingErrorRetryCount(轮询错误重试次数。如果设置为 -1,则无限次)这个功能?请求失败设置轮询次数。在 onError 生命周期钩子记录一个轮询错误次数 countRef.current,每次被调用就加 1。请求成功重置轮询次数。在 onSuccess 钩子里重置 countRef.current 为 0轮询之前判断是否满足条件。const countRef = useRef<number>(0);
// 判断轮询重试次数
if (
pollingErrorRetryCount === -1 ||
(pollingErrorRetryCount !== -1 && countRef.current <= pollingErrorRetryCount)
) {
// setTimeout 轮询
} else {
// 重置
countRef.current = 0;
}如何实现 pollingWhenHidden(在页面隐藏时,是否继续轮询。如果设置为 false,在页面隐藏时会暂时停止轮询,页面重新显示时继续上次轮询)功能?判断页面是否可见:isDocumentVisible 方法主要针对 pollingWhenHidden 参数为 false 的情况,订阅页面可见状态的变化 visibilitychange每次一次新的请求需要在 onBefore 取消订阅// 判断页面是否处于可见状态
function isDocumentVisible(): boolean {
if (isBrowser) {
return document.visibilityState !== 'hidden';
}
return true;
}
timerRef.current = setTimeout(() => {
// !pollingWhenHidden && 页面不可见
if (!pollingWhenHidden && !isDocumentVisible()) {
// 通过 subscribeReVisible 方法进行订阅监听页面可见状态的变化
unsubscribeRef.current = subscribeReVisible(() => {
fetchInstance.refresh();
});
} else {
fetchInstance.refresh();
}
}, pollingInterval);来看看 subscribeReVisible 方法的实现,实际就类似于发布订阅:// 事件监听队列
const listeners: Listener[] = [];
// 订阅事件
function subscribe(listener: Listener) {
// 将监听函数添加到事件队列
listeners.push(listener);
// 返回取消事件订阅的方法
return function unsubscribe() {
const index = listeners.indexOf(listener);
listeners.splice(index, 1);
};
}
if (isBrowser) {
const revalidate = () => {
// 页面不可见时不处理
if (!isDocumentVisible()) return;
// 页面可见,循环执行事件监听队列的事件
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
};
// 监听 visibilitychange
window.addEventListener('visibilitychange', revalidate, false);
}完整源码ReadyuseRequest 提供了一个 options.ready 参数,当其值为 false 时,请求永远都不会发出。其具体行为如下:当 manual=false 自动请求模式时,每次 ready 从 false 变为 true 时,都会自动发起请求,会带上参数 options.defaultParams。当 manual=true 手动请求模式时,只要 ready=false,则通过 run/runAsync 触发的请求都不会执行。用法手动模式下 ready 的行为。只有当 ready 等于 true 时,run 才会执行。Demoexport default () => {
const [ready, { toggle }] = useToggle(false);
const { data, loading, run } = useRequest(getUsername, {
ready,
manual: true,
});
};实现const useAutoRunPlugin: Plugin<any, any[]> = (
fetchInstance,
{ manual, ready = true, defaultParams = [], refreshDeps = [], refreshDepsAction },
) => {
const hasAutoRun = useRef(false);
hasAutoRun.current = false;
// 只在依赖更新时执行
useUpdateEffect(() => {
// manual 为 false 表示自动请求模式
if (!manual && ready) {
hasAutoRun.current = true;
fetchInstance.run(...defaultParams); // 自动执行请求
}
}, [ready]);
return {
// 请求前的钩子
onBefore: () => {
// ready 为 false,返回 stopNow 为 true,表示请求不会发出
if (!ready) {
return {
stopNow: true,
};
}
},
};
};在 Fetch 类中执行请求前:const {
stopNow = false,
returnNow = false,
...state
} = this.runPluginHandler('onBefore', params);
// 为 true,则停止发送请求,返回空的 promise
if (stopNow) {
return new Promise(() => {});
}完整源码依赖刷新useRequest 提供了一个 options.refreshDeps 参数,当它的值变化后,会重新触发请求。用法const [userId, setUserId] = useState('1');
const { data, run } = useRequest(() => getUserSchool(userId), {
refreshDeps: [userId],
});上面的示例代码,useRequest 会在初始化和 userId 变化时,触发函数执行。与下面代码实现功能完全一致:const [userId, setUserId] = useState('1');
const { data, refresh } = useRequest(() => getUserSchool(userId));
useEffect(() => {
refresh();
}, [userId]);实现这个也是由 useAutoRunPlugin 插件实现,主要就是监听 refreshDeps 依赖const hasAutoRun = useRef(false);
hasAutoRun.current = false; // 每一次重新执行都重置为 false
useUpdateEffect(() => {
// manual 默认值为 false,ready 默认值为true
if (!manual && ready) {
hasAutoRun.current = true; // 请求前设置为 true
fetchInstance.run(...defaultParams); // 自动执行请求
}
}, [ready]);
useUpdateEffect(() => {
if (hasAutoRun.current) {
return;
}
if (!manual) {
hasAutoRun.current = true;
// 依赖变化的时候的回调,有传就执行
if (refreshDepsAction) {
refreshDepsAction();
} else {
fetchInstance.refresh(); // 重新发起请求
}
}
}, [...refreshDeps]);完整源码屏幕聚焦重新请求通过设置 options.refreshOnWindowFocus,在浏览器窗口 refocus 和 revisible 时,会重新发起请求。用法const { data } = useRequest(getUsername, {
refreshOnWindowFocus: true,
});你可以点击浏览器外部,再点击当前页面来体验效果(或者隐藏当前页面,重新展示),如果和上一次请求间隔大于 5000ms,则会重新请求一次。Demo实现该功能由 useRefreshOnWindowFocusPlugin 插件实现。通过监听 visibilitychange 和 focus 事件,监听变化的时候执行判断逻辑const useRefreshOnWindowFocusPlugin: Plugin<any, any[]> = (
fetchInstance,
{ refreshOnWindowFocus, focusTimespan = 5000 },
) => {
const unsubscribeRef = useRef<() => void>();
const stopSubscribe = () => {
unsubscribeRef.current?.();
};
useEffect(() => {
if (refreshOnWindowFocus) {
const limitRefresh = limit(fetchInstance.refresh.bind(fetchInstance), focusTimespan);
// 订阅
unsubscribeRef.current = subscribeFocus(() => {
limitRefresh();
});
}
return () => {
stopSubscribe();
};
}, [refreshOnWindowFocus, focusTimespan]);
useUnmount(() => {
stopSubscribe();
});
return {};
};limit 实际就是节流函数function limit(fn: any, timespan: number) {
let pending = false;
return (...args: any[]) => {
if (pending) return; // pending 阶段不进行请求,直接返回
pending = true;
fn(...args);
setTimeout(() => {
// 超过 timespan 时间范围,重置 pending
pending = false;
}, timespan);
};
}接下来的关键就是这个 subscribeFocus 方法了,跟我们上面写的 subscribeReVisible 差不多// 事件监听队列
const listeners: Listener[] = [];
// 订阅事件
function subscribe(listener: Listener) {
// 将监听函数添加到事件队列
listeners.push(listener);
// 返回取消事件订阅的方法
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
}
if (isBrowser) {
const revalidate = () => {
// 页面不可见的时候和没网络的时候不处理
if (!isDocumentVisible() || !isOnline()) return;
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
};
// 监听 `visibilitychange` 和 `focus` 事件
window.addEventListener('visibilitychange', revalidate, false);
window.addEventListener('focus', revalidate, false);
}完整源码防抖通过设置 options.debounceWait,进入防抖模式,此时如果频繁触发 run 或者 runAsync,则会以防抖策略进行请求。用法const { data, run } = useRequest(getUsername, {
debounceWait: 300,
manual: true
});如上示例代码,频繁触发 run,只会在最后一次触发结束后等待 300ms 执行。实现该功能是通过 useDebouncePlugin 插件实现的,实际底层调用的是 lodash/debounce,所以也支持参数 options.debounceWait、options.debounceLeading、options.debounceTrailing、options.debounceMaxWait下面只放出关键代码:useEffect(() => {
if (debounceWait) {
// 保存 runAsync 原方法
const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
// 基于lodash 的 debounce 封装,执行完的返回结果有 cancel 方法
debouncedRef.current = debounce(
(callback) => {
callback();
},
debounceWait,
options,
);
// debounce runAsync should be promise
// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
fetchInstance.runAsync = (...args) => {
// 返回 Promise
return new Promise((resolve, reject) => {
debouncedRef.current?.(() => {
// 执行原函数
_originRunAsync(...args)
.then(resolve)
.catch(reject);
});
});
};
return () => {
// 取消函数调用
debouncedRef.current?.cancel();
// 还原成原来的 runAsync 函数
fetchInstance.runAsync = _originRunAsync;
};
}
}, [debounceWait, options]);节流通过设置 options.throttleWait,进入节流模式,此时如果频繁触发 run 或者 runAsync,则会以节流策略进行请求。用法const { data, run } = useRequest(getUsername, {
throttleWait: 300,
manual: true
});如上示例代码,频繁触发 run,只会每隔 300ms 执行一次。实现该功能使用 useThrottlePlugin 实现,与 useDebouncePlugin 的思路一致,只不过换成了 lodash/throttle 去实现useEffect(() => {
if (throttleWait) {
// 保存 runAsync 原方法
const _originRunAsync = fetchInstance.runAsync.bind(fetchInstance);
// 基于lodash 的 throttle 封装,执行完的返回结果有 cancel 方法
throttledRef.current = throttle(
(callback) => {
callback();
},
throttleWait,
options,
);
// throttle runAsync should be promise
// https://github.com/lodash/lodash/issues/4400#issuecomment-834800398
fetchInstance.runAsync = (...args) => {
// 返回 Promise
return new Promise((resolve, reject) => {
// 执行原函数
throttledRef.current?.(() => {
_originRunAsync(...args)
.then(resolve)
.catch(reject);
});
});
};
return () => {
// 还原成原来的 runAsync 函数
fetchInstance.runAsync = _originRunAsync;
// 取消函数调用
throttledRef.current?.cancel();
};
}
}, [throttleWait, throttleLeading, throttleTrailing]);缓存 & SWR设置了 options.cacheKey,useRequest 会将当前请求成功的数据缓存起来。下次组件初始化时,如果有缓存数据,我们会优先返回缓存数据,然后在背后发送新请求,也就是 SWR 的能力。通过 options.staleTime 设置数据保持新鲜时间,在该时间内,我们认为数据是新鲜的,不会重新发起请求。通过 options.cacheTime 设置数据缓存时间,超过该时间,我们会清空该条缓存数据。用法const { data, loading, refresh } = useRequest(getArticle, {
cacheKey: 'cacheKey-share',
});实现缓存主要由 useCachePlugin 插件实现。在看主逻辑前,先看看涉及该插件有关的三个 utils 文件。utils/cache.ts先来看看缓存是如何管理的,ahooks 是抽到一个 packages/hooks/src/useRequest/src/utils/cache.ts 里面,暴露三个 utils 方法:getCache、setCache、clearCache 方法,通过 Map 数据结构来管理缓存数据// 通过 Map 数据结构来缓存
const cache = new Map<CachedKey, RecordData>();
// 设置缓存
const setCache = (key: CachedKey, cacheTime: number, cachedData: CachedData) => {
// 获取当前缓存
const currentCache = cache.get(key);
// 设置缓存之前,先清除缓存定时器
if (currentCache?.timer) {
clearTimeout(currentCache.timer);
}
let timer: Timer | undefined = undefined;
// 有设置缓存时间
if (cacheTime > -1) {
// 有设置超过缓存时间 cacheTime,则删除该条缓存数据
timer = setTimeout(() => {
cache.delete(key);
}, cacheTime);
}
// 设置该条缓存数据
cache.set(key, {
...cachedData,
timer,
});
};
// 读取缓存
const getCache = (key: CachedKey) => {
return cache.get(key);
};
// 支持清空单个缓存,或一组缓存。在自定义缓存模式下不会生效
const clearCache = (key?: string | string[]) => {
if (key) {
const cacheKeys = Array.isArray(key) ? key : [key];
cacheKeys.forEach((cacheKey) => cache.delete(cacheKey));
} else {
cache.clear();
}
};
export { getCache, setCache, clearCache };utils/cacheSubscribe.ts实现了发布订阅,主要暴露两个方法:subscribe:订阅事件trigger 触发指定缓存 key 对应的所有事件// 事件监听器
const listeners: Record<string, Listener[]> = {};
// 触发指定缓存 key 对应的所有事件
const trigger = (key: string, data: any) => {
if (listeners[key]) {
listeners[key].forEach((item) => item(data));
}
};
// 订阅事件
const subscribe = (key: string, listener: Listener) => {
// 一个 key 值可对应多个事件
if (!listeners[key]) {
listeners[key] = [];
}
// 添加到事件监听数组
listeners[key].push(listener);
// 返回清除订阅的方法
return function unsubscribe() {
const index = listeners[key].indexOf(listener);
listeners[key].splice(index, 1);
};
};utils/cachePromise.ts主要暴露两个方法:getCachePromise:获取缓存 promisesetCachePromise:设置 promise 缓存代码概览插件初始化时先尝试获取缓存,有缓存且没过期则使用缓存的 data 和 params 进行替换,订阅同一 cacheKey 更新实现住逻辑用到了钩子函数:onBefore、onRequest、onSuccess、onMutateconst useCachePlugin: Plugin<any, any[]> = (
fetchInstance,
{
cacheKey, // 请求唯一标识
cacheTime = 5 * 60 * 1000, // 缓存数据回收时间
staleTime = 0, // 缓存数据保持新鲜时间
setCache: customSetCache, // 自定义设置缓存
getCache: customGetCache, // 自定义读取缓存
},
) => {
const unSubscribeRef = useRef<() => void>();
const currentPromiseRef = useRef<Promise<any>>();
// 设置缓存
const _setCache = (key: string, cachedData: CachedData) => {
// 有传入自定义设置缓存则优先使用自定义方法
if (customSetCache) {
customSetCache(cachedData);
} else {
cache.setCache(key, cacheTime, cachedData);
}
// 触发指定缓存 key 对应的所有事件。key 值相同的话 data 数据是共享的
cacheSubscribe.trigger(key, cachedData.data);
};
// 读取缓存
const _getCache = (key: string, params: any[] = []) => {
// 有传入自定义读取缓存则优先使用自定义方法
if (customGetCache) {
return customGetCache(params);
}
return cache.getCache(key);
};
useCreation(() => {
if (!cacheKey) {
return;
}
// 初始化时尝试从缓存获取数据
const cacheData = _getCache(cacheKey);
// 有缓存
if (cacheData && Object.hasOwnProperty.call(cacheData, 'data')) {
// 使用缓存的 data 和 params 替换
fetchInstance.state.data = cacheData.data;
fetchInstance.state.params = cacheData.params;
// staleTime 为-1表示数据永远新鲜 或 当前时间还在新鲜时间范围内
if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
// 直接使用缓存的话无需 loading
fetchInstance.state.loading = false;
}
}
// 订阅 cacheKey 更新(同个 cacheKey 缓存数据相同)
unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (data) => {
fetchInstance.setState({ data });
});
}, []);
// 页面卸载时取消订阅
useUnmount(() => {
unSubscribeRef.current?.();
});
// 没有设置 cacheKey 则直接返回空对象
if (!cacheKey) {
return {};
}
// 核心逻辑
return {
onBefore: (params) => {},
onRequest: (service, args) => {},
onSuccess: (data, params) => {},
onMutate: (data) => {}
}
}onBefore 阶段主要逻辑:判断是否有缓存数据,有则返回数据,关键是 returnNow: true;没有也返回数据,但不会阻塞请求进行returnNow 参数在 runAsync 方法会用到:async runAsync(...params: TParams): Promise<TData> {
const {
returnNow = false,
} = this.runPluginHandler('onBefore', params);
// 返回停止请求
if (returnNow) {
return Promise.resolve(state.data);
}
}useCachePlugin 的 onBefore 实现:onBefore: (params) => {
// 读取 cacheKey 缓存
const cacheData = _getCache(cacheKey, params);
// 无缓存数据
if (!cacheData || !Object.hasOwnProperty.call(cacheData, 'data')) {
return {};
}
// staleTime 为-1表示数据永远新鲜 或 当前时间还在新鲜时间范围内
if (staleTime === -1 || new Date().getTime() - cacheData.time <= staleTime) {
return {
loading: false,
data: cacheData?.data, // 缓存数据
error: undefined,
returnNow: true, // 标记立即返回
};
} else {
// 数据不新鲜,需要重新请求
// If the data is stale, return data, and request continue
return {
data: cacheData?.data,
error: undefined,
};
}
},onRequest 阶段主要逻辑:缓存 promise。通过记录本地请求 promise 比对的方式保证同一时间同个 cacheKey 的请求只能发起一个onRequest: (service, args) => {
// 获取 promise 缓存
let servicePromise = cachePromise.getCachePromise(cacheKey);
// 有缓存 promise && 不等于上一次触发的请求,保证统
if (servicePromise && servicePromise !== currentPromiseRef.current) {
// 返回 servicePromise
return { servicePromise };
}
servicePromise = service(...args);
// 保存当前触发的 promise
currentPromiseRef.current = servicePromise;
// 设置 promise 缓存
cachePromise.setCachePromise(cacheKey, servicePromise);
return { servicePromise };
},onSuccess & onMutate 阶段这两个钩子里面做的事情是一样的,分别是:先取消订阅再 _setCache 更新缓存值最后重新订阅 cacheKeyonSuccess: (data, params) => {
// 有缓存 cacheKey
if (cacheKey) {
// 先取消订阅
unSubscribeRef.current?.();
// 更新缓存值
_setCache(cacheKey, {
data,
params,
time: new Date().getTime(),
});
// 重新订阅
unSubscribeRef.current = cacheSubscribe.subscribe(cacheKey, (d) => {
fetchInstance.setState({ data: d });
});
}
},错误重试该功能是通过 useRetryPlugin 插件实现的。通过设置 options.retryCount,指定错误重试次数,则 useRequest 在失败后会进行重试。还可以设置 options.retryInterval 指定重试时间间隔。用法const { data, run } = useRequest(getUsername, {
retryCount: 3,
});如上示例代码,在请求异常后,会做 3 次重试。实现核心实现:在 onError 生命周期钩子计数错误次数,然后通过定时器重试。onError: () => {
countRef.current += 1; // 累计错误次数
// retryCount 为 -1 或 重试次数小于等于 retryCount,则执行
if (retryCount === -1 || countRef.current <= retryCount) {
// 如果不设置 retryInterval,默认采用简易的指数退避算法,取 1000 * 2 ** retryCount,也就是第一次重试等待 2s,第二次重试等待 4s,以此类推,如果大于 30s,则取 30s
const timeout = retryInterval ?? Math.min(1000 * 2 ** countRef.current, 30000);
timerRef.current = setTimeout(() => {
triggerByRetry.current = true; // 标记触发错误重试
fetchInstance.refresh(); // 重新请求
}, timeout);
} else {
countRef.current = 0;
}
},结语useRequest 的源码写到这了,文章比较长,有些不是特别关键的逻辑会遗漏.
lucky0-0
【解读 ahooks 源码系列】Advanced篇
前言本文是 ahooks 源码(v3.7.4)系列的第七篇——Advanced 篇往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate本文主要解读 useControllableValue、useCreation、useEventEmitter、useIsomorphicLayoutEffect、useLatest、useMemoizedFn、useReactive 的源码实现useControllableValue在某些组件开发时,我们需要组件的状态既可以自己管理,也可以被外部控制,useControllableValue 就是帮你管理这种状态的 Hook。官方文档基本用法非受控组件:如果 props 中没有 value,则组件内部自己管理 state受控组件:如果 props 有 value 字段,则由父级接管控制 state无 value,有 onChange 的组件:只要 props 中有 onChange 字段,则在 state 变化时,就会触发 onChange 函数官方在线 Demo如果 props 有 value 字段,则由父级接管控制 state:import React, { useState } from 'react';
import { useControllableValue } from 'ahooks';
const ControllableComponent = (props: any) => {
const [state, setState] = useControllableValue<string>(props);
return <input value={state} onChange={(e) => setState(e.target.value)} style={{ width: 300 }} />;
};
const Parent = () => {
const [state, setState] = useState<string>('');
const clear = () => {
setState('');
};
return (
<>
<ControllableComponent value={state} onChange={setState} />
<button type="button" onClick={clear} style={{ marginLeft: 8 }}>
Clear
</button>
</>
);
};受控组件与非受控组件React 官方对受控组件和非受控组件的解释:受控组件:在 HTML 中,表单元素(如<input>、 <textarea> 和 <select>)通常自己维护 state,并根据用户输入进行更新。而在 React 中,可变状态(mutable state)通常保存在组件的 state 属性中,并且只能通过使用 setState()来更新。 非受控组件:表单数据将交由 DOM 节点来处理。但是受控组件/非受控组件又是一个相对的概念,子组件相对父组件来说才有受控/非受控的说法。当组件中有数据受父级组件的控制(比如数据的来源和修改的方式由父级组件的 props 提供),就是受控组件;而当组件的数据完全由组件自身维护,这样的组件是非受控组件。从这个角度看,antd 的 Input 组件既可以是受控也可以非受控,这取决于我们如何使用。使用场景表单组件既支持受控又要支持非受控的场景,目前很多 UI 库目前都基本支持这两种场景实现思路根据 props 是否有[valuePropName]属性来判断是否受控。如受控:则值由父级接管;否则组件内部状态维护;初始值的设置也遵循该逻辑。核心实现function useControllableValue<T = any>(
props: StandardProps<T>,
): [T, (v: SetStateAction<T>) => void];
function useControllableValue<T = any>(
props?: Props,
options?: Options<T>,
): [T, (v: SetStateAction<T>, ...args: any[]) => void];
function useControllableValue<T = any>(props: Props = {}, options: Options<T> = {}) {
const {
defaultValue, // 默认值,会被 props.defaultValue 和 props.value 覆盖
defaultValuePropName = 'defaultValue', // 默认值的属性名
valuePropName = 'value', // 值的属性名
trigger = 'onChange', // 修改值时,触发的函数
} = options;
// 外部(父级)传递进来的 props 值
const value = props[valuePropName] as T;
// 是否受控:判断 valuePropName(默认即表示value属性),有该属性代表受控
const isControlled = props.hasOwnProperty(valuePropName);
// 首次默认值
const initialValue = useMemo(() => {
// 受控:则由外部的props接管控制 state
if (isControlled) {
return value;
}
// 外部有传递 defaultValue,则优先取外部的默认值
if (props.hasOwnProperty(defaultValuePropName)) {
return props[defaultValuePropName];
}
// 优先级最低,组件内部的默认值
return defaultValue;
}, []);
const stateRef = useRef(initialValue);
// 受控组件:如果 props 有 value 字段,则由父级接管控制 state
if (isControlled) {
stateRef.current = value;
}
// update:调用该函数会强制组件重新渲染
const update = useUpdate();
function setState(v: SetStateAction<T>, ...args: any[]) {
const r = isFunction(v) ? v(stateRef.current) : v;
// 非受控
if (!isControlled) {
stateRef.current = r;
update(); // 更新状态
}
// 只要 props 中有 onChange(trigger 默认值未 onChange)字段,则在 state 变化时,就会触发 onChange 函数
if (props[trigger]) {
props[trigger](r, ...args);
}
}
// 返回 [状态值, 修改 state 的函数]
return [stateRef.current, useMemoizedFn(setState)] as const;
}完整源码useCreationuseCreation 是 useMemo 或 useRef 的替代品。因为 useMemo 不能保证被 memo 的值一定不会被重计算,而 useCreation 可以保证这一点。以下为 React 官方文档中的介绍:You may rely on useMemo as a performance optimization, not as a semantic guarantee. In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.而相比于 useRef,你可以使用 useCreation 创建一些常量,这些常量和 useRef 创建出来的 ref 有很多使用场景上的相似,但对于复杂常量的创建,useRef 却容易出现潜在的性能隐患。const a = useRef(new Subject()); // 每次重渲染,都会执行实例化 Subject 的过程,即便这个实例立刻就被扔掉了
const b = useCreation(() => new Subject(), []); // 通过 factory 函数,可以避免性能隐患官方文档基本用法官方在线 Demo确保实例不会被重复创建。点击 "Rerender" 按钮,触发组件的更新,但 Foo 的实例会保持不变import React, { useState } from 'react';
import { useCreation } from 'ahooks';
class Foo {
constructor() {
this.data = Math.random();
}
data: number;
}
export default function () {
const foo = useCreation(() => new Foo(), []);
const [, setFlag] = useState({});
return (
<>
<p>{foo.data}</p>
<button
type="button"
onClick={() => {
setFlag({});
}}
>
Rerender
</button>
</>
);
}我们发现看下来用法与 useMemo 完全一致,算是 useMemo 的再优化版本实现思路useCreation 的核心依赖是 useRef把相关值(依赖,具体值,是否初始化)保存在 useRef 中,将值进行缓存初始化或依赖变更(检测 useRef 的旧值依赖与当前依赖用 Object.is()比对)时,不一致则进行更新。核心实现// 通过 Object.is 比较依赖数组的值是否相等
function depsAreSame(oldDeps: DependencyList, deps: DependencyList): boolean {
if (oldDeps === deps) return true;
for (let i = 0; i < oldDeps.length; i++) {
if (!Object.is(oldDeps[i], deps[i])) return false;
}
return true;
}
function useCreation<T>(factory: () => T, deps: DependencyList) {
const { current } = useRef({
deps,
obj: undefined as undefined | T,
initialized: false,
});
// 初始化或依赖变更时,重新初始化
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
current.deps = deps; // 更新依赖
current.obj = factory(); // 执行创建所需对象的函数
current.initialized = true; // 初始化标识为 true
}
return current.obj as T;
}这个 Hooks 属于是精益求精了,本来使用 useMemo 这个优化型的 Hook 就要考量场景,暂时还不知道哪些精细化场景用 useCreation 比较好完整源码useIsomorphicLayoutEffect在 SSR 模式下,使用 useLayoutEffect 时,会出现警告,为了避免该警告,可以使用 useIsomorphicLayoutEffect 代替 useLayoutEffect。官方文档核心实现const isBrowser = !!(
typeof window !== 'undefined' &&
window.document &&
window.document.createElement
);
const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;完整源码useEventEmitter在多个组件之间进行事件通知有时会让人非常头疼,借助 EventEmitter ,可以让这一过程变得更加简单。const event$ = useEventEmitter();通过 props 或者 Context ,可以将 event$ 共享给其他组件。然后在其他组件中,可以调用 EventEmitter 的方法官方文档基本用法官方在线 Demo父组件向子组件共享事件父组件创建了一个 focus$ 事件,并且将它传递给了两个子组件。在 MessageBox 中调用 focus$.emit ,InputBox 组件就可以收到通知。import React, { useRef, FC } from 'react';
import { useEventEmitter } from 'ahooks';
import { EventEmitter } from 'ahooks/lib/useEventEmitter';
const MessageBox: FC<{
focus$: EventEmitter<void>;
}> = function(props) {
return (
<div style={{ paddingBottom: 24 }}>
<p>You received a message</p>
<button
type="button"
onClick={() => {
props.focus$.emit();
}}
>
Reply
</button>
</div>
);
};
const InputBox: FC<{
focus$: EventEmitter<void>;
}> = function(props) {
const inputRef = useRef<any>();
props.focus$.useSubscription(() => {
inputRef.current.focus();
});
return (
<input
ref={inputRef}
placeholder="Enter reply"
style={{ width: '100%', padding: '4px' }}
/>
);
};
export default function() {
const focus$ = useEventEmitter();
return (
<>
<MessageBox focus$={focus$} />
<InputBox focus$={focus$} />
</>
);
}使用场景同级跨组件(距离较远的组件)通信对于子组件通知父组件的情况,我们仍然推荐直接使用 props 传递一个 onEvent 函数。而对于父组件通知子组件的情况,可以使用 forwardRef 获取子组件的 ref ,再进行子组件的方法调用。 useEventEmitter 适合的是在距离较远的组件之间进行事件通知,或是在多个组件之间共享事件通知。实现思路通过发布订阅模式实现。主要实现两个方法useSubscription和emit首先要保证每次渲染调用 useEventEmitter 得到的返回值会保持不变: useRef来判断useSubscription 会在组件创建时自动注册订阅,并在组件销毁时自动取消订阅:Set 数据结构记录订阅的事件列表,在useEffect里面实现监听和自动取消订阅操作emit 方法,推送一个事件:循环 Set 事件列表取出时间执行核心实现主函数function useEventEmitter<T = void>() {
const ref = useRef<EventEmitter<T>>();
if (!ref.current) {
// 在组件多次渲染时,每次渲染调用 useEventEmitter 得到的返回值会保持不变,不会重复创建 EventEmitter 的实例。
ref.current = new EventEmitter();
}
return ref.current;
}核心其实就是实现 EventEmitter 类class EventEmitter<T> {
// Set 结构存放订阅的事件列表
private subscriptions = new Set<Subscription<T>>();
// 发送一个事件通知来触发事件
emit = (val: T) => {
// 触发订阅列表的所有事件
for (const subscription of this.subscriptions) {
subscription(val);
}
};
// 订阅事件
useSubscription = (callback: Subscription<T>) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const callbackRef = useRef<Subscription<T>>();
callbackRef.current = callback; // 保证拿到最新引用
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
function subscription(val: T) {
if (callbackRef.current) {
callbackRef.current(val);
}
}
// 添加到订阅事件列表
this.subscriptions.add(subscription);
return () => {
// 卸载的时候自动删除(取消订阅)
this.subscriptions.delete(subscription);
};
}, []);
};
}个人建议如果是单独父子通信的话,没必要使用这个 Hook,直接传递参数就行了;而大量的事件管理建议使用全局状态库管理完整源码useLatest返回当前最新值的 Hook,可以避免闭包问题。官方文档基本用法官方在线 Demoimport React, { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const latestCountRef = useLatest(count);
useEffect(() => {
const interval = setInterval(() => {
setCount(latestCountRef.current + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<p>count: {count}</p>
</>
);
};核心实现通过 useRef,保持最新的引用值function useLatest<T>(value: T) {
const ref = useRef(value);
// useRef 保存能保证每次获取到的都是最新的值
ref.current = value;
return ref;
}完整源码这个 Hook 还算比较实用点useMemoizedFn持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。在某些场景中,我们需要使用 useCallback 来记住一个函数,但是在第二个参数 deps 变化时,会重新生成函数,导致函数地址变化。使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化。它的功能和 useCallback 类似,不过使用更简单,不需要提供 dep 数组。官方文档基本用法useMemoizedFn 与 useCallback 可以实现同样的效果。但 useMemoizedFn 函数地址不会变化,可以用于性能优化。示例中 memoizedFn 是不会变化的,callbackFn 在 count 变化时变化。官方在线 Demoimport { useMemoizedFn } from 'ahooks';
import { message } from 'antd';
import React, { useCallback, useRef, useState } from 'react';
export default () => {
const [count, setCount] = useState(0);
const callbackFn = useCallback(() => {
message.info(`Current count is ${count}`);
}, [count]);
const memoizedFn = useMemoizedFn(() => {
message.info(`Current count is ${count}`);
});
return (
<>
<p>count: {count}</p>
<button
type="button"
onClick={() => {
setCount((c) => c + 1);
}}
>
Add Count
</button>
<p>You can click the button to see the number of sub-component renderings</p>
<div style={{ marginTop: 32 }}>
<h3>Component with useCallback function:</h3>
{/* use callback function, ExpensiveTree component will re-render on state change */}
<ExpensiveTree showCount={callbackFn} />
</div>
<div style={{ marginTop: 32 }}>
<h3>Component with useMemoizedFn function:</h3>
{/* use memoized function, ExpensiveTree component will only render once */}
<ExpensiveTree showCount={memoizedFn} />
</div>
</>
);
};
// some expensive component with React.memo
const ExpensiveTree = React.memo<{ [key: string]: any }>(({ showCount }) => {
const renderCountRef = useRef(0);
renderCountRef.current += 1;
return (
<div>
<p>Render Count: {renderCountRef.current}</p>
<button type="button" onClick={showCount}>
showParentCount
</button>
</div>
);
});使用场景useMemoizedFn 能保证每次运行过程中保持最新的函数地址引用,适用于用于较为复杂的场景/组件,在属性传递过程减少不必要的 re-render实现思路针对上面 Demo 来说,如果我们要自己实现 useMemoizedFn,就是解决 useCallback 在 Demo 存在的缺陷。callbackFn 的引用地址不能随 render 而改变,并在需要 count 值的时候能实时拿到(ref 保持引用地址不变)无需添加 dep 依赖(在 render 期间 ref 就要维持赋值最新的引用)核心实现一个 useRef 保持外部传入的 fn 的引用 fnRef另一个 useRef 负责返回持久化函数 memoizedFn(内部获取并执行 fnRef),当实例后引用地址永远保持不变源码不难,但确实是巧妙的实现。/**
* 持久化 function 的 Hook
* @param fn 需要持久化的函数
* @returns 引用地址永远不会变化的 fn
*/
function useMemoizedFn<T extends noop>(fn: T) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useMemoizedFn expected parameter is a function, got ${typeof fn}`);
}
}
// 通过 useRef 保证持有最新的引用
const fnRef = useRef<T>(fn);
// why not write `fnRef.current = fn`?
// https://github.com/alibaba/hooks/issues/728
// useMemo 在这里并不是核心,只是避免在 devtool 模式下的异常行为;
fnRef.current = useMemo(() => fn, [fn]); // 在 render 期间实时拿到最新的fn,直观看就是:fnRef.current = fn
const memoizedFn = useRef<PickFunction<T>>();
if (!memoizedFn.current) {
// 内部定义方法,赋值给 memoizedFn.current,这样返回出去的方法实例化后引用地址永远保持不变
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args); // 调用的时候再去取 fnRef(存有最新的 fn 引用)
};
}
// 返回的持久化函数
return memoizedFn.current as T;
}完整源码useReactive提供一种数据响应式的操作体验,定义数据状态不需要写 useState,直接修改属性即可刷新视图。官方文档基本用法这种用法跟 Vue 一样,实现了响应式修改数据官方在线 Demoimport React from 'react';
import { useReactive } from 'ahooks';
export default () => {
const state = useReactive({
count: 0,
inputVal: '',
obj: {
value: '',
},
});
return (
<div>
<p> state.count:{state.count}</p>
<button style={{ marginRight: 8 }} onClick={() => state.count++}>
state.count++
</button>
<button onClick={() => state.count--}>state.count--</button>
<p style={{ marginTop: 20 }}> state.inputVal: {state.inputVal}</p>
<input onChange={(e) => (state.inputVal = e.target.value)} />
<p style={{ marginTop: 20 }}> state.obj.value: {state.obj.value}</p>
<input onChange={(e) => (state.obj.value = e.target.value)} />
</div>
);
};核心实现本质是使用 Proxy 代理进行数据劫持和修改。WeakMap:WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。WeakMap 的作用就是可以更有效的垃圾回收、释放内存Reflect.get:Reflect.get()方法与从对象 (target[key]) 中读取属性类似,但它是通过一个函数执行来操作的。tip:由于 React 中更新页面状态值需要重新渲染,故 Proxy 代理改变/删除值后需要手动强制渲染更新/**
* target:需要取值的目标对象
* propertyKey:需要获取的值的键值
* receiver:如果target对象中指定了getter,receiver则为getter调用时的this值。
*/
Reflect.get(target, propertyKey[, receiver])function useReactive<S extends Record<string, any>>(initialState: S): S {
const update = useUpdate();
const stateRef = useRef<S>(initialState);
// useCreation 是 useMemo 或 useRef 的替代品。对于 useMemo 来说,useCreation能保证被 memo 的值一定不会被重计算
const state = useCreation(() => {
return observer(stateRef.current, () => {
update(); // 强制组件重新渲染
});
}, []);
return state;
}核心是 observer 函数的实现:function observer<T extends Record<string, any>>(initialVal: T, cb: () => void): T {
const existingProxy = proxyMap.get(initialVal);
// 添加缓存 防止重新构建proxy
if (existingProxy) {
return existingProxy;
}
// 防止代理已经代理过的对象
// https://github.com/alibaba/hooks/issues/839
if (rawMap.has(initialVal)) {
return initialVal;
}
// 使用 Proxy 代理进行拦截和更新
const proxy = new Proxy<T>(initialVal, {
// 拦截对象的读取属性操作
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
// 如果值是对象,继续递归代理;否则直接返回属性值
return isObject(res) ? observer(res, cb) : res;
},
// 设置属性值操作的捕获器
set(target, key, val) {
const ret = Reflect.set(target, key, val);
cb(); // 属性赋值时触发回调
return ret;
},
// 拦截对对象属性的删除操作
deleteProperty(target, key) {
const ret = Reflect.deleteProperty(target, key);
cb(); // 删除属性时触发回调
return ret;
},
});
proxyMap.set(initialVal, proxy);
rawMap.set(proxy, initialVal);
return proxy;
}结合上边代码,可以看到 observer函数的第二个参数回调函数的值是固定执行update(),故对属性进行set和delete操作都会重新渲染。
lucky0-0
基于react-router v6实现多页签功能。从零开始搭建一个高颜值后台管理系统全栈框架(十四)
前言有不少兄弟想让我实现多页签功能,这一期我们就来实现一下。多页签功能我以前实现过,大家可以先看下我以前的文章。以前的文章内容是基于antd pro框架实现多页签,这次基于react-router v6来实现,原理差不多,稍微改点代码就行了。手把手带你基于ant design pro 5实现多tab页(路由keepalive)实现思路借助antd的Tabs组件能够缓存组件的能力,我们只需要把浏览过的路由缓存到一个数组中,交给Tabs渲染就行了。实战改造src/layouts/index.tsx文件这里以前是直接渲染页面组件,现在需要给这里加上tabs组件写死一个假数据测试一下,因为我们children绑定的时候Outlet组件,所以我们切路由,内容会跟着变化。样式有点丑,使用Tabs组件的额card类型,并调一下样式这样就好看多了新加tabs-layout组件多页签的内容可能会有点多,所以把这一块抽出来做成一个单独的组件,新建src/layouts/tabs-layout.tsx文件// src/layouts/tabs-layout.tsx
import React from "react";
import { Tabs } from 'antd';
import { Outlet } from 'react-router-dom';
const TabsLayout: React.FC = () => {
return (
<Tabs
defaultActiveKey='test'
items={[{
label: '测试',
key: 'test',
children: (
<div className='px-[16px]'>
<Outlet />
</div>
)
}]}
type='card'
/>
)
}
export default TabsLayout;封装获取当前路由信息hook上面我们tab的label和key都是写死的假数据,现在我们写一个hook获取当前匹配到的路由的名称、路由、图标信息。在动态添加路由的时候,把这些信息可以存到handle属性里。前面文章有说过,reactr-router v6提供了一个hook可以获取匹配到的路由,正好我们可以使用这个hook,不过这个hook返回的数组,默认最后一个就是当前路由,需要处理一下。// src/hooks/use-match-router/index.tsx
import { useEffect, useState } from 'react';
import { useLocation, useMatches, useOutlet } from 'react-router-dom';
interface MatchRouteType {
// 菜单名称
title: string;
// tab对应的url
pathname: string;
// 要渲染的组件
children: any;
// 路由,和pathname区别是,详情页 pathname是 /:id,routePath是 /1
routePath: string;
// 图标
icon?: string;
}
export function useMatchRoute(): MatchRouteType | undefined {
// 获取路由组件实例
const children = useOutlet();
// 获取所有路由
const matches = useMatches();
// 获取当前url
const { pathname } = useLocation();
const [matchRoute, setMatchRoute] = useState<MatchRouteType | undefined>();
// 监听pathname变了,说明路由有变化,重新匹配,返回新路由信息
useEffect(() => {
// 获取当前匹配的路由
const lastRoute = matches.at(-1);
if (!lastRoute?.handle) return;
setMatchRoute({
title: (lastRoute?.handle as any)?.name,
pathname,
children,
routePath: lastRoute?.pathname || '',
icon: (lastRoute?.handle as any)?.icon,
});
}, [pathname])
return matchRoute;
}在tabs-layout引入测试一下// src/layouts/tabs-layout.tsx
import React from "react";
import { Tabs } from 'antd';
import { useMatchRoute } from '@/hooks/use-match-router';
import { antdIcons } from '@/assets/antd-icons';
const TabsLayout: React.FC = () => {
const matchRoute = useMatchRoute();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
return (
<Tabs
defaultActiveKey='test'
items={matchRoute ? [{
label: (
<>
{getIcon(matchRoute.icon)}
{matchRoute.title}
</>
),
key: matchRoute.routePath,
children: (
<div className='px-[16px]'>
{matchRoute.children}
</div>
)
}] : []}
type='card'
/>
)
}
export default TabsLayout;菜单名称、icon、内容可以正常的显示了封装hook,管理打开过的路由上面永远只能展示最新一个菜单内容,现在我们封装一个hook来管理打开过的页面。使用刚才封装的hook获取当前匹配到的路由信息,发现缓存里已经有了,就不往缓存里添加了,没有则添加到缓存里。// /src/layouts/useTabs.tsx
import { useMatchRoute } from '@/hooks/use-match-router';
import { useEffect, useState } from 'react';
export interface KeepAliveTab {
title: string;
routePath: string;
key: string;
pathname: string;
icon?: any;
children: any;
}
function getKey() {
return new Date().getTime().toString();
}
export function useTabs() {
// 存放页面记录
const [keepAliveTabs, setKeepAliveTabs] = useState<KeepAliveTab[]>([]);
// 当前激活的tab
const [activeTabRoutePath, setActiveTabRoutePath] = useState<string>('');
const matchRoute = useMatchRoute();
useEffect(() => {
if (!matchRoute) return;
const existKeepAliveTab = keepAliveTabs.find(o => o.routePath === matchRoute?.routePath);
// 如果不存在则需要插入
if (!existKeepAliveTab) {
setKeepAliveTabs(prev => [...prev, {
title: matchRoute.title,
key: getKey(),
routePath: matchRoute.routePath,
pathname: matchRoute.pathname,
children: matchRoute.children,
icon: matchRoute.icon,
}]);
}
setActiveTabRoutePath(matchRoute.routePath);
}, [matchRoute])
return {
tabs: keepAliveTabs,
activeTabRoutePath,
}
}
改造tabs-layout文件,引入上面封装的hook// src/layouts/tabs-layout.tsx
import React, { useCallback, useMemo } from "react";
import { Tabs } from 'antd';
import { antdIcons } from '@/assets/antd-icons';
import { useTabs } from '@/hooks/use-tabs';
import { router } from '@/router';
const TabsLayout: React.FC = () => {
const { activeTabRoutePath, tabs } = useTabs();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
const tabItems = useMemo(() => {
return tabs.map(tab => ({
label: (
<>
{getIcon(tab.icon)}
{tab.title}
</>
),
key: tab.routePath,
children: (
<div className='px-[16px]'>
{tab.children}
</div>
),
closable: tabs.length > 1, // 剩最后一个就不能删除了
}))
}, [tabs]);
const onTabsChange = useCallback((tabRoutePath: string) => {
router.navigate(tabRoutePath);
}, []);
return (
<Tabs
activeKey={activeTabRoutePath}
items={tabItems}
type='card'
onChange={onTabsChange}
/>
)
}
export default TabsLayout;效果展示实现删除tab功能useTabs中实现关闭tab功能在后面导出在tabs-layout中使用closeTab方法增加右键tab菜单功能,支持关闭、关闭其它、刷新操作改造useTabs增加刷新tab和关闭其它tab方法改造tab的label字段支持右键菜单功能,这个可以使用antd的Dropdown组件。// src/layouts/tabs-layout.tsx
import React, { useCallback, useMemo } from "react";
import { Dropdown, Tabs } from 'antd';
import { antdIcons } from '@/assets/antd-icons';
import { KeepAliveTab, useTabs } from '@/hooks/use-tabs';
import { router } from '@/router';
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
enum OperationType {
REFRESH = 'refresh',
CLOSE = 'close',
CLOSEOTHER = 'close-other',
}
const TabsLayout: React.FC = () => {
const { activeTabRoutePath, tabs, closeTab, refreshTab, closeOtherTab } = useTabs();
const getIcon = (icon?: string): React.ReactElement | undefined => {
return icon && antdIcons[icon] && React.createElement(antdIcons[icon]);
}
const menuItems: MenuItemType[] = useMemo(
() => [
{
label: '刷新',
key: OperationType.REFRESH,
},
tabs.length <= 1 ? null : {
label: '关闭',
key: OperationType.CLOSE,
},
tabs.length <= 1 ? null : {
label: '关闭其他',
key: OperationType.CLOSEOTHER,
},
].filter(o => o !== null) as MenuItemType[],
[tabs]
);
const menuClick = useCallback(({ key, domEvent }: any, tab: KeepAliveTab) => {
domEvent.stopPropagation();
if (key === OperationType.REFRESH) {
refreshTab(tab.routePath);
} else if (key === OperationType.CLOSE) {
closeTab(tab.routePath);
} else if (key === OperationType.CLOSEOTHER) {
closeOtherTab(tab.routePath);
}
}, [closeOtherTab, closeTab, refreshTab]);
const renderTabTitle = useCallback((tab: KeepAliveTab) => {
return (
<Dropdown
menu={{ items: menuItems, onClick: (e) => menuClick(e, tab) }}
trigger={['contextMenu']}
>
<div style={{ margin: '-12px 0', padding: '12px 0' }}>
{getIcon(tab.icon)}
{tab.title}
</div>
</Dropdown>
)
}, [menuItems]);
const tabItems = useMemo(() => {
return tabs.map(tab => {
return {
key: tab.routePath,
label: renderTabTitle(tab),
children: (
<div
key={tab.key}
className='px-[16px]'
>
{tab.children}
</div>
),
closable: tabs.length > 1, // 剩最后一个就不能删除了
}
})
}, [tabs]);
const onTabsChange = useCallback((tabRoutePath: string) => {
router.navigate(tabRoutePath);
}, []);
const onTabEdit = (
targetKey: React.MouseEvent | React.KeyboardEvent | string,
action: 'add' | 'remove',
) => {
if (action === 'remove') {
closeTab(targetKey as string);
}
};
return (
<Tabs
activeKey={activeTabRoutePath}
items={tabItems}
type="editable-card"
onChange={onTabsChange}
hideAdd
onEdit={onTabEdit}
/>
)
}
export default TabsLayout;
右键tab菜单效果展示把这些方法设置成全局方法,在业务代码中可以主动调用这个可以使用react中useContext,可以跨组件使用变量新建src/layouts/tabs-context.tsx文件/* eslint-disable @typescript-eslint/no-empty-function */
import { createContext } from 'react'
interface KeepAliveTabContextType {
refreshTab: (path?: string) => void;
closeTab: (path?: string) => void;
closeOtherTab: (path?: string) => void;
}
const defaultValue = {
refreshTab: () => { },
closeTab: () => { },
closeOtherTab: () => { },
}
export const KeepAliveTabContext = createContext<KeepAliveTabContextType>(defaultValue);在tabs-layout文件中使用这个context,并注入上面那些方法。在业务组件中,可以导入这些方法,然后使用支持拖拽排序antd官网有Tabs组件支持拖拽的例子,可以直接拿来用。封装一个可拖拽排序的Tabs组件// src/components/draggable-tab/index.tsx
import type { DragEndEvent } from '@dnd-kit/core';
import { DndContext, PointerSensor, useSensor } from '@dnd-kit/core';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { useEffect, useState } from 'react';
import { Tabs, TabsProps } from 'antd';
import {
restrictToHorizontalAxis,
} from '@dnd-kit/modifiers';
interface DraggableTabPaneProps extends React.HTMLAttributes<HTMLDivElement> {
'data-node-key': string;
}
const DraggableTabNode = (props: DraggableTabPaneProps) => {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: props['data-node-key'],
});
const style: React.CSSProperties = {
...props.style,
transform: CSS.Transform.toString(transform && { ...transform, scaleX: 1 }),
transition,
};
return React.cloneElement(props.children as React.ReactElement, {
ref: setNodeRef,
style,
...attributes,
...listeners,
});
};
const DraggableTab: React.FC<TabsProps & { onItemsChange?: (items: TabsProps['items']) => void }> = ({ onItemsChange, ...props }) => {
const [items, setItems] = useState(props.items || []);
const sensor = useSensor(PointerSensor, { activationConstraint: { distance: 10 } });
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (active.id !== over?.id) {
setItems((prev) => {
const activeIndex = prev.findIndex((i) => i.key === active.id);
const overIndex = prev.findIndex((i) => i.key === over?.id);
return arrayMove(prev, activeIndex, overIndex);
});
}
};
useEffect(() => {
setItems(props.items || []);
}, [props.items]);
useEffect(() => {
if (onItemsChange) {
onItemsChange(items);
}
}, [items]);
return (
<Tabs
renderTabBar={(tabBarProps, DefaultTabBar) => (
<DndContext sensors={[sensor]} onDragEnd={onDragEnd} modifiers={[restrictToHorizontalAxis]}>
<SortableContext items={items.map((i) => i.key)} strategy={horizontalListSortingStrategy}>
<DefaultTabBar {...tabBarProps}>
{(node) => (
<DraggableTabNode {...node.props} key={node.key}>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
)}
{...props}
items={items}
className='tab-layout'
/>
);
};
export default DraggableTab;需要安装几个依赖pnpm i @dnd-kit/core
pnpm i @dnd-kit/utilities
pnpm i @dnd-kit/modifiers官网给的例子中,card类型的Tabs,拖拽时很卡,调试一段时间,发现editable-card类型Tabs会给tab加上transitioncss属性,这个用来添加和删除tab动画用的。但是我们这里拖拽排序的原理是改变元素的transform属性的位移,每次位移都会触发动画,所以就不流畅了,这边写了个样式给transition设置成了none就行了。效果展示最后如果文章对你有帮助,帮忙点个赞,谢谢。
lucky0-0
【解读 ahooks 源码系列】DOM篇(三)
前言本文是 ahooks 源码系列的第四篇,往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover本文主要解读 useMutationObserver、useInViewport、useKeyPress、useLongPress 源码实现useMutationObserver一个监听指定的 DOM 树发生变化的 Hook官方文档MutationObserver APIMutationObserver 接口提供了监视对 DOM 树所做更改的能力。利用 MutationObserver API 我们可以监视 DOM 的变化,比如节点的增加、减少、属性的变动、文本内容的变动等等。可参考学习:MutationObserver你不知道的 MutationObserver基本用法官方在线 Demo点击按钮,改变 width,触发 div 的 width 属性变更,打印的 mutationsList 如下:import { useMutationObserver } from 'ahooks';
import React, { useRef, useState } from 'react';
const App: React.FC = () => {
const [width, setWidth] = useState(200);
const [count, setCount] = useState(0);
const ref = useRef<HTMLDivElement>(null);
useMutationObserver(
(mutationsList) => {
mutationsList.forEach(() => setCount((c) => c + 1));
},
ref,
{ attributes: true },
);
return (
<div>
<div ref={ref} style={{ width, padding: 12, border: '1px solid #000', marginBottom: 8 }}>
current width:{width}
</div>
<button onClick={() => setWidth((w) => w + 10)}>widening</button>
<p>Mutation count {count}</p>
</div>
);
};核心实现这个实现比较简单,主要还是理解 MutationObserver API:useMutationObserver(
callback: MutationCallback, // 触发的回调函数
target: Target,
options?: MutationObserverInit, // 设置项:https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#parameters
);const useMutationObserver = (
callback: MutationCallback,
target: BasicTarget,
options: MutationObserverInit = {},
): void => {
const callbackRef = useLatest(callback);
useDeepCompareEffectWithTarget(
() => {
const element = getTargetElement(target);
if (!element) {
return;
}
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callbackRef.current);
observer.observe(element, options); // 启动监听,指定所要观察的 DOM 节点
return () => {
if (observer) {
observer.disconnect(); // 停止观察变动
}
};
},
[options],
target,
);
};完整源码useInViewport观察元素是否在可见区域,以及元素可见比例。官方文档基本用法官方在线 Demoimport React, { useRef } from 'react';
import { useInViewport } from 'ahooks';
export default () => {
const ref = useRef(null);
const [inViewport] = useInViewport(ref);
return (
<div>
<div style={{ width: 300, height: 300, overflow: 'scroll', border: '1px solid' }}>
scroll here
<div style={{ height: 800 }}>
<div
ref={ref}
style={{
border: '1px solid',
height: 100,
width: 100,
textAlign: 'center',
marginTop: 80,
}}
>
observer dom
</div>
</div>
</div>
<div style={{ marginTop: 16, color: inViewport ? '#87d068' : '#f50' }}>
inViewport: {inViewport ? 'visible' : 'hidden'}
</div>
</div>
);
};使用场景图片懒加载:当图片滚动到可见位置的时候才加载无限滚动加载:滑动到底部时开始加载新的内容检测广告的曝光率:广告是否被用户看到用户看到某个区域时执行任务或播放动画IntersectionObserver APIIntersectionObserver API,可以自动"观察"元素是否可见。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。Intersection Observer API可参考学习:IntersectionObserver API 使用教程实现思路监听目标元素,支持传入原生 IntersectionObserver API 选项对 IntersectionObserver 构造函数的回调函数设置可见状态与可见比例值借助 intersection-observer 库实现 polyfill核心实现export interface Options {
// 根(root)元素的外边距
rootMargin?: string;
// 可以控制在可见区域达到该比例时触发 ratio 更新。默认值是 0 (意味着只要有一个 target 像素出现在 root 元素中,回调函数将会被执行)。该值为 1.0 含义是当 target 完全出现在 root 元素中时候 回调才会被执行。
threshold?: number | number[];
// 指定根(root)元素,用于检查目标的可见性
root?: BasicTarget<Element>;
}function useInViewport(target: BasicTarget, options?: Options) {
const [state, setState] = useState<boolean>(); // 是否可见
const [ratio, setRatio] = useState<number>(); // 当前可见比例
useEffectWithTarget(
() => {
const el = getTargetElement(target);
if (!el) {
return;
}
// 可以自动观察元素是否可见,返回一个观察器实例
const observer = new IntersectionObserver(
(entries) => {
// callback函数的参数(entries)是一个数组,每个成员都是一个IntersectionObserverEntry对象。如果同时有两个被观察的对象的可见性发生变化,entries数组就会有两个成员。
for (const entry of entries) {
setRatio(entry.intersectionRatio); // 设置当前目标元素的可见比例
setState(entry.isIntersecting); // isIntersecting:如果目标元素与交集观察者的根相交,则该值为true
}
},
{
...options,
root: getTargetElement(options?.root),
},
);
observer.observe(el); // 开始监听一个目标元素
return () => {
observer.disconnect(); // 停止监听目标
};
},
[options?.rootMargin, options?.threshold],
target,
);
return [state, ratio] as const;
}完整源码useKeyPress监听键盘按键,支持组合键,支持按键别名。官方文档KeyEvent 基础JS 的键盘事件keydown:触发于键盘按键按下的时候。keyup:在按键被松开时触发。(已过时)keypress:按下有值的键时触发,即按下 Ctrl、Alt、Shift、Meta 这样无值的键,这个事件不会触发。对于有值的键,按下时先触发 keydown 事件,再触发 keypress 事件关于 keyCode(已过时)event.keyCode(返回按下键的数字代码),虽然目前大部分代码依然使用并保持兼容。但如果我们自己实现的话应该尽可能使用 event.key(按下的键的实际值)属性。具体可见KeyboardEvent如何监听按键组合修饰键有四个const modifierKey = {
ctrl: (event: KeyboardEvent) => event.ctrlKey,
shift: (event: KeyboardEvent) => event.shiftKey,
alt: (event: KeyboardEvent) => event.altKey,
meta: (event: KeyboardEvent) => {
if (event.type === 'keyup') {
// 这里使用数组判断是因为 meta 键分左边和右边的键(MetaLeft 91、MetaRight 93)
return aliasKeyCodeMap['meta'].includes(event.keyCode);
}
return event.metaKey;
},
};当按下的组合键包含 Ctrl 键时,event.ctrlKey 属性为 true当按下的组合键包含 Shift 键时,event.shiftKey 属性为 true当按下的组合键包含 Alt 键时,event.altKey 属性为 true当按下的组合键包含 meta 键时,event.meta 属性为 true(Mac 是 command 键,Windows 电脑是 win 键)如按下 Alt+K 组合键,会触发两次 keydown事件,其中 Alt 键和 K 键打印的 altKey 都为 true,可以这么判断:if (event.altKey && keyCode === 75) {
console.log("按下了 Alt + K 键");
}在线测试这里推荐个在线网站 Keyboard Events Playground测试键盘事件,只需要输入任意键即可查看有关它打印的信息,还可以通过复选框来过滤事件,辅助我们开发验证。基本用法官方在线 Demo在看源码之前,需要了解下该 Hook 支持的用法:// 支持键盘事件中的 keyCode 和别名
useKeyPress('uparrow', () => {
// TODO
});
// keyCode value for ArrowDown
useKeyPress(40, () => {
// TODO
});
// 监听组合按键
useKeyPress('ctrl.alt.c', () => {
// TODO
});
// 开启精确匹配。比如按 [shift + c] ,不会触发 [c]
useKeyPress(
['c'],
() => {
// TODO
},
{
exactMatch: true,
},
);
// 监听多个按键。如下 a s d f, Backspace, 8
useKeyPress([65, 83, 68, 70, 8, '8'], (event) => {
setKey(event.key);
});
// 自定义监听方式。支持接收一个返回 boolean 的回调函数,自己处理逻辑。
const filterKey = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
useKeyPress(
(event) => !filterKey.includes(event.key),
(event) => {
// TODO
},
{
events: ['keydown', 'keyup'],
},
);
// 自定义 DOM。默认监听挂载在 window 上的事件,也可以传入 DOM 指定监听区域,如常见的监听输入框事件
useKeyPress(
'enter',
(event: any) => {
// TODO
},
{
target: inputRef,
},
);useKeyPress 的参数:type keyType = number | string;
// 支持 keyCode、别名、组合键、数组,自定义函数
type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);
// 回调函数
type EventHandler = (event: KeyboardEvent) => void;
type KeyEvent = 'keydown' | 'keyup';
type Options = {
events?: KeyEvent[]; // 触发事件
target?: Target; // DOM 节点或者 ref
exactMatch?: boolean; // 精确匹配。如果开启,则只有在按键完全匹配的情况下触发事件。比如按键 [shif + c] 不会触发 [c]
useCapture?: boolean; // 是否阻止事件冒泡
};
// useKeyPress 参数
useKeyPress(
keyFilter: KeyFilter,
eventHandler: EventHandler,
options?: Options
);实现思路监听 keydown 或 keyup 事件,处理事件回调函数。在事件回调函数中传入 keyFilter 配置进行判断,兼容自定义函数、keyCode、别名、组合键、数组,支持精确匹配如果满足该回调最终判断结果,则触发 eventHandler 回调核心实现genKeyFormatter:键盘输入预处理方法 genFilterKey:判断按键是否激活沿着上述三点,我们来看这部分精简代码:function useKeyPress(keyFilter: KeyFilter, eventHandler: EventHandler, option?: Options) {
const { events = defaultEvents, target, exactMatch = false, useCapture = false } = option || {};
const eventHandlerRef = useLatest(eventHandler);
const keyFilterRef = useLatest(keyFilter);
// 监听元素(深比较)
useDeepCompareEffectWithTarget(
() => {
const el = getTargetElement(target, window);
if (!el) {
return;
}
// 事件回调函数
const callbackHandler = (event: KeyboardEvent) => {
// 键盘输入预处理方法
const genGuard: KeyPredicate = genKeyFormatter(keyFilterRef.current, exactMatch);
// 判断是否匹配 keyFilter 配置结果,返回 true 则触发传入的回调函数
if (genGuard(event)) {
return eventHandlerRef.current?.(event);
}
};
// 监听事件(默认事件:keydown)
for (const eventName of events) {
el?.addEventListener?.(eventName, callbackHandler, useCapture);
}
return () => {
// 取消监听
for (const eventName of events) {
el?.removeEventListener?.(eventName, callbackHandler, useCapture);
}
};
},
[events],
target,
);
}上面的代码看起来比较好理解,需要推敲的就是 genKeyFormatter 函数。/**
* 键盘输入预处理方法
* @param [keyFilter: any] 当前键
* @returns () => Boolean
*/
function genKeyFormatter(keyFilter: KeyFilter, exactMatch: boolean): KeyPredicate {
// 支持自定义函数
if (isFunction(keyFilter)) {
return keyFilter;
}
// 支持 keyCode、别名、组合键
if (isString(keyFilter) || isNumber(keyFilter)) {
return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch);
}
// 支持数组
if (Array.isArray(keyFilter)) {
return (event: KeyboardEvent) =>
keyFilter.some((item) => genFilterKey(event, item, exactMatch));
}
// 等同 return keyFilter ? () => true : () => false;
return () => Boolean(keyFilter);
}看完发现上面的重点实现还是在 genFilterKey 函数:aliasKeyCodeMap这段逻辑需要各位代入实际数值帮助理解,如输入组合键 shift.c/**
* 判断按键是否激活
* @param [event: KeyboardEvent]键盘事件
* @param [keyFilter: any] 当前键
* @returns Boolean
*/
function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: boolean) {
// 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
if (!event.key) {
return false;
}
// 数字类型直接匹配事件的 keyCode
if (isNumber(keyFilter)) {
return event.keyCode === keyFilter;
}
// 字符串依次判断是否有组合键
const genArr = keyFilter.split('.'); // 如 keyFilter 可以传 ctrl.alt.c,['shift.c']
let genLen = 0;
for (const key of genArr) {
// 组合键
const genModifier = modifierKey[key]; // ctrl/shift/alt/meta
// keyCode 别名
const aliasKeyCode: number | number[] = aliasKeyCodeMap[key.toLowerCase()];
if ((genModifier && genModifier(event)) || (aliasKeyCode && aliasKeyCode === event.keyCode)) {
genLen++;
}
}
/**
* 需要判断触发的键位和监听的键位完全一致,判断方法就是触发的键位里有且等于监听的键位
* genLen === genArr.length 能判断出来触发的键位里有监听的键位
* countKeyByEvent(event) === genArr.length 判断出来触发的键位数量里有且等于监听的键位数量
* 主要用来防止按组合键其子集也会触发的情况,例如监听 ctrl+a 会触发监听 ctrl 和 a 两个键的事件。
*/
if (exactMatch) {
return genLen === genArr.length && countKeyByEvent(event) === genArr.length;
}
return genLen === genArr.length;
}
// 根据 event 计算激活键数量
function countKeyByEvent(event: KeyboardEvent) {
// 计算激活的修饰键数量
const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {
// (event: KeyboardEvent) => Boolean
if (modifierKey[key](event)) {
return total + 1;
}
return total;
}, 0);
// 16 17 18 91 92 是修饰键的 keyCode,如果 keyCode 是修饰键,那么激活数量就是修饰键的数量,如果不是,那么就需要 +1
return [16, 17, 18, 91, 92].includes(event.keyCode) ? countOfModifier : countOfModifier + 1;
}完整源码useLongPress监听目标元素的长按事件。官方文档基本用法支持参数:export interface Options {
delay?: number;
moveThreshold?: { x?: number; y?: number };
onClick?: (event: EventType) => void;
onLongPressEnd?: (event: EventType) => void;
}官方在线 Demoimport React, { useState, useRef } from 'react';
import { useLongPress } from 'ahooks';
export default () => {
const [counter, setCounter] = useState(0);
const ref = useRef<HTMLButtonElement>(null);
useLongPress(() => setCounter((s) => s + 1), ref);
return (
<div>
<button ref={ref} type="button">
Press me
</button>
<p>counter: {counter}</p>
</div>
);
};touch 事件touchstart:在一个或多个触点与触控设备表面接触时被触发touchmove:在触点于触控平面上移动时触发touchend:当触点离开触控平面时触发 touchend 事件实现思路判断当前环境是否支持 touch 事件:支持则监听 touchstart、touchend 事件,不支持则监听 mousedown、mouseup、mouseleave 事件根据触发监听事件和定时器共同来判断是否达到长按事件,达到则触发外部回调如果外部有传 moveThreshold(按下后移动阈值)参数 ,则需要监听 mousemove 或touchmove 事件进行处理核心实现根据[实现思路]第一条,很容易看懂实现大致框架代码:type EventType = MouseEvent | TouchEvent;
// 是否支持 touch 事件
const touchSupported =
isBrowser &&
// @ts-ignore
('ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch));
function useLongPress(
onLongPress: (event: EventType) => void,
target: BasicTarget,
{ delay = 300, moveThreshold, onClick, onLongPressEnd }: Options = {},
) {
const onLongPressRef = useLatest(onLongPress);
const onClickRef = useLatest(onClick);
const onLongPressEndRef = useLatest(onLongPressEnd);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const isTriggeredRef = useRef(false);
// 是否有设置移动阈值
const hasMoveThreshold = !!(
(moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
);
useEffectWithTarget(
() => {
const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {
return;
}
const overThreshold = (event: EventType) => {};
function getClientPosition(event: EventType) {}
const onStart = (event: EventType) => {};
const onMove = (event: TouchEvent) => {};
const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {};
const onEndWithClick = (event: EventType) => onEnd(event, true);
if (!touchSupported) {
// 不支持 touch 事件
targetElement.addEventListener('mousedown', onStart);
targetElement.addEventListener('mouseup', onEndWithClick);
targetElement.addEventListener('mouseleave', onEnd);
if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove);
} else {
// 支持 touch 事件
targetElement.addEventListener('touchstart', onStart);
targetElement.addEventListener('touchend', onEndWithClick);
if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove);
}
// 卸载函数解绑监听事件
return () => {
// 清除定时器,重置状态
if (timerRef.current) {
clearTimeout(timerRef.current);
isTriggeredRef.current = false;
}
if (!touchSupported) {
targetElement.removeEventListener('mousedown', onStart);
targetElement.removeEventListener('mouseup', onEndWithClick);
targetElement.removeEventListener('mouseleave', onEnd);
if (hasMoveThreshold) targetElement.removeEventListener('mousemove', onMove);
} else {
targetElement.removeEventListener('touchstart', onStart);
targetElement.removeEventListener('touchend', onEndWithClick);
if (hasMoveThreshold) targetElement.removeEventListener('touchmove', onMove);
}
};
},
[],
target,
);
}对于是否支持 touch 事件的判断代码,需要了解一种场景,在搜的时候发现一篇文章可以看下:touchstart 与 click 不得不说的故事如何判断长按事件:在 onStart 设置一个定时器 setTimeout 用来判断长按时间,在定时器回调将 isTriggeredRef.current 设置为 true,表示触发了长按事件;在 onEnd 清除定时器并判断 isTriggeredRef.current 的值,true 代表触发了长按事件;false 代表没触发 setTimeout 里面的回调,则不触发长按事件。const onStart = (event: EventType) => {
timerRef.current = setTimeout(() => {
// 达到设置的长按时间
onLongPressRef.current(event);
isTriggeredRef.current = true;
}, delay);
};
const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {
// 清除 onStart 设置的定时器
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 判断是否达到长按时间
if (isTriggeredRef.current) {
onLongPressEndRef.current?.(event);
}
// 是否触发点击事件
if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) {
onClickRef.current(event);
}
// 重置
isTriggeredRef.current = false;
};实现了[实现思路]的前两点,接下来需要实现第三点,传 moveThreshold 的情况const hasMoveThreshold = !!(
(moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
);clientX、clientY:点击位置距离当前 body 可视区域的 x,y 坐标const onStart = (event: EventType) => {
if (hasMoveThreshold) {
const { clientX, clientY } = getClientPosition(event);
// 记录首次点击/触屏时的位置
pervPositionRef.current.x = clientX;
pervPositionRef.current.y = clientY;
}
// ...
};
// 传 moveThreshold 需绑定 onMove 事件
const onMove = (event: TouchEvent) => {
if (timerRef.current && overThreshold(event)) {
// 超过移动阈值不触发长按事件,并清除定时器
clearInterval(timerRef.current);
timerRef.current = undefined;
}
};
// 判断是否超过移动阈值
const overThreshold = (event: EventType) => {
const { clientX, clientY } = getClientPosition(event);
const offsetX = Math.abs(clientX - pervPositionRef.current.x);
const offsetY = Math.abs(clientY - pervPositionRef.current.y);
return !!(
(moveThreshold?.x && offsetX > moveThreshold.x) ||
(moveThreshold?.y && offsetY > moveThreshold.y)
);
};
function getClientPosition(event: EventType) {
if (event instanceof TouchEvent) {
return {
clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY,
};
}
if (event instanceof MouseEvent) {
return {
clientX: event.clientX,
clientY: event.clientY,
};
}
console.warn('Unsupported event type');
return { clientX: 0, clientY: 0 };
}
lucky0-0
【解读 ahooks 源码系列】DOM篇(一)
前言本文是 ahooks 源码系列的第二篇,下面链接是第一篇 DOM 篇的前置讲解:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素后续的文章将会直入主题,每篇文章解读四至六个 Hooks 源码实现。useEventListener优雅的使用 addEventListener。官方文档用法import React, { useState, useRef } from 'react';
import { useEventListener } from 'ahooks';
export default () => {
const [value, setValue] = useState(0);
const ref = useRef(null);
useEventListener(
'click',
() => {
setValue(value + 1);
},
{ target: ref },
);
return (
<button ref={ref} type="button">
You click {value} times
</button>
);
};使用场景通用事件监听 Hook,简化写法(无需在 useEffect 卸载函数中手动移除监听函数,由内部去移除)实现思路判断是否支持 addEventListener在单独只有 useEffect 实现事件监听移除的基础上,将相关参数都由外部传入,并添加到依赖项处理事件参数的 TS 类型,addEventListener 的第三个参数也需要由外部传入核心实现EventTarget.addEventListener():将指定的监听器注册到 EventTarget 上,当该对象触发指定的事件时,指定的回调函数就会被执行EventTarget 指任何其他支持事件的对象/元素 HTMLElement | Element | Document | Window符合 EventTarget 接口的都具有下列三个方法EventTarget.addEventListener()
EventTarget.removeEventListener()
EventTarget.dispatchEvent()
TS 函数重载函数重载指使用相同名称和不同参数数量或类型创建多个方法,让我们定义以多种方式调用的函数。在 TS 中为同一个函数提供多个函数类型定义来进行函数重载function useEventListener<K extends keyof HTMLElementEventMap>(
eventName: K,
handler: (ev: HTMLElementEventMap[K]) => void,
options?: Options<HTMLElement>,
): void;
function useEventListener<K extends keyof ElementEventMap>(
eventName: K,
handler: (ev: ElementEventMap[K]) => void,
options?: Options<Element>,
): void;
function useEventListener<K extends keyof DocumentEventMap>(
eventName: K,
handler: (ev: DocumentEventMap[K]) => void,
options?: Options<Document>,
): void;
function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (ev: WindowEventMap[K]) => void,
options?: Options<Window>,
): void;实现:function useEventListener(eventName: string, handler: noop, options: Options = {}) {
const handlerRef = useLatest(handler);
useEffectWithTarget(
() => {
const targetElement = getTargetElement(options.target, window);
if (!targetElement?.addEventListener) {
return;
}
const eventListener = (event: Event) => {
return handlerRef.current(event);
};
// 添加监听事件
targetElement.addEventListener(eventName, eventListener, {
// true 表示事件在捕获阶段执行,false(默认) 表示事件在冒泡阶段执行
capture: options.capture,
// true 表示事件在触发一次后移除,默认 false
once: options.once,
// true 表示 listener 永远不会调用 preventDefault()。如果 listener 仍然调用了这个函数,客户端将会忽略它并抛出一个控制台警告
passive: options.passive,
});
// 移除监听事件
return () => {
targetElement.removeEventListener(eventName, eventListener, {
capture: options.capture,
});
};
},
[eventName, options.capture, options.once, options.passive],
options.target,
);
}完整源码useClickAway监听目标元素外的点击事件。官方文档type Target = Element | (() => Element) | React.MutableRefObject<Element>;
/**
* 监听目标元素外的点击事件。
* @param onClickAway 触发函数
* @param target DOM 节点或者 Ref,支持数组
* @param eventName DOM 节点或者 Ref,支持数组,默认事件是 click
*/
useClickAway<T extends Event = Event>(
onClickAway: (event: T) => void,
target: Target | Target[],
eventName?: string | string[]
);用法import React, { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
const [counter, setCounter] = useState(0);
const ref = useRef<HTMLButtonElement>(null);
useClickAway(() => {
setCounter((s) => s + 1);
}, ref);
return (
<div>
<button ref={ref} type="button">
box
</button>
<p>counter: {counter}</p>
</div>
);
};使用场景比如点击显示弹窗之后,此时点击弹窗之外的任意区域时(如弹窗的全局蒙层),该弹窗要自动隐藏。简而言之,属于"点击页面其他元素,XX组件自动关闭"的功能。实现思路在 document 上绑定全局事件。如默认支持点击事件,组件卸载的时候移除事件监听触发事件后,可通过事件代理获取到触发事件的对象的引用 e,如果该目标元素 e.target 不在外部传入的 target 元素(列表)中,则触发 onClickAway 函数核心实现假如只支持点击事件,只能传单个元素且只能是 Ref 类型,实现代码如下:export default function useClickAway<T extends HTMLElement>(
onClickAway: (event: MouseEvent) => void,
refObject: React.RefObject<T>,
) {
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (
!refObject.current ||
refObject.current.contains(e.target as HTMLElement)
) {
return
}
onClickAway(e)
}
document.addEventListener('click', handleClick)
return () => {
document.removeEventListener('click', handleClick)
}
}, [refObject, onClickAway])
}ahooks 则继续拓展,思路如下:同时支持传入 DOM 节点、Ref:需要区分是DOM节点、函数、还是Ref,获取的时候要兼顾所有情况可传入多个目标元素(支持数组):通过循环绑定事件,用数组some方法判断任一元素包含则触发可指定监听事件(支持数组):eventName 由外部传入,不传默认为 click 事件来看看源码整体实现:第1、2点的实现// documentOrShadow 这部分忽略不深究,一般开发场景就是 document
const documentOrShadow = getDocumentOrShadow(target);
const eventNames = Array.isArray(eventName) ? eventName : [eventName];
// 循环绑定事件
eventNames.forEach((event) => documentOrShadow.addEventListener(event, handler));
return () => {
eventNames.forEach((event) => documentOrShadow.removeEventListener(event, handler));
};第3点 handler 函数的实现:const handler = (event: any) => {
const targets = Array.isArray(target) ? target : [target];
if (
// 判断点击的目标元素是否在外部传入的元素(列表)中,是则 return 不执行回调
targets.some((item) => {
const targetElement = getTargetElement(item); // 这里处理了传入的target是函数、DOM节点、Ref 类型的情况
return !targetElement || targetElement.contains(event.target);
})
) {
return;
}
// 触发事件
onClickAwayRef.current(event);
};这里注意触发事件的代码是:onClickAwayRef.current(event);,实际是为了保证能拿到最新的函数,可以避免闭包问题const onClickAwayRef = useLatest(onClickAway);
// 等同于
const onClickAwayRef = useRef(onClickAway);
onClickAwayRef.current = onClickAway;getTargetElement 方法获取目标元素实现如下:if (isFunction(target)) {
targetElement = target();
} else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}完整源码注意React17+版本的坑Reactv17前,React 将事件委托到 document 上,在Reactv17及之后版本,则委托到根节点,具体见该文:ahooks 的 useClickAway 在 React 17 中不工作了!解决方案是给 useClickAway 的事件类型设置为 mousedown 和 touchstart在写这篇文章的时候,还没更新:具体可见 useClickAway判断不对其它写法实现参考总体来说 ahooks 的实现功能更齐全考虑的场景更多,但业务开发如果是自己写 Hooks 实现的话,推荐下面的写法,足以覆盖日常开发场景:react-use 的 useClickAwayuseHooks 的 useOnClickOutsideuseDocumentVisibility监听页面是否可见。官方文档用法import React, { useEffect } from 'react';
import { useDocumentVisibility } from 'ahooks';
export default () => {
const documentVisibility = useDocumentVisibility();
useEffect(() => {
console.log(`Current document visibility state: ${documentVisibility}`);
}, [documentVisibility]);
return <div>Current document visibility state: {documentVisibility}</div>;
};使用场景当页面在背景中或窗口最小化时禁止或开启某些活动,如离开页面停止播放音视频、暂停轮询接口请求实现思路定义并暴露给外部document.visibilityState状态值,通过该字段判断页面是否可见监听 visibilitychange 事件(使用 document 注册),触发回调时更新状态值Document.visibilityState 与 visibilitychange 事件Document.visibilityState(只读属性)返回 document 的可见性,即当前可见元素的上下文环境。由此可以知道当前文档 (即为页面) 是在背后,或是不可见的隐藏的标签页,或者 (正在) 预渲染,共有三个可能的值。visible: 此时页面内容至少是部分可见。即此页面在前景标签页中,并且窗口没有最小化。hidden: 此时页面对用户不可见。即文档处于背景标签页或者窗口处于最小化状态,或者操作系统正处于 '锁屏状态' .prerender: 页面此时正在渲染中,因此是不可见的。文档只能从此状态开始,永远不能从其他值变为此状态。(prerender 状态只在支持"预渲染"的浏览器上才会出现)。visibilitychange当其选项卡的内容变得可见或被隐藏时,会在文档上触发 visibilitychange (能见度更改) 事件。警告: 出于兼容性原因,请确保使用 document.addEventListener 而不是 window.addEventListener 来注册回调。Safari <14.0 仅支持前者。推荐阅读:Page Visibility API 教程核心实现type VisibilityState = 'hidden' | 'visible' | 'prerender' | undefined;
const getVisibility = () => {
if (!isBrowser) {
return 'visible';
}
// 返回document的可见性,即当前可见元素的上下文环境
return document.visibilityState;
};
function useDocumentVisibility(): VisibilityState {
const [documentVisibility, setDocumentVisibility] = useState(() => getVisibility());
// 监听事件
useEventListener(
'visibilitychange',
() => {
setDocumentVisibility(getVisibility());
},
{
target: () => document,
},
);
return documentVisibility;
}
export default useDocumentVisibility;完整源码useDrop处理元素拖拽的 Hook。官方文档用法import React, { useRef, useState } from 'react';
import { useDrop, useDrag } from 'ahooks';
const DragItem = ({ data }) => {
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
useDrag(data, dragRef, {
onDragStart: () => {
setDragging(true);
},
onDragEnd: () => {
setDragging(false);
},
});
return (
<div
ref={dragRef}
style={{
border: '1px solid #e8e8e8',
padding: 16,
width: 80,
textAlign: 'center',
marginRight: 16,
}}
>
{dragging ? 'dragging' : `box-${data}`}
</div>
);
};
export default () => {
const [isHovering, setIsHovering] = useState(false);
const dropRef = useRef(null);
useDrop(dropRef, {
onText: (text, e) => {
console.log(e);
alert(`'text: ${text}' dropped`);
},
onFiles: (files, e) => {
console.log(e, files);
alert(`${files.length} file dropped`);
},
onUri: (uri, e) => {
console.log(e);
alert(`uri: ${uri} dropped`);
},
onDom: (content: string, e) => {
alert(`custom: ${content} dropped`);
},
onDragEnter: () => setIsHovering(true),
onDragLeave: () => setIsHovering(false),
});
return (
<div>
<div ref={dropRef} style={{ border: '1px dashed #e8e8e8', padding: 16, textAlign: 'center' }}>
{isHovering ? 'release here' : 'drop here'}
</div>
<div style={{ display: 'flex', marginTop: 8 }}>
{['1', '2', '3', '4', '5'].map((e, i) => (
<DragItem key={e} data={e} />
))}
</div>
</div>
);
};使用场景useDrop 可以单独使用来接收文件、文字和网址的拖拽。向节点内触发粘贴动作也会被视为拖拽涉及的拖拽 API拖拽相关事件:dragenter:事件在可拖动的元素或者被选择的文本进入一个有效的放置目标时触发。dragleave:在拖动的元素或选中的文本离开一个有效的放置目标时被触发。dragover:在可拖动的元素或者被选择的文本被拖进一个有效的放置目标时(每几百毫秒)触发。drop:当一个元素或是选中的文字被拖拽释放到一个有效的释放目标位置时,drop 事件被抛出。paste:当用户在浏览器用户界面发起“粘贴”操作时,会触发 paste 事件。实现思路监听以上 5 个事件另外在 drop 和 paste 事件中获取到 DataTransfer 数据,并根据数据类型进行特定的处理,将处理好的数据通过回调(onText/onFiles/onUri/onDom)给外部直接获取使用。export interface Options {
// 根据 drop 事件数据类型自定义回调函数
onFiles?: (files: File[], event?: React.DragEvent) => void;
onUri?: (url: string, event?: React.DragEvent) => void;
onDom?: (content: any, event?: React.DragEvent) => void;
onText?: (text: string, event?: React.ClipboardEvent) => void;
// 原生事件
onDragEnter?: (event?: React.DragEvent) => void;
onDragOver?: (event?: React.DragEvent) => void;
onDragLeave?: (event?: React.DragEvent) => void;
onDrop?: (event?: React.DragEvent) => void;
onPaste?: (event?: React.ClipboardEvent) => void;
}
const useDrop = (target: BasicTarget, options: Options = {}) => {}核心实现主函数实现比较简单,需要注意的时候在特定事件需要阻止默认事件event.preventDefault();和阻止事件冒泡event.stopPropagation();,让拖拽能正常的工作const useDrop = (target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
// https://stackoverflow.com/a/26459269
const dragEnterTarget = useRef<any>();
useEffectWithTarget(
() => {
const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {
return;
}
// 处理 DataTransfer 不同数据类型数据
const onData = (dataTransfer: DataTransfer, event: React.DragEvent | React.ClipboardEvent) => {};
const onDragEnter = (event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
dragEnterTarget.current = event.target;
optionsRef.current.onDragEnter?.(event);
};
const onDragOver = (event: React.DragEvent) => {
event.preventDefault(); // 调用 event.preventDefault() 使得该元素能够接收 drop 事件
optionsRef.current.onDragOver?.(event);
};
const onDragLeave = (event: React.DragEvent) => {
if (event.target === dragEnterTarget.current) {
optionsRef.current.onDragLeave?.(event);
}
};
const onDrop = (event: React.DragEvent) => {
event.preventDefault();
onData(event.dataTransfer, event);
optionsRef.current.onDrop?.(event);
};
const onPaste = (event: React.ClipboardEvent) => {
onData(event.clipboardData, event);
optionsRef.current.onPaste?.(event);
};
targetElement.addEventListener('dragenter', onDragEnter as any);
targetElement.addEventListener('dragover', onDragOver as any);
targetElement.addEventListener('dragleave', onDragLeave as any);
targetElement.addEventListener('drop', onDrop as any);
targetElement.addEventListener('paste', onPaste as any);
return () => {
targetElement.removeEventListener('dragenter', onDragEnter as any);
targetElement.removeEventListener('dragover', onDragOver as any);
targetElement.removeEventListener('dragleave', onDragLeave as any);
targetElement.removeEventListener('drop', onDrop as any);
targetElement.removeEventListener('paste', onPaste as any);
};
},
[],
target,
);
};在 drop 和 paste 事件中,获取到 DataTransfer 数据并传给 onData 方法,根据数据类型进行特定的处理DataTransfer:DataTransfer 对象用于保存拖动并放下(drag and drop)过程中的数据。它可以保存一项或多项数据,这些数据项可以是一种或者多种数据类型。关于拖放的更多信息,请参见 Drag and DropDataTransfer.getData()接受指定类型的拖放(以 DOMString 的形式)数据。如果拖放行为没有操作任何数据,会返回一个空字符串。数据类型有:text/plain,text/uri-listDataTransferItem:拖拽项。const onData = (
dataTransfer: DataTransfer,
event: React.DragEvent | React.ClipboardEvent,
) => {
const uri = dataTransfer.getData('text/uri-list'); // URL格式列表(链接)
const dom = dataTransfer.getData('custom'); // 自定义数据,需要与 useDrag 搭配使用
// 根据数据类型进行特定的处理
// 拖拽/粘贴自定义 DOM 节点的回调
if (dom && optionsRef.current.onDom) {
let data = dom;
try {
data = JSON.parse(dom);
} catch (e) {
data = dom;
}
optionsRef.current.onDom(data, event as React.DragEvent);
return;
}
// 拖拽/粘贴链接的回调
if (uri && optionsRef.current.onUri) {
optionsRef.current.onUri(uri, event as React.DragEvent);
return;
}
// 拖拽/粘贴文件的回调
// dataTransfer.files:拖动操作中的文件列表,操作中每个文件的一个列表项。如果拖动操作没有文件,此列表为空
if (dataTransfer.files && dataTransfer.files.length && optionsRef.current.onFiles) {
optionsRef.current.onFiles(Array.from(dataTransfer.files), event as React.DragEvent);
return;
}
// 拖拽/粘贴文字的回调
if (dataTransfer.items && dataTransfer.items.length && optionsRef.current.onText) {
// dataTransfer.items:拖动操作中 数据传输项的列表。该列表包含了操作中每一项目的对应项,如果操作没有项目,则列表为空
// getAsString:使用拖拽项的字符串作为参数执行指定回调函数
dataTransfer.items[0].getAsString((text) => {
optionsRef.current.onText!(text, event as React.ClipboardEvent);
});
}
};完整源码useDrag处理元素拖拽的 Hook。官方文档使用场景useDrag 允许一个 DOM 节点被拖拽,需要配合 useDrop 使用。涉及的拖拽事件dragstart: 在用户开始拖动元素或被选择的文本时调用。dragend: 在拖放操作结束时触发(通过释放鼠标按钮或单击 escape 键)。实现思路内部监听 dragstart 和 dragend 方法触发回调给外部使用dragstart 事件触发时支持设置自定义数据到 dataTransfer 中核心实现export interface Options {
// 在用户开始拖动元素或被选择的文本时调用
onDragStart?: (event: React.DragEvent) => void;
// 在拖放操作结束时触发(通过释放鼠标按钮或单击 escape 键)
onDragEnd?: (event: React.DragEvent) => void;
}
const useDrag = <T>(data: T, target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
const dataRef = useLatest(data);
useEffectWithTarget(
() => {
const targetElement = getTargetElement(target);
if (!targetElement?.addEventListener) {
return;
}
const onDragStart = (event: React.DragEvent) => {
optionsRef.current.onDragStart?.(event);
// 设置自定义数据到 dataTransfer 中,搭配 useDrop 的 onDom 回调可获取当前设置的内容
event.dataTransfer.setData('custom', JSON.stringify(dataRef.current));
};
const onDragEnd = (event: React.DragEvent) => {
optionsRef.current.onDragEnd?.(event);
};
targetElement.setAttribute('draggable', 'true');
targetElement.addEventListener('dragstart', onDragStart as any);
targetElement.addEventListener('dragend', onDragEnd as any);
return () => {
targetElement.removeEventListener('dragstart', onDragStart as any);
targetElement.removeEventListener('dragend', onDragEnd as any);
};
},
[],
target,
);
};
lucky0-0
【解读 ahooks 源码系列】Effect 篇(一)
本文是 ahooks 源码(v3.7.4)系列的第十篇——Effect 篇(一)往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState本文主要解读 useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect 的源码实现useUpdateEffectuseUpdateEffect 用法等同于 useEffect,但是会忽略首次执行,只在依赖更新时执行。官方文档API 与 React.useEffect 完全一致。基本用法官方在线 Demoimport React, { useEffect, useState } from 'react';
import { useUpdateEffect } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const [effectCount, setEffectCount] = useState(0);
const [updateEffectCount, setUpdateEffectCount] = useState(0);
useEffect(() => {
setEffectCount((c) => c + 1);
}, [count]);
useUpdateEffect(() => {
setUpdateEffectCount((c) => c + 1);
return () => {
// do something
};
}, [count]); // you can include deps array if necessary
return (
<div>
<p>effectCount: {effectCount}</p>
<p>updateEffectCount: {updateEffectCount}</p>
<p>
<button type="button" onClick={() => setCount((c) => c + 1)}>
reRender
</button>
</p>
</div>
);
};实现思路初始化一个 isMounted 标识,默认为 false;首次 useEffect 执行完后置为 true后续执行 useEffect 的时候判断 isMounted 标识是否为 true,true 则执行外部传入的 effect 函数;卸载的时候将 isMounted 标识重置为 false核心实现里面其实是实现了 createUpdateEffect 这个函数:export default createUpdateEffect(useEffect);type EffectHookType = typeof useEffect | typeof useLayoutEffect;
export const createUpdateEffect: (hook: EffectHookType) => EffectHookType =
(hook) => (effect, deps) => {
const isMounted = useRef(false);
// for react-refresh
hook(() => {
// 卸载时重置 isMounted 为 false
return () => {
isMounted.current = false;
};
}, []);
hook(() => {
if (!isMounted.current) {
// 首次执行完设置为 true
isMounted.current = true;
} else {
// 第一次后则执行函数
return effect();
}
}, deps);
};完整源码useUpdateLayoutEffectuseUpdateLayoutEffect 用法等同于 useLayoutEffect,但是会忽略首次执行,只在依赖更新时执行。官方文档API 与 React.useLayoutEffect 完全一致。基本用法官方在线 Demo使用上与 useLayoutEffect 完全相同,只是它忽略了首次执行,且只在依赖项更新时执行。import React, { useLayoutEffect, useState } from 'react';
import { useUpdateLayoutEffect } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
const [layoutEffectCount, setLayoutEffectCount] = useState(0);
const [updateLayoutEffectCount, setUpdateLayoutEffectCount] = useState(0);
useLayoutEffect(() => {
setLayoutEffectCount((c) => c + 1);
}, [count]);
useUpdateLayoutEffect(() => {
setUpdateLayoutEffectCount((c) => c + 1);
return () => {
// do something
};
}, [count]); // you can include deps array if necessary
return (
<div>
<p>layoutEffectCount: {layoutEffectCount}</p>
<p>updateLayoutEffectCount: {updateLayoutEffectCount}</p>
<p>
<button type="button" onClick={() => setCount((c) => c + 1)}>
reRender
</button>
</p>
</div>
);
};核心实现和 useUpdateEffect 一样,都是调用了 createUpdateEffect 方法,区别只是传入的 hook 是 useLayoutEffect。其余源码同上,就不再列举了export default createUpdateEffect(useLayoutEffect);完整源码useAsyncEffectuseEffect 支持异步函数。官方文档基本用法官方在线 Demo组件加载时进行异步的检查import { useAsyncEffect } from 'ahooks';
import React, { useState } from 'react';
function mockCheck(): Promise<boolean> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, 3000);
});
}
export default () => {
const [pass, setPass] = useState<boolean>();
useAsyncEffect(async () => {
setPass(await mockCheck());
}, []);
return (
<div>
{pass === undefined && 'Checking...'}
{pass === true && 'Check passed.'}
</div>
);
};为什么需要该 Hook在使用 useEffect 进行数据获取的时候,如果使用 async/await 的时候,会看到控制台有警告:Warning: An effect function must not return anything besides a function, which is used for clean-up. It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the async function inside your effect and call it immediately:useEffect(async () => {
const data = await fetchData();
}, [fetchData])第一个参数是函数,它可以不返回内容 (return undefined) 或一个销毁函数。如果返回的是异步函数(Promise),则会导致 React 在调用销毁函数的时候报错。返回值是异步,也难以预知代码的执行结果,可能出现难以定位的 Bug,故返回值不支持异步。如何让 useEffect 支持使用异步函数自执行函数 IIFEuseEffect(async () => {
(async function getData() {
const data = await fetchData();
})()
}, [])useEffect 里面抽离封装异步函数,再调用useEffect(() => {
const getData = async () => {
const data = await fetchData();
};
getData()
}, [])外部定义异步函数,useEffect 直接调用const getData = async () => {
const data = await fetchData();
};
useEffect(() => {
getData()
}, [])自定义 Hook 实现useAsyncEffect 就是一种实现了,省略上面那些代码处理APIfunction useAsyncEffect(
effect: () => AsyncGenerator | Promise,
deps: DependencyList
);核心实现Symbol.asyncIterator 符号指定了一个对象的默认异步迭代器。如果一个对象设置了这个属性,它就是异步可迭代对象,可用于 for await...of 循环。ahooks 的实现使用了上述的第二种解决方式,不过还增加了 AsyncGenerator 支持function useAsyncEffect(
effect: () => AsyncGenerator<void, void, void> | Promise<void>,
deps?: DependencyList,
) {
// 判断是否为 AsyncGenerator
function isAsyncGenerator(
val: AsyncGenerator<void, void, void> | Promise<void>,
): val is AsyncGenerator<void, void, void> {
return isFunction(val[Symbol.asyncIterator]);
}
useEffect(() => {
// effect 异步函数
const e = effect();
let cancelled = false;
async function execute() {
// 如果是 Generator 异步函数,则通过 next() 的方式执行
if (isAsyncGenerator(e)) {
while (true) {
const result = await e.next();
// [Generator 函数执行完] 或 [当前 useEffect 已经被清理]
if (result.done || cancelled) {
break;
}
}
} else {
// Promise 函数
await e;
}
}
execute(); // 执行异步函数
return () => {
// 设置表示当前 useEffect 已执行完销毁操作的标识
cancelled = true;
};
}, deps);
}看到上面的实现,发现 useAsyncEffect 并没有百分百兼容 useEffect 用法,它的销毁函数是只设置了已清理标识:return () => {
cancelled = true;
};这种做法的观点是认为延迟清除机制是不对的,应该是一种取消机制。否则,在钩子已经被取消之后,回调函数仍然有机会对外部状态产生影响。具体可以看这个大佬的文章:如何让 useEffect 支持 async...await?完整源码useDebounceFn用来处理防抖函数的 Hook。官方文档基本用法官方在线 Demo频繁调用 run,但只会在所有点击完成 500ms 后执行一次相关函数import { useDebounceFn } from 'ahooks';
import React, { useState } from 'react';
export default () => {
const [value, setValue] = useState(0);
const { run } = useDebounceFn(
() => {
setValue(value + 1);
},
{
wait: 500,
},
);
return (
<div>
<p style={{ marginTop: 16 }}> Clicked count: {value} </p>
<button type="button" onClick={run}>
Click fast!
</button>
</div>
);
};核心实现支持的选项,都是 lodash.debounce 里面的参数:interface DebounceOptions {
wait?: number; // 等待时间,单位为毫秒
leading?: boolean; // 是否在延迟开始前调用函数
trailing?: boolean; // 是否在延迟开始后调用函数
maxWait?: number; // 最大等待时间,单位为毫秒
}function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useDebounceFn expected parameter is a function, got ${typeof fn}`);
}
}
// 最新的 fn 防抖函数
const fnRef = useLatest(fn);
// 默认是 1000 毫秒
const wait = options?.wait ?? 1000;
// 防抖函数
const debounced = useMemo(
() =>
debounce(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);
// 卸载时取消防抖函数调用
useUnmount(() => {
debounced.cancel();
});
return {
run: debounced, // 触发执行 fn
cancel: debounced.cancel, // 取消当前防抖
flush: debounced.flush, // 当前防抖立即调用
};
}完整源码useDebounceEffect为 useEffect 增加防抖的能力。官方文档基本用法官方在线 Demoimport { useDebounceEffect } from 'ahooks';
import React, { useState } from 'react';
export default () => {
const [value, setValue] = useState('hello');
const [records, setRecords] = useState<string[]>([]);
useDebounceEffect(
() => {
setRecords((val) => [...val, value]);
},
[value],
{
wait: 1000,
},
);
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Typed value"
style={{ width: 280 }}
/>
<p style={{ marginTop: 16 }}>
<ul>
{records.map((record, index) => (
<li key={index}>{record}</li>
))}
</ul>
</p>
</div>
);
};核心实现它的实现依赖于 useDebounceFn hook。实现逻辑:本来 deps 更新,effect 函数就立即执行的;现在 deps 更新,执行 防抖函数 setFlag 来更新 flag,而 flag 又被 useUpdateEffect 监听,通过 useUpdateEffect Hook 来执行 effect 函数function useDebounceEffect(
// 执行函数
effect: EffectCallback,
// 依赖数组
deps?: DependencyList,
// 配置防抖的行为
options?: DebounceOptions,
) {
const [flag, setFlag] = useState({});
const { run } = useDebounceFn(() => {
setFlag({}); // 设置新的空对象,强制触发更新
}, options);
useEffect(() => {
return run();
}, deps);
// 只在 flag 依赖更新时执行,但是会忽略首次执行
useUpdateEffect(effect, [flag]);
}完整源码useThrottleFn用来处理函数节流的 Hook。官方文档基本用法官方在线 Demo频繁调用 run,但只会每隔 500ms 执行一次相关函数。import React, { useState } from 'react';
import { useThrottleFn } from 'ahooks';
export default () => {
const [value, setValue] = useState(0);
const { run } = useThrottleFn(
() => {
setValue(value + 1);
},
{ wait: 500 },
);
return (
<div>
<p style={{ marginTop: 16 }}> Clicked count: {value} </p>
<button type="button" onClick={run}>
Click fast!
</button>
</div>
);
};核心实现实现原理是调用封装 lodash 的 throttle 方法。支持的选项,都是 lodash.throttle 里面的参数:interface ThrottleOptions {
wait?: number; // 等待时间,单位为毫秒
leading?: boolean; // 是否在延迟开始前调用函数
trailing?: boolean; // 是否在延迟开始后调用函数
}function useThrottleFn<T extends noop>(fn: T, options?: ThrottleOptions) {
if (isDev) {
if (!isFunction(fn)) {
console.error(`useThrottleFn expected parameter is a function, got ${typeof fn}`);
}
}
// 最新的 fn 节流函数
const fnRef = useLatest(fn);
// 默认是 1000 毫秒
const wait = options?.wait ?? 1000;
// 节流函数
const throttled = useMemo(
() =>
throttle(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);
// 卸载时取消节流函数调用
useUnmount(() => {
throttled.cancel();
});
return {
run: throttled, // 触发执行 fn
cancel: throttled.cancel, // 取消当前节流
flush: throttled.flush, // 当前节流立即调用
};
}完整源码useThrottleEffect为 useEffect 增加节流的能力。官方文档基本用法官方在线 Demoimport React, { useState } from 'react';
import { useThrottleEffect } from 'ahooks';
export default () => {
const [value, setValue] = useState('hello');
const [records, setRecords] = useState<string[]>([]);
useThrottleEffect(
() => {
setRecords((val) => [...val, value]);
},
[value],
{
wait: 1000,
},
);
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Typed value"
style={{ width: 280 }}
/>
<p style={{ marginTop: 16 }}>
<ul>
{records.map((record, index) => (
<li key={index}>{record}</li>
))}
</ul>
</p>
</div>
);
};核心实现它的实现依赖于 useThrottleFn hook,具体实现逻辑同 useDebounceEffect,只是把 useDebounceFn 换成 useThrottleFnfunction useThrottleEffect(
// 执行函数
effect: EffectCallback,
// 依赖数组
deps?: DependencyList,
// 配置节流的行为
options?: ThrottleOptions,
) {
const [flag, setFlag] = useState({});
const { run } = useThrottleFn(() => {
setFlag({}); // 设置新的空对象,强制触发更新
}, options);
useEffect(() => {
return run();
}, deps);
// 只在 flag 依赖更新时执行,但是会忽略首次执行
useUpdateEffect(effect, [flag]);
}
lucky0-0
使用黑科技实现前端按钮权限控制,太优雅了。——从零开始搭建一个高颜值后台管理系统全栈框架(九)
前言按钮权限控制在后台管理系统中是一个比较常见的需求,下面和大家分享一下再react中常见的几种实现方式,不过最想分享的还是后面的黑科技方案。实现添加按钮权限功能改造上一篇文章中添加菜单的表单,添加一个按钮类型菜单。这里权限代码建议页面路由:按钮类型格式,保证全局唯一。改造菜单模型,添加权限代码字段常见前端按钮权限控制实现方案前言从用户信息中获取按钮菜单,把authCode取出来存到全局用户信息中。封装判断权限全局方法// src/utils/auth.ts
import {useUserStore} from '@/stores/global/user';
/**
* 判断是否有权限
* @param authCode 权限代码
* @returns
*/
export const isAuth = (authCode: string) => {
if (!authCode) return false;
// 从全局数据中获取当前用户按钮权限列表
const {currentUser} = useUserStore.getState();
const {authList = []} = currentUser || {};
// 判断传进来权限代码是否存在权限列表中
return authList.includes(authCode);
};这里使用zustand做状态存储太爽了,可以直接在组件外优雅的获取全局数据,以前使用redux的时候,在组件外获取全局值还是挺麻烦的。zustand不止可以在组件外获取值,还可以设置值,甚至还可以监听某个值的变化。封装完上面方法,我们可以在代码中使用。封装权限hooks有了全局公共方法了,为啥还要用hooks。如果直接在方法体里面使用方法,每次组件渲染的时候都会执行一下这个方法,如果权限比较多的话,可能会有性能问题。我们封装一个hooks,借助useMemo在authCode不变的时候,不用重新执行判断权限的方法了。// src/hooks/use-auth/index.tsx
import { useUserStore } from '@/stores/global/user';
import { isAuth } from '@/utils/auth';
import { useMemo } from 'react';
export const useAuth = (authCode: string) => {
const { currentUser } = useUserStore();
const auth = useMemo(() => {
return isAuth(authCode);
}, [authCode, currentUser?.authList]);
return auth;
}使用测试封装权限hooks有了全局公共方法了,为啥还要用hooks。如果直接在方法体里面使用方法,每次组件渲染的时候都会执行一下这个方法,如果权限比较多的话,可能会有性能问题。我们封装一个hooks,借助useMemo在authCode不变的时候,不用重新执行判断权限的方法了。// src/hooks/use-auth/index.tsx
import { useUserStore } from '@/stores/global/user';
import { isAuth } from '@/utils/auth';
import { useMemo } from 'react';
export const useAuth = (authCode: string) => {
const { currentUser } = useUserStore();
const auth = useMemo(() => {
return isAuth(authCode);
}, [authCode, currentUser?.authList]);
return auth;
}
使用测试封装高阶组件// src/components/with-auth/index.tsx
import { isAuth } from '@/utils/auth';
export function withAuth(authCode: string) {
return function (Component: any) {
return function (props: any) {
return isAuth(authCode) ? <Component {...props}></Component> : null;
}
}
}
使用测试使用黑科技实现按钮权限控制背景说是黑科技也不算是黑科技,只是在react中算是黑科技,就是通过类似于vue的指令来实现组件权限控制。在vue中实现指令很简单,但是react中不支持自定义指令。我前面写了一篇在react中自定义类似于vue指令的文章,我自己写了一个库,可以在react中使用一些vue指令,并且还可以自定义指令,大家可以先去看下,本文说的黑科技就是基于指令实现的,原理就是重新react的createElement方法。安装依赖,并在项目中配置pnpm i @dbfu/react-directive修改tsconfig.json文件在vite配置中安装插件然后我们就能快乐的在react代码中使用vue指令了自定义指令import { isAuth } from '@/utils/auth';
import { directive } from "@dbfu/react-directive/directive";
export const registerAuthDirective = () => {
directive('v-auth', {
create: (authCode: string) => {
return isAuth(authCode)
},
})
}自定义指令前面文章中有说明,我这里就不详细说了。在App.tsx文件中注册指令在代码中使用在任意组件中都可以使用这个指令,不用引入其他依赖,简直太优雅了。测试在测试页面中创建4个按钮在菜单中配置4个按钮只给用户角色分配一个新建按钮权限然后用用户这个用户分配用户角色登录用户帐号,进入测试测试这时候只能看到一个按钮在给用户这个角色分配删除权限刷新页面,再次进入测试页面,可以看到删除按钮出现了总结上面方案还有可以提高开发体验的点,就是本地加了一个按钮,还要在线上配置一下,这一块其实可以写一个脚本自动把本地代码中的按钮保存到远程,这个我后面再说。
lucky0-0
通过ip获取用户登录地点,实现登录日志功能。——从零开始搭建一个高颜值后台管理系统全栈框架(十一)
前言上一篇文章中留了一个坑,pm2开启多进程,会导致给用户推送消息失败,具体原因上一篇文章中已经说过了。这一篇我们先解决一下这个问题。现在各大平台都支持显示用户地址,其实实现起来很简单。我们这一篇就实现一下通过用户ip获取用户地址。使用redis消息广播解决上篇文章的坑实现思路改造发消息的方法,通过redis消息广播把消息发给各个进程,各个进程监听对应频道,如果收到消息,通过userId找到用户websocket连接,然后把消息发出去。具体实现后端redis发布订阅方法和普通redis不能使用同一个redis实例,发布订阅也不能使用同一个实例,所以我们需要配置三个实例。default:默认实例,给正常代码中使用。publish:发布消息使用subscribe:订阅消息使用改造SocketService代码,代码很简单。其他代码不用改。import { Autoload, Init, InjectClient, Singleton } from '@midwayjs/core';
import { Context } from '@midwayjs/ws';
import { SocketMessage } from './message';
import { RedisService, RedisServiceFactory } from '@midwayjs/redis';
const socketChannel = 'socket-message';
@Singleton()
@Autoload()
export class SocketService {
connects = new Map<string, Context[]>();
// 导入发布消息的redis实例
@InjectClient(RedisServiceFactory, 'publish')
publishRedisService: RedisService;
// 导入订阅消息的redis实例
@InjectClient(RedisServiceFactory, 'subscribe')
subscribeRedisService: RedisService;
@Init()
async init() {
// 系统启动的时候,这个方法会自动执行,监听频道。
await this.subscribeRedisService.subscribe(socketChannel);
// 如果接受到消息,通过userId获取连接,如果存在,通过连接给前端发消息
this.subscribeRedisService.on(
'message',
(channel: string, message: string) => {
if (channel === socketChannel && message) {
const messageData = JSON.parse(message);
const { userId, data } = messageData;
const clients = this.connects.get(userId);
if (clients?.length) {
clients.forEach(client => {
client.send(JSON.stringify(data));
});
}
}
}
);
}
/**
* 添加连接
* @param userId 用户id
* @param connect 用户socket连接
*/
addConnect(userId: string, connect: Context) {
const curConnects = this.connects.get(userId);
if (curConnects) {
curConnects.push(connect);
} else {
this.connects.set(userId, [connect]);
}
}
/**
* 删除连接
* @param connect 用户socket连接
*/
deleteConnect(connect: Context) {
const connects = [...this.connects.values()];
for (let i = 0; i < connects.length; i += 1) {
const sockets = connects[i];
const index = sockets.indexOf(connect);
if (index >= 0) {
sockets.splice(index, 1);
break;
}
}
}
/**
* 给指定用户发消息
* @param userId 用户id
* @param data 数据
*/
sendMessage<T>(userId: string, data: SocketMessage<T>) {
// 通过redis广播消息
this.publishRedisService.publish(
socketChannel,
JSON.stringify({ userId, data })
);
}
}
获取登录用户ipmidway中可以从请求上下文获取ip不过前面有::ffff:,我们可以使用replace方法给替换掉。如果用这个方式获取不到ip,我们还可以this.ctx.req.socket.remoteAddress获取ip。如果线上使用nginx配置了反向代理,我们可以从请求头上获取ip,使用this.ctx.req.headers['x-forwarded-for']或this.ctx.req.headers['X-Real-IP']这两个方法就行。nginx配置反向代理的时候,这两个配置不要忘记加了。封装一个统一获取ip的方法,this.ctx.req.headers['x-forwarded-for']有可能会返回两个ip地址,中间用,隔开,所以需要split一下,取第一个ip就行了。export const getIp = (ctx: Context) => {
const ips =
(ctx.req.headers['x-forwarded-for'] as string) ||
(ctx.req.headers['X-Real-IP'] as string) ||
(ctx.ip.replace('::ffff:', '') as string) ||
(ctx.req.socket.remoteAddress.replace('::ffff:', '') as string);
console.log(ips.split(',')?.[0], 'ip');
return ips.split(',')?.[0];
};
通过ip获取地址通过ip获取地址可以使用ip2region这个库,也可以调用一些公共接口获取,这里我们使用第一种方式。封装公共方法import IP2Region from 'ip2region';
export const getAddressByIp = (ip: string): string => {
if (!ip) return '';
const query = new IP2Region();
const res = query.search(ip);
return [res.province, res.city].join(' ');
};
查询结果中包含国家、省份、城市、供应商4个字段获取浏览器信息可以从请求头上获取浏览器信息打印出来的结果如下:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36我们可以用useragent这个库来解析里面的数据,获取用户使用的是什么浏览器,以及操作系统。封装一个公共方法:import * as useragent from 'useragent';
export const getUserAgent = (ctx: Context): useragent.Agent => {
return useragent.parse(ctx.headers['user-agent'] as string);
};返回这几个属性,family表示浏览器,os表示操作系统。用户登录日志功能实现使用下面命令快速创建一个登录日志模块。node ./script/create-module login.log改造LoginLogEntity实体import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
@Entity('sys_login_log')
export class LoginLogEntity extends BaseEntity {
@Column({ comment: '用户名' })
userName?: string;
@Column({ comment: '登录ip' })
ip?: string;
@Column({ comment: '登录地点' })
address?: string;
@Column({ comment: '浏览器' })
browser?: string;
@Column({ comment: '操作系统' })
os?: string;
@Column({ comment: '登录状态' })
status?: boolean;
@Column({ comment: '登录消息' })
message?: string;
}在用户登录方法中添加登录日志status设置位true,message为成功。登录失败时把status设置位false,message为错误消息。最后在finally中把数据添加到数据库,这里不要用await,做成异步的,不影响正常接口响应速度。前端查询实现就是一个正常的表格展示,没啥好说的。效果展示总结到此我们把上篇文章中留下的坑和登录日志功能搞定了。如果文章对你有帮忙,帮忙点个赞吧,谢谢了。
lucky0-0
【解读 ahooks 源码系列】DOM篇(二)
前言本文是 ahooks 源码系列的第三篇,往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素【解读 ahooks 源码系列】DOM篇(一)本文主要解读 useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover 源码实现useEventTarget常见表单控件(通过 e.target.value 获取表单值) 的 onChange 跟 value 逻辑封装,支持自定义值转换和重置功能。官方文档export interface Options<T, U> {
initialValue?: T; // 初始值
transformer?: (value: U) => T; // 自定义回调值的转化
}基本用法import React from 'react';
import { useEventTarget } from 'ahooks';
export default () => {
const [value, { reset, onChange }] = useEventTarget({ initialValue: 'this is initial value' });
return (
<div>
<input value={value} onChange={onChange} style={{ width: 200, marginRight: 20 }} />
<button type="button" onClick={reset}>
reset
</button>
</div>
);
};使用场景适用于较为简单的表单受控控件(如 input 输入框)管理实现思路监听表单的 onChange 事件,拿到值后更新 value 值支持自定义回调值的转化,对外暴露 value 值、onChange 和 reset 方法核心实现这个实现比较简单,这里结尾代码有个as const,它表示强制 TypeScript 将变量或表达式的类型视为不可变的具体可以看下这篇文章: 杀手级的 TypeScript 功能:const 断言function useEventTarget<T, U = T>(options?: Options<T, U>) {
const { initialValue, transformer } = options || {};
const [value, setValue] = useState(initialValue);
const transformerRef = useLatest(transformer);
const reset = useCallback(() => setValue(initialValue), []);
const onChange = useCallback((e: EventTarget<U>) => {
const _value = e.target.value;
if (isFunction(transformerRef.current)) {
return setValue(transformerRef.current(_value));
}
// no transformer => U and T should be the same
return setValue(_value as unknown as T);
}, []);
return [
value,
{
onChange,
reset,
},
] as const; // 将数组变为只读元组,可以确保其内容不会在其声明和函数调用之间发生变化
}完整源码useExternal动态注入 JS 或 CSS 资源,useExternal 可以保证资源全局唯一。官方文档基本用法import React from 'react';
import { useExternal } from 'ahooks';
export default () => {
const status = useExternal('/useExternal/test-external-script.js', {
js: {
async: true,
},
});
return (
<>
<p>
Status: <b>{status}</b>
</p>
<p>
Response: <i>{status === 'ready' ? window.TEST_SCRIPT?.start() : '-'}</i>
</p>
</>
);
};实现思路原理:通过 script 标签加载 JS 资源 / 创建 link 标签加载 CSS 资源,再通过创建标签返回的 Element 元素监听 load 和 error 事件 获取加载状态正则判断传入的路径 path 是 JS 还是 CSS加载 CSS/JS:创建 link/script 标签传入 path,支持传入 link/script 标签支持的属性,添加到 head/body 中,并返回 Element 元素与加载状态;这里需判断标签路径匹配是否存在,存在则返回上一次结果,以保证资源全局唯一利用创建标签返回的 Element 元素监听 load 和 error 事件,并在回调中改变加载状态核心实现主体实现结构:export interface Options {
type?: 'js' | 'css';
js?: Partial<HTMLScriptElement>;
css?: Partial<HTMLStyleElement>;
}
const useExternal = (path?: string, options?: Options) => {
const [status, setStatus] = useState<Status>(path ? 'loading' : 'unset');
const ref = useRef<Element>();
useEffect(() => {
if (!path) {
setStatus('unset');
return;
}
const pathname = path.replace(/[|#].*$/, '');
if (options?.type === 'css' || (!options?.type && /(^css!|\.css$)/.test(pathname))) {
const result = loadCss(path, options?.css);
} else if (options?.type === 'js' || (!options?.type && /(^js!|\.js$)/.test(pathname))) {
const result = loadScript(path, options?.js);
} else {
}
if (!ref.current) {
return;
}
const handler = (event: Event) => {};
ref.current.addEventListener('load', handler);
ref.current.addEventListener('error', handler);
return () => {
// 移除监听 & 清除操作
};
}, [path]);
return status;
};主函数中判断加载 CSS 还是 JS 资源:const pathname = path.replace(/[|#].*$/, '');
if (options?.type === 'css' || (!options?.type && /(^css!|\.css$)/.test(pathname))) {
const result = loadCss(path, options?.css); // 加载 css 资源并返回结果
ref.current = result.ref; // 返回创建 link 标签返回的 Element 元素,用于后续绑定监听 load 和 error事件
setStatus(result.status); // 设置加载状态
} else if (options?.type === 'js' || (!options?.type && /(^js!|\.js$)/.test(pathname))) {
const result = loadScript(path, options?.js);
ref.current = result.ref;
setStatus(result.status);
} else {
// do nothing
console.error(
"Cannot infer the type of external resource, and please provide a type ('js' | 'css'). " +
'Refer to the https://ahooks.js.org/hooks/dom/use-external/#options',
);
}loadCss 方法:往 HTML 标签上添加任意以 "data-" 为前缀来设置我们需要的自定义属性,可以进行一些数据的存放const loadCss = (path: string, props = {}): loadResult => {
const css = document.querySelector(`link[href="${path}"]`);
// 不存在则创建
if (!css) {
const newCss = document.createElement('link');
newCss.rel = 'stylesheet';
newCss.href = path;
// 设置 link 标签支持的属性
Object.keys(props).forEach((key) => {
newCss[key] = props[key];
});
// IE9+
const isLegacyIECss = 'hideFocus' in newCss;
// use preload in IE Edge (to detect load errors)
if (isLegacyIECss && newCss.relList) {
newCss.rel = 'preload';
newCss.as = 'style';
}
// 设置自定义属性[data-status]为loading状态
newCss.setAttribute('data-status', 'loading');
// 添加到 head 标签
document.head.appendChild(newCss);
// 标签路径匹配存在则直接返回现有结果,保证全局资源全局唯一
return {
ref: newCss,
status: 'loading',
};
}
// 如果标签存在则直接返回,并取 data-status 中的值
return {
ref: css,
status: (css.getAttribute('data-status') as Status) || 'ready',
};
}loadScript 方法的实现也类似:const loadScript = (path: string, props = {}): loadResult => {
const script = document.querySelector(`script[src="${path}"]`);
if (!script) {
const newScript = document.createElement('script');
newScript.src = path;
// 设置 script 标签支持的属性
Object.keys(props).forEach((key) => {
newScript[key] = props[key];
});
newScript.setAttribute('data-status', 'loading');
// 添加到 body 标签
document.body.appendChild(newScript);
return {
ref: newScript,
status: 'loading',
};
}
return {
ref: script,
status: (script.getAttribute('data-status') as Status) || 'ready',
};
};前面获取到 Element 元素后,监听 Element 的 load 和 error 事件,判断其加载状态并更新状态const handler = (event: Event) => {
const targetStatus = event.type === 'load' ? 'ready' : 'error';
ref.current?.setAttribute('data-status', targetStatus);
setStatus(targetStatus);
};
ref.current.addEventListener('load', handler);
ref.current.addEventListener('error', handler);完整源码useTitle用于设置页面标题。官方文档基本用法import React from 'react';
import { useTitle } from 'ahooks';
export default () => {
useTitle('Page Title');
return (
<div>
<p>Set title of the page.</p>
</div>
);
};使用场景当进入某页面需要改浏览器 Tab 中展示的标题时核心实现这个实现比较简单const DEFAULT_OPTIONS: Options = {
restoreOnUnmount: false, // 组件卸载时,是否恢复上一个页面标题
};
function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
const titleRef = useRef(isBrowser ? document.title : '');
useEffect(() => {
document.title = title;
}, [title]);
useUnmount(() => {
if (options.restoreOnUnmount) {
// 组件卸载时,恢复上一个页面标题
document.title = titleRef.current;
}
});
}如果项目中我们自己实现的话,有个需要注意的地方,不要把document.title = title;写在外层,要写在 useEffect 里面,具体见该文:检测意外的副作用完整源码useFavicon设置页面的 favicon。官方文档favicon 指显示在浏览器收藏夹、地址栏和标签标题前面的个性化图标基本用法import React, { useState } from 'react';
import { useFavicon } from 'ahooks';
export const DEFAULT_FAVICON_URL = 'https://ahooks.js.org/simple-logo.svg';
export const GOOGLE_FAVICON_URL = 'https://www.google.com/favicon.ico';
export default () => {
const [url, setUrl] = useState<string>(DEFAULT_FAVICON_URL);
useFavicon(url);
return (
<>
<p>
Current Favicon: <span>{url}</span>
</p>
<button
style={{ marginRight: 16 }}
onClick={() => {
setUrl(GOOGLE_FAVICON_URL);
}}
>
Change To Google Favicon
</button>
<button
onClick={() => {
setUrl(DEFAULT_FAVICON_URL);
}}
>
Back To AHooks Favicon
</button>
</>
);
};使用场景当需要改浏览器 Tab 中展示的图标 icon 时核心实现原理:通过 link 标签设置 favicon更多 favicon 知识可见: 详细介绍 HTML favicon 尺寸 格式 制作等相关知识源代码仅支持图标四种类型:const ImgTypeMap = {
SVG: 'image/svg+xml',
ICO: 'image/x-icon',
GIF: 'image/gif',
PNG: 'image/png',
};
type ImgTypes = keyof typeof ImgTypeMap;const useFavicon = (href: string) => {
useEffect(() => {
if (!href) return;
const cutUrl = href.split('.');
// 取出文件后缀
const imgSuffix = cutUrl[cutUrl.length - 1].toLocaleUpperCase() as ImgTypes;
const link: HTMLLinkElement =
document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = ImgTypeMap[imgSuffix];
// 指定被链接资源的地址
link.href = href;
// rel 属性用于指定当前文档与被链接文档的关系,直接使用 rel=icon 就可以,源码下方的 `shortcut icon` 是一种过时的用法
link.rel = 'shortcut icon';
document.getElementsByTagName('head')[0].appendChild(link);
}, [href]);
};完整源码useFullscreen管理 DOM 全屏的 Hook。官方文档基本用法import React, { useRef } from 'react';
import { useFullscreen } from 'ahooks';
export default () => {
const ref = useRef(null);
const [isFullscreen, { enterFullscreen, exitFullscreen, toggleFullscreen }] = useFullscreen(ref);
return (
<div ref={ref} style={{ background: 'white' }}>
<div style={{ marginBottom: 16 }}>{isFullscreen ? 'Fullscreen' : 'Not fullscreen'}</div>
<div>
<button type="button" onClick={enterFullscreen}>
enterFullscreen
</button>
<button type="button" onClick={exitFullscreen} style={{ margin: '0 8px' }}>
exitFullscreen
</button>
<button type="button" onClick={toggleFullscreen}>
toggleFullscreen
</button>
</div>
</div>
);
};原生全屏 APIElement.requestFullscreen():用于发出异步请求使元素进入全屏模式Document.exitFullscreen():用于让当前文档退出全屏模式。调用这个方法会让文档回退到上一个调用 Element.requestFullscreen()方法进入全屏模式之前的状态[已过时不建议使用]:Document.fullscreen:只读属性报告文档当前是否以全屏模式显示内容Document.fullscreenElement:返回当前文档中正在以全屏模式显示的 Element 节点,如果没有使用全屏模式,则返回 nullDocument.fullscreenEnabled:返回一个布尔值,表明浏览器是否支持全屏模式。全屏模式只在那些不包含窗口化的插件的页面中可用fullscreenchange:元素过渡到或过渡到全屏模式时触发的全屏更改事件的事件fullscreenerror:在 Element 过渡到或退出全屏模式发生错误后处理事件screenfull 库useFullscreen 内部主要是依赖 screenfull 这个库进行实现的。screenfull 对各种浏览器全屏的 API 进行封装,兼容性好。下面是该库的 API:.request(element, options?):使元素或者页面切换到全屏.exit():退出全屏.toggle(element, options?):在全屏和非全屏之间切换.on(event, function):添加一个监听器,监听全屏切换或者错误事件。event 支持 change 或者 error.off(event, function):移除之前注册的事件监听.isFullscreen:判断是否为全屏.isEnabled:判断当前环境是否支持全屏.element:返回该元素是否是全屏模式展示,否则返回 undefined实现思路看看 useFullscreen 的导出值:return [
state,
{
enterFullscreen: useMemoizedFn(enterFullscreen),
exitFullscreen: useMemoizedFn(exitFullscreen),
toggleFullscreen: useMemoizedFn(toggleFullscreen),
isEnabled: screenfull.isEnabled,
},
] as const;那么实现的方向就比较简单了:内部封装并暴露 toggleFullscreen、enterFullscreen、exitFullscreen 方法,暴露内部是否全屏的状态,还有是否支持全屏的状态通过 screenfull 库监听change事件,在change事件里面改变全屏状态与处理执行回调核心实现三个方法的实现:// 进入全屏方法
const enterFullscreen = () => {
const el = getTargetElement(target);
if (!el) {
return;
}
if (screenfull.isEnabled) {
try {
screenfull.request(el);
screenfull.on('change', onChange);
} catch (error) {
console.error(error);
}
}
};
// 退出全屏方法
const exitFullscreen = () => {
const el = getTargetElement(target);
if (screenfull.isEnabled && screenfull.element === el) {
screenfull.exit();
}
};
const toggleFullscreen = () => {
if (state) {
exitFullscreen();
} else {
enterFullscreen();
}
};onChange 方法const onChange = () => {
if (screenfull.isEnabled) {
const el = getTargetElement(target);
// screenfull.element:当前元素以全屏模式显示
if (!screenfull.element) {
// 退出全屏
onExitRef.current?.();
setState(false);
screenfull.off('change', onChange); // 卸载 change 事件
} else {
// 全屏模式展示
const isFullscreen = screenfull.element === el; // 判断当前全屏元素是否为目标元素
if (isFullscreen) {
onEnterRef.current?.();
} else {
onExitRef.current?.();
}
setState(isFullscreen);
}
}
};上方onChange以及exitFullscreen执行退出全屏前有行需要判断的代码注意下,具体原因可以看下修复 useFullScreen 当全屏后,子元素重复全屏和退出全屏操作后父元素也会退出全屏// 判断当前全屏元素是否为目标元素,支持对多个元素同时全屏
const isFullscreen = screenfull.element === el;screenfull.element 的实现:element: {
enumerable: true,
get: () => document[nativeAPI.fullscreenElement] ?? undefined,
},完整源码useHover监听 DOM 元素是否有鼠标悬停。官方文档基本用法import React, { useRef } from 'react';
import { useHover } from 'ahooks';
export default () => {
const ref = useRef(null);
const isHovering = useHover(ref);
return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>;
};鼠标监听事件mouseenter:第一次移动到触发事件元素中的激活区域时触发mouseleave:在定点设备(通常是鼠标)的指针移出某个元素时被触发扩展下几个鼠标事件的区别:mouseenter:当鼠标移入某元素时触发。mouseleave:当鼠标移出某元素时触发。mouseover:当鼠标移入某元素时触发,移入和移出其子元素时也会触发。mouseout:当鼠标移出某元素时触发,移入和移出其子元素时也会触发。mousemove:鼠标在某元素上移动时触发,即使在其子元素上也会触发。核心实现原理是监听 mouseenter 触发 onEnter 回调,切换状态为 true;监听 mouseleave 触发 onLeave回调,切换状态为 false。完整实现:export interface Options {
onEnter?: () => void;
onLeave?: () => void;
onChange?: (isHovering: boolean) => void;
}
export default (target: BasicTarget, options?: Options): boolean => {
const { onEnter, onLeave, onChange } = options || {};
// useBoolean:优雅的管理 boolean 状态的 Hook
const [state, { setTrue, setFalse }] = useBoolean(false);
// 监听 mouseenter 判断有鼠标进入目标元素
useEventListener(
'mouseenter',
() => {
onEnter?.();
setTrue();
onChange?.(true);
},
{
target,
},
);
// 监听 mouseleave 判断有鼠标是否移出目标元素
useEventListener(
'mouseleave',
() => {
onLeave?.();
setFalse();
onChange?.(false);
},
{
target,
},
);
return state;
};
lucky0-0
实现前后端全自动化部署,解放你的双手。——从零开始搭建一个高颜值后台管理系统全栈框架(五)
背景上一期我整了个服务器和域名,然后请教了我们公司的运维大佬,简单的实现了前后端自动化部署,只需要把代码push上去,然后就能自动编译和部署。这里没有用到k8s或Jenkins这种重量级的CI/CD工具,只使用了github actions和docker-compose,适合个人小项目使用。使用github actions自动编译介绍GitHub Actions是GitHub提供的一种自动化工作流程(workflow)管理工具。它可以根据特定的事件触发,执行各种操作和任务,例如编译代码、运行测试、部署应用等。使用GitHub Actions,开发者可以定义一个或多个工作流程,每个工作流程由一系列步骤(steps)组成。每个步骤可以包含命令行脚本、调用API、运行测试等任务。这些步骤可以在不同的操作系统环境下执行,如Linux、macOS和Windows。GitHub Actions提供了一系列预定义的事件(events),如提交代码、创建分支、打标签等,当这些事件发生时,可以触发相应的工作流程执行。同时,开发者也可以通过手动方式触发工作流程的执行。GitHub Actions还与其他工具和服务集成,例如Docker、AWS、Azure等,可以通过这些集成实现更复杂的自动化流程。总之,GitHub Actions是一种灵活强大的自动化工作流程工具,使开发者能够轻松地设置和管理与代码相关的自动化任务,并提高开发效率和质量。总结:我们使用github actions就不用自己手动在本地build代码和build镜像了,只需要往main分支推送代码就行了。自动部署前端服务介绍如果想把前端打成Docker镜像,需要写在项目里编写Dockerfile。这里我就不介绍什么是docker了,如果对docker不了解的,可以从网上找相关资料学习了解一下docker,现在大部分公司都会选择把前后端部署到镜像里,方便维护和迁移。在项目根目录下创建Dockerfile文件这里就不多做解释了,大家看代码中的注视吧,很简单的。# Dockerfile
# 因为我们项目使用的是pnpm安装依赖,所以找了个支持pnpm的基础镜像,如果你们使用npm,这里可以替换成node镜像
# FROM nginx:alpine
FROM gplane/pnpm:8.4.0 as builder
# 设置工作目录
WORKDIR /data/web
# 这里有个细节,为了更好的使用node_modules缓存,我们先把这两个文件拷贝到镜像中,镜像会检测发现这两个文件没有变化,就不会去重新安装依赖了。
COPY pnpm-lock.yaml .
COPY package.json .
# 安装依赖,如果上面两个文件没有改动,就不会重现安装依赖。
RUN pnpm install
# 把当前仓库代码拷贝到镜像中
COPY . .
# 运行build命令,可以替换成 npm run build
RUN pnpm run build
# 上面我们把代码编译完成后,会在镜像里生成dist文件夹。
# 下面我们把打包出来的静态资源放到nginx中部署
# 使用nginx做基础镜像
FROM nginx:alpine as nginx
# 设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
# 设置工作目录
WORKDIR /data/web
# 在nginx镜像中创建 /app/www文件夹
RUN mkdir -p /app/www
# 把上一步编译出来dist文件夹拷贝到刚才新建的/app/www文件夹中
COPY --from=builder /data/web/dist /app/www
# 暴露 80端口和443端口,因为我们服务监听的端口就是80,443端口是为了支持https。
EXPOSE 80
EXPOSE 443
# 如果镜像中有nginx配置,先给删了
RUN rm -rf /etc/nginx/conf.d/default.conf
# 把项目里的./nginx/config.sh shell脚本复制到ngxin镜像/root文件夹下
COPY ./nginx/config.sh /root
# 给刚复制进去的shell脚本设置权限,让镜像启动的时候可以正常运行这个shell脚本。
RUN chmod +x /root/config.sh
# 镜像启动的时候运行config.sh脚本
CMD ["/root/config.sh"]在项目中创建/nginx/config.sh文件上一步我们说了,把项目里的/nginx/config.sh复制到nginx中,所以我们需要在项目里创建这个文件,这个文件其实就是nginx配置。#! /bin/sh -e
cat >> /etc/nginx/conf.d/default.conf <<EOF
server {
listen 80;
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
#gzip_http_version 1.0;
gzip_comp_level 2;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary off;
gzip_disable "MSIE [1-6].";
proxy_read_timeout 600;
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
if (\$request_filename ~* .*\.(?:htm|html)$)
{
add_header Cache-Control "private, no-store, no-cache, must-revalidate, proxy-revalidate";
}
root /app/www/;
index index.html;
client_max_body_size 500m;
}
location /api {
proxy_pass $SERVER_URL;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
location /file/ {
proxy_pass $FILE_URL;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
}
}
EOF
echo "starting web server"
nginx -g 'daemon off;'
很基本的nginx配置,这里有两个地方需要说一下。为啥这里要写一个shell脚本而不是直接把配置复制到nginx中呢,是因为加了后端服务和文件服务的反向代理,为了不把反向代理的地址写死,(就是上面的$SERVER_URL和 $FILE_URL)我们需要使用环境变量,然后只能在shell脚本中才能使用环境变量,这两个环境变量在后面docker-compose里面去配置。if (\$request_filename ~* .*\.(?:htm|html)$)这一段配置是设置html文件不缓存,这样我们每次发布后,别人就不用清缓存后才能访问新的内容。html文件很小,所以只把html设置不缓存完全没问题,至于其他js,每次打包过后如果js文件有变化,他对应的hash也会变,如果文件内容不变,使用老的缓存也没问题。编译镜像上面两步做完,如果本地装了docker,已经可以在本地编译镜像了,在项目根目录下执行下面命令。docker build -t test:v1.0.0 .
test是镜像名,v1.0.0是版本号,后面那个点别忘记了。build成功后,我们可以使用docker desktop运行镜像也可以使用docker run -p 8000:80 test:v1.0.0命令去执行,现在执行肯定会报错的,因为有两个环境变量还没配呢。编写github actions yml文件基于上面我们可以本地编译镜像,然后把镜像推到镜像仓库,然后在服务器上拉镜像,最后运行镜像。这样虽然可以实现,但是每一步都是手动的,太不优雅了。这时候github actions登场了,用了它我们就可以解放自己的双手了,写完一个功能,代码一推,就可以去干别的事了。在项目根目录下,创建.github/workflows/docker-publish.yml文件,文件名可以随便起。name: Docker
on:
push:
branches: ['main']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: SSH Command
uses: D3rHase/ssh-command-action@v0.2.1
with:
HOST: ${{ secrets.SERVER_IP }}
PORT: 22
USER: root
PRIVATE_SSH_KEY: ${{ secrets.SERVER_KEY }}
COMMAND: cd /root && ./run.sh
这些都是固定写法,兄弟们可以拿去直接用,有两个地方需要注意一下。branches: ['main']这个表示代码往main分支push后触发自动编译,可以改成别的,比如打版本,merge代码等。最后后面SSH Command是用来执行服务器上/root/run.sh脚本的,因为目前我们使用github actions只做了编译代码和编译镜像以及上传镜像,还差一步自动部署镜像,这个脚本就是干这个的,我们后面再说。我们可以再github这个标签里面看到执行进度,点进去可以看到日志。编译完镜像后,github这里会显示你的镜像。点进去可以看到完整的镜像名称,你可以去拉这个镜像在本地或服务器上部署,后面我们在部署的时候会用到这个镜像名称。自动部署后端服务编写后端Dockerfile文件FROM gplane/pnpm:8.4.0 as builder
WORKDIR /app
# 先复制pnpm-lock.yaml和package.json文件,上文说过为了实现缓存
COPY pnpm-lock.yaml .
COPY package.json .
# 使用pnpm安装依赖
RUN pnpm install
COPY . .
# 编译代码
RUN pnpm run build
# 我们使用pm2启动后端服务,至于为什么,可以看下神光大佬的这篇文章。https://juejin.cn/post/7229595897813712957
FROM keymetrics/pm2:16-jessie
WORKDIR /app
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
ENV TZ="Asia/Shanghai"
RUN npm install pnpm -g
# node项目不像前端项目,编译代码的时候,会把node_modules里的文件也编译到dist中,后端还需要保留node_modules,不过我们可以把开发环境用到的包给移除掉,减少包的体积,pnpm install加--prod可以把开发环境中用到的包给移除掉,也就是安装在devDependencies中的依赖。
RUN pnpm install --prod
# 把需要的代码复制到镜像中
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/bootstrap.js ./
COPY --from=builder /app/script ./script
COPY --from=builder /app/src/config ./src/config
COPY --from=builder /app/tsconfig.json ./
COPY --from=builder /app/src/migration ./src/migration
EXPOSE 7001
# 启动项目
CMD ["npm", "run", "start"]编写后端github actions文件在项目根目录下,创建.github/workflows/docker-publish.yml文件,文件名可以随便起。文件内容和前端一模一样,直接把前端的拿过来就行了。name: Docker
on:
push:
branches: ['main']
tags: ['v*.*.*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Docker buildx
uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: SSH Command
uses: D3rHase/ssh-command-action@v0.2.1
with:
HOST: ${{ secrets.SERVER_IP }}
PORT: 22 # optional, default is 22
# user of the server
USER: root
# private ssh key registered on the server
PRIVATE_SSH_KEY: ${{ secrets.SERVER_KEY }}
# command to be executed
COMMAND: cd /root && ./run.sh数据库迁移我们在本地开发的时候,一般都是开启表结构自动同步,改了实体会自动把改的地方同步到数据库。但是线上环境最好不要开这玩意,很有可能会把线上的数据搞没,因为有时候你改字段名称,他自动同步的时候,会把原来的字段删掉,然后新建一个字段,这样那一列的数据就没了,线上开自动同步风险很大,建议不要开。那我们不开自动同步,部署到其他环境的时候,怎么把当前的表结构同步过去呢,typeorm提供解决了方案,使用migration,本地改了实体结构后,执行一个命令,会自动生成一个迁移文件,然后我们发布到线上的时候,执行一下这个文件,就能把变更的内容同步到线上数据库了。这一块midway文档中也有介绍。封装生成迁移的命令node ./node_modules/@midwayjs/typeorm/cli.js migration:generate -d ./src/config/config.default.ts ./src/migration/migration封装执行迁移的命令node ./node_modules/@midwayjs/typeorm/cli.js migration:run -d ./src/config/typeorm.prod.ts
改造start命令,再执行迁移前执行了一个脚本,这个后面再说。node ./script/init-database && npm run migration:run && NODE_ENV=production pm2-runtime start ./bootstrap.js --name midway_app -i 4改造后的package.json scripts把typeorm的生产配置单独提出一个文件,里面用了很多环境变量,这样可以从外部注入配置值,而不是写死在代码中。// src/config/typeorm.prod.ts
import { env } from 'process';
export default {
typeorm: {
dataSource: {
default: {
type: 'mysql',
host: env.DB_HOST,
port: env.DB_PORT || 3306,
username: env.DB_USERNAME,
password: env.DB_PASSWORD,
database: env.DB_NAME || 'fluxy-admin',
synchronize: false, // 关闭自动同步
logging: false,
// 扫描entity文件夹
entities: ['**/entity/*{.ts,.js}'],
timezone: '+00:00',
// 迁移存在的文件路径
migrations: ['**/migration/*.ts'],
// 迁移存在的文件夹路径
cli: {
migrationsDir: 'migration',
},
},
},
},
};
实战。如果已经开发了一段时间,本地数据库中已经有了表结构,这时候我们需要把表全删了,然后执行生成迁移的命令,因为生成迁移的时候会对比当前代码中实体和数据库中表结构是否一样,有变化才会生成变更,所以把数据库中的表删除后,就可以生成一个完整的数据库表结构迁移脚本。执行npm run migration:generate命令,会生成下图中迁移文件,up是升级执行的方法,down是回滚执行的方法。可以看到up方法中生成了一些建表的语句,后面插入管理员账号是我手动加的,typeorm migration迁移只迁移表结构,数据不会帮你迁移,所以要自己写,数据迁移这一块很复杂,后面会单独写一篇文章介绍种子数据迁移。迁移流程。第一次迁移,线上数据库还没有建,这时候可以把本地数据库中的表删掉,然后运行npm run migration:generate:dev命令,会生成一个创建完整表结构的迁移,这时候如果要初始化数据,也可以写在里面。如果线上数据库已经建好了,后面改了实体,只需要提交代码的时候执行npm run migration命令生成迁移就行了,开发过程中不要多次执行这个命令,因为每次执行这个命令都会对比远程数据库和本地数据库的差异,生成变更,多次执行会生成重复的变更,比如加了一个表,每次执行都会生成创建表的更变,然后线上执行的时候就会报错,某某表已经存在,因为同一个表会建多次。上面说了在执行start命令前,执行了一个node ./script/init-database命令,这个是用来检查线上有没有我们用的数据库,如果没有的话,需要自动建一个,不然启动后端服务会因为无法创建数据库连接而报错。说明一下这里创建数据库不是创建数据库服务,而是连上数据库服务后创建我们用到的数据库,对应sql是CREATE DATABASE 数据库名称。这个脚本还有一个作用,我们在使用docker-compose启动服务的时候,虽然设置了后端服务依赖数据库服务,数据库服务启动后,后端服务才会启动,但是只是让数据库服务先启动,而不是数据库服务启动完成后,再启动后端服务。所以如果数据库服务还没启动完成,这时候我们去连数据库就会失败,我在这个脚本中,写了个轮询检查数据库有没有启动完成,启动完成再去走后面的流程。// script/init-database.js
const mysql = require('mysql2');
let count = 0;
function connect() {
const host = process.env.DB_HOST;
const user = process.env.DB_USERNAME;
const password = process.env.DB_PASSWORD;
const database = process.env.DB_NAME || 'fluxy-admin';
const connection = mysql.createConnection({
host,
user,
password,
});
connection.connect(error => {
if (error) {
console.log(`host: ${host}`);
console.log(`user: ${user}`);
console.log(`password: ${password}`);
console.log('数据库连接失败,正在重新连接');
console.log(error);
setTimeout(() => {
if (count >= 60) {
console.log('数据库连接失败,请检查数据库服务是否正常启动。');
return;
}
connect();
count += 1;
}, 1000);
return;
}
connection.query(
`SELECT * FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${database}'`,
(err, result) => {
if (err) {
console.log(err);
return;
}
if (result.length === 0) {
console.log('检测到数据库不存在,正在为你创建数据库...');
connection.query(`CREATE DATABASE \`${database}\``, () => {
console.log('数据库创建成功');
process.exit();
});
} else {
process.exit();
}
}
);
});
connection.end();
}
connect();小结到此我们前后端自动编译代码和生成镜像完成了,下面我们写如何自动在服务器上部署我们的服务。使用docker compose自动部署介绍前面我们已经把前后端代码编译成了镜像,这时候我们可以在服务器上拉取镜像,然后启动镜像。前端还好,没有啥依赖,后端用到了数据库、redis、minio文件服务器,需要我们一个一个把这些服务部署好,后端才能启动。后面如果想迁移到一个新环境,还是需要一个一个部署,太麻烦了。有没有更好的方式呢,docker compose可以实现这个,我们可以把这些服务放到一个配置文件中统一管理,如果想迁移服务,直接在对应的服务器上运行一个命令就行了,是不是很方便,下面和大家说一下怎么使用。环境准备首先需要在服务器上安装docker和docker-compose。如何安装docker我就不说了,网上太多教程了,安装docker-compose可以用下面的命令。curl -Lo /usr/bin/docker-compose https://ghproxy.com/https://github.com/docker/compose/releases/download/v2.11.2/docker-compose-linux-x86_64
chmod +x /usr/bin/docker-compose编写docker-compose.yml文件在服务器上/root文件夹下,创建docker-compose.yml文件version: '3.7'
services:
web:
image: ghcr.dockerproxy.com/dbfu/fluxy-admin-web:main
container_name: web
restart: unless-stopped
environment:
SERVER_URL: http://server:7001
FILE_URL: http://file:9000/
ports:
- 8080:80
networks:
- general
server:
image: ghcr.dockerproxy.com/dbfu/fluxy-admin-server:main
container_name: server
restart: unless-stopped
environment:
DB_HOST: db
DB_PASSWORD: 123654
DB_NAME: fluxy-admin
REDIS_HOST: redis
REDIS_PASSWORD: 123654
MINIO_HOST: file
MINIO_SECRET_KEY: 123654
MINIO_PORT: 9000
networks:
- general
depends_on:
- db
- redis
- file
db:
image: mysql:latest
container_name: db
restart: unless-stopped
volumes:
- db:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: 123654
networks:
general:
ipv4_address: 172.18.0.200
redis:
image: redis:latest
container_name: redis
command: --requirepass 123654
restart: unless-stopped
volumes:
- redis:/data
networks:
general
ipv4_address: 172.18.0.201
file:
image: minio/minio:latest
container_name: file
command: server --console-address :9001 /data
restart: unless-stopped
environment:
MINIO_ROOT_USER: root
MINIO_ROOT_PASSWORD: 123654
volumes:
- file:/data
- /etc/localtime:/etc/localtime
networks:
- general
volumes:
db:
file:
redis:上面我们编排了5个服务,web、server、db、redis、file。image:镜像名称,就是我们github上编译后的镜像,上面有说。这里有个注意的地方,因为我们的镜像推到了github的镜像仓库中,我们的服务器在国内访问还是有点慢的,我可以用github镜像仓库的代理地址ghcr.dockerproxy.com,拉取镜像的速度就会快很多。container_name:容器名command:执行对应的命令,有的服务有,有的服务没有environment:环境变量,就是我们后端服务中,写的那些process.env.XXX,就是从这里取值的。volumes:映射卷,把镜像里的目录映射到服务器上,不然每次重启服务,数据都丢了。有个小细节,举个例子,db的映射写的是db:/var/lib/mysql,正常来说前面的db应该是服务器的某个路径,但是如果我们写死了一个,服务器上没有这个目录就会报错了。所以在最外面定义了一个卷有db、file、redis,这样docker-compose启动的时候,会自动创建卷对应的目录,不用担心因为目录不存在而报错了。networks:网络,如果服务都设置成同一个,上面配代理地址的时候,就不用写ip了,直接用服务名称。前端反向代理的地址就是用这种方式配的,SERVER_URL: http://server:7001。 还有一个问题,有的服务配置了ipv4_address,有的没配,是因为只发布后端的时候,如果不设置固定ip的话,后端服务的ip会变,而前端代理的地址还是老的,就会出现无法访问后端的情况。ports:把容器内的端口映射到服务器上,在外部可以访问,正常来说只需要把前端的端口映射出来就行了,其他都通过服务名访问。如果你想远程访问数据库或其他服务,只需要给映射出来就行了。depends_on:控制服务启动顺序然后我们在当前目录下执行docker-compose pull && docker-compose up --remove-orphans命令就会自动部署服务了。如果以后我们想迁移,直接把这个docker-compose.yml文件复制过去,然后执行当前命令,就能实现一键迁移了。每次都写那么长的命令有点麻烦,为了方便我们写个shell脚本。在当前目录下创建run.sh文件,把上面命令复制到run.sh中,我们只需要执行sh run.sh就行了。利用github actions实现自动部署如果我们改了代码,重新编译了镜像,每次都需要在服务器上手动执行sh run.sh命令有点麻烦。不过github actions支持远程调用服务器上的命令,只需要配置服务器公网ip和密钥就行了。上面github actions yml文件中我们已经提了这个。在github上配置服务器ip和密钥,注意是密钥,不是密码,每个云服务器都可以生成密钥的。自动绑定域名并支持https如果想用你的域名访问web服务,并且想支持https,正常操作需要先申请证书,然后在nginx中配置你刚申请的证书,这些操作虽然不难,但是挺麻烦的。我请教了我们公司的运维大佬,他给我提供了个快捷方法,超级简单,这里分享给大家。我们在docker-compose中添加两个服务,并在下面添加几个卷。这两个服务会自动申请证书,并配置nginx。version: '3.7'
services:
nginx-proxy:
image: nginxproxy/nginx-proxy
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- conf:/etc/nginx/conf.d
- vhost:/etc/nginx/vhost.d
- html:/usr/share/nginx/html
- certs:/etc/nginx/certs:ro
- /var/run/docker.sock:/tmp/docker.sock:ro
networks:
- general
acme-companion:
image: nginxproxy/acme-companion
container_name: nginx-proxy-acme
environment:
- DEFAULT_EMAIL=XXXXXX@163.com
volumes_from:
- nginx-proxy
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- general
...
volumes:
conf:
vhost:
html:
certs:
acme:
db:
redis:
file:如果你想用这个的话,需要把那个XXXXXX@163.com邮箱改成自己的就行。改造一下web服务,加几个环境变量。把上面的域名换成自己的就行了。开启github action通知github action执行流程的时候,成功后不会发送通知,只有失败才会,需要我们手动开启一下。这个默认是勾上的,去掉就行了。总结好了到这里一套完整的自动化部署方案就完成了,兄弟们可以把这一套用在自己的个人项目中,还是挺方便的。
lucky0-0
低代码动态属性和在线执行脚本——低代码知识点详解(三)
前言这一篇我们来实现一下组件属性绑定变量、修改变量、在线执行脚本低代码常用功能。组件属性绑定变量前言为了能做出更复杂的页面,组件属性支持绑定变量是低代码必不可少的功能,下面我们实现一下这个功能。定义变量就像写代码一样,想使用变量,需要先定义变量,所以我们先实现定义变量功能。变量数据结构interface Variable {
// 变量名
name: string,
// 变量类型
type: string,
// 默认值
defaultValue: string;
// 备注
remark: string;
}定义一个存放变量数据的store// src/editor/stores/variable.ts
import {create} from 'zustand';
export interface Variable {
/**
* 变量名
*/
name: string;
/**
* 默认值
*/
defaultValue: string;
/**
* 备注
*/
remark: string;
}
interface State {
variables: Variable[];
}
interface Action {
/**
* 添加组件
* @param component 组件属性
* @param parentId 上级组件id
* @returns
*/
setVariables: (variables: Variable[]) => void;
}
export const useVariablesStore = create<State & Action>((set) => ({
variables: [],
setVariables: (variables) => set({variables}),
}));这里使用antd的Form.List组件快速实现功能,type先写死一个string类型,后面可以拓展很多类型,比如bool、number和更高级的json和接口等。// src/editor/layouts/header/define-variable.tsx
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { Button, Form, Input, Modal, Select, Space } from 'antd';
import React, { useEffect } from 'react';
import { useVariablesStore } from '../../stores/variable';
interface Props {
open: boolean,
onCancel: () => void
}
interface Variable {
// 变量名
name: string,
// 变量类型
type: string,
// 默认值
defaultValue: string;
// 备注
remark: string;
}
const DefineVariable: React.FC<Props> = ({ open, onCancel }) => {
const [form] = Form.useForm();
const { setVariables, variables } = useVariablesStore();
function onFinish(values: { variables: Variable[] }) {
setVariables(values.variables);
onCancel && onCancel();
}
useEffect(() => {
if (open) {
form.setFieldsValue({ variables });
}
}, [open])
return (
<Modal
open={open}
title="定义变量"
onCancel={onCancel}
destroyOnClose
onOk={() => { form.submit() }}
width={700}
>
<Form<{ variables: Variable[] }>
onFinish={onFinish}
autoComplete="off"
className='py-[20px]'
form={form}
initialValues={{ variables }}
>
<Form.List name="variables">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, 'name']}
rules={[{ required: true, message: '变量名不能为空' }]}
>
<Input placeholder="变量名" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'type']}
>
<Select style={{ width: 140 }} options={[{ label: '字符串', value: 'string' }]} placeholder="类型" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'defaultValue']}
>
<Input placeholder="默认值" />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'remark']}
>
<Input placeholder="备注" />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add({ type: 'string' })} block icon={<PlusOutlined />}>
添加变量
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</Modal>
)
};
export default DefineVariable;头上工具栏再加一个定义变量的按钮控制定义变量弹框显示和隐藏。效果展示绑定变量所有组件属性都需要支持绑定变量,所以需要让属性配置的表单元素都支持选择变量,我们给这些表单组件封装一下,方便使用。下面是组件属性新数据结构,分为静态和变量两种类型。interface Value {
type: 'static' | 'variable';
value: any;
}封装支持设置变量的输入框// src/editor/common/setting-form-item/input.tsx
import { SettingOutlined } from '@ant-design/icons';
import { Input } from 'antd';
import { useState } from 'react';
import SelectVariableModal from '../select-variable-modal';
interface Value {
type: 'static' | 'variable';
value: any;
}
interface Props {
value?: Value,
onChange?: (value: Value) => void;
}
const SettingFormItemInput: React.FC<Props> = ({ value, onChange }) => {
const [visible, setVisible] = useState(false);
function valueChange(e: any) {
onChange && onChange({
type: 'static',
value: e?.target?.value,
});
}
function select(record: any) {
onChange && onChange({
type: 'variable',
value: record.name,
});
setVisible(false);
}
return (
<div className='flex gap-[8px]'>
<Input
disabled={value?.type === 'variable'}
value={(value?.type === 'static' || !value) ? value?.value : ''}
onChange={valueChange}
/>
<SettingOutlined
onClick={() => { setVisible(true) }}
className='cursor-pointer'
style={{ color: value?.type === 'variable' ? 'blue' : '' }}
/>
<SelectVariableModal
open={visible}
onCancel={() => { setVisible(false) }}
onSelect={select}
/>
</div>
)
}
export default SettingFormItemInput;属性设置组件里antd的Input改成刚封装SettingFormItemInput组件效果展示渲染的时候,根据类型处理props效果展示设置变量事件再添加一个设置变量的动作类型,选择设置变量后,先选择一个要设置的变量,然后输入变量的值。效果展示先定义一个存放变量值的store,然后在点击事件里调用setData给变量设置值。// src/editor/stores/page-data.ts
import {create} from 'zustand';
interface State {
data: any;
}
interface Action {
/**
* 设置变量值
* @param component key
* @param parentId 值
* @returns
*/
setData: (key: string, value: any) => void;
/**
* 重置数据
* @returns
*/
resetData: () => void;
}
export const usePageDataStore = create<State & Action>((set) => ({
data: {},
setData: (key, value) =>
set((state) => ({data: {...state.data, [key]: value}})),
resetData: () => set({data: {}}),
}));
事件处理那里再加一个动作类型判断,这一块其实可以用策略模式优化一下代码,但是这是demo,先不优化了。前面渲染那里改造一下,先从data中取值,取不到再使用变量的默认值。效果展示动态执行脚本前言js中常用的动态执行脚本方式有两种,一个是使用eval,另外一个是new Function,这里我们使用new Function,传参方便一点。实战动态执行脚本依然是由事件触发的,所以给事件添加一个执行脚本的动作类型。脚本最好使用代码编辑器来编写,因为这里是demo,先用文本输入框代替,后面实战时会用代码编辑器的。我们把一些常用的方法注入到ctx中,可以在脚本里调用我们注入的方法,比如设置变量值方法,和调用某个组件方法等。封装执行脚本方法,把设置变量值方法和获取组件实例方法注入到ctx中,代码很简单。事件那里加一个执行脚本动作设置变量值执行组件方法最后到此我们简单的实现了组件属性绑定变量、修改变量、在线执行脚本低代码常用功能,下一篇我们来实现动态加载远程组件。
lucky0-0
封装axios,让请求变得丝滑——从零开始搭建一个高颜值后台管理系统全栈框架(四)
前言掘金有很多关于axios封装的文章,但是我没有看到把token自动刷新,自动回放以及限流(后端防止流量攻击做了限流,同一个用户在很短的时间内,只能调几个接口,如果某个页面一进来就掉很多接口,后端就会因为限流而报错,这时候做前端限流了,不过最好的方案还是一个页面不要同时调很多接口,能合并的就合并。)写的很完善的,即使有的文章写了,也写的很粗,很多细节没有写出来。比如刷新完token回放的时候没有用到队列,还有如果在刷新token期间,又来了一个请求怎么办,还有在axios中如何实现限流,说实话没有看到过把这两个写的很清楚的,这篇文章我会把我在公司里面封装的axios分享给大家,欢迎大家在评论区说出你们的意见和建议。实现刷新token接口前端把refreshToken传给后端,后端拿到refreshToken去redis中检查当前refreshToken是否失效,如果失效就报错,没失效就颁发一个新的token给前端。这里注意一下,不要生成新的refreshToken返回给前端,不然如果别人拿到一次refreshToken后,可以一直刷新拿新token了。// src/module/auth/controller/auth.ts
...
@Post('/refresh/token', { description: '刷新token' })
@NotLogin()
async refreshToken(@Body(ALL) data: RefreshTokenDTO) {
return this.authService.refreshToken(data);
}
...
// src/module/auth/service/auth.ts
...
async refreshToken(refreshToken: RefreshTokenDTO): Promise<TokenVO> {
const userId = await this.redisService.get(
`refreshToken:${refreshToken.refreshToken}`
);
if (!userId) {
throw R.error('刷新token失败');
}
const { expire } = this.tokenConfig;
const token = uuid();
await this.redisService
.multi()
.set(`token:${token}`, userId)
.expire(`token:${token}`, expire)
.exec();
const refreshExpire = await this.redisService.ttl(
`refreshToken:${refreshToken.refreshToken}`
);
return {
expire,
token,
refreshExpire,
refreshToken: refreshToken.refreshToken,
} as TokenVO;
}
...使用中间件校验token,并给上下文注入当前用户信息说明Web 中间件是在控制器调用 之前 和 之后(部分)调用的函数。 中间件函数可以访问请求和响应对象。创建auth中间件// src/middleware/auth.ts
import {
Middleware,
IMiddleware,
Inject,
MidwayWebRouterService,
RouterInfo,
} from '@midwayjs/core';
import { NextFunction, Context } from '@midwayjs/koa';
import { R } from '../common/base.error.util';
import { RedisService } from '@midwayjs/redis';
@Middleware()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
@Inject()
redisService: RedisService;
@Inject()
webRouterService: MidwayWebRouterService;
@Inject()
notLoginRouters: RouterInfo[];
resolve() {
return async (ctx: Context, next: NextFunction) => {
const token = ctx.header.authorization?.replace('Bearer ', '');
if (!token) {
throw R.unauthorizedError('未授权');
}
const userInfoStr = await this.redisService.get(`token:${token}`);
if (!userInfoStr) {
throw R.unauthorizedError('未授权');
}
const userInfo = JSON.parse(userInfoStr);
ctx.userInfo = userInfo;
ctx.token = token;
return next();
};
}
static getName(): string {
return 'auth';
}
}在src/configuration.ts注册auth中间件// src/configuration.ts
export class ContainerLifeCycle {
@App()
app: koa.Application;
async onReady() {
// add middleware
this.app.useMiddleware([AuthMiddleware]);
// add filter
this.app.useFilter([
ValidateErrorFilter,
CommonErrorFilter,
NotFoundFilter,
UnauthorizedErrorFilter,
]);
}
}
为了让使用上下文的地方,有代码提示,拓展上下文参数// src/interface.ts
import '@midwayjs/core';
interface UserContext {
userId: number;
refreshToken: string;
}
declare module '@midwayjs/core' {
interface Context {
userInfo: UserContext;
token: string;
}
}
设置部分接口不鉴权看了上面代码,细心的兄弟可能看到问题了,在auth中间件中对所有接口进行了token验证,登录接口还没登录呢,哪来的token传,所以我们需要实现一下可以对部分接口不鉴权。关于这个有个简单的实现方案,不需要鉴权的接口用统一的前缀,然后在中间件中判断。还有一种方案,在维护一个接口url白名单。这两个方案虽然都能实现,但是我觉得不够优雅,理想做法是在不需要鉴权的接口上加上某个装饰器就行了,所以我自定义了一个免登录的装饰器。自定义免登录装饰器实现思路和上面说的白名单思路是一样的,只不过上面说的白名单是手动维护的,这个是通过给接口加装饰器,然后自动把使用该装饰器的接口动态添加到白名单中,最后在中间件中判断当前请求是不是在白名单中,如果在就不校验了。如何自定义装饰器大家看一下官方文档,官方文档写的很详细的。// src/decorator/not.login.ts
import {
IMidwayContainer,
MidwayWebRouterService,
Singleton,
} from '@midwayjs/core';
import {
ApplicationContext,
attachClassMetadata,
Autoload,
CONTROLLER_KEY,
getClassMetadata,
Init,
Inject,
listModule,
} from '@midwayjs/decorator';
// 提供一个唯一 key
export const NOT_LOGIN_KEY = 'decorator:not.login';
export function NotLogin(): MethodDecorator {
return (target, key, descriptor: PropertyDescriptor) => {
attachClassMetadata(NOT_LOGIN_KEY, { methodName: key }, target);
return descriptor;
};
}
@Autoload()
@Singleton()
export class NotLoginDecorator {
@Inject()
webRouterService: MidwayWebRouterService;
@ApplicationContext()
applicationContext: IMidwayContainer;
@Init()
async init() {
const controllerModules = listModule(CONTROLLER_KEY);
const whiteMethods = [];
for (const module of controllerModules) {
const methodNames = getClassMetadata(NOT_LOGIN_KEY, module) || [];
const className = module.name[0].toLowerCase() + module.name.slice(1);
whiteMethods.push(
...methodNames.map(method => `${className}.${method.methodName}`)
);
}
const routerTables = await this.webRouterService.getFlattenRouterTable();
const whiteRouters = routerTables.filter(router =>
whiteMethods.includes(router.handlerName)
);
this.applicationContext.registerObject('notLoginRouters', whiteRouters);
}
}改造auth中间件中的resolve方法,判断如果当前接口在白名单中,就不鉴权了。resolve() {
return async (ctx: Context, next: NextFunction) => {
const routeInfo = await this.webRouterService.getMatchedRouterInfo(
ctx.path,
ctx.method
);
if (!routeInfo) {
await next();
return;
}
if (
this.notLoginRouters.some(
o =>
o.requestMethod === routeInfo.requestMethod &&
o.url === routeInfo.url
)
) {
await next();
return;
}
const token = ctx.header.authorization?.replace('Bearer ', '');
if (!token) {
throw R.unauthorizedError('未授权');
}
const userId = await this.redisService.get(`token:${token}`);
if (!userId) {
throw R.unauthorizedError('未授权');
}
const userInfo = JSON.parse(userInfoStr);
ctx.userInfo = userInfo;
ctx.token = token;
return next();
};
}
具体使用,在接口上使用NotLogin装饰器实现获取当前用户信息接口从上下文中拿到userId,然后通过userId去查询用户信息...
// src/module/auth/controller/auth.ts
@Get('/current/user')
async getCurrentUser(): Promise<UserVO> {
const user = await this.userService.getById(this.ctx.userInfo.userId);
return user.toVO();
}
封装antd message、notification、modal方法开始打算在axios异常拦截中统一弹出错误提示,但是antd 5最近的版本使用message的一些方法会报警告。[antd: message] Static function can not consume context like dynamic theme. Please use 'App' component instead.意思是如果使用这种方式没办法使用ConfigProvider中定义的主题,希望使用antd的App组件代替。只能使用上面这种hooks方式调用,但是在axios的拦截器里面没办法用hooks,所以我想了办法,封装一个对象来保存message这些引用,让其他不能使用hooks方法的地方也能使用。// src/utils/antd.ts
import { MessageInstance } from 'antd/es/message/interface';
import { ModalStaticFunctions } from 'antd/es/modal/confirm';
import { NotificationInstance } from 'antd/es/notification/interface';
type ModalInstance = Omit<ModalStaticFunctions, 'warn'>;
class AntdUtils {
message: MessageInstance | null = null;
notification: NotificationInstance | null = null;
modal: ModalInstance | null = null;
setMessageInstance(message: MessageInstance) {
this.message = message;
this.message.success
}
setNotificationInstance(notification: NotificationInstance) {
this.notification = notification;
}
setModalInstance(modal: ModalInstance) {
this.modal = modal;
}
}
export const antdUtils = new AntdUtils();
在src/layouts/index.tsx文件中注入这些引用layouts组件需要被antd的App组件包裹 然后就可以在任何地方使用message方法了封装axios统一返回值我不喜欢使用try catch捕获接口异常,所以我在axios的响应拦截器中捕获了异常,接口报错统一在响应拦截器中弹出,不用自己在每一处单独处理了。// src/request/index.ts
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
import { useGlobalStore } from '@/stores/global';
import { antdUtils } from '@/utils/antd';
export type Response<T> = Promise<[boolean, T, AxiosResponse<T>]>;
class Request {
constructor(config?: CreateAxiosDefaults) {
this.axiosInstance = axios.create(config);
this.axiosInstance.interceptors.request.use(
(axiosConfig: InternalAxiosRequestConfig) => this.requestInterceptor(axiosConfig)
);
this.axiosInstance.interceptors.response.use(
(response: AxiosResponse<unknown, unknown>) => this.responseSuccessInterceptor(response),
(error: any) => this.responseErrorInterceptor(error)
);
}
private axiosInstance: AxiosInstance;
private async requestInterceptor(axiosConfig: InternalAxiosRequestConfig): Promise<any> {
const { token } = useGlobalStore.getState();
// 为每个接口注入token
if (token) {
axiosConfig.headers.Authorization = `Bearer ${token}`;
}
return Promise.resolve(axiosConfig);
}
private async responseSuccessInterceptor(response: AxiosResponse<any, any>): Promise<any> {
return Promise.resolve([
false,
response.data,
response,
]);
}
private async responseErrorInterceptor(error: any): Promise<any> {
const { status } = error?.response || {};
if (status === 401) {
// TODO 刷新token
} else {
antdUtils.notification?.error({
message: '出错了',
description: error?.response?.data?.message,
});
return Promise.resolve([true, error?.response?.data]);
}
}
request<T, D = any>(config: AxiosRequestConfig<D>): Response<T> {
return this.axiosInstance(config);
}
get<T, D = any>(url: string, config?: AxiosRequestConfig<D>): Response<T> {
return this.axiosInstance.get(url, config);
}
post<T, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Response<T> {
return this.axiosInstance.post(url, data, config);
}
put<T, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Response<T> {
return this.axiosInstance.put(url, data, config);
}
delete<T, D = any>(url: string, config?: AxiosRequestConfig<D>): Response<T> {
return this.axiosInstance.delete(url, config);
}
}
const request = new Request({ timeout: 30000 });
export default request;
接口响应是一个元组类型,元组第一个值是boolean类型,表示接口是成功还是失败,第二个值是后端响应的数据,第三个值是axios的response对象。看下面的使用案例,这样就不用写try catch了。每次掉接口都要手动维护一个loading参数,这个很麻烦,可以用使用ahooks里面的useRequest方法,把接口包装一下,请求就变得优雅了。但是useRequest这个hooks不支持我上面定义的响应结构,所以我模仿useRequest的api自己实现了一个useRequest,目前只实现了useRequest的部分功能,不过暂时够用了,后面慢慢完善。
// src/hooks/use-request/index.ts
import { useCallback, useEffect, useState } from 'react';
import { Response } from '@/request';
interface RequestOptions {
manual?: boolean;
defaultParams?: any[];
}
interface RequestResponse<T> {
error: boolean | undefined;
data: T | undefined;
loading: boolean;
run(...params: any): void;
runAsync(...params: any): Response<T>;
}
export function useRequest<T>(
serviceMethod: (...args: any) => Response<T>,
options?: RequestOptions
): RequestResponse<T> {
const [loading, setLoading] = useState<boolean>(false);
const [data, setData] = useState<T>();
const [error, setError] = useState<boolean>();
const resolveData = async () => {
setLoading(true);
const [error, requestData] = await serviceMethod(...(options?.defaultParams || []));
setLoading(false);
setData(requestData);
setError(error);
}
const runAsync = useCallback(async (...params: any) => {
setLoading(true);
const res = await serviceMethod(...params);
setLoading(false);
return res;
}, [serviceMethod]);
const run = useCallback(async (...params: any) => {
setLoading(true);
const [error, requestData] = await serviceMethod(...params);
setLoading(false);
setData(requestData);
setError(error);
}, [serviceMethod]);
useEffect(() => {
if (!options?.manual) {
resolveData();
}
}, [options]);
return {
loading,
error,
data,
run,
runAsync,
}
}
改造上面代码,组件一渲染,接口会自动调用,不用自己在useEffect里面自己调用了,优雅了很多。如果想自己手动掉接口,把manual为true,然后手动调用run方法就行了。无感刷新token背景为啥要做这个功能,我在上一篇文章中已经解释过了,这里就不做解释了。先说一下实现思路,在axios异常拦截器,发现响应的code是401的,说明token已经过期了,这时候我们需要调刷新token获取新的token,然后使用新的token回放上一次401的接口。如果刷新token的接口报错了,说明刷新token也过期了,这时候我们跳到登录页面让用户重新登录。刷新token的时候有2个需要注意的点。如果同时有很多接口401,那我们不能每个都调一下刷新token接口去刷新token,理论上只需要刷新一次就行了。在刷新token没返回之前,又有新接口进来,如果正常请求的话,必然也会401,所以我们需要给拦截一下。想解决上面问题,我们需要设置一个表示是否正在刷新token的变量和一个请求队列,如果正在刷新token,那我们把新的401接口都插入到一个队列中,然后等刷新接口拿到新接口了,把队列里的请求一起回放。解决第二个问题也很简单了,在请求拦截器中,判断是否正在刷新token,如果正在刷新token,也加入到队列中,等刷新token返回后,也一起回放。定义变量和队列改造响应拦截器添加刷新token方法改造请求拦截器测试验证测试这个过程中发现一个估计很多人都不知道的google浏览器的特性,大家应该都知道google浏览器最多同时能进行6个请求,但是很多人不知道google浏览器对同一个接口多次请求,是窜行的,必须等上一个接口响应结束后才会请求下一个,开始遇到这个问题,我一度怀疑是后端接口的问题,但是我用其他工具同时发送10个请求,都是一起返回的,后来查到这方面的资料才发现是Google浏览器的特性。同时发送10个相同的请求,正常来说应该是一起返回的,但是不是,看下请求瀑布图。明显是等上一个请求结束后,才发送下一个请求。其他浏览器针对这种情况都有优化,Safari浏览器更离谱只发送一个请求。为了正常测试,我们给接口加一个随机数参数。同时发送10个请求,token都是失效的,但是只调了一次刷新token接口,然后重放也是正常的。接口响应值也能正常打印出来测试一下在刷新过程中来了一个新请求场景,首先把刷新token加一个延时1.5s后响应,然后在前端设置一个定时器,每隔1s发送一个请求。从上面动态图可以看到,在刷新token的时候,新的请求都不会发送,进入了请求队列,等刷新token结束后,一次性发了5个请求。实现请求限流背景有的服务器害怕被攻击,设置了同一个用户同一时间只能请求几个接口,如果超出限制就会报错,这个我们在前端可以控制一下,这种使用场景不多,一般一个页面不会发送很多请求,能合并的还是尽量合并一下。我在做可视化大屏的时候,遇到过这个场景,一个大屏上配了很多小区块,每个区块都是单独调接口去查数据,然后渲染,最多同时能请求10几个接口,我们后端开启了限流后,前端疯狂报错,为了解决这个问题,只好改造请求工具。不过后来我给大屏做成按需加载小模块,屏幕外的不请求接口,这样同时请求的接口少了很多。实现思路和实现刷新token思路差不多,设置一个变量表示当前有多少个请求正在请求,如果同时请求的数量超出我们设置的最大值,就进入队列不要请求,在接口响应成功后,检查队列里有没有请求,如果有就取出(最大值-当前请求数量)个请求去执行。这里代码比较多,我就不贴了,大家可以去仓库里看对应的源码,代码在这个src/request/index.ts这个文件里。效果展示我设置的并发数量是3,同时发送了10个请求,从上面图中可以看到同时只执行了三个。获取当前用户信息设置到全局创建user store,这里存放公共的用户信息// src/stores/global/user.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware';
interface User {
id: number;
userName: string;
nickName: string;
phoneNumber: string;
email: string;
createDate: string;
updateDate: string;
}
interface State {
currentUser: User | null;
}
interface Action {
setCurrentUser: (currentUser: State['currentUser']) => void;
}
export const useUserStore = create<State & Action>()(
devtools(
(set) => {
return {
currentUser: null,
setCurrentUser: (currentUser: State['currentUser']) => set({ currentUser }),
};
},
{ name: 'globalUserStore' }
)
)
请求用户信息,并设置到用户store中。先使用我们刚封装的useRequest包裹获取用户信息的请求,并设置为手动调用,页面一进来或refreshToken变化的时候,重新请求用户信息。为啥refreshToken变化也需要重新请求,这个后面说。// src/layouts/index.tsx
...
const {
loading,
data: currentUserDetail,
run: getCurrentUserDetail,
} = useRequest(
userService.getCurrentUserDetail,
{ manual: true }
);
useEffect(() => {
if (!refreshToken) {
navigate('/user/login');
return;
}
getCurrentUserDetail();
}, [refreshToken, getCurrentUserDetail, navigate]);
useEffect(() => {
setCurrentUser(currentUserDetail || null);
}, [currentUserDetail, setCurrentUser]);
...
当获取正在获取用户信息的时候,加一个全局loading跨浏览器页签共享token框架使用zustand的persist数据持久化中间件,在设置值的时候,会把当前store里的值同步到localStorage中,store一初始化也会从localStorage中同步数据到store中。因为token和rereshToken会存到localStorage中,我们打开新页签也能取到token,这样我们就很简单的实现了跨浏览器页签共享token,用户打开新页签的时候,不用重新登录了。跨浏览器页面同步用户信息背景上面虽然实现了跨页签共享token,但是有一个问题需要处理一下,假设我们在一个新的浏览器页签中,退出登录或者切换用户了,这时候我们在另外一个页签也需要退出登录或重新获取用户信息。这个借助zustand实现起来就简单了。我们只需要监听localStorage中refreshToken值是否变动,如果有变动说明有退出或重新登录,这里不能监听token变动是因为token过期后重新获取token,这时候并不需要重新获取用户信息。实现退出登录调后端接口,把当前token和refreshToken从redis中删除,然后调用前端store里面的方法把前端存的token和refreshToken清除掉。上面说了,当我们设置值的时候,persist中间件会把值自动同步到localStorage中,也就是说我们只要在系统中监听localStorage中全局信息是否有变动,如果有变动我们调用persist里面的方法,重新把localStorage里面的值同步到store中去,然后因为localStorage是共享的,所以所有页签都能监听到变化,然后发现token或refreshToken没了,也退到登录页面。后端退出接口实现前端调退出登录接口,并把token和refreshToken设置为空监听localStorage变化,如果有对应key的值变化就重新同步localStorage中值到store中监听refreshToken变化,如果为空则表示退出登录,不为空则表示其他页签重新登录了,我们需要重新获取一下用户信息。这里为啥监听refreshToken而不是token,是因为token失效会重新获取新的,这时候并不需要重新获取用户信息,只有refreshToken变化才说明退出登录或重新登录了。最后整了个服务器和域名,把前后端部署了一下,体验地址:fluxyadmin.cn。有些兄弟私信我,问我会不会烂尾,我这里向大家保证不会烂尾的,因为很多功能我都实现了,只是还没有整理成文章。写作不易,如果文章对你有帮助,麻烦兄弟们给个star吧。
lucky0-0
使用低代码实战开发页面(上)——低代码知识点详解(六)
前言前面低代码核心功能实现的差不多了,这篇我们开发一个经典的CRUD页面来实战一下。不过在做页面之前需要先封装一些组件,有了组件,开发页面就像拼积木一样把组件拼起来。CRUD页面包括搜索区、表格、新建按钮、新建表单、弹框,所以这一篇我们需要先实现这些组件。往期回顾《保姆级》低代码知识点详解(一) 低代码事件绑定和组件联动——低代码知识点详解(二) 低代码动态属性和在线执行脚本——低代码知识点详解(三) 低代码在线加载远程组件——低代码知识点详解(四) 低代码可视化逻辑编排——低代码知识点详解(五)代码优化背景为了让封装组件变简单点,很多功能直接可以使用配置的方式去配置,以组件为单位,把所有东西都在当前组件中搞定,不用去管框架代码。实现数据结构从前面实现的功能来看,物料组件的数据结构可以设计成下面这样。// src/editor/interface.ts
export interface ComponentSetter {
name: string;
label: string;
type: string;
[key: string]: any;
}
export interface ComponentEvent {
name: string;
desc: string;
}
export interface ComponentMethod {
name: string;
desc: string;
}
export interface ComponentConfig {
/**
* 组件名称
*/
name: string;
/**
* 组件描述
*/
desc: string;
/**
* 组件默认属性
*/
defaultProps:
| {
[key: string]: {
type: 'variable' | 'static';
value: any;
};
}
| (() => {
[key: string]: {
type: 'variable' | 'static';
value: any;
};
});
/**
* 编辑模式下加载的组件
*/
dev: any;
/**
* 正式模式下加载的组件
*/
prod: any;
/**
* 组件属性配置
*/
setter: ComponentSetter[];
/**
* 组件方法
*/
methods: ComponentMethod[];
/**
* 组件事件
*/
events: ComponentEvent[];
/**
* 组件排序
*/
order: number;
}
以按钮组件为例目录结构是这样的dev:编辑模式下渲染的组件prod:预览模型或正式模式下渲染的组件index:配置文件配置文件内容
// src/editor/components/button/index.ts
import {ComponentConfig} from '../../interface';
import Dev from './dev';
import Prod from './prod';
export default {
name: 'Button',
desc: '按钮',
defaultProps: {
text: {type: 'static', value: '按钮'},
},
dev: Dev,
prod: Prod,
setter: [
{
name: 'type',
label: '按钮类型',
type: 'select',
options: [
{label: '主按钮', value: 'primary'},
{label: '次按钮', value: 'default'},
],
},
{
name: 'text',
label: '文本',
type: 'input',
},
],
methods: [
{
name: 'startLoading',
desc: '开始loading',
},
{
name: 'endLoading',
desc: '结束loading',
},
],
events: [
{
name: 'onClick',
desc: '点击事件',
},
],
order: 2,
} as ComponentConfig;这样我们新增组件的时候,只要按这个格式配置就行了。优化上面直接导出配置文件,虽然可以实现需求,但是扩展性会降低,比如异步添加组件就不行了。所以好的方式是对外暴露一个注册组件的方法,在任何地方和任何时间都可以调用这个方法,注册组件。改造一下按钮组件配置文件import {Context} from '../../interface';
import ButtonDev from './dev';
import ButtonProd from './prod';
export default (ctx: Context) => {
return new Promise((resolve) => {
setTimeout(() => {
ctx.registerComponent('Button', {
name: 'Button',
desc: '按钮',
defaultProps: {
text: {type: 'static', value: '按钮'},
},
dev: ButtonDev,
prod: ButtonProd,
setter: [
{
name: 'type',
label: '按钮类型',
type: 'select',
options: [
{label: '主按钮', value: 'primary'},
{label: '次按钮', value: 'default'},
],
},
{
name: 'text',
label: '文本',
type: 'input',
},
],
methods: [
{
name: 'startLoading',
desc: '开始loading',
},
{
name: 'endLoading',
desc: '结束loading',
},
],
events: [
{
name: 'onClick',
desc: '点击事件',
},
],
order: 2,
});
resolve({});
}, 1000);
});
};
这种方式可以支持异步添加组件实现注册组件方法先创建一个store,存放注册的组件配置// src/editor/stores/component-config.ts
import {create} from 'zustand';
import {ComponentConfig} from '../interface';
interface State {
componentConfig: {[key: string]: ComponentConfig};
}
interface Action {
setComponentConfig: (componentConfig: State['componentConfig']) => void;
}
export const useComponentConfigStore = create<State & Action>((set) => ({
componentConfig: {},
setComponentConfig: (componentConfig) => set({componentConfig}),
}));
实现注册组件方法,这里用一个黑科技,正常我们每添加一个组件,都需要把组件配置引入进来,然后执行里面的方法,这样比较麻烦。在vite项目里可以使用import.meta.glob方法动态加载模块,非常好用。这样以后我们新加组件,只需要关注当前组件就行了,其他都不用管,会自动加载。// src/editor/layouts/index.tsx
import { Allotment } from "allotment";
import "allotment/dist/style.css";
import React, { useEffect, useState } from 'react';
import { Spin } from 'antd';
import { ComponentConfig } from '../interface';
import { useComponentConfigStore } from '../stores/component-config';
import { useComponetsStore } from '../stores/components';
import Header from './header';
import Material from './material';
import Setting from './setting';
import EditStage from './stage/edit';
import ProdStage from './stage/prod';
const Layout: React.FC = () => {
const { mode } = useComponetsStore();
const { setComponentConfig } = useComponentConfigStore();
const [loading, setLoading] = useState(true);
const componentConfigRef = React.useRef<any>({});
// 注册组件
function registerComponent(name: string, componentConfig: ComponentConfig) {
componentConfigRef.current[name] = componentConfig;
}
// 加载组件配置
async function loadComponentConfig() {
// 匹配components文件夹下的index.ts文件,加载组件配置模块代码
const modules = import.meta.glob('../components/*/index.ts', { eager: true });
const tasks = Object.values(modules).map((module: any) => {
if (module?.default) {
// 执行组件配置里的方法,把注册组件方法传进去
return module.default({ registerComponent });
}
});
// 等待所有组件配置加载完成
await Promise.all(tasks);
// 注册组件到全局
setComponentConfig(componentConfigRef.current);
setLoading(false);
}
useEffect(() => {
loadComponentConfig();
}, []);
if (loading) {
return (
<div className='text-center mt-[100px]'>
<Spin />
</div>
)
}
return (
<div className='h-[100vh] flex flex-col'>
<div className='h-[50px] flex items-centen border-solid border-[1px] border-[#ccc]'>
<Header />
</div>
{mode === 'edit' ? (
<Allotment>
<Allotment.Pane preferredSize={240} maxSize={400} minSize={200}>
<Material />
</Allotment.Pane>
<Allotment.Pane>
<EditStage />
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
<Setting />
</Allotment.Pane>
</Allotment>
) : (
<ProdStage />
)}
</div>
)
}
export default Layout;因为把组件配置存到了全局,所以后面关于组件的一些配置信息,直接从全局里取就好了。这里举个例子,渲染组件列表。// src/editor/layouts/material/index.tsx
import { useMemo } from 'react';
import ComponentItem from '../../common/component-item';
import { useComponentConfigStore } from '../../stores/component-config';
import { useComponetsStore } from '../../stores/components';
import { ComponentConfig } from '../../interface';
const Material: React.FC = () => {
const { addComponent } = useComponetsStore();
const { componentConfig } = useComponentConfigStore();
/**
* 拖拽结束,添加组件到画布
* @param dropResult
*/
const onDragEnd = (dropResult: { name: string, id?: number, props: any }) => {
addComponent({
id: new Date().getTime(),
name: dropResult.name,
props: dropResult.props,
}, dropResult.id);
}
const components = useMemo(() => {
// 加载所有组件
const coms = Object.values(componentConfig).map((config: ComponentConfig) => {
return {
name: config.name,
description: config.desc,
order: config.order,
}
})
// 排序
coms.sort((x, y) => x.order - y.order);
return coms;
}, [componentConfig]);
return (
<div className='flex p-[10px] gap-4 flex-wrap'>
{components.map(item => <ComponentItem key={item.name} onDragEnd={onDragEnd} {...item} />)}
</div>
)
}
export default Material;其它比如组件事件和组件方法都可以从这里取,就不一一展示了。封装组件增删改查页面肯定少不了表格,先封装一个表格组件,表格可以绑定一个请求接口url,支持动态列,对外暴露搜索和刷新方法。按照上面数据结构,先创建index.ts,内容如下:
// src/editor/components/table/index.ts
import {Context} from '../../interface';
import TableDev from './dev';
import TableProd from './prod';
export default (ctx: Context) => {
ctx.registerComponent('Table', {
name: 'Table',
desc: '表格',
defaultProps: {},
dev: TableDev,
prod: TableProd,
setter: [
{
name: 'url',
label: 'url',
type: 'input',
},
],
methods: [
{
name: 'search',
desc: '搜索',
},
{
name: 'reload',
desc: '刷新',
},
],
order: 4,
});
};dev.tsx// src/editor/components/table/dev.tsx
import { Table as AntdTable } from 'antd';
import React, { useMemo } from 'react';
import { useDrop } from 'react-dnd';
import { ItemType } from '../../item-type';
interface Props {
id: number;
children?: any[];
}
const Table: React.FC<Props> = ({ id, children }) => {
const [{ canDrop }, drop] = useDrop(() => ({
accept: [ItemType.TableColumn],
drop: (_, monitor) => {
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}
return {
id,
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}));
const columns: any = useMemo(() => {
return React.Children.map(children, (item: any) => {
return {
title: (
<div className='m-[-16px] p-[16px]' data-component-id={item.props?.id}>{item.props?.title}</div>
),
dataIndex: item.props?.dataIndex,
}
})
}, [children]);
return (
<div
className='w-[100%]'
ref={drop}
data-component-id={id}
style={{ border: canDrop ? '1px solid #ccc' : 'none' }}
>
<AntdTable
columns={columns}
dataSource={[]}
pagination={false}
/>
</div>
);
}
export default Table;prod.tsximport { Table as AntdTable } from 'antd';
import dayjs from 'dayjs';
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import axios from 'axios';
interface Props {
url: string;
children: any;
}
const Table = ({ url , children }: Props, ref: any) => {
const [data, setData] = useState<any[]>([]);
const [searchParams, setSearchParams] = useState({});
const [loading, setLoading] = useState(false);
const getData = async (params?: any) => {
if (url) {
setLoading(true);
const { data } = await axios.get(url, { params });
setData(data);
setLoading(false);
}
}
useEffect(() => {
getData(searchParams);
}, [searchParams]);
useImperativeHandle(ref, () => {
return {
search: setSearchParams,
reload: () => {
getData(searchParams)
},
}
}, [searchParams])
const columns: any = useMemo(() => {
return React.Children.map(children, (item: any) => {
if (item?.props?.type === 'date') {
return {
title: item.props?.title,
dataIndex: item.props?.dataIndex,
render: (value: any) => dayjs(value).format('YYYY-MM-DD')
}
}
return {
title: item.props?.title,
dataIndex: item.props?.dataIndex,
}
})
}, [children]);
return (
<AntdTable
columns={columns}
dataSource={data}
pagination={false}
rowKey="id"
loading={loading}
/>
);
}
export default forwardRef(Table);表格列组件可以拖放到表格组件中,index.tsimport {Context} from '../../interface';
import Dev from './dev';
import Prod from './prod';
export default (ctx: Context) => {
ctx.registerComponent('TableColumn', {
name: 'TableColumn',
desc: '表格列',
defaultProps: () => {
return {
dataIndex: {type: 'static', value: `col_${new Date().getTime()}`},
title: {type: 'static', value: '标题'},
type: 'text',
};
},
dev: Dev,
prod: Prod,
setter: [
{
name: 'type',
label: '类型',
type: 'select',
options: [
{
label: '文本',
value: 'text',
},
{
label: '日期',
value: 'date',
},
],
},
{
name: 'title',
label: '标题',
type: 'input',
},
{
name: 'dataIndex',
label: '字段',
type: 'input',
},
],
order: 5,
});
};因为这个组件不用真正的渲染,dev和prod返回空就行了。dev.tsx和prod.tsxconst TableColumn = () => {
return <></>
}
export default TableColumn;搜索区组件、弹框组件、表单组件都按照这个流程实现就行了。开发页面整体布局先把组件整体布局拖好,然后再一个一个组件设置。拖一个间距组件,设置为垂直布局,然后拖一个搜索区、一个按钮、一个表格、一个弹框、再把表单拖到弹框中。配置搜索区拖一个搜索项放进去,把搜索项标题改为姓名,字段改为fullName,并且把搜索事件绑定表格组件搜索方法。配置表格拖两个表格列放到表格组件中,一个展示姓名、一个展示添加日期,然后再设置请求url。搜索效果展示配置表单拖一个表单项进去,标题改为姓名,字段改为fullName,设置表单请求url。实现新建功能实现新建功能,需要按照下面流程来实现。按钮点击事件绑定弹框显示方法,弹框确定按钮绑定表单提交方法,并且因为提交调接口是异步的,所以需要把弹框的确定按钮设置为loading。表单提交成功事件先绑定显示成功提示,然后调用弹框隐藏方法,继续调用弹框停止确定按钮loading方法,最后调用表格刷新方法。表单提交失败事件直接调用弹框结束loading方法。整体功能演示最后这一篇我们先实现增加和搜索功能,下一篇把难度升级一下,实现编辑和删除功能,并且还会多增加几个表单类型,比如日期、下拉框等,还会用到变量脚本以及条件节点。
lucky0-0
【解读 ahooks 源码系列】State篇(一)
前言本文是 ahooks 源码(v3.7.4)系列的第八篇——State 篇(一)往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive本文主要解读 useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle 的源码实现useSetState管理 object 类型 state 的 Hooks,用法与 class 组件的 this.setState 基本一致。官方文档基本用法官方在线 Demoimport React from 'react';
import { useSetState } from 'ahooks';
interface State {
hello: string;
count: number;
[key: string]: any;
}
export default () => {
const [state, setState] = useSetState<State>({
hello: '',
count: 0,
});
return (
<div>
<pre>{JSON.stringify(state, null, 2)}</pre>
<p>
<button type="button" onClick={() => setState({ hello: 'world' })}>
set hello
</button>
<button type="button" onClick={() => setState({ foo: 'bar' })} style={{ margin: '0 8px' }}>
set foo
</button>
<button type="button" onClick={() => setState((prev) => ({ count: prev.count + 1 }))}>
count + 1
</button>
</p>
</div>
);
};使用场景setState 对象时想省略合并运算符,保证每一次设置值都是自动合并实现思路该 Hook 主要就是内部做了自动合并操作处理可见 官网 useState 解释主要原因是因为 useState 不会自动合并更新对象,大部分情况下需要我们自己手动合并,因此提供了 useSetState hooks 来解决这个问题const [state, setState] = useState({});
setState(prevState => {
// 也可以使用 Object.assign
return {...prevState, ...updatedValues};
});核心实现const useSetState = <S extends Record<string, any>>(
initialState: S | (() => S),
): [S, SetState<S>] => {
const [state, setState] = useState<S>(initialState);
// 自定义新的 setState 函数,返回自动合并的值
const setMergeState = useCallback((patch) => {
setState((prevState) => {
// 传入的 patch 值是否为函数:如果是函数则执行,表示旧的状态。否则直接作为新的状态值
const newState = isFunction(patch) ? patch(prevState) : patch;
// 拓展运算符合并返回新的对象
return newState ? { ...prevState, ...newState } : prevState;
});
}, []);
return [state, setMergeState];
};完整源码useToggle用于在两个状态值间切换的 Hook。官方文档基本用法官方在线 Demo接受两个可选参数,在它们之间进行切换。import React from 'react';
import { useToggle } from 'ahooks';
export default () => {
// Hello 表示左值(默认值), World 表示右值(取反的状态值)
const [state, { toggle, set, setLeft, setRight }] = useToggle('Hello', 'World');
return (
<div>
<p>Effects:{state}</p>
<p>
<button type="button" onClick={toggle}>
Toggle
</button>
<button type="button" onClick={() => set('Hello')} style={{ margin: '0 8px' }}>
Set Hello
</button>
<button type="button" onClick={() => set('World')}>
Set World
</button>
<button type="button" onClick={setLeft} style={{ margin: '0 8px' }}>
Set Left
</button>
<button type="button" onClick={setRight}>
Set Right
</button>
</p>
</div>
);
};相关字段解释state:状态值defaultValue:传入默认的状态值reverseValue:传入取反的状态值toggle:切换 stateset:修改 statesetLeft:设置为 defaultValuesetRight:如果传入了 reverseValue, 则设置为 reverseValue。 否则设置为 defaultValue 的反值先来看看它的类型定义函数重载:针对不同参数个数和类型,推断返回值类型const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T>(defaultValue: T);
const [state, { toggle, set, setLeft, setRight }] = useToggle<T, U>(defaultValue: T, reverseValue: U);核心实现实现比较简单:入参可传有两个值,第一个参数是默认值(左值);第二个是取反之后的值(右值),可以不传;当不传的时候,为 defaultValue 的反值根据这两个值,实现函数 toggle、set、setLeft、setRight(该 Hook 忽略 defaultValue、reverseValue 这两个值的变更,也就是说无需监听;在使用中也需要注意,这两个值是固定值才可使用该 Hook)function useToggle<D, R>(defaultValue: D = false as unknown as D, reverseValue?: R) {
const [state, setState] = useState<D | R>(defaultValue);
const actions = useMemo(() => {
// 取反的状态值
const reverseValueOrigin = (reverseValue === undefined ? !defaultValue : reverseValue) as D | R;
// 切换值(左值与右值)
const toggle = () => setState((s) => (s === defaultValue ? reverseValueOrigin : defaultValue));
// 修改 state
const set = (value: D | R) => setState(value);
// 修改 state
const setLeft = () => setState(defaultValue);
// setRight:如果传入了 reverseValue, 则设置为 reverseValue。 否则设置为 defaultValue 的反值
const setRight = () => setState(reverseValueOrigin);
return {
toggle,
set,
setLeft,
setRight,
};
// useToggle ignore value change
// }, [defaultValue, reverseValue]);
}, []);
return [state, actions];
}完整源码useBoolean优雅的管理 boolean 状态的 Hook。官方文档基本用法上面讲了 useToggle,而 useBoolean 是 useToggle 的其中一种使用场景,下面是 useToggle 的其中一种函数类型定义:const [state, { toggle, set, setLeft, setRight }] = useToggle(defaultValue?: boolean);官方在线 Demo切换 boolean,可以接收默认值。import React from 'react';
import { useBoolean } from 'ahooks';
export default () => {
const [state, { toggle, setTrue, setFalse }] = useBoolean(true);
return (
<div>
<p>Effects:{JSON.stringify(state)}</p>
<p>
<button type="button" onClick={toggle}>
Toggle
</button>
<button type="button" onClick={setFalse} style={{ margin: '0 16px' }}>
Set false
</button>
<button type="button" onClick={setTrue}>
Set true
</button>
</p>
</div>
);
};相关字段解释toggle:切换 stateset:设置 statesetTrue:设置为 truesetFalse:设置为 false核心实现有了 useToggle 的基础,实现比较简单,直接看代码:export default function useBoolean(defaultValue = false): [boolean, Actions] {
const [state, { toggle, set }] = useToggle(defaultValue);
const actions: Actions = useMemo(() => {
const setTrue = () => set(true);
const setFalse = () => set(false);
return {
toggle,
set: (v) => set(!!v),
setTrue,
setFalse,
};
}, []);
return [state, actions];
}完整源码useCookieState一个可以将状态存储在 Cookie 中的 Hook 。官方文档基本用法将 state 存储在 Cookie 中官方在线 Demo刷新页面后,可以看到输入框中的内容被从 Cookie 中恢复了。import React from 'react';
import { useCookieState } from 'ahooks';
export default () => {
// useCookieStateString 表示 Cookie 的 key 值
const [message, setMessage] = useCookieState('useCookieStateString');
return (
<input
value={message}
placeholder="Please enter some words..."
onChange={(e) => setMessage(e.target.value)}
style={{ width: 300 }}
/>
);
};Cookie 相关Document.cookie:获取并设置与当前文档相关联的 cookie。可以把它当成一个 getter and setterJS 操作 cookie 常用的库是 js-cookie,js-cookie 是一个上手简单,轻量的,处理 cookies 的库。它的优点是:简单易用:直接通过 js-cookie 的 API 可以很容易操作 cookie轻量级:js-cookie 压缩后小于 800 字节支持所有浏览器安全性高:js-cookie 带有防止 XSS 攻击的处理机制。它保证了 Cookie 的安全性,可以预防网络劫持或脚本注入等攻击方式。支持 ESM/AMD/CommonJs核心实现useCookieState 返回的是[state, setState]格式默认值的实现:(优先级最高)如果本地 cookie 中已有该值,则直接读取。外部设置的默认值是函数则执行。否则直接返回(options.defaultValue)需要注意的是 options.defaultValue 定义的 Cookie 默认值,但不同步到本地 Cookiefunction useCookieState(cookieKey: string, options: Options = {}) {
const [state, setState] = useState<State>(() => {
const cookieValue = Cookies.get(cookieKey);
// 如果本地 cookie 中已有该值,则直接读取
if (isString(cookieValue)) return cookieValue;
// 外部设置的默认值是函数则执行
if (isFunction(options.defaultValue)) {
return options.defaultValue();
}
// 返回外部传入的默认值
return options.defaultValue;
});
// 设置 Cookie 值
const updateState = useMemoizedFn(
(
newValue: State | ((prevState: State) => State),
newOptions: Cookies.CookieAttributes = {},
) => {
// setState 可以更新 cookie options,会与 useCookieState 设置的 options 进行 merge 操作。
const { defaultValue, ...restOptions } = { ...options, ...newOptions };
setState((prevState) => {
const value = isFunction(newValue) ? newValue(prevState) : newValue;
// 值为 undefined 则清除 cookie
if (value === undefined) {
Cookies.remove(cookieKey);
} else {
// 设置 cookie
Cookies.set(cookieKey, value, restOptions);
}
return value;
});
},
);
return [state, updateState] as const;
}完整源码useLocalStorageState将状态存储在 localStorage 中的 Hook 。官方文档基本用法官方在线 Demo将 state 存储在 localStorage 中import React from 'react';
import { useLocalStorageState } from 'ahooks';
export default function () {
const [message, setMessage] = useLocalStorageState<string | undefined>(
'use-local-storage-state-demo1',
{
defaultValue: 'Hello~',
},
);
return (
<>
<input
value={message || ''}
placeholder="Please enter some words..."
onChange={(e) => setMessage(e.target.value)}
/>
<button style={{ margin: '0 8px' }} type="button" onClick={() => setMessage('Hello~')}>
Reset
</button>
<button type="button" onClick={() => setMessage(undefined)}>
Clear
</button>
</>
);
}核心实现实际上是实现了 createUseStorageState 方法,useLocalStorageState 是调用了 createUseStorageState 返回的结果。useLocalStorageState 在往 localStorage 写入数据前,会先调用一次 serializer,在读取数据之后,会先调用一次 deserializer// 判断是否为浏览器环境
const useLocalStorageState = createUseStorageState(() => (isBrowser ? localStorage : undefined));serializer:序列化方法(存入 storage 使用)deserializer:反序列化方法(从 storage 取出)getStoredValue:获取 storage 的值updateState: 更新 storage 状态值// 序列化
const serializer = (value: T) => {
if (options?.serializer) {
// 支持自定义序列化
return options?.serializer(value);
}
return JSON.stringify(value);
};
// 反序列化
const deserializer = (value: string) => {
if (options?.deserializer) {
// 支持自定义反序列化
return options?.deserializer(value);
}
return JSON.parse(value);
};
// 获取 storage 的值
function getStoredValue() {
try {
const raw = storage?.getItem(key);
if (raw) {
// 反序列化取出值
return deserializer(raw);
}
} catch (e) {
console.error(e);
}
// raw 没值,则使用默认值
if (isFunction(options?.defaultValue)) {
return options?.defaultValue();
}
return options?.defaultValue;
}对于普通的字符串,可能不需要默认的 JSON.stringify/JSON.parse 来序列化。serializer: (v) => v ?? '',
deserializer: (v) => v,再来看下 updateState 方法:如果传入函数,优先取值函数执行后的结果传入 undefined,则表示删除这条数据否则直接设置值// 定义 state 状态同步拿到 storage 值
const [state, setState] = useState<T>(() => getStoredValue());
// 当 key 更新的时候执行
// useUpdateEffect:忽略首次执行,只在依赖更新时执行
useUpdateEffect(() => {
setState(getStoredValue());
}, [key]);
// 更新 storage 状态值
const updateState = (value: T | IFuncUpdater<T>) => {
// 传入函数优先取函数执行后的结果
const currentState = isFunction(value) ? value(state) : value;
setState(currentState);
// 值为 undefined,表示移除该 storage
if (isUndef(currentState)) {
storage?.removeItem(key);
} else {
// 否则直接设置值
try {
storage?.setItem(key, serializer(currentState));
} catch (e) {
console.error(e);
}
}
};完整源码useSessionStorageState将状态存储在 sessionStorage 中的 Hook。同样调用了 createUseStorageState 方法,只需把 localStorage 改为 sessionStorage,其它一致;这里就不展开写了const useSessionStorageState = createUseStorageState(() =>
isBrowser ? sessionStorage : undefined,
);useDebounce用来处理防抖值的 Hook。官方文档基本用法官方在线 DemoDebouncedValue 只会在输入结束 500ms 后变化。import React, { useState } from 'react';
import { useDebounce } from 'ahooks';
export default () => {
const [value, setValue] = useState<string>();
const debouncedValue = useDebounce(value, { wait: 500 });
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Typed value"
style={{ width: 280 }}
/>
<p style={{ marginTop: 16 }}>DebouncedValue: {debouncedValue}</p>
</div>
);
};核心实现来看看支持的选项,都是 lodash.debounce 里面的参数:interface DebounceOptions {
wait?: number; // 等待时间,单位为毫秒
leading?: boolean; // 是否在延迟开始前调用函数
trailing?: boolean; // 是否在延迟开始后调用函数
maxWait?: number; // 最大等待时间,单位为毫秒
}看代码实现主要是依赖 useDebounceFn 这个 Hook,这个 Hook 内部使用的是 lodash 的 debounce 方法。function useDebounce<T>(value: T, options?: DebounceOptions) {
const [debounced, setDebounced] = useState(value);
const { run } = useDebounceFn(() => {
setDebounced(value);
}, options);
// 监听需要防抖的值变化
useEffect(() => {
run(); // 变化就执行 debounced 函数
}, [value]);
return debounced;
}useDebounceFn 的实现:/** 用来处理防抖函数的 Hook。 */
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
// 最新的 fn 防抖函数
const fnRef = useLatest(fn);
// 默认是 1000 毫秒
const wait = options?.wait ?? 1000;
// 防抖函数
const debounced = useMemo(
() =>
debounce(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);
// 卸载时取消防抖函数调用
useUnmount(() => {
debounced.cancel();
});
return {
run: debounced, // 触发执行 fn
cancel: debounced.cancel, // 取消当前防抖
flush: debounced.flush, // 当前防抖立即调用
};
}完整源码useThrottle用来处理节流值的 Hook。官方文档基本用法官方在线 DemoThrottledValue 每隔 500ms 变化一次。import React, { useState } from 'react';
import { useThrottle } from 'ahooks';
export default () => {
const [value, setValue] = useState<string>();
const throttledValue = useThrottle(value, { wait: 500 });
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Typed value"
style={{ width: 280 }}
/>
<p style={{ marginTop: 16 }}>throttledValue: {throttledValue}</p>
</div>
);
};核心实现来看看支持的选项,都是 lodash.throttle 里面的参数:interface ThrottleOptions {
wait?: number; // 等待时间,单位为毫秒
leading?: boolean; // 是否在延迟开始前调用函数
trailing?: boolean; // 是否在延迟开始后调用函数
}看代码实现主要是依赖 useThrottleFn 这个 Hook,这个 Hook 内部使用的是 lodash 的 throttle 方法。function useThrottle<T>(value: T, options?: ThrottleOptions) {
const [throttled, setThrottled] = useState(value);
const { run } = useThrottleFn(() => {
setThrottled(value);
}, options);
useEffect(() => {
run();
}, [value]);
return throttled;
}useThrottleFn 的实现:function useThrottleFn<T extends noop>(fn: T, options?: ThrottleOptions) {
// 最新的 fn 节流函数
const fnRef = useLatest(fn);
// 默认是 1000 毫秒
const wait = options?.wait ?? 1000;
// 节流函数
const throttled = useMemo(
() =>
throttle(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
[],
);
// 卸载时取消节流函数调用
useUnmount(() => {
throttled.cancel();
});
return {
run: throttled, // 触发执行 fn
cancel: throttled.cancel, // 取消当前节流
flush: throttled.flush, // 当前节流立即调用
};
}
lucky0-0
【解读 ahooks 源码系列】DOM篇(四)
前言本文是 ahooks 源码(v3.7.4)系列的第五篇,也是 DOM 篇的完结篇,往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress本文主要解读 useMouse、useResponsive、useScroll、useSize、useFocusWithin的源码实现useMouse监听鼠标位置。官方文档基本用法API:const state: {
screenX: number, // 距离显示器左侧的距离
screenY: number, // 距离显示器顶部的距离
clientX: number, // 距离当前视窗左侧的距离
clientY: number, // 距离当前视窗顶部的距离
pageX: number, // 距离完整页面左侧的距离
pageY: number, // 距离完整页面顶部的距离
elementX: number, // 距离指定元素左侧的距离
elementY: number, // 距离指定元素顶部的距离
elementH: number, // 指定元素的高
elementW: number, // 指定元素的宽
elementPosX: number, // 指定元素距离完整页面左侧的距离
elementPosY: number, // 指定元素距离完整页面顶部的距离
} = useMouse(target?: Target);官方在线 Demoimport React, { useRef } from 'react';
import { useMouse } from 'ahooks';
export default () => {
const ref = useRef(null);
const mouse = useMouse(ref.current);
return (
<>
<div
ref={ref}
style={{
width: '200px',
height: '200px',
backgroundColor: 'gray',
color: 'white',
lineHeight: '200px',
textAlign: 'center',
}}
>
element
</div>
<div>
<p>
Mouse In Element - x: {mouse.elementX}, y: {mouse.elementY}
</p>
<p>
Element Position - x: {mouse.elementPosX}, y: {mouse.elementPosY}
</p>
<p>
Element Dimensions - width: {mouse.elementW}, height: {mouse.elementH}
</p>
</div>
</>
);
};核心实现实现原理:通过监听 mousemove 方法,获取鼠标的位置。通过 getBoundingClientRect(提供了元素的大小及其相对于视口的位置) 获取到 target 元素的位置大小,计算出鼠标相对于元素的位置。export default (target?: BasicTarget) => {
const [state, setState] = useRafState(initState);
useEventListener(
'mousemove',
(event: MouseEvent) => {
const { screenX, screenY, clientX, clientY, pageX, pageY } = event;
const newState = {
screenX,
screenY,
clientX,
clientY,
pageX,
pageY,
elementX: NaN,
elementY: NaN,
elementH: NaN,
elementW: NaN,
elementPosX: NaN,
elementPosY: NaN,
};
const targetElement = getTargetElement(target);
if (targetElement) {
const { left, top, width, height } = targetElement.getBoundingClientRect();
// 计算鼠标相对于元素的位置
newState.elementPosX = left + window.pageXOffset; // window.pageXOffset:window.scrollX 的别名
newState.elementPosY = top + window.pageYOffset; // scrollY 的别名
newState.elementX = pageX - newState.elementPosX;
newState.elementY = pageY - newState.elementPosY;
newState.elementW = width;
newState.elementH = height;
}
setState(newState);
},
{
target: () => document,
},
);
return state;
};完整源码useResponsive获取响应式信息。官方文档基本用法官方在线 Demoimport React from 'react';
import { configResponsive, useResponsive } from 'ahooks';
configResponsive({
small: 0,
middle: 800,
large: 1200,
});
export default function () {
const responsive = useResponsive();
return (
<>
<p>Please change the width of the browser window to see the effect: </p>
{Object.keys(responsive).map((key) => (
<p key={key}>
{key} {responsive[key] ? '✔' : '✘'}
</p>
))}
</>
);
}实现思路监听 resize 事件,在 resize 事件处理函数中需要计算,且判断是否需要更新处理(性能优化)。计算:遍历对比 window.innerWidth 与配置项的每一种屏幕宽度,大于设置为 true,否则为 false核心实现type Subscriber = () => void;
const subscribers = new Set<Subscriber>();
type ResponsiveConfig = Record<string, number>;
type ResponsiveInfo = Record<string, boolean>;
let info: ResponsiveInfo;
// 默认的响应式配置和 bootstrap 是一致的
let responsiveConfig: ResponsiveConfig = {
xs: 0,
sm: 576,
md: 768,
lg: 992,
xl: 1200,
};
function handleResize() {
const oldInfo = info;
calculate();
if (oldInfo === info) return; // 没有更新,不处理
for (const subscriber of subscribers) {
subscriber();
}
}
let listening = false; // 避免多次监听
// 计算当前的屏幕宽度与配置比较
function calculate() {
const width = window.innerWidth; // 返回窗口的的宽度
const newInfo = {} as ResponsiveInfo;
let shouldUpdate = false; // 判断是否需要更新
for (const key of Object.keys(responsiveConfig)) {
newInfo[key] = width >= responsiveConfig[key];
if (newInfo[key] !== info[key]) {
shouldUpdate = true;
}
}
if (shouldUpdate) {
info = newInfo;
}
}
// 自定义配置响应式断点(只需配置一次)
export function configResponsive(config: ResponsiveConfig) {
responsiveConfig = config;
if (info) calculate();
}
export function useResponsive() {
if (isBrowser && !listening) {
info = {};
calculate();
window.addEventListener('resize', handleResize);
listening = true;
}
const [state, setState] = useState<ResponsiveInfo>(info);
useEffect(() => {
if (!isBrowser) return;
// In React 18's StrictMode, useEffect perform twice, resize listener is remove, so handleResize is never perform.
// https://github.com/alibaba/hooks/issues/1910
if (!listening) {
window.addEventListener('resize', handleResize);
}
const subscriber = () => {
setState(info);
};
// 添加订阅
subscribers.add(subscriber);
return () => {
// 组件卸载时取消订阅
subscribers.delete(subscriber);
// 当全局订阅器不再有订阅器,则移除 resize 监听事件
if (subscribers.size === 0) {
window.removeEventListener('resize', handleResize);
listening = false;
}
};
}, []);
return state;
}完整源码useScroll监听元素的滚动位置。官方文档基本用法官方在线 Demo,下方代码的执行结果import React, { useRef } from 'react';
import { useScroll } from 'ahooks';
export default () => {
const ref = useRef(null);
const scroll = useScroll(ref);
return (
<>
<p>{JSON.stringify(scroll)}</p>
<div
style={{
height: '160px',
width: '160px',
border: 'solid 1px #000',
overflow: 'scroll',
whiteSpace: 'nowrap',
fontSize: '32px',
}}
ref={ref}
>
<div>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. A aspernatur atque, debitis ex
excepturi explicabo iste iure labore molestiae neque optio perspiciatis
</div>
<div>
Aspernatur cupiditate, deleniti id incidunt mollitia omnis! A aspernatur assumenda
consequuntur culpa cumque dignissimos enim eos, et fugit natus nemo nesciunt
</div>
<div>
Alias aut deserunt expedita, inventore maiores minima officia porro rem. Accusamus ducimus
magni modi mollitia nihil nisi provident
</div>
<div>
Alias aut autem consequuntur doloremque esse facilis id molestiae neque officia placeat,
quia quisquam repellendus reprehenderit.
</div>
<div>
Adipisci blanditiis facere nam perspiciatis sit soluta ullam! Architecto aut blanditiis,
consectetur corporis cum deserunt distinctio dolore eius est exercitationem
</div>
<div>Ab aliquid asperiores assumenda corporis cumque dolorum expedita</div>
<div>
Culpa cumque eveniet natus totam! Adipisci, animi at commodi delectus distinctio dolore
earum, eum expedita facilis
</div>
<div>
Quod sit, temporibus! Amet animi fugit officiis perspiciatis, quis unde. Cumque
dignissimos distinctio, dolor eaque est fugit nisi non pariatur porro possimus, quas quasi
</div>
</div>
</>
);
};核心实现function useScroll(
target?: Target, // DOM 节点或者 ref
shouldUpdate: ScrollListenController = () => true, // 控制是否更新滚动信息
): Position | undefined {
const [position, setPosition] = useRafState<Position>();
const shouldUpdateRef = useLatest(shouldUpdate); // 控制是否更新滚动信息,默认值: () => true
useEffectWithTarget(
() => {
const el = getTargetElement(target, document);
if (!el) {
return;
}
// 核心处理
const updatePosition = () => {};
updatePosition();
// 监听 scroll 事件
el.addEventListener('scroll', updatePosition);
return () => {
el.removeEventListener('scroll', updatePosition);
};
},
[],
target,
);
return position; // 滚动容器当前的滚动位置
}接下来看看updatePosition方法的实现:const updatePosition = () => {
let newPosition: Position;
// target属性传 document
if (el === document) {
// scrollingElement 返回滚动文档的 Element 对象的引用。
// 在标准模式下,这是文档的根元素, document.documentElement。
// 当在怪异模式下,scrollingElement 属性返回 HTML body 元素(若不存在返回 null)
if (document.scrollingElement) {
newPosition = {
left: document.scrollingElement.scrollLeft,
top: document.scrollingElement.scrollTop,
};
} else {
// 怪异模式的处理:取 window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop 三者中最大值
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/scrollingElement
// https://stackoverflow.com/questions/28633221/document-body-scrolltop-firefox-returns-0-only-js
newPosition = {
left: Math.max(
window.pageXOffset,
document.documentElement.scrollLeft,
document.body.scrollLeft,
),
top: Math.max(
window.pageYOffset,
document.documentElement.scrollTop,
document.body.scrollTop,
),
};
}
} else {
newPosition = {
left: (el as Element).scrollLeft, // 获取滚动条到元素左边的距离(滚动条滚动了多少像素)
top: (el as Element).scrollTop,
};
}
// 判断是否更新滚动信息
if (shouldUpdateRef.current(newPosition)) {
setPosition(newPosition);
}
};Element.scrollLeft 获取滚动条到元素左边的距离Element.scrollTop 获取滚动条到元素顶部的距离useSize监听 DOM 节点尺寸变化的 Hook。官方文档基本用法官方在线 Demoimport React, { useRef } from 'react';
import { useSize } from 'ahooks';
export default () => {
const ref = useRef(null);
const size = useSize(ref);
return (
<div ref={ref}>
<p>Try to resize the preview window </p>
<p>
width: {size?.width}px, height: {size?.height}px
</p>
</div>
);
};核心实现这里涉及 ResizeObserver源码较容易理解,就不展开了// 目标 DOM 节点的尺寸
type Size = { width: number; height: number };
function useSize(target: BasicTarget): Size | undefined {
const [state, setState] = useRafState<Size>();
useIsomorphicLayoutEffectWithTarget(
() => {
const el = getTargetElement(target);
if (!el) {
return;
}
// Resize Observer API 提供了一种高性能的机制,通过该机制,代码可以监视元素的大小更改,并且每次大小更改时都会向观察者传递通知
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
// 返回 DOM 节点的尺寸
const { clientWidth, clientHeight } = entry.target;
setState({
width: clientWidth,
height: clientHeight,
});
});
});
// 监听目标元素
resizeObserver.observe(el);
return () => {
resizeObserver.disconnect();
};
},
[],
target,
);
return state;
}完整源码useFocusWithin监听当前焦点是否在某个区域之内,同 css 属性: focus-within官方文档基本用法官方在线 Demo使用 ref 设置需要监听的区域。可以通过鼠标点击外部区域,或者使用键盘的 tab 等按键来切换焦点。import React, { useRef } from 'react';
import { useFocusWithin } from 'ahooks';
import { message } from 'antd';
export default () => {
const ref = useRef(null);
const isFocusWithin = useFocusWithin(ref, {
onFocus: () => {
message.info('focus');
},
onBlur: () => {
message.info('blur');
},
});
return (
<div>
<div
ref={ref}
style={{
padding: 16,
backgroundColor: isFocusWithin ? 'red' : '',
border: '1px solid gray',
}}
>
<label style={{ display: 'block' }}>
First Name: <input />
</label>
<label style={{ display: 'block', marginTop: 16 }}>
Last Name: <input />
</label>
</div>
<p>isFocusWithin: {JSON.stringify(isFocusWithin)}</p>
</div>
);
};核心实现主要还是监听了 focusin 和 focusout 事件focusin:当元素聚焦时会触发。和 focus 一样,只是 focusin 事件支持冒泡;focusout:当元素即将失去焦点时会被触发。和 blur 一样,只是 focusout 事件支持冒泡。触发顺序:在同时支持四种事件的浏览器中,当焦点在两个元素之间切换时,触发顺序如下(不同浏览器效果可能不同):focusin 在第一个目标元素获得焦点前触发focus 在第一个目标元素获得焦点后触发focusout 第一个目标失去焦点时触发focusin 第二个元素获得焦点前触发blur 第一个元素失去焦点时触发focus 第二个元素获得焦点后触发参考:focus/blur VS focusin/focusoutMouseEvent.relatedTarget 属性返回与触发鼠标事件的元素相关的元素:export default function useFocusWithin(target: BasicTarget, options?: Options) {
const [isFocusWithin, setIsFocusWithin] = useState(false);
const { onFocus, onBlur, onChange } = options || {};
// 监听 focusin 事件
useEventListener(
'focusin',
(e: FocusEvent) => {
if (!isFocusWithin) {
onFocus?.(e);
onChange?.(true);
setIsFocusWithin(true);
}
},
{
target,
},
);
// 监听 focusout 事件
useEventListener(
'focusout',
(e: FocusEvent) => {
// relatedTarget 属性返回与触发鼠标事件的元素相关的元素。
// https://developer.mozilla.org/zh-CN/docs/Web/API/MouseEvent/relatedTarget
if (isFocusWithin && !(e.currentTarget as Element)?.contains?.(e.relatedTarget as Element)) {
onBlur?.(e);
onChange?.(false);
setIsFocusWithin(false);
}
},
{
target,
},
);
return isFocusWithin; // 焦点是否在当前区域
}
lucky0-0
《保姆级》低代码知识点详解(一)
背景在公司里负责低代码平台研发也有几年了,有一些低代码开发心得,这里和大家分享一下。这一篇文章内容比较基础,主要是让大家先了解一些低代码的知识点,为后面一点点带着大家开发一套企业级低代码平台做准备。初始化项目使用vite初始化项目npm create vite安装tailwindcss安装依赖pnpm i -D tailwindcss postcss autoprefixer
pnpx tailwindcss init -p
配置文件把下面内容复制到tailwind.config.js文件中
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}把下面复制到src/index.css中@tailwind base;
@tailwind components;
@tailwind utilities;修改代码启动服务测试npm run dev文件结构按照下面结构创建文件夹,这个文件夹结构是我比较喜欢的结构,大家可以根据自己喜好更改。├── editor\
│ ├── common // 存放公共组件
│ ├── components // 存放物料组件
│ ├── contexts // 存放react Context
│ ├── layouts // 布局
│ │ ├── header // 头
│ │ ├── material // 物料区
│ │ ├── setting // 配置区
│ │ └── renderer // 中间渲染区
│ └── utils // 存放工具方法布局介绍现在市面上的低代码平台大多是像下面截图一样的布局方式。主要结构就这4个头-工具栏左侧-组件库中间-画布右侧-组件属性配置实现简单实现// src/editor/layouts/index.tsx
import React from 'react';
import Header from './header';
import Material from './material';
import Setting from './setting';
import Stage from './stage';
const Layout: React.FC = () => {
return (
<div className='h-[100vh] flex flex-col'>
<div className='h-[50px] flex items-center bg-red-300'>
<Header />
</div>
<div className='flex-1 flex'>
<div className='w-[200px] bg-green-400'>
<Material />
</div>
<div className='flex-1 bg-blue-400'>
<Stage />
</div>
<div className='w-[200px] bg-orange-400'>
<Setting />
</div>
</div>
</div>
)
}
export default Layout;进阶实现让物料区和设置区可以拖拽调整大小,实现这个功能可以使用allotment库,用react-split-pane这个库也行,不过我觉得allotment更好用更简单。pnpm i allotmentimport { Allotment } from "allotment";
import "allotment/dist/style.css";
import React from 'react';
import Header from './header';
import Material from './material';
import Setting from './setting';
import Stage from './stage';
const Layout: React.FC = () => {
return (
<div className='h-[100vh] flex flex-col'>
<div className='h-[50px] flex items-center bg-red-300'>
<Header />
</div>
<Allotment>
<Allotment.Pane preferredSize={200} maxSize={400} minSize={200}>
<Material />
</Allotment.Pane>
<Allotment.Pane>
<Stage />
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
<Setting />
</Allotment.Pane>
</Allotment>
</div>
)
}
export default Layout;效果展示数据结构渲染画布的数据结构interface Component {
/**
* 组件唯一标识
*/
id: number;
/**
* 组件名称
*/
name: string;
/**
* 组件属性
*/
props: any;
/**
* 子组件
*/
children?: Component[];
}
动态渲染组件这个是低代码核心功能,低代码所有的一切都是基于这个构建出来的,不过这个很简单,就是按条件渲染组件。mock数据const components: Component[] = [
{
id: 1,
name: 'Button',
props: {
type: 'primary',
children: '按钮',
},
},
{
id: 2,
name: 'Space',
props: {
size: 'large',
},
children: [{
id: 3,
name: 'Button',
props: {
type: 'primary',
children: '按钮1',
},
}, {
id: 4,
name: 'Button',
props: {
type: 'primary',
children: '按钮2',
},
}]
},
];
渲染组件动态渲染组件最简单的方式是根据组件名称判断渲染某个组件import { Button, Space } from 'antd';
interface Component {
/**
* 组件唯一标识
*/
id: number;
/**
* 组件名称
*/
name: string;
/**
* 组件属性
*/
props: any;
/**
* 子组件
*/
children?: Component[];
}
const components: Component[] = [
{
id: 1,
name: 'Button',
props: {
type: 'primary',
children: '按钮',
},
},
{
id: 2,
name: 'Space',
props: {
size: 'large',
},
children: [{
id: 3,
name: 'Button',
props: {
type: 'primary',
children: '按钮1',
},
}, {
id: 4,
name: 'Button',
props: {
type: 'primary',
children: '按钮2',
},
}]
},
];
const Stage: React.FC = () => {
function renderComponents(components: Component[]) {
return components.map((component) => {
if (component.name === 'Button') {
return (
<Button {...component.props}>{component.props.children}</Button>
)
} else if (component.name === 'Space') {
return (
<Space {...component.props}>{renderComponents(component.children || [])}</Space>
)
}
})
}
return (
<div className='p-[24px]'>
{renderComponents(components)}
</div>
)
}
export default Stage;使用if/else判断,组件多了,代码会越来越多。我们使用策略模式改造一下,使用React.createElement动态创建组件。 function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
if (!ComponentMap[component.name]) {
return null;
}
if (ComponentMap[component.name]) {
return React.createElement(ComponentMap[component.name], component.props, component.props.children || renderComponents(component.children || []))
}
})
}渲染效果拖拽前言前面数据是写死的,现在我们可以从物料区拖拽组件到画布区动态渲染。这里拖拽库使用大名鼎鼎的react-dnd,功能很强大,可以满足我们所有要求。对react-dnd不了解的,可以先看下官网示例。安装react-dnd依赖pnpm i react-dnd-html5-backend react-dnd
实战改造main.tsx文件,使用DndProvider包裹Layout组件。// src/main.tsx
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import ReactDOM from 'react-dom/client'
import Layout from './editor/layouts'
import './index.css'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<DndProvider backend={HTML5Backend}>
<Layout />
</DndProvider>
)定义组件类型映射为了统一组件名称,我们定义一个组件名称映射对象,后面组件名称都从这里取。// src/editor/item-type.ts
export const ItemType = {
Button: 'Button',
Space: 'Space',
};改造画布文件,可以放置从物料区拖拽过来的组件这里可以使用react-dnd里的useDrop。// src/editor/layouts/stage/index.tsx
import { Button } from 'antd';
import React from 'react';
import { useDrop } from 'react-dnd';
import Space from '../../components/space';
import { ItemType } from '../../item-type';
import { Component } from '../../stores/components';
const ComponentMap: { [key: string]: any } = {
Button: Button,
Space: Space,
}
const Stage: React.FC = () => {
const components: Component[] = [];
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
if (!ComponentMap[component.name]) {
return null;
}
if (ComponentMap[component.name]) {
return React.createElement(
ComponentMap[component.name],
{ key: component.id, id: component.id, ...component.props },
component.props.children || renderComponents(component.children || [])
)
}
return null;
})
}
// 如果拖拽的组件是可以放置的,canDrop则为true,通过这个可以给组件添加边框
const [{ canDrop }, drop] = useDrop(() => ({
// 可以接受的元素类型
accept: [
ItemType.Space,
ItemType.Button,
],
drop: (_, monitor) => {
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}
return {
id: 0,
}
},
collect: (monitor) => ({
canDrop: monitor.canDrop(),
}),
}));
return (
<div ref={drop} style={{ border: canDrop ? '1px solid #ccc' : 'none' }} className='p-[24px] h-[100%]'>
{renderComponents(components)}
</div>
)
}
export default Stage;改造物料区,添加可拖拽组件首先封装一个公共的可拖拽组件,使用react-dnd里面的useDrag。// src/editor/common/component-item.tsx
import { useDrag } from 'react-dnd';
import { ItemType } from '../item-type';
interface ComponentItemProps {
// 组件名称
name: string,
// 组件描述
description: string,
// 拖拽结束回调
onDragEnd: any,
}
const ComponentItem: React.FC<ComponentItemProps> = ({ name, description, onDragEnd }) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: name,
end: (_, monitor) => {
const dropResult = monitor.getDropResult();
console.log(dropResult, 'dropResult');
if (!dropResult) return;
onDragEnd && onDragEnd({
name,
props: name === ItemType.Button ? { children: '按钮' } : {},
...dropResult,
});
},
collect: (monitor) => ({
isDragging: monitor.isDragging(),
handlerId: monitor.getHandlerId(),
}),
}));
const opacity = isDragging ? 0.4 : 1;
return (
<div
ref={drag}
className='border-dashed border-[1px] border-[gray] bg-white cursor-move py-[8px] px-[20px] rounded-lg'
style={{
opacity,
}}
>
{description}
</div>
)
}
export default ComponentItem;在物料组件中使用上面功能组件import ComponentItem from '../../common/component-item';
import { ItemType } from '../../item-type';
const Material: React.FC = () => {
const onDragEnd = (dropResult: any) => {
console.log(dropResult);
}
return (
<div className='flex p-[10px] gap-4 flex-wrap'>
<ComponentItem onDragEnd={onDragEnd} description='按钮' name={ItemType.Button} />
<ComponentItem onDragEnd={onDragEnd} description='间距' name={ItemType.Space} />
</div>
)
}
export default Material;效果展示动态添加组件监听拖拽完成事件,往组件树里添加当前拖拽的组件。因为经常跨层级操作组件树,所以这里使用zustand来存储组件树。安装zustand依赖pnpm i zustand创建store// src/editor/stores/components.ts
import {create} from 'zustand';
export interface Component {
/**
* 组件唯一标识
*/
id: number;
/**
* 组件名称
*/
name: string;
/**
* 组件属性
*/
props: any;
/**
* 子组件
*/
children?: Component[];
}
interface State {
components: Component[];
}
interface Action {
/**
* 添加组件
* @param component 组件属性
* @returns
*/
addComponent: (component: Component) => void;
}
export const useComponets = create<State & Action>((set) => ({
components: [],
addComponent: (component) =>
set((state) => {
return {components: [...state.components, component]};
}),
}));改造画布渲染组件,从store中获取组件树。改造物料拖拽结束事件import ComponentItem from '../../common/component-item';
import { ItemType } from '../../item-type';
import { useComponets } from '../../stores/components';
const Material: React.FC = () => {
const { addComponent } = useComponets();
/**
* 拖拽结束,添加组件到画布
* @param dropResult
*/
const onDragEnd = (dropResult: { name: string, props: any }) => {
addComponent({
id: new Date().getTime(),
name: dropResult.name,
props: dropResult.props,
});
}
return (
<div className='flex p-[10px] gap-4 flex-wrap'>
<ComponentItem onDragEnd={onDragEnd} description='按钮' name={ItemType.Button} />
<ComponentItem onDragEnd={onDragEnd} description='间距' name={ItemType.Space} />
</div>
)
}
export default Material;
效果展示支持嵌套组件假设现在有一个间距组件(Space),需要往间距组件中放置按钮组件,只需要使用useDrop把间距组件(Space)改造成可放置组件就行了。在components文件夹自定义间距组件(Space)// src/editor/components/space/index.tsx
import { Space as AntdSpace } from 'antd';
import React from "react";
import { useDrop } from 'react-dnd';
import { ItemType } from '../../item-type';
interface Props {
// 当前组件的子节点
children: any;
// 当前组件的id
id: number;
}
const Space: React.FC<Props> = ({ children, id }) => {
const [{ canDrop }, drop] = useDrop(() => ({
accept: [ItemType.Space, ItemType.Button],
drop: (_, monitor) => {
const didDrop = monitor.didDrop()
if (didDrop) {
return;
}
// 这里把当前组件的id返回出去,在拖拽结束事件里可以拿到这个id。
return {
id,
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
}));
if (!children?.length) {
return (
<AntdSpace ref={drop} className='p-[16px]' style={{ border: canDrop ? '1px solid #ccc' : 'none' }}>
暂无内容
</AntdSpace>
)
}
return (
<AntdSpace ref={drop} className='p-[16px]' style={{ border: canDrop ? '1px solid #ccc' : 'none' }}>
{children}
</AntdSpace>
)
}
export default Space;改造store中addComponent方法递归查找父组件的方法实现改造拖拽后事件,把drop传过来的id传给addComponent方法效果展示选中组件高亮显示前言想修改组件配置,首先要选中组件,选中组件有两种常用实现方式一种是给每个组件加点击事件,点击后把当前组件id设置到全局,然后在组件内部加选中蒙版。还有一种方案是给每个组件添加一个data-component-id,然后监听渲染点击事件,点击后判断点击的元素是否有data-component-id属性,如果有,获取data-component-id值设置到全局,然后根据当前元素坐标和大小动态渲染一个遮罩盖在上面。这里我推荐使用第二种,简单而优雅。实战渲染时给每个组件添加data-component-id属性store中添加当前选中组件id属性和设置当前选中组件id方法给stage添加点击事件封装选中遮罩组件这里用到了react-dom里的createPortal方法,把一个组件渲染到别的元素里,antd的弹框就是用这个api渲染到body上。// src/editor/common/selected-mask.tsx
import {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { createPortal } from 'react-dom';
interface Props {
// 组件id
componentId: number,
// 容器class
containerClassName: string,
// 相对容器class
offsetContainerClassName: string
}
function SelectedMask({ componentId, containerClassName, offsetContainerClassName }: Props, ref: any) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
});
// 对外暴露更新位置方法
useImperativeHandle(ref, () => ({
updatePosition,
}));
useEffect(() => {
updatePosition();
}, [componentId]);
function updatePosition() {
if (!componentId) return;
const container = document.querySelector(`.${offsetContainerClassName}`);
if (!container) return;
const node = document.querySelector(`[data-component-id="${componentId}"]`);
if (!node) return;
// 获取节点位置
const { top, left, width, height } = node.getBoundingClientRect();
// 获取容器位置
const { top: containerTop, left: containerLeft } = container.getBoundingClientRect();
console.log(top - containerTop + container.scrollTop, left - containerLeft);
// 计算位置
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft,
width,
height,
});
}
return createPortal((
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(66, 133, 244, 0.2)",
border: "1px solid rgb(66, 133, 244)",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 1003,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
), document.querySelector(`.${containerClassName}`)!)
}
export default forwardRef(SelectedMask);改造stage组件,引入遮罩组件效果展示属性前言现在组件属性都是写死的,我们希望选中组件后,能够更改当前组件属性。store中添加更改组件属性方法更改setting.tsx文件根据当前选中的组件类型动态渲染配置表单,监听表单元素值改变,修改组件属性。import { Form, Input, Select } from 'antd';
import { useEffect } from 'react';
import { ItemType } from '../../item-type';
import { useComponets } from '../../stores/components';
const componentSettingMap = {
[ItemType.Button]: [{
name: 'type',
label: '按钮类型',
type: 'select',
options: [{ label: '主按钮', value: 'primary' }, { label: '次按钮', value: 'default' }],
}, {
name: 'children',
label: '文本',
type: 'input',
}],
[ItemType.Space]: [
{
name: 'size',
label: '间距大小',
type: 'select',
options: [
{ label: '大', value: 'large' },
{ label: '中', value: 'middle' },
{ label: '小', value: 'small' },
],
},
],
}
const Setting: React.FC = () => {
const { curComponentId, updateComponentProps, curComponent } = useComponets();
const [form] = Form.useForm();
useEffect(() => {
// 初始化表单
form.setFieldsValue(curComponent?.props);
}, [curComponent])
/**
* 动态渲染表单元素
* @param setting 元素配置
* @returns
*/
function renderFormElememt(setting: any) {
const { type, options } = setting;
if (type === 'select') {
return (
<Select options={options} />
)
} else if (type === 'input') {
return (
<Input />
)
}
}
// 监听表单值变化,更新组件属性
function valueChange(changeValues: any) {
if (curComponentId) {
updateComponentProps(curComponentId, changeValues);
}
}
if (!curComponentId || !curComponent) return null;
// 根据组件类型渲染表单
return (
<div className='pt-[20px]'>
<Form
form={form}
onValuesChange={valueChange}
labelCol={{ span: 8 }}
wrapperCol={{ span: 14 }}
>
{(componentSettingMap[curComponent.name] || []).map(setting => {
return (
<Form.Item name={setting.name} label={setting.label}>
{renderFormElememt(setting)}
</Form.Item>
)
})}
</Form>
</div> )}export default Setting;效果展示效果展示预览前言页面编辑完,一般需要先预览一下,我们实现一下预览功能。实战给store加一个mode属性,表示当前是编辑模式还是预览模式改造header.tsx组件,添加预览和退出预览按钮// src/editor/layouts/header/index.tsx
import { Button, Space } from 'antd';
import { useComponets } from '../../stores/components';
const Header: React.FC = () => {
const { mode, setMode, setCurComponentId } = useComponets();
return (
<div className='flex justify-end w-[100%] px-[24px]'>
<Space>
{mode === 'edit' && (
<Button
onClick={() => {
setMode('preview');
setCurComponentId(null);
}}
type='primary'
>
预览
</Button>
)}
{mode === 'preview' && (
<Button
onClick={() => { setMode('edit') }}
type='primary'
>
退出预览
</Button>
)}
</Space>
</div>
)
}
export default Header;
添加预览模式画布组件和编辑模式下的画布组件差不多,把物料区和属性配置区隐藏掉。// src/editor/layouts/stage/prod.tsx
import { Button, Space } from 'antd';
import React from 'react';
import { Component, useComponets } from '../../stores/components';
const ComponentMap: { [key: string]: any } = {
Button: Button,
Space: Space,
}
const ProdStage: React.FC = () => {
const { components } = useComponets();
function renderComponents(components: Component[]): React.ReactNode {
return components.map((component: Component) => {
if (!ComponentMap[component.name]) {
return null;
}
if (ComponentMap[component.name]) {
return React.createElement(
ComponentMap[component.name],
{
key: component.id,
id: component.id,
...component.props,
},
component.props.children || renderComponents(component.children || [])
)
}
return null;
})
}
return (
<div>
{renderComponents(components)}
</div>
);
}
export default ProdStage;修改布局组件,根据模式渲染不同的画布// src/editor/layouts/index.tsx
import { Allotment } from "allotment";
import "allotment/dist/style.css";
import React from 'react';
import { useComponets } from '../stores/components';
import Header from './header';
import Material from './material';
import Setting from './setting';
import EditStage from './stage/edit';
import ProdStage from './stage/prod';
const Layout: React.FC = () => {
const { mode } = useComponets();
return (
<div className='h-[100vh] flex flex-col'>
<div className='h-[50px] flex items-centen border-solid border-[1px] border-b-[#ccc]'>
<Header />
</div>
{mode === 'edit' ? (
<Allotment>
<Allotment.Pane preferredSize={200} maxSize={400} minSize={200}>
<Material />
</Allotment.Pane>
<Allotment.Pane>
<EditStage />
</Allotment.Pane>
<Allotment.Pane preferredSize={300} maxSize={500} minSize={300}>
<Setting />
</Allotment.Pane>
</Allotment>
) : (
<ProdStage />
)}
</div>
)
}
export default Layout;效果展示最后由于篇幅有限,这篇就到这里了。后面还有组件属性动态绑定变量,组件联动,远程加载组件等知识点讲解,敬请关注。
lucky0-0
【解读 ahooks 源码系列】LifeCycle 篇 与 Scene 篇(一)
前言本文是 ahooks 源码(v3.7.4)系列的第十二篇——LifeCycle 篇 与 Scene 篇(一)往期文章:【解读 ahooks 源码系列】(开篇)如何获取和监听 DOM 元素:useEffectWithTarget【解读 ahooks 源码系列】DOM 篇(一):useEventListener、useClickAway、useDocumentVisibility、useDrop、useDrag【解读 ahooks 源码系列】DOM 篇(二):useEventTarget、useExternal、useTitle、useFavicon、useFullscreen、useHover【解读 ahooks 源码系列】DOM 篇(三):useMutationObserver、useInViewport、useKeyPress、useLongPress【解读 ahooks 源码系列】DOM 篇(四):useMouse、useResponsive、useScroll、useSize、useFocusWithin【解读 ahooks 源码系列】Dev 篇——useTrackedEffect 和 useWhyDidYouUpdate【解读 ahooks 源码系列】Advanced 篇:useControllableValue、useCreation、useIsomorphicLayoutEffect、useEventEmitter、useLatest、useMemoizedFn、useReactive【解读 ahooks 源码系列】State 篇(一):useSetState、useToggle、useBoolean、useCookieState、useLocalStorageState、useSessionStorageState、useDebounce、useThrottle【解读 ahooks 源码系列】State 篇(二):useMap、useSet、usePrevious、useRafState、useSafeState、useGetState、useResetState【解读 ahooks 源码系列】Effect 篇(一):useUpdateEffect、useUpdateLayoutEffect、useAsyncEffect、useDebounceFn、useDebounceEffect、useThrottleFn、useThrottleEffect【解读 ahooks 源码系列】Effect 篇(二):useDeepCompareEffect、useDeepCompareLayoutEffect、useInterval、useTimeout、useRafInterval、useRafTimeout、useLockFn、useUpdate、useThrottleEffect本文主要解读 useMount、useUnmount、useUnmountedRef、useCounter、useNetwork、useSelections、useHistoryTravel 的源码实现LifeCycle 篇的三个 Hook 都很简单,都是看了名字和 Demo 基本就知道怎么实现的,所以不单独抽离篇章了。useMount只在组件初始化时执行的 Hook。官方文档基本用法官方在线 Demoimport { useMount, useBoolean } from 'ahooks';
import { message } from 'antd';
import React from 'react';
const MyComponent = () => {
useMount(() => {
message.info('mount');
});
return <div>Hello World</div>;
};
export default () => {
const [state, { toggle }] = useBoolean(false);
return (
<>
<button type="button" onClick={toggle}>
{state ? 'unmount' : 'mount'}
</button>
{state && <MyComponent />}
</>
);
};核心实现实现就只是在 useEffect 封装了第一个参数回调(依赖为空数组)const useMount = (fn: () => void) => {
useEffect(() => {
fn?.();
}, []);
};完整源码useUnmount在组件卸载(unmount)时执行的 Hook。官方文档基本用法官方在线 Demoimport { useBoolean, useUnmount } from 'ahooks';
import { message } from 'antd';
import React from 'react';
const MyComponent = () => {
useUnmount(() => {
message.info('unmount');
});
return <p>Hello World!</p>;
};
export default () => {
const [state, { toggle }] = useBoolean(true);
return (
<>
<button type="button" onClick={toggle}>
{state ? 'unmount' : 'mount'}
</button>
{state && <MyComponent />}
</>
);
};核心实现实现就只是在 useEffect 的返回值中执行传入的函数const useUnmount = (fn: () => void) => {
const fnRef = useLatest(fn);
useEffect(
() => () => {
fnRef.current();
},
[],
);
};完整源码useUnmountedRef获取当前组件是否已经卸载的 Hook。官方文档基本用法官方在线 Demoimport { useBoolean, useUnmountedRef } from 'ahooks';
import { message } from 'antd';
import React, { useEffect } from 'react';
const MyComponent = () => {
const unmountedRef = useUnmountedRef();
useEffect(() => {
setTimeout(() => {
if (!unmountedRef.current) {
message.info('component is alive');
}
}, 3000);
}, []);
return <p>Hello World!</p>;
};
export default () => {
const [state, { toggle }] = useBoolean(true);
return (
<>
<button type="button" onClick={toggle}>
{state ? 'unmount' : 'mount'}
</button>
{state && <MyComponent />}
</>
);
};核心实现实现原理:通过判断有无执行 useEffect 中的返回值来判断组件是否已卸载。const useUnmountedRef = () => {
const unmountedRef = useRef(false);
useEffect(() => {
unmountedRef.current = false;
return () => {
// 组件卸载
unmountedRef.current = true;
};
}, []);
return unmountedRef;
};完整源码以下是 Scene 篇:useCounter管理计数器的 Hook。官方文档基本用法官方在线 Demo简单的 counter 管理示例。import React from 'react';
import { useCounter } from 'ahooks';
export default () => {
const [current, { inc, dec, set, reset }] = useCounter(100, { min: 1, max: 10 });
return (
<div>
<p>{current} [max: 10; min: 1;]</p>
<div>
<button
type="button"
onClick={() => {
inc();
}}
style={{ marginRight: 8 }}
>
inc()
</button>
<button
type="button"
onClick={() => {
dec();
}}
style={{ marginRight: 8 }}
>
dec()
</button>
<button
type="button"
onClick={() => {
set(3);
}}
style={{ marginRight: 8 }}
>
set(3)
</button>
<button type="button" onClick={reset} style={{ marginRight: 8 }}>
reset()
</button>
</div>
</div>
);
};核心实现这个 hooks 的实现不难,其实就是内部封装暴露相应方法对数值进行管理。function useCounter(initialValue: number = 0, options: Options = {}) {
// 获取外部传入的最小值与最大值
const { min, max } = options;
const [current, setCurrent] = useState(() => {
return getTargetValue(initialValue, {
min,
max,
});
});
// 设置值(支持传入 number 类型或函数)
const setValue = (value: ValueParam) => {
setCurrent((c) => {
const target = isNumber(value) ? value : value(c);
return getTargetValue(target, {
max,
min,
});
});
};
// 增加数值(默认加1)
const inc = (delta: number = 1) => {
setValue((c) => c + delta);
};
// 减少数值(默认减1)
const dec = (delta: number = 1) => {
setValue((c) => c - delta);
};
// 设置值
const set = (value: ValueParam) => {
setValue(value);
};
// 重置为初始值
const reset = () => {
setValue(initialValue);
};
return [
current,
{
inc: useMemoizedFn(inc),
dec: useMemoizedFn(dec),
set: useMemoizedFn(set),
reset: useMemoizedFn(reset),
},
] as const;
}接下来重点看看 getTargetValue 的实现,该方法利用 Math.min 和 Math.max 进行取值,最终保证返回的值范围是大于等于 min,小于等于 max。// 获取目标数值
function getTargetValue(val: number, options: Options = {}) {
const { min, max } = options;
let target = val;
if (isNumber(max)) {
// 取小于等于 max 的值
target = Math.min(max, target);
}
if (isNumber(min)) {
// 取大于等于 min 的值
target = Math.max(min, target);
}
return target;
}完整源码useNetwork管理网络连接状态的 Hook。官方文档基本用法官方在线 Demo返回网络状态信息import React from 'react';
import { useNetwork } from 'ahooks';
export default () => {
const networkState = useNetwork();
return (
<div>
<div>Network information: </div>
<pre>{JSON.stringify(networkState, null, 2)}</pre>
</div>
);
};核心实现浏览器事件:online:浏览器在线工作时,online 事件被触发offline:当浏览器失去网络连接时,offline 事件被触发Network Change Event:监听网络连接状态该 Hook 用到的是 NetworkInformation API实现思路:监听 online、offline、navigator.connection.onchange 三个事件来处理逻辑,如自定义 state 变量 online(网络是否为在线) 和 since(online 最后改变时间),在事件回调改变值function useNetwork(): NetworkState {
const [state, setState] = useState(() => {
return {
since: undefined,
online: navigator?.onLine,
...getConnectionProperty(),
};
});
useEffect(() => {
// 在线,设置 online 为 true
const onOnline = () => {
setState((prevState) => ({
...prevState,
online: true,
since: new Date(), // 记录最后一次更新的时间
}));
};
// 离线,设置 online 为 false
const onOffline = () => {
setState((prevState) => ({
...prevState,
online: false,
since: new Date(), // 记录最后一次更新的时间
}));
};
// 监听网络连接状态
const onConnectionChange = () => {
setState((prevState) => ({
...prevState,
...getConnectionProperty(),
}));
};
window.addEventListener(NetworkEventType.ONLINE, onOnline);
window.addEventListener(NetworkEventType.OFFLINE, onOffline);
const connection = getConnection();
connection?.addEventListener(NetworkEventType.CHANGE, onConnectionChange);
return () => {
window.removeEventListener(NetworkEventType.ONLINE, onOnline);
window.removeEventListener(NetworkEventType.OFFLINE, onOffline);
connection?.removeEventListener(NetworkEventType.CHANGE, onConnectionChange);
};
}, []);
return state;
}getConnectionProperty 方法的实现,实际就是取 nav.connection 属性,然后获取里面属性,暴露出来// 获取网络状态
function getConnection() {
const nav = navigator as any;
if (!isObject(nav)) return null;
return nav.connection || nav.mozConnection || nav.webkitConnection;
}
function getConnectionProperty(): NetworkState {
const c = getConnection();
if (!c) return {};
return {
rtt: c.rtt,
type: c.type,
saveData: c.saveData,
downlink: c.downlink,
downlinkMax: c.downlinkMax,
effectiveType: c.effectiveType,
};
}完整源码useSelections常见联动 Checkbox 逻辑封装,支持多选,单选,全选逻辑,还提供了是否选择,是否全选,是否半选的状态。官方文档基本用法官方在线 Demoimport { Checkbox, Col, Row } from 'antd';
import React, { useMemo, useState } from 'react';
import { useSelections } from 'ahooks';
export default () => {
const [hideOdd, setHideOdd] = useState(false);
const list = useMemo(() => {
if (hideOdd) {
return [2, 4, 6, 8];
}
return [1, 2, 3, 4, 5, 6, 7, 8];
}, [hideOdd]);
const { selected, allSelected, isSelected, toggle, toggleAll, partiallySelected } = useSelections(
list,
[1],
);
return (
<div>
<div>Selected : {selected.join(',')}</div>
<div style={{ borderBottom: '1px solid #E9E9E9', padding: '10px 0' }}>
<Checkbox checked={allSelected} onClick={toggleAll} indeterminate={partiallySelected}>
Check all
</Checkbox>
<Checkbox checked={hideOdd} onClick={() => setHideOdd((v) => !v)}>
Hide Odd
</Checkbox>
</div>
<Row style={{ padding: '10px 0' }}>
{list.map((o) => (
<Col span={12} key={o}>
<Checkbox checked={isSelected(o)} onClick={() => toggle(o)}>
{o}
</Checkbox>
</Col>
))}
</Row>
</div>
);
};核心实现这个实现主要是结合 Set 结构使用数组来存储和操作数据,封装后暴露方法function useSelections<T>(items: T[], defaultSelected: T[] = []) {
const [selected, setSelected] = useState<T[]>(defaultSelected);
const selectedSet = useMemo(() => new Set(selected), [selected]);
// 判断是否选中
const isSelected = (item: T) => selectedSet.has(item);
// 添加选中项到数组
const select = (item: T) => {
selectedSet.add(item);
// 这里需要使用 Array.from 将 Set 结构转为数组再存储
return setSelected(Array.from(selectedSet));
};
// 取消/移除选中项
const unSelect = (item: T) => {
selectedSet.delete(item);
return setSelected(Array.from(selectedSet));
};
// 反选元素
const toggle = (item: T) => {
if (isSelected(item)) {
unSelect(item);
} else {
select(item);
}
};
// 选择全部元素
const selectAll = () => {
items.forEach((o) => {
selectedSet.add(o);
});
setSelected(Array.from(selectedSet));
};
// 取消选择全部元素
const unSelectAll = () => {
items.forEach((o) => {
selectedSet.delete(o);
});
setSelected(Array.from(selectedSet));
};
// 是否一个都没有选择
const noneSelected = useMemo(() => items.every((o) => !selectedSet.has(o)), [items, selectedSet]);
// 是否全选
const allSelected = useMemo(
() => items.every((o) => selectedSet.has(o)) && !noneSelected,
[items, selectedSet, noneSelected],
);
// 是否半选
const partiallySelected = useMemo(
() => !noneSelected && !allSelected,
[noneSelected, allSelected],
);
// 反选全部元素
const toggleAll = () => (allSelected ? unSelectAll() : selectAll());
return {
selected,
noneSelected,
allSelected,
partiallySelected,
setSelected,
isSelected,
select: useMemoizedFn(select),
unSelect: useMemoizedFn(unSelect),
toggle: useMemoizedFn(toggle),
selectAll: useMemoizedFn(selectAll),
unSelectAll: useMemoizedFn(unSelectAll),
toggleAll: useMemoizedFn(toggleAll),
} as const;完整源码useHistoryTravel管理状态历史变化记录,方便在历史记录中前进与后退。官方文档基本用法官方在线 Demoimport { useHistoryTravel } from 'ahooks';
import React from 'react';
export default () => {
const { value, setValue, backLength, forwardLength, back, forward } = useHistoryTravel<string>();
return (
<div>
<input value={value || ''} onChange={(e) => setValue(e.target.value)} />
<button disabled={backLength <= 0} onClick={back} style={{ margin: '0 8px' }}>
back
</button>
<button disabled={forwardLength <= 0} onClick={forward}>
forward
</button>
</div>
);
};核心实现实现思路:通过队列的方式维护过去和未来的队列,实现了两个工具函数 dumpIndex 和 splitfunction useHistoryTravel<T>(initialValue?: T, maxLength: number = 0) {
const [history, setHistory] = useState<IData<T | undefined>>({
present: initialValue, // 当前值
past: [], // 可回退历史队列
future: [], // 可前进历史队列
});
const { present, past, future } = history;
const initialValueRef = useRef(initialValue);
// 重置
const reset = (...params: any[]) => {
const _initial = params.length > 0 ? params[0] : initialValueRef.current;
initialValueRef.current = _initial;
setHistory({
present: _initial, // 重置到初始值或提供一个新的初始值
future: [],
past: [],
});
};
// 设置 value 值,都是往可回退的队列里添加值
const updateValue = (val: T) => {
const _past = [...past, present];
const maxLengthNum = isNumber(maxLength) ? maxLength : Number(maxLength);
// 有传历史记录最大长度 && 可回退历史长度大于最大长度
if (maxLengthNum > 0 && _past.length > maxLengthNum) {
// 删除第一个记录
_past.splice(0, 1);
}
setHistory({
present: val,
future: [], // 置空可前进历史队列
past: _past,
});
};
// 前进,默认前进一步(调用 split 函数,第二个参数传 future)
const _forward = (step: number = 1) => {
if (future.length === 0) {
return;
}
const { _before, _current, _after } = split(step, future);
setHistory({
// 旧状态,加上现在以及刚过去的
past: [...past, present, ..._before],
present: _current,
future: _after,
});
};
// 后退,默认后退一步(调用 split 函数,第二个参数传 past
const _backward = (step: number = -1) => {
if (past.length === 0) {
return;
}
const { _before, _current, _after } = split(step, past);
setHistory({
past: _before,
present: _current,
future: [..._after, present, ...future],
});
};
// 前进步数
const go = (step: number) => {
const stepNum = isNumber(step) ? step : Number(step);
if (stepNum === 0) {
return;
}
if (stepNum > 0) {
return _forward(stepNum);
}
_backward(stepNum);
};
return {
value: present, // 当前值
backLength: past.length, // 可回退历史长度
forwardLength: future.length, // 可前进历史长度
setValue: useMemoizedFn(updateValue), // 设置 value
go: useMemoizedFn(go), // 前进步数, step < 0 为后退, step > 0 时为前进
back: useMemoizedFn(() => {
go(-1); // 向后回退一步
}),
forward: useMemoizedFn(() => {
go(1); // 向前前进一步
}),
reset: useMemoizedFn(reset), // 重置到初始值,或提供一个新的初始值
};
}dumpIndex:计算一个数组中的索引值,可以向前或向后移动指定的步数,并确保返回的索引值在数组的索引范围内正数:step > 0,index = step - 1 (数组的索引值从 0 开始)负数:step < 0 ,index = arr.length + step边界限制: 0 <= index <= arr.length - 1const dumpIndex = <T>(step: number, arr: T[]) => {
let index =
step > 0
? step - 1 // move forward
: arr.length + step; // move backward
if (index >= arr.length - 1) {
index = arr.length - 1;
}
if (index < 0) {
index = 0;
}
return index;
};split:根据传入的 targetArr、step,返回当前、之前、未来状态的队列const split = <T>(step: number, targetArr: T[]) => {
const index = dumpIndex(step, targetArr);
return {
_current: targetArr[index],
_before: targetArr.slice(0, index),
_after: targetArr.slice(index + 1),
};
};
lucky0-0
低代码事件绑定和组件联动——低代码知识点详解(二)
背景上篇文章实现了低代码基础功能,这一篇来实现低代码高级一点的东西,事件绑定和组件联动,有了这两个东西,低代码做出来的东西就不再是静态页面,可以拥有逻辑。大纲下面我们实现一下查看组件大纲树功能,使用antd的Tree组件。封装一个组件树弹框组件
// src/editor/layouts/header/component-tree.tsx
import { Modal, Tree } from 'antd';
import { useComponets } from '../../stores/components';
interface ComponentTreeProps {
open: boolean,
onCancel: () => void,
}
const ComponentTree = ({ open, onCancel }: ComponentTreeProps) => {
const { components, setCurComponentId } = useComponets();
// 选择组件后,高亮当前组件,并关闭弹框
function componentSelect([selectedKey]: any[]) {
setCurComponentId(selectedKey);
onCancel && onCancel();
}
return (
<Modal
open={open}
title="组件树"
onCancel={onCancel}
destroyOnClose
footer={null}
>
<Tree
fieldNames={{ title: 'name', key: 'id' }}
treeData={components as any}
showLine
defaultExpandAll
onSelect={componentSelect}
/>
</Modal>
)
}
export default ComponentTree;
在头上工具栏加一个查看大纲按钮。选择一个后事件前言前端很多组件都会有事件,组件之间联动基本都是由事件发起的。下面实现一个简单demo,点击按钮显示一个消息提示。改造属性配置文件,增加事件配置为了不让配置文件代码变得很多,把属性配置和事件配置拆成两个组件。import { Segmented } from 'antd';
import type { SegmentedValue } from 'antd/es/segmented';
import { useState } from 'react';
import { useComponets } from '../../stores/components';
import ComponentAttr from './attr';
import ComponentEvent from './event';
const Setting: React.FC = () => {
const { curComponentId, curComponent } = useComponets();
const [key, setKey] = useState<SegmentedValue>('属性');
if (!curComponentId || !curComponent) return null;
return (
<div>
<Segmented value={key} onChange={setKey} block options={['属性', '事件']} />
<div className='pt-[20px]'>
{
key === '属性' && (
<ComponentAttr />
)
}
{
key === '事件' && (
<ComponentEvent />
)
}
</div>
</div>
)
}
export default Setting;
事件数据结构{
id: 1,
name: 'Button',
props: {
// 点击事件绑定显示消息动作
onClick: {
// 动作类型
type: 'ShowMessage',
// 动作配置
config: {
// 消息类型
type: 'success',
// 消息文本
text: '点击了按钮',
}
}
}
}
根据上面数据结构实现事件动作配置功能代码如下// src/editor/layouts/setting/event.tsx
import { Collapse, Input, Select } from 'antd';
import { ItemType } from '../../item-type';
import { useComponets } from '../../stores/components';
const componentEventMap = {
[ItemType.Button]: [{
name: 'onClick',
label: '点击事件',
}],
}
const ComponentEvent = () => {
const { curComponent, curComponentId, updateComponentProps } = useComponets();
// 事件类型改变
function typeChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, { [eventName]: { type: value, } })
}
// 消息类型改变
function messageTypeChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
type: value,
},
}
})
}
// 消息文本改变
function messageTextChange(eventName: string, value: string) {
if (!curComponentId) return;
updateComponentProps(curComponentId, {
[eventName]: {
...curComponent?.props?.[eventName],
config: {
...curComponent?.props?.[eventName]?.config,
text: value,
},
},
})
}
if (!curComponent) return null;
return (
<div className='px-[12px]'>
{(componentEventMap[curComponent.name] || []).map(setting => {
return (
<Collapse key={setting.name} defaultActiveKey={setting.name}>
<Collapse.Panel header={setting.label} key={setting.name}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div>动作:</div>
<div>
<Select
style={{ width: 160 }}
options={[
{ label: '显示提示', value: 'showMessage' },
]}
onChange={(value) => { typeChange(setting.name, value) }}
value={curComponent?.props?.[setting.name]?.type}
/>
</div>
</div>
{
curComponent?.props?.[setting.name]?.type === 'showMessage' && (
<div className='flex flex-col gap-[12px] mt-[12px]'>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div>类型:</div>
<div>
<Select
className='w-[160px]'
options={[
{ label: '成功', value: 'success' },
{ label: '失败', value: 'error' },
]}
onChange={(value) => { messageTypeChange(setting.name, value) }}
value={curComponent?.props?.[setting.name]?.config?.type}
/>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div>文本:</div>
<div>
<Input
className='w-[160px]'
onChange={(e) => { messageTextChange(setting.name, e.target.value) }}
value={curComponent?.props?.[setting.name]?.config?.text}
/>
</div>
</div>
</div>
)
}
</Collapse.Panel>
</Collapse>
)
})}
</div>
)
}
export default ComponentEvent;
效果展示预览时实现点击按钮显示消息实现思路预览时把上面数据结构改造成下面这样就行了{
id: 1,
name: 'Button',
props: {
// 点击事件绑定显示消息动作
onClick: {
// 动作类型
type: 'ShowMessage',
// 动作配置
config: {
// 消息类型
type: 'success',
// 消息文本
text: '点击了按钮',
}
}
}
}
{
id: 1,
name: 'Button',
props: {
// 点击事件显示消息
onClick: () => {
message.success('点击了按钮');
}
}
}
代码实现渲染时处理事件效果展示组件联动前言下面我们来实现一个简单的组件联动功能。假设页面中有三个按钮,第一个是普通按钮,第二个按钮让第一个按钮loading,第三个按钮可以让第一个按钮结束loading。想实现上面demo,有两种方式:第一个按钮loading属性绑定一个变量,第二第三个按钮通过点击事件控制这个变量。按钮暴露出一个开始loading和结束loading的方法,第二第三个按钮通过点击事件调用第一个按钮暴露出来的方法。两个方案我们后面都会实现,这里先实现第二种方案。实现思路封装一个自己的按钮组件,组件内部通过react的useImperativeHandleapi把想要暴露出去的方法暴露出去,然后在渲染组件的地方通过ref获取到组件实例,这时候我们就能调用组件里面暴露出来的方法了。实战给事件添加组件方法动作类型事件添加组件方法动作类型,当事件那里选的动作类型时组件方法的时候,动态显示组件树下拉框,选择一个组件后,动态显示这个组件暴露出来的方法下拉框,然后选择一个方法。事件动作类型下拉框中天际一个组件方法类型配置组件类型暴露出哪些方法选择组件方法后,动态渲染组件树下拉框和组件方法下拉框效果展示封装按钮import { Button as AntdButton } from 'antd';
import { forwardRef, useImperativeHandle, useState } from 'react';
const Button = (props: any, ref: any) => {
const [loading, setLoading] = useState(false);
// 暴露方法,父组件可以使用ref获取组件里暴露出去的方法
useImperativeHandle(ref, () => {
return {
startLoading: () => {
setLoading(true);
},
endLoading: () => {
setLoading(false);
},
}
}, []);
return (
<AntdButton loading={loading} {...props}>{props.children}</AntdButton>
)
}
export default forwardRef(Button);
改造渲染方法先定义一个map,存放组件id和组件实例的映射。动态渲染组件时,注入ref属性,拿到组件实例。处理事件的地方添加处理组件方法的方法,通过组件id获取到组件实例,然后调用配置的方法。效果展示最后这一篇我们简单的实现了大纲、事件绑定、组件之间联动功能,虽说简单,但是万变不离其宗,后续可以在这基础上去拓展一些其他动作,让其可以实现更复杂的页面和逻辑。
lucky0-0
实现登录功能jwt or token+redis?—从零开始搭建一个高颜值后台管理系统全栈框架(三)
前言这一期我们来实现登录功能,登录实现方案比较多,下面给大家分析一下各种方案的优缺点。实现方案分析jwt or token+redis常用的登录实现方案:基于Session/Cookie的登录:用户在输入用户名和密码后,服务器会验证用户的身份,并将用户信息存储在Session或Cookie中。在随后的请求中,服务器会检查相应的Session或Cookie来验证用户身份。Token-Based登录:用户在输入用户名和密码后,服务器会颁发一个加密的Token给客户端,并把token对应的用户信息存到redis中,客户端需要在随后的请求中将Token添加到请求头中,服务器会从redis中检查Token是否存在以验证用户身份。JWT(JSON Web Token)登录:JWT是一种基于Token的身份验证方案,它使用JSON格式来传输信息,并对其进行签名以保证安全性,后续不需要做服务器验证。上面方案中我们首先把Session/Cookie排除掉,下面我们从token+redis和jwt方案中选一个。先看一下JWT方案的优点:去中心化,便于分布式系统使用基本信息可以直接放在token中。 username,nickname,role...功能权限较少的话,可以直接放在token中。用bit位表示用户所具有的功能权限在我看来,JWT某些优点,对于后台管理系统的登录方案可能是缺点。做过后端管理系统的人应该知道,用户信息或权限可能会经常变更,如果使用JWT方案在用户权限变更后,没办法使已颁发的token失效,有的人说服务器存一个黑名单可以解决这个问题,这种其实就是有状态了,就不是去中心化了,那就失去了使用JWT的意义了,所以我们这个后台管理系统登录实现方案使用token+redis方案。个人觉得JWT适用论坛以及一些用户一旦注册后,信息就不会再变更的系统。单token or 双token基本每个axios封装自动刷新token的文章下面都会有人说都搞自动刷新了,还不如用一个token,这里我说一下我的理解。先说明我打算使用双token这种方案,即一个普通token和一个用来刷新token的token。使用双token主要还是从安全角度来说,如果是个人的小网站,不考虑安全的情况下是可以用单token的,甚至都可以在每个请求上都带上用户账号和密码,然后后端用账号密码做验证,这样连token都不需要了。个人理解的双token的好处是:access token每个请求都要求被携带,这样它暴露的概率会变大,但是它的有效期又很短,即使暴露了,也不会造成特别大的损失。而refresh token只有在access token失效的时候才会用到,使用的频率比access token低很多,所以暴露的概率也小一些。如果只使用一个token,要么把这个token的有效期设置的时间很长,要么动态在后端刷新,那如果这个token暴露后,别人可以一直用这个token干坏事,如果把这个token有效期设置很短,并且后端也不自动刷新,那用户可能用一会就要跳到登录页取登录一下,这样用户体验很差。所以本文采用双token的方式去实现登录。用户管理实现登录功能之前,需要先实现用户增删改查功能,没有用户没办法登录。使用脚本快速创建文件和模版代码node ./script/create-module user改造实体文件,添加字段// src/module/user/entity/user.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';
import { omit } from 'lodash';
import { UserVO } from '../vo/user';
@Entity('sys_user')
export class UserEntity extends BaseEntity {
@Column({ comment: '用户名称' })
userName: string;
@Column({ comment: '用户昵称' })
nickName: string;
@Column({ comment: '手机号' })
phoneNumber: string;
@Column({ comment: '邮箱' })
email: string;
@Column({ comment: '头像', nullable: true })
avatar?: string;
@Column({ comment: '性别(0:女,1:男)', nullable: true })
sex?: number;
@Column({ comment: '密码' })
password: string;
toVO(): UserVO {
return omit<UserEntity>(this, ['password']) as UserVO;
}
}
启动项目后,因为typeorm配置里给自动同步打开了,实体会自动创建表和字段。改造DTO,添加一些字段校验// src/module/user/dto/user.ts
import { ApiProperty } from '@midwayjs/swagger';
import { UserEntity } from '../entity/user';
import { BaseDTO } from '../../../common/base.dto';
import { Rule } from '@midwayjs/validate';
import { R } from '../../../common/base.error.util';
import {
email,
phone,
requiredString,
} from '../../../common/common.validate.rules';
export class UserDTO extends BaseDTO<UserEntity> {
@ApiProperty({ description: '用户名称' })
@Rule(requiredString.error(R.validateError('用户名称不能为空')))
userName: string;
@ApiProperty({ description: '用户昵称' })
@Rule(requiredString.error(R.validateError('用户昵称不能为空')))
nickName: string;
@ApiProperty({ description: '手机号' })
@Rule(phone.error(R.validateError('无效的手机号格式')))
phoneNumber: string;
@ApiProperty({ description: '邮箱' })
@Rule(email.error(R.validateError('无效的邮箱格式')))
email: string;
@ApiProperty({ description: '头像', nullable: true })
avatar?: string;
@ApiProperty({ description: '性别(0:女,1:男)', nullable: true })
sex?: number;
}
改造service里面的create方法// src/module/user/service/user.ts
import { Provide } from '@midwayjs/decorator';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { omit } from 'lodash';
import { BaseService } from '../../../common/base.service';
import { UserEntity } from '../entity/user';
import { R } from '../../../common/base.error.util';
import { UserVO } from '../vo/user';
@Provide()
export class UserService extends BaseService<UserEntity> {
@InjectEntityModel(UserEntity)
userModel: Repository<UserEntity>;
getModel(): Repository<UserEntity> {
return this.userModel;
}
async create(entity: UserEntity): Promise<UserVO> {
const { userName, phoneNumber, email } = entity;
let isExist = (await this.userModel.countBy({ userName })) > 0;
if (isExist) {
throw R.error('当前用户名已存在');
}
isExist = (await this.userModel.countBy({ phoneNumber })) > 0;
if (isExist) {
throw R.error('当前手机号已存在');
}
isExist = (await this.userModel.countBy({ email })) > 0;
if (isExist) {
throw R.error('当前邮箱已存在');
}
// 添加用户的默认密码是123456,对密码进行加盐加密
const password = bcrypt.hashSync('123456', 10);
entity.password = password;
await this.userModel.save(entity);
// 把entity中的password移除返回给前端
return omit(entity, ['password']) as UserVO;
}
async edit(entity: UserEntity): Promise<void | UserVO> {
const { userName, phoneNumber, email, id } = entity;
let user = await this.userModel.findOneBy({ userName });
if (user && user.id !== id) {
throw R.error('当前用户名已存在');
}
user = await this.userModel.findOneBy({ phoneNumber });
if (user && user.id !== id) {
throw R.error('当前手机号已存在');
}
user = await this.userModel.findOneBy({ email });
if (user && user.id !== id) {
throw R.error('当前邮箱已存在');
}
await this.userModel.save(entity);
return omit(entity, ['password']) as UserVO;
}
}
这里说一下密码加盐的好处:提高密码安全性 密码加盐后,即使多个用户采用了相同的密码,其存储的哈希值也不同,从而避免了彩虹表攻击。攻击者无法通过事先计算出来的哈希值来破解密码。防止暴力破解 如果所有用户的密码都使用相同的哈希函数和密钥进行加密,则攻击者可以通过对已知的哈希值进行逆推来破解大量的用户密码。因此,每个用户使用不同的盐值可以防止这种攻击方式。加强数据安全性 用户密码是非常敏感的信息,泄露后可能会导致严重的后果。使用加盐技术可以保护用户密码,在密码泄露事件发生时,黑客也无法轻易地获取用户的真实密码。避免重复密码 由于一些用户可能会采用相同的密码,如果不使用加盐技术,那么他们的哈希值也会相同,从而为攻击者提供了更多的破解机会。使用加盐技术可以避免这种情况的发生。目前大部分系统都是用这种方案存储密码。测试接口我们虽然可以使用swagger ui去测试,但是这个不太好用,推荐使用postman或Apifox,我这里使用Apifox。使用Apifox新建一个项目,然后通过swagger把接口导入进去。接口自动导进来了,我们测试一下新增用户接口:查看数据库,数据已经插入进去了。又新增了一条数据,虽然默认密码都是123456,但是数据库中存的是不一样的,这就是密码加盐的结果。测试一下分页接口实现前端用户管理页面列表页import { t } from '@/utils/i18n';
import { Space, Table, Form, Row, Col, Input, Button, Popconfirm, App, Modal, FormInstance } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useAntdTable, useRequest } from 'ahooks'
import dayjs from 'dayjs'
import { useRef, useState } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import NewAndEditForm from './newAndEdit';
import userService, { User } from './service';
const UserPage = () => {
const [form] = Form.useForm();
const { message } = App.useApp();
const { tableProps, search: { submit, reset } } = useAntdTable(userService.getUserListByPage, { form });
const { runAsync: deleteUser } = useRequest(userService.deleteUser, { manual: true });
const [editData, setEditData] = useState<User | null>(null);
const [saveLoading, setSaveLoading] = useState(false);
const formRef = useRef<FormInstance>(null);
const columns: ColumnsType<any> = [
{
title: t("qYznwlfj" /* 用户名 */),
dataIndex: 'userName',
},
{
title: t("gohANZwy" /* 昵称 */),
dataIndex: 'nickName',
},
{
title: t("yBxFprdB" /* 手机号 */),
dataIndex: 'phoneNumber',
},
{
title: t("XWVvMWig" /* 邮箱 */),
dataIndex: 'email',
},
{
title: t("ykrQSYRh" /* 性别 */),
dataIndex: 'sex',
render: (value: number) => value === 1 ? t("AkkyZTUy" /* 男 */) : t("yduIcxbx" /* 女 */),
},
{
title: t("TMuQjpWo" /* 创建时间 */),
dataIndex: 'createDate',
render: (value: number) => value && dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: t("QkOmYwne" /* 操作 */),
key: 'action',
render: (_, record) => record.userName !== 'admin' && (
<Space size="middle">
<a
onClick={() => {
setEditData(record);
setFormOpen(true);
}}
>{t("qEIlwmxC" /* 编辑 */)}</a>
<Popconfirm
title={t("JjwFfqHG" /* 警告 */)}
description={t("nlZBTfzL" /* 确认删除这条数据? */)}
onConfirm={async () => {
await deleteUser(record.id);
message.success(t("bvwOSeoJ" /* 删除成功! */));
submit();
}}
>
<a>{t("HJYhipnp" /* 删除 */)}</a>
</Popconfirm>
</Space>
),
},
];
const [formOpen, setFormOpen] = useState(false);
const openForm = () => {
setFormOpen(true);
};
const closeForm = () => {
setFormOpen(false);
setEditData(null);
};
const saveHandle = () => {
submit();
setFormOpen(false);
setEditData(null);
}
return (
<div>
<Form onFinish={submit} form={form} size="large" className='dark:bg-[rgb(33,41,70)] bg-white p-[24px] rounded-lg'>
<Row gutter={24}>
<Col className='w-[100%]' lg={24} xl={8} >
<Form.Item name="nickName" label={t("rnyigssw" /* 昵称 */)}>
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Form.Item name="phoneNumber" label={t("SPsRnpyN" /* 手机号 */)}>
<Input onPressEnter={submit} />
</Form.Item>
</Col>
<Col className='w-[100%]' lg={24} xl={8}>
<Space>
<Button onClick={submit} type='primary'>{t("YHapJMTT" /* 搜索 */)}</Button>
<Button onClick={reset}>{t("uCkoPyVp" /* 清除 */)}</Button>
</Space>
</Col>
</Row>
</Form>
<div className="mt-[16px] dark:bg-[rgb(33,41,70)] bg-white rounded-lg px-[12px]">
<div className='py-[16px] '>
<Button onClick={openForm} type='primary' size='large' icon={<PlusOutlined />}>{t("morEPEyc" /* 新增 */)}</Button>
</div>
<Table
rowKey="id"
scroll={{ x: true }}
columns={columns}
className='bg-transparent'
{...tableProps}
/>
</div>
<Modal
title={editData ? t("wXpnewYo" /* 编辑 */) : t("VjwnJLPY" /* 新建 */)}
open={formOpen}
onOk={() => {
formRef.current?.submit();
}}
destroyOnClose
width={640}
zIndex={1001}
onCancel={closeForm}
confirmLoading={saveLoading}
>
<NewAndEditForm
ref={formRef}
editData={editData}
onSave={saveHandle}
open={formOpen}
setSaveLoading={setSaveLoading}
/>
</Modal>
</div>
);
}
export default UserPage;
这里我们底层请求工具是用axios,然后用ahooks里面的useRequest做接口请求管理,这个还挺好用的。简单的业务代码中,我基本很少使用useCallBack和useMemo,因为简单的页面使用这两个hooks做优化,性能提高不大。我也不推荐在业务代码中使用状态管理库,状态在本页面管理就行了,除非开发的功能很复杂需要跨功能共享数据,才使用状态管理库共享数据。这里先简单的实现一下功能,会面会把这个常用页面封装成组件,这样以后我们就可以快速开发一个crud页面了。axios也还没封装,后面再封装,封装的思路和代码也会分享给大家。表单页// src/pages/user/newAndEdit.tsx
import { t } from '@/utils/i18n';
import { Form, Input, Radio, App, FormInstance } from 'antd'
import { forwardRef, useImperativeHandle, ForwardRefRenderFunction } from 'react'
import userService, { User } from './service';
import { useRequest } from 'ahooks';
interface PropsType {
open: boolean;
editData?: any;
onSave: () => void;
setSaveLoading: (loading: boolean) => void;
}
const NewAndEditForm: ForwardRefRenderFunction<FormInstance, PropsType> = ({
editData,
onSave,
setSaveLoading,
}, ref) => {
const [form] = Form.useForm();
const { message } = App.useApp();
const { runAsync: updateUser } = useRequest(userService.updateUser, { manual: true });
const { runAsync: addUser } = useRequest(userService.addUser, { manual: true });
useImperativeHandle(ref, () => form, [form])
const finishHandle = async (values: User) => {
try {
setSaveLoading(true);
if (editData) {
await updateUser({ ...editData, ...values });
message.success(t("NfOSPWDa" /* 更新成功! */));
} else {
await addUser(values);
message.success(t("JANFdKFM" /* 创建成功! */));
}
onSave();
} catch (error: any) {
message.error(error?.response?.data?.message);
}
setSaveLoading(false);
}
return (
<Form
labelCol={{ sm: { span: 24 }, md: { span: 5 } }}
wrapperCol={{ sm: { span: 24 }, md: { span: 16 } }}
form={form}
onFinish={finishHandle}
initialValues={editData}
>
<Form.Item
label={t("qYznwlfj" /* 用户名 */)}
name="userName"
rules={[{
required: true,
message: t("jwGPaPNq" /* 不能为空 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("rnyigssw" /* 昵称 */)}
name="nickName"
rules={[{
required: true,
message: t("iricpuxB" /* 不能为空 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("SPsRnpyN" /* 手机号 */)}
name="phoneNumber"
rules={[{
required: true,
message: t("UdKeETRS" /* 不能为空 */),
}, {
pattern: /^(13[0-9]|14[5-9]|15[0-3,5-9]|16[2567]|17[0-8]|18[0-9]|19[89])\d{8}$/,
message: t("AnDwfuuT" /* 手机号格式不正确 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("XWVvMWig" /* 邮箱 */)}
name="email"
rules={[{
required: true,
message: t("QFkffbad" /* 不能为空 */),
}, {
pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
message: t("EfwYKLsR" /* 邮箱格式不正确 */),
}]}
>
<Input />
</Form.Item>
<Form.Item
label={t("ykrQSYRh" /* 性别 */)}
name="sex"
initialValue={1}
>
<Radio.Group>
<Radio value={1}>{t("AkkyZTUy" /* 男 */)}</Radio>
<Radio value={0}>{t("yduIcxbx" /* 女 */)}</Radio>
</Radio.Group>
</Form.Item>
</Form>
)
}
export default forwardRef(NewAndEditForm);
service// src/pages/user/service.ts
import axios from 'axios'
export interface User {
id: number;
userName: string;
nickName: string;
phoneNumber: string;
email: string;
createDate: string;
updateDate: string;
}
export interface PageData {
data: User[],
total: number;
}
const userService = {
// 分页获取用户列表
getUserListByPage: ({ current, pageSize }: { current: number, pageSize: number }, formData: any) => {
return axios.get<PageData>('/api/user/page', {
params: {
page: current - 1,
size: pageSize,
...formData,
}
}).then(({ data }) => {
return ({
list: data.data,
total: data.total,
})
})
},
// 添加用户
addUser: (data: User) => {
return axios.post('/api/user', data);
},
// 更新用户
updateUser: (data: User) => {
return axios.put('/api/user', data);
},
// 删除用户
deleteUser: (id: number) => {
return axios.delete(`/api/user/${id}`);
}
}
export default userService;
配置接口反向代理vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import WindiCSS from 'vite-plugin-windicss'
// https://vitejs.dev/config/
export default defineConfig({
base: './',
plugins: [
react(),
WindiCSS(),
],
resolve: {
alias: {
'@': '/src/',
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:7001',
changeOrigin: true,
}
}
}
})
效果登录功能新建auth模块node ./script/create-module auth后端引入验证码组件使用captcha组件可以快速生成验证码图片,也支持验证码值验证。安装依赖pnpm i @midwayjs/captcha@3 --save启用组件在 src/configuration.ts 中引入组件。import * as captcha from '@midwayjs/captcha';
@Configuration({
imports: [
// ...other components
captcha
],
})
export class MainConfiguration {}
在auth controller中实现登录校验,和获取验证码接口import {
Body,
Controller,
Inject,
Post,
Provide,
ALL,
Get,
} from '@midwayjs/decorator';
import { AuthService } from '../service/auth';
import { ApiResponse } from '@midwayjs/swagger';
import { TokenVO } from '../vo/token';
import { LoginDTO } from '../dto/login';
import { CaptchaService } from '../service/captcha';
import { R } from '../../../common/base.error.util';
@Provide()
@Controller('/auth')
export class AuthController {
@Inject()
authService: AuthService;
@Inject()
captchaService: CaptchaService;
@Get('/captcha')
async getImageCaptcha() {
const { id, imageBase64 } = await this.captchaService.formula({
height: 40,
width: 120,
noise: 1,
color: true,
});
return {
id,
imageBase64,
};
}
}
正常captchaService是从captcha组件中导出来可以直接使用,我这里单独写了一个,是因为midway自带的captcha组件,不支持改文字颜色,导致生成的验证码在暗色主题下看不清,我就把代码拉了下来,改了一下,支持改文字颜色,过两天提个pr给midway。实现登录功能实现思路用户登录时把账号密码和验证码相关信息传给后端,后端先验证码验证然后账号密码校验,校验成功后,生成两个token返回给前端,同时把这两个token存到redis中并且设置过期时间。controller代码实现// src/module/auth/controller/auth.ts
import {
Body,
Controller,
Inject,
Post,
Provide,
ALL,
Get,
} from '@midwayjs/decorator';
import { AuthService } from '../service/auth';
import { ApiResponse } from '@midwayjs/swagger';
import { TokenVO } from '../vo/token';
import { LoginDTO } from '../dto/login';
import { CaptchaService } from '../service/captcha';
import { R } from '../../../common/base.error.util';
@Provide()
@Controller('/auth')
export class AuthController {
@Inject()
authService: AuthService;
@Inject()
captchaService: CaptchaService;
@Post('/login', { description: '登录' })
@ApiResponse({ type: TokenVO })
async login(@Body(ALL) loginDTO: LoginDTO) {
const { captcha, captchaId } = loginDTO;
const result = await this.captchaService.check(captchaId, captcha);
if (!result) {
throw R.error('验证码错误');
}
return await this.authService.login(loginDTO);
}
@Get('/captcha')
async getImageCaptcha() {
const { id, imageBase64 } = await this.captchaService.formula({
height: 40,
width: 120,
noise: 1,
color: true,
});
return {
id,
imageBase64,
};
}
}
service实现// src/module/auth/service/auth.ts
import { Config, Inject, Provide } from '@midwayjs/decorator';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { UserEntity } from '../../user/entity/user';
import { R } from '../../../common/base.error.util';
import { LoginDTO } from '../dto/login';
import { TokenVO } from '../vo/token';
import { TokenConfig } from '../../../interface/token.config';
import { RedisService } from '@midwayjs/redis';
import { uuid } from '../../../utils/uuid';
@Provide()
export class AuthService {
@InjectEntityModel(UserEntity)
userModel: Repository<UserEntity>;
@Config('token')
tokenConfig: TokenConfig;
@Inject()
redisService: RedisService;
async login(loginDTO: LoginDTO): Promise<TokenVO> {
const { accountNumber } = loginDTO;
const user = await this.userModel
.createQueryBuilder('user')
.where('user.phoneNumber = :accountNumber', {
accountNumber,
})
.orWhere('user.username = :accountNumber', { accountNumber })
.orWhere('user.email = :accountNumber', { accountNumber })
.select(['user.password', 'user.id'])
.getOne();
if (!user) {
throw R.error('账号或密码错误!');
}
if (!bcrypt.compareSync(loginDTO.password, user.password)) {
throw R.error('用户名或密码错误!');
}
const { expire, refreshExpire } = this.tokenConfig;
const token = uuid();
const refreshToken = uuid();
// multi可以实现redis指令并发执行
await this.redisService
.multi()
.set(`token:${token}`, user.id)
.expire(`token:${token}`, expire)
.set(`refreshToken:${refreshToken}`, user.id)
.expire(`refreshToken:${refreshToken}`, refreshExpire)
.exec();
return {
expire,
token,
refreshExpire,
refreshToken,
} as TokenVO;
}
}
前端实现globalStore里面添加token和refreshToken属性,和对应的设置方法在layout里面拦截,如果全局属性里面的token为空,就说明没登录,退到登录页面从后端获取验证图片在前端展示,支持点击图片刷新验证码登录代码,登录成功后,把后端返回的token和refreshToken存到全局状态中登录效果演示前端密码加密背景可以看到刚才我们传给后端的密码是明文的,这样是不安全的,所以我们需要给密码加密,即使被人拦截了,密码也不会泄漏。加密方式我见过有人用base64给密码编码一下,这样做可以骗骗不懂技术的人,懂点技术的一些就破解了。有人说前端加密是没用的,因为前端js是透明的,别人可以轻松知道你的加密方式,普通的加密方式确实是这样的,非对称加密可以解决这个问题。非对称加密通俗一点的解释就是通过某种方法生成一对公钥和私钥,把公钥暴露出去给别人,私钥自己保存,别人用公钥加密的文本,然后用私钥把加密过后的文本解密出来。实现思路如果使用固定的公钥和私钥,一旦私钥泄漏,所有人的密码都会受到威胁,这种方案安全性不高。我们使用动态的公钥和私钥,前端在登录的时候,先从后端获取一下公钥,后端动态生成公钥和私钥,公钥返回给前端,私钥存到redis中。前端拿到公钥后,使用公钥对密码加密,然后把公钥和加密过后的密码传给后端,后端通过公钥从redis中获取私钥去解密,解密成功后,把私钥从redis中删除。具体实现auth controller中添加获取公钥接口改造login方法,解密密码后才去校验密码前端登录方法改造效果最后现在没实现多少功能,所以暂时没有部署后端,后面实现功能多一点了,我就把后端部署一下,就可以让大家体验一下了。
lucky0-0
后端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(二)
前言上期已经说过,我们这个后台管理系统的后端框架采用midwayjs,作为一个从.net后端转前端的我来说,这个框架用起来真的很简单,语法和.net和java差不多。这篇文章主要针对对midway不了解的人群,按照下面的教程不用看官方文档也能轻轻松松入门。midway介绍Midway 是阿里巴巴 - 淘宝前端架构团队,基于渐进式理念研发的 Node.js 框架,通过自研的依赖注入容器,搭配各种上层模块,组合出适用于不同场景的解决方案。Midway 基于 TypeScript 开发,结合了面向对象(OOP + Class + IoC)与函数式(FP + Function + Hooks)两种编程范式,并在此之上支持了 Web / 全栈 / 微服务 / RPC / Socket / Serverless 等多种场景,致力于为用户提供简单、易用、可靠的 Node.js 服务端研发体验。简单例子// src/controller/home.ts
import { Controller, Get } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
@Controller('/')
export class HomeController {
@Inject()
ctx: Context
@Get('/')
async home() {
return {
message: 'Hello Midwayjs!',
query: this.ctx.ip
}
}
}
搭建项目初始化项目使用 npm init midway 查看完整的脚手架列表,选中某个项目后,Midway 会自动创建示例目录,代码,以及安装依赖。这里选koa3然后输入项目名称,回车后,项目会自动使用npm install安装依赖,如果不想使用npm,这里可以停掉,然后自己在项目里执行pnpm install安装依赖。如果安装完成后启动失败,执行pnpx midway-version -u -w命令后,然后再重新安装依赖,然后就能正常启动了。将下面代码覆盖掉.vscode/launch.json文件内容{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [{
"name": "Midway Local",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "npm",
"windows": {
"runtimeExecutable": "npm.cmd"
},
"runtimeArgs": [
"run",
"dev"
],
"env": {
"NODE_ENV": "local"
},
"console": "integratedTerminal",
"protocol": "auto",
"restart": true,
"port": 7001,
"autoAttachChildProcesses": true
}]
}启动项目 数据库mysql安装mysql数据库数据库选用mysql,为了方便,我们使用docker启动mysql服务。到官网下载docker desktop,并安装。安装完docker desktop,然后打开docker desktop,搜索mysql,然后拉取镜像。启动mysql服务配置数据库密码、数据映射卷和端口映射使用typeormTypeORM是node.js现有社区最成熟的对象关系映射器(ORM )。安装 typeorm 组件,提供数据库 ORM 能力。 sh复制代码pnpm i @midwayjs/typeorm@3 typeorm --save在src/configuration.ts引入 orm 组件安装数据库Driverpnpm install mysql2 --savetypeorm配置修改src/config/config.default.ts文件import { MidwayConfig } from '@midwayjs/core';
export default {
// use for cookie sign key, should change to your own and keep security
keys: '1684629293601_5943',
koa: {
port: 7001,
},
typeorm: {
dataSource: {
default: {
/**
* 单数据库实例
*/
type: 'mysql',
host: 'localhost', // 数据库ip地址,本地就写localhost
port: 3306,
username: 'root',
password: '123456',
database: 'test', // 数据库名称
synchronize: true, // 如果第一次使用,不存在表,有同步的需求可以写 true,注意会丢数据
logging: true,
// 扫描entity文件夹
entities: ['**/entity/*{.ts,.js}'],
},
},
},
} as MidwayConfig;使用DBeaver连接mysql,创建数据库mysql客户端推荐使用Navicat,但是这个收费。只好找一个免费并且好用的客户端,DBeaver还挺好用的。创建连接创建实体模型新建entity文件夹,然后创建user.ts文件 ts复制代码// ./src/entity/user.ts import { Column, PrimaryGeneratedColumn } from 'typeorm'; export class User { @PrimaryGeneratedColumn() // 主键自增列 id: number; @Column() // 普通列 name: string; @Column() // 普通列 age: number; }启动项目,可以发现表自动创建了测试一下typeorm,改造src/controller/home.controller.ts文件// ./src/controller/home.controller.ts
import { Controller, Get } from '@midwayjs/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { User } from '../entity/user';
import { Repository } from 'typeorm';
@Controller('/')
export class HomeController {
// 自动注入模型
@InjectEntityModel(User)
userModel: Repository<User>;
@Get('/')
async home(): Promise<User[]> {
// 查询user表数据
return await this.userModel.find();
}
}启动项目,访问http://127.0.0.1:7001/,因为没有数据返回空数组。可以在控制台中查看生成sql,有利于调试复杂的sql手动在数据库中添加一条数据,再测试一下。缓存redisredis的作用下面是chatgpt给出的答案 Redis 是一个开源的内存数据结构存储系统,也是一种 NoSQL 数据库。它支持多种数据结构,包括字符串、哈希表、列表、集合和有序集合,并提供了丰富的操作接口。 Redis 主要用于以下几个方面: 缓存:Redis 可以将热点数据存储在内存中,加速读写访问速度。对于需要频繁读取的数据,将其缓存到 Redis 中可以大大降低数据库的访问负载,提高性能。 分布式锁:Redis 的原子性操作保证了分布式锁的实现,在分布式环境下确保资源的互斥访问,防止因并发访问而导致的数据错乱或冲突。 消息队列:Redis 提供了 List 和 Pub/Sub 两种模式,可以实现简单的消息队列与发布订阅功能。 计数器:使用 Redis 的自增或者自减操作可以实现计数器的功能。在高并发场景下,使用 Redis 实现计数器可以避免线程安全问题。 搜索引擎:Redis 的 Sorted Set 可以很好地支持搜索引擎的实现。例如,可以将文章的关键词作为 Score 存储到 Sorted Set 中,然后根据用户的搜索关键字来查询相关文章。 游戏服务器:Redis 可以存储游戏中的数据、状态等信息,并提供快速的读写访问能力,支持高并发的游戏服务器。 总之,Redis 作为一种高性能的内存数据库,可以用于多种场景下的数据存储和处理。它具有高效、可扩展、易用等特点,在互联网领域得到广泛应用。使用docker desktop安装redis启动redis服务-使用redis客户端测试,推荐redis客户端使用Another Redis Desktop Manager。可以看到已经连接成功了在项目中安装redis依赖pnpm i @midwayjs/redis@3 --save引入redis组件,在 src/configuration.ts 中导入import { Configuration } from '@midwayjs/core';
import * as redis from '@midwayjs/redis';
import { join } from 'path';
@Configuration({
imports: [
// ...
redis // 导入 redis 组件
],
importConfigs: [
join(__dirname, 'config')
],
})
export class MainConfiguration {
}
配置redis// src/config/config.default.ts
export default {
// ...
redis: {
client: {
port: 6379, // Redis port
host: 'localhost', // Redis host
password: '123456',
db: 0,
},
},
}
代码中使用redis服务import { Controller, Get, Inject } from '@midwayjs/core';
import { RedisService } from '@midwayjs/redis';
@Controller('/')
export class HomeController {
// 自动注入redis服务
@Inject()
redisService: RedisService;
@Get('/')
async home(): Promise<string> {
// 设置值
await this.redisService.set('foo', 'bar');
// 获取值
return await this.redisService.get('foo');
}
}
验证swagger ui Swagger是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务。它可以在线自动生成接口文档,以及快速测试接口。安装依赖pnpm install @midwayjs/swagger@3 --save
pnpm install swagger-ui-dist --save-dev导入组件import { Configuration } from '@midwayjs/core';
import * as swagger from '@midwayjs/swagger';
@Configuration({
imports: [
// ...
{
component: swagger,
enabledEnvironment: ['local']
}
]
})
export class MainConfiguration {
}
然后启动项目,访问地址: UI: http://127.0.0.1:7001/swagger-ui/index.html JSON: http://127.0.0.1:7001/swagger-ui/index.json效果展示这里我们使用swagger-ui对接口进行快速测试,后续会使用swagger生成接口文档。国际化前端框架都做了国际化,后端肯定也是要做的,midway已经内置了国际化方案,我们直接用就行了。安装依赖pnpm i @midwayjs/i18n@3 --save导入组件import { Configuration } from '@midwayjs/core';
import * as i18n from '@midwayjs/i18n';
@Configuration({
imports: [
// ...
i18n
]
})
export class MainConfiguration {
//...
}
配置多语言文案 在src目录下,新建locales目录,在locales目录下,新建en_US.json文件和zh_CN.json文件。// src/locales/en_US.json
{
"hello": "hello"
}
// src/locales/zh_CN.json
{
"hello": "你好"
}
配置i18n// src/config/config.default.ts
export default {
// ...
i18n: {
// 把你的翻译文本放到这里
localeTable: {
en_US: require('../locales/en_US'),
zh_CN: require('../locales/zh_CN'),
},
}
}
测试import { Controller, Get, Inject } from '@midwayjs/core';
import { MidwayI18nService } from '@midwayjs/i18n';
@Controller('/')
export class HomeController {
// 自动注入i18n服务
@Inject()
i18nService: MidwayI18nService;
@Get('/')
async home(): Promise<string> {
// 获取值
return this.i18nService.translate('hello', {
locale: 'en_US',
});
}
}
参数校验midway内置了参数校验组件,主要是不想在业务代码中增加一些重复的判断语句,把校验和模型绑定到一起。安装依赖pnpm i @midwayjs/validate@3 --save导入组件// configuration.ts
import { Configuration, App } from '@midwayjs/core';
import * as koa from '@midwayjs/koa';
import * as validate from '@midwayjs/validate';
import { join } from 'path';
@Configuration({
imports: [koa, validate],
importConfigs: [join(__dirname, './config')],
})
export class MainConfiguration {
@App()
app: koa.Application;
async onReady() {
// ...
}
}
使用校验组件并测试首先在src下新建dto目录,新建user.ts文件// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/validate';
export class UserDTO {
@Rule(RuleType.number().required()) // id不能为空,并且是数字
id: number;
@Rule(RuleType.number().max(60)) // 年龄字段必须是数字,并且不能大于60
age: number;
}
// src/controller/home.controller.ts
import { Body, Controller, Post } from '@midwayjs/core';
import { UserDTO } from '../dto/user';
@Controller('/')
export class HomeController {
@Post('/')
async home(@Body() user: UserDTO): Promise<void> {
console.log(user);
}
}
使用swagger-ui测试一下,先传一个空对象给后端可以看到返回给前端的状态不是200,而是422了传入id测试一下控制台没有报错,并且把user打印了出来自定义报错消息// src/dto/user.ts
import { Rule, RuleType } from '@midwayjs/validate';
export class UserDTO {
@Rule(RuleType.number().required().error(new Error('不能为空啊啊啊啊啊'))) // id不能为空,并且是数字
id: number;
@Rule(RuleType.number().max(60)) // 年龄字段必须是数字,并且不能大于60
age: number;
}
校验报错信息国际化 官网文档已经写的很详细了,我这边就不说了。自定义消息的多语言,官网上没写,这个在下面错误拦截器里面处理。异常处理可以看到,上面参数校验失败时返回出去的是一串html,这个对于前端来说不好解析,这时候我们我们需要拦截然后返回给前端统一json格式。Midway提供了一个内置的异常处理器,负责处理应用程序中所有未处理的异常。当您的应用程序代码抛出一个异常处理时,该处理器就会捕获该异常,然后等待用户处理。异常处理器的执行位置处于中间件之后,所以它能拦截所有的中间件和业务抛出的错误。在filter文件夹下,创建validate.filter.ts文件,拦截校验失败的错误// src/filter/validate.filter.ts
import { Catch } from '@midwayjs/decorator';
import { MidwayValidationError } from '@midwayjs/validate';
import { Context } from '@midwayjs/koa';
import { MidwayI18nService } from '@midwayjs/i18n';
@Catch(MidwayValidationError)
export class ValidateErrorFilter {
async catch(err: MidwayValidationError, ctx: Context) {
// 获取国际化服务
const i18nService = await ctx.requestContext.getAsync(MidwayI18nService);
// 翻译
const message = i18nService.translate(err.message) || err.message;
// 未捕获的错误,是系统错误,错误码是500
ctx.status = 422;
return {
code: 422,
message,
};
}
}
在configuration.ts文件中,注册刚才我们创建的过滤器测试一下对error做多语言现在已经按照我们想要的格式返回给前端了封装公共业务异常方法 在开发过程中,可能会需要做一些业务校验,业务校验的时候,我们需要对外抛出异常,这时候我们需要封装公共的业务异常类,和业务异常过滤器。 新建common文件夹,存放公共类,在common下新建common.error.ts文件// src/common/common.error.ts
import { MidwayError } from '@midwayjs/core';
export class CommonError extends MidwayError {
constructor(message: string) {
super(message);
}
}在filter新建common.filter.ts文件// src/filter/common.error.ts
import { Catch } from '@midwayjs/decorator';
import { Context } from '@midwayjs/koa';
import { CommonError } from '../common/common.error';
import { MidwayI18nService } from '@midwayjs/i18n';
@Catch(CommonError)
export class CommonErrorFilter {
async catch(err: CommonError, ctx: Context) {
// 获取国际化服务
const i18nService = await ctx.requestContext.getAsync(MidwayI18nService);
// 翻译
const message = i18nService.translate(err.message) || err.message;
// 未捕获的错误,是系统错误,错误码是500
ctx.status = 400;
return {
code: 400,
message,
};
}
}
在src/configuration.ts中注册过滤器测试// src/controller/home.controller.ts
import { Controller, Inject, Post } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { CommonError } from '../common/common.error';
@Controller('/')
export class HomeController {
@Inject()
logger: ILogger;
@Post('/')
async home(): Promise<void> {
throw new CommonError('error');
}
}
这里先这样简单使用,后面会封装公共的抛出异常方法,减少代码量。日志对于后端来说日志还是很重要的,有利于后期定位线上bug,midway也内置了一套日志组件,用起来很简单。import { Body, Controller, Inject, Post } from '@midwayjs/core';
import { UserDTO } from '../dto/user';
import { ILogger } from '@midwayjs/logger';
@Controller('/')
export class HomeController {
@Inject()
logger: ILogger;
@Post('/')
async home(@Body() user: UserDTO): Promise<void> {
this.logger.info('hello');
console.log(user);
}
}
除了支持info方法,还支持error、warn、debug方法,它们的具体用法,请查看官网文档。实战下面我们开始实战了,做一个简单但是完整的增删改查功能。创建实体// src/entity/user.ts
import {
Column,
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('user')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ comment: '姓名' })
name: string;
@Column({ comment: '年龄' })
age: number;
@CreateDateColumn({ comment: '创建日期' })
create_date: Date;
@UpdateDateColumn({ comment: '更新日期' })
update_date: Date;
}
创建DTO,前端向后端传送数据的模型。// src/dto/user.ts
import { ApiProperty } from '@midwayjs/swagger';
import { Rule, RuleType } from '@midwayjs/validate';
export class UserDTO {
@ApiProperty({
description: 'id',
})
@Rule(RuleType.allow(null))
id?: number;
@ApiProperty({
description: '姓名',
})
@Rule(RuleType.string().required().error(new Error('姓名不能为空'))) // 这个错误消息正常需要做多语言的,这里demo偷懒不做了
name: string;
@ApiProperty({
description: '年龄',
})
@Rule(RuleType.number().required().error(new Error('年龄不能为空')))
age: number;
}
创建service// src/service/user.service.ts
import { Provide } from '@midwayjs/core';
import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from '../entity/user';
import { InjectEntityModel } from '@midwayjs/typeorm';
@Provide()
export class UserService {
@InjectEntityModel(User)
userModel: Repository<User>;
// 新增
async create(user: User) {
await this.userModel.save(user);
return user;
}
// 删除
async remove(user: User) {
await await this.userModel.remove(user);
}
// 修改
async edit(user: User): Promise<User> {
return await this.userModel.save(user);
}
// 分页查询
async page(page: number, pageSize: number, where?: FindOptionsWhere<User>) {
// 按照创建日期倒序返回
const order: any = { create_date: 'desc' };
const [data, total] = await this.userModel.findAndCount({
order,
skip: page * pageSize,
take: pageSize,
where,
});
return { data, total };
}
// 根据查询条件返回全部
async list(where?: FindOptionsWhere<User>) {
const order: any = { create_time: 'desc' };
const data = await this.userModel.find({
where,
order,
});
return data;
}
}
创建controller// src/controller/user.controller.ts
import {
Body,
Controller,
Get,
Inject,
Post,
Provide,
Query,
ALL,
Put,
Param,
Del,
} from '@midwayjs/decorator';
import { Validate } from '@midwayjs/validate';
import { UserDTO } from '../dto/user';
import { UserService } from '../service/user.service';
import { User } from '../entity/user';
@Provide()
@Controller('/user')
export class UserController {
@Inject()
userService: UserService;
@Post('/')
@Validate()
async create(@Body(ALL) data: UserDTO) {
const user = new User();
user.name = data.name;
user.age = data.age;
return await this.userService.create(user);
}
@Put('/')
@Validate()
async edit(@Body(ALL) data: UserDTO) {
const user = await this.userService.getById(data.id);
// update
user.name = data.name;
user.age = data.age;
return await this.userService.edit(user);
}
@Del('/:id')
async remove(@Param('id') id: number) {
const user = await this.userService.getById(id);
await this.userService.remove(user);
}
@Get('/:id')
async getById(@Param('id') id: number) {
return await this.userService.getById(id);
}
@Get('/page')
async page(@Query('page') page: number, @Query('size') size: number) {
return await this.userService.page(page, size);
}
@Get('/list')
async list() {
return await this.userService.list();
}
}
启动项目,使用swagger-ui测试分页查询修改数据测试删除 再次查询id=3的已经被删除封装常用方法经过上面的例子,我们可以把常用代码封装一下。封装基础entity实体类我们可以看到实体中id、创建日期、更新日期这三个字段每个实体都会有,为了不每次都写这个,我们可以封装一个基础实体类。// src/common/base.entity.ts
import {
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export class BaseEntity {
@PrimaryGeneratedColumn()
id?: string;
@CreateDateColumn()
create_time?: Date;
@UpdateDateColumn()
update_time?: Date;
}
// src/entity/user.ts
import { Column, Entity } from 'typeorm';
import { BaseEntity } from '../common/base.entity';
@Entity('user')
export class User extends BaseEntity {
@Column({ comment: '姓名' })
name: string;
@Column({ comment: '年龄' })
age: number;
}
封装基础service// src/common/base.service.ts
import { Inject } from '@midwayjs/decorator';
import { Context } from '@midwayjs/koa';
import { FindOptionsWhere, Repository } from 'typeorm';
import { BaseEntity } from './base.entity';
export abstract class BaseService<T extends BaseEntity> {
@Inject()
ctx: Context;
abstract getModel(): Repository<T>;
async create(entity: T) {
return await this.getModel().save(entity);
}
async edit(entity: T): Promise<T | void> {
return await this.getModel().save(entity);
}
async remove(entity: T) {
await this.getModel().remove(entity);
}
async getById(id: string): Promise<T> {
return await this.getModel()
.createQueryBuilder('model')
.where('model.id = :id', { id })
.getOne();
}
async page(page: number, pageSize: number, where?: FindOptionsWhere<T>) {
const order: any = { create_time: 'desc' };
const [data, total] = await this.getModel().findAndCount({
where,
order,
skip: page * pageSize,
take: pageSize,
});
return { data, total };
}
async list(where?: FindOptionsWhere<T>) {
const order: any = { create_time: 'desc' };
const data = await this.getModel().find({
where,
order,
});
return data;
}
}
// src/service/user.service.ts
import { Provide } from '@midwayjs/core';
import { Repository } from 'typeorm';
import { User } from '../entity/user';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { BaseService } from '../common/base.service';
@Provide()
export class UserService extends BaseService<User> {
@InjectEntityModel(User)
userModel: Repository<User>;
getModel(): Repository<User> {
return this.userModel;
}
}
这样我们userService代码简单了很多封装异常公共方法我们上面抛异常,需要手动取new,这个我们可以封装一个公共异常类,方便使用。// src/common/base.error.util.ts
import { MidwayValidationError } from '@midwayjs/validate';
import { CommonError } from './common.error';
export class R {
static error(message: string) {
return new CommonError(message);
}
static validateError(message: string) {
return new MidwayValidationError(message, 422, null);
}
}
// src/controller/home.controller.ts
import { Controller, Inject, Post } from '@midwayjs/core';
import { ILogger } from '@midwayjs/logger';
import { R } from '../common/base.error.util';
@Controller('/')
export class HomeController {
@Inject()
logger: ILogger;
@Post('/')
async home(): Promise<void> {
// throw new CommonError('error');
throw R.error('error');
}
}
封装常用校验规则import { RuleType } from '@midwayjs/validate';
// 手机号
export const phone = RuleType.string().pattern(
/^1(3\d|4[5-9]|5[0-35-9]|6[567]|7[0-8]|8\d|9[0-35-9])\d{8}$/
);
// 邮箱
export const email = RuleType.string().pattern(
/^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/
);
// 字符串
export const string = RuleType.string();
// 字符串不能为空
export const requiredString = string.required();
// 字符串最大长度
export const maxString = (length: number) => string.max(length);
// 字符最小串长度
export const minString = (length: number) => string.min(length);
// 数字
export const number = RuleType.number();
// 数字不能为空
export const requiredNumber = number.required();
// bool
export const bool = RuleType.bool();
写一个脚本,快速生成controller、service、entity、dto文件脚本代码很简单,内置了几个模版,然后根据传入的参数动态替换一下模版里面的变量就行。代码放在script文件夹下。测试脚本node ./script/create-module book自动生成的文件自动生成的文件增删改查的方法自动生成了总结接口返回值很多系统喜欢把返回给前端的数据统一封装,无论成功还是失败,返回的数据格式一般都会有code,data,message这三个字段,除了系统异常,其他的一些业务报错或参数校验报错返回给前端的状态码都是200。我不太喜欢这种封装,我觉得业务报错或一些其他的报错使用http的状态码都能表示了,比如业务报错,返回400,未授权,返回401,禁止访问,返回403等,像这些不是200的,可以统一返回一个数据结构。200的时候直接返回真正的数据就行了。