From 1191594e9a95cb855d31046ae0df0443c65cadbf Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote <posva13@gmail.com> Date: Fri, 15 Dec 2023 14:27:24 +0100 Subject: [PATCH] perf(RouterView): avoid parent rerenders when possible Close #1701 --- packages/playground/src/App.vue | 110 +++++++----------- packages/playground/src/SimpleView.vue | 48 ++++++++ packages/playground/src/router.ts | 12 ++ .../playground/src/views/RerenderCheck.vue | 11 ++ packages/router/src/RouterView.ts | 39 ++++--- 5 files changed, 138 insertions(+), 82 deletions(-) create mode 100644 packages/playground/src/SimpleView.vue create mode 100644 packages/playground/src/views/RerenderCheck.vue diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index b686c0c54..b4a618e90 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,3 +1,28 @@ +<script lang="ts" setup> +import { inject, computed, ref } from 'vue' +import { useLink, useRoute } from 'vue-router' +import AppLink from './AppLink.vue' +import SimpleView from './SimpleView.vue' + +const route = useRoute() +const state = inject('state') + +useLink({ to: '/' }) +useLink({ to: '/documents/hello' }) +useLink({ to: '/children' }) + +const currentLocation = computed(() => { + const { matched, ...rest } = route + return rest +}) + +const nextUserLink = computed( + () => '/users/' + String((Number(route.params.id) || 0) + 1) +) + +const simple = ref(false) +</script> + <template> <div> <pre>{{ currentLocation }}</pre> @@ -37,6 +62,11 @@ <input type="checkbox" v-model="state.cancelNextNavigation" /> Cancel Next Navigation </label> + + <label> + <input type="checkbox" v-model="simple" /> Use Simple RouterView + </label> + <ul> <li> <router-link to="/n/%E2%82%AC">/n/%E2%82%AC</router-link> @@ -158,76 +188,18 @@ <li> <router-link to="/p_1/absolute-a">/p_1/absolute-a</router-link> </li> + <li> + <RouterLink to="/rerender" v-slot="{ href }">{{ href }}</RouterLink> + </li> + <li> + <RouterLink to="/rerender/a" v-slot="{ href }">{{ href }}</RouterLink> + </li> + <li> + <RouterLink to="/rerender/b" v-slot="{ href }">{{ href }}</RouterLink> + </li> </ul> <button @click="toggleViewName">Toggle view</button> - <RouterView :name="viewName" v-slot="{ Component, route }"> - <Transition - :name="route.meta.transition || 'fade'" - mode="out-in" - @before-enter="flushWaiter" - @before-leave="setupWaiter" - > - <!-- <KeepAlive> --> - <Suspense> - <template #default> - <component - :is="Component" - :key="route.name === 'repeat' ? route.path : route.meta.key" - /> - </template> - <template #fallback> Loading... </template> - </Suspense> - <!-- </KeepAlive> --> - </Transition> - </RouterView> + + <SimpleView :simple="simple"></SimpleView> </div> </template> - -<script lang="ts"> -import { defineComponent, inject, computed, ref } from 'vue' -import { scrollWaiter } from './scrollWaiter' -import { useLink, useRoute } from 'vue-router' -import AppLink from './AppLink.vue' - -export default defineComponent({ - name: 'App', - components: { AppLink }, - setup() { - const route = useRoute() - const state = inject('state') - const viewName = ref('default') - - useLink({ to: '/' }) - useLink({ to: '/documents/hello' }) - useLink({ to: '/children' }) - - const currentLocation = computed(() => { - const { matched, ...rest } = route - return rest - }) - - function flushWaiter() { - scrollWaiter.flush() - } - function setupWaiter() { - scrollWaiter.add() - } - - const nextUserLink = computed( - () => '/users/' + String((Number(route.params.id) || 0) + 1) - ) - - return { - currentLocation, - nextUserLink, - state, - flushWaiter, - setupWaiter, - viewName, - toggleViewName() { - viewName.value = viewName.value === 'default' ? 'other' : 'default' - }, - } - }, -}) -</script> diff --git a/packages/playground/src/SimpleView.vue b/packages/playground/src/SimpleView.vue new file mode 100644 index 000000000..0275d952e --- /dev/null +++ b/packages/playground/src/SimpleView.vue @@ -0,0 +1,48 @@ +<script lang="ts" setup> +import { ref } from 'vue' +import { scrollWaiter } from './scrollWaiter' + +defineProps<{ simple: boolean }>() +const viewName = ref('default') + +function flushWaiter() { + scrollWaiter.flush() +} +function setupWaiter() { + scrollWaiter.add() +} +</script> + +<template> + <RouterView v-if="simple" v-slot="{ Component, route }"> + <component :is="Component" :key="route.meta.key" /> + </RouterView> + + <RouterView + v-else + :name="viewName" + v-slot="{ Component, route }" + key="not-simple" + > + <Transition + :name="route.meta.transition || 'fade'" + mode="out-in" + @before-enter="flushWaiter" + @before-leave="setupWaiter" + > + <!-- <KeepAlive> --> + <!-- <Suspense> + <template #default> --> + <!-- <div v-if="route.path.endsWith('/a')">A</div> + <div v-else>B</div> --> + <component + :is="Component" + :key="route.name === 'repeat' ? route.path : route.meta.key" + /> + <!-- </template> + <template #fallback> Loading... </template> + </Suspense> --> + <!-- </KeepAlive> --> + </Transition> + </RouterView> +</template> diff --git a/packages/playground/src/router.ts b/packages/playground/src/router.ts index 981b4192f..1935666a3 100644 --- a/packages/playground/src/router.ts +++ b/packages/playground/src/router.ts @@ -15,6 +15,9 @@ import ComponentWithData from './views/ComponentWithData.vue' import { globalState } from './store' import { scrollWaiter } from './scrollWaiter' import RepeatedParams from './views/RepeatedParams.vue' +import RerenderCheck from './views/RerenderCheck.vue' +import { h } from 'vue' + let removeRoute: (() => void) | undefined export const routerHistory = createWebHistory() @@ -159,6 +162,15 @@ export const router = createRouter({ { path: 'settings', component }, ], }, + + { + path: '/rerender', + component: RerenderCheck, + children: [ + { path: 'a', component: { render: () => h('div', 'Child A') } }, + { path: 'b', component: { render: () => h('div', 'Child B') } }, + ], + }, ], async scrollBehavior(to, from, savedPosition) { await scrollWaiter.wait() diff --git a/packages/playground/src/views/RerenderCheck.vue b/packages/playground/src/views/RerenderCheck.vue new file mode 100644 index 000000000..5c2668442 --- /dev/null +++ b/packages/playground/src/views/RerenderCheck.vue @@ -0,0 +1,11 @@ +<script lang="ts" setup> +import { onUpdated } from 'vue' +let count = 0 +onUpdated(() => { + console.log(`RerenderCheck.vue render: ${++count}`) +}) +</script> + +<template> + <RouterView key="fixed" /> +</template> diff --git a/packages/router/src/RouterView.ts b/packages/router/src/RouterView.ts index ad4d8f720..6e422be77 100644 --- a/packages/router/src/RouterView.ts +++ b/packages/router/src/RouterView.ts @@ -135,12 +135,30 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ { flush: 'post' } ) + let matchedRoute: RouteLocationMatched | undefined + let currentName: string + // Since in Vue the entering view mounts first and then the leaving unmounts, + // we need to keep track of the last route in order to use it in the unmounted + // event + let lastMatchedRoute: RouteLocationMatched | undefined + let lastCurrentName: string + + const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => { + // remove the instance reference to prevent leak + if (lastMatchedRoute && vnode.component!.isUnmounted) { + lastMatchedRoute.instances[lastCurrentName] = null + } + } + return () => { const route = routeToDisplay.value + lastMatchedRoute = matchedRoute + lastCurrentName = currentName // we need the value at the time we render because when we unmount, we // navigated to a different location so the value is different - const currentName = props.name - const matchedRoute = matchedRouteRef.value + currentName = props.name + matchedRoute = matchedRouteRef.value + const ViewComponent = matchedRoute && matchedRoute.components![currentName] @@ -149,7 +167,8 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ } // props from route configuration - const routePropsOption = matchedRoute.props[currentName] + // matchedRoute exists since we check with if (ViewComponent) + const routePropsOption = matchedRoute!.props[currentName] const routeProps = routePropsOption ? routePropsOption === true ? route.params @@ -158,13 +177,6 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ : routePropsOption : null - const onVnodeUnmounted: VNodeProps['onVnodeUnmounted'] = vnode => { - // remove the instance reference to prevent leak - if (vnode.component!.isUnmounted) { - matchedRoute.instances[currentName] = null - } - } - const component = h( ViewComponent, assign({}, routeProps, attrs, { @@ -181,9 +193,10 @@ export const RouterViewImpl = /*#__PURE__*/ defineComponent({ // TODO: can display if it's an alias, its props const info: RouterViewDevtoolsContext = { depth: depth.value, - name: matchedRoute.name, - path: matchedRoute.path, - meta: matchedRoute.meta, + // same as above: ensured with if (ViewComponent) above + name: matchedRoute!.name, + path: matchedRoute!.path, + meta: matchedRoute!.meta, } const internalInstances = isArray(component.ref)