Framer Motion の AnimatePresense のコードリーディングしてみた
Framer Motion の AnimatePresense、コンポーネントが Unmount する時にアニメーションできる便利関数ですが、どうやって実現しているのか不思議だったのでコードリーディングしてみました。
TL;DR
- children として渡ってきた Element の key を保存しておく
- Unmount される時に key がなくなった Element たちをアニメーションさせてから、取り除かれた children を強制的に再描画する
コードリーディング
まずは最終的なゴール return されているものから見てみます。
return (
<>
{exiting.size
? childrenToRender
: childrenToRender.map((child) => cloneElement(child))}
</>
)
exiting.size
とやらがあれば childrenToRender
を描画、そうでなければそれを clone したものを返していますね。
次に exiting
が何かを見てみます。
名前が指す通り、いなくなるコンポーネントの key の集合のようです。
// A living record of all currently exiting components.
const exiting = useRef(new Set<ComponentKey>()).current
では次にこの exiting
がどのように更新されているかを見てみます。
// Filter out any children that aren't ReactElements. We can only track ReactElements with a props.key
const filteredChildren = onlyElements(children)
// Keep a living record of the children we're actually rendering so we
// can diff to figure out which are entering and exiting
const presentChildren = useRef(filteredChildren)
// Diff the keys of the currently-present and target children to update our
// exiting list.
const presentKeys = presentChildren.current.map(getChildKey)
const targetKeys = filteredChildren.map(getChildKey)
// Diff the present children with our target children and mark those that are exiting
const numPresent = presentKeys.length
for (let i = 0; i < numPresent; i++) {
const key = presentKeys[i]
if (targetKeys.indexOf(key) === -1) {
exiting.add(key)
} else {
// In case this key has re-entered, remove from the exiting list
exiting.delete(key)
}
}
長いですが、 children が更新された時に、 children の中に初期値(presentKeys)に含まれていない child があったらその key を exiting
に追加します。逆に再び追加されたものは exiting から削除されています。
ここで Unmount されているコンポーネントの key を管理している訳ですね。
最後に、 exiting
に追加されたコンポーネントにどんな処理がなされているかを見てみます。
// Loop through all currently exiting components and clone them to overwrite `animate`
// with any `exit` prop they might have defined.
exiting.forEach((key) => {
// If this component is actually entering again, early return
if (targetKeys.indexOf(key) !== -1) return
const child = allChildren.get(key)
if (!child) return
const insertionIndex = presentKeys.indexOf(key)
const onExit = () => {
...
}
childrenToRender.splice(
insertionIndex,
0,
<PresenceChild
key={getChildKey(child)}
isPresent={false}
onExitComplete={onExit}
custom={custom}
presenceAffectsLayout={presenceAffectsLayout}
>
{child}
</PresenceChild>
)
})
exiting
に追加されたコンポーネントは PresenseChild
というアニメーションを実行するコンポーネントで囲まれて置換されています。
そして、アニメーションが終了したら onExit
関数が実行されて exiting
からそのコンポーネントの key を削除したり、初期値を保存している presentChildren
から消すなどの後処理を行うと終わりです。
const onExit = () => {
allChildren.delete(key)
exiting.delete(key)
// Remove this child from the present children
const removeIndex = presentChildren.current.findIndex(
(presentChild) => presentChild.key === key
)
presentChildren.current.splice(removeIndex, 1)
// Defer re-rendering until all exiting children have indeed left
if (!exiting.size) {
presentChildren.current = filteredChildren
forceRender()
onExitComplete && onExitComplete()
}
}
という感じで、魔法のように見えましたが読んでみると意外とシンプルなロジックでした。children の key を監視するテクニックはどこかで使い所あるかもしれないですね。
Discussion