diff --git a/.eslintignore b/.eslintignore index 1521c8b76..2b88bf081 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ dist +*.ts diff --git a/build/configs.js b/build/configs.js index 33117a641..0910f9c0f 100644 --- a/build/configs.js +++ b/build/configs.js @@ -5,8 +5,7 @@ const cjs = require('@rollup/plugin-commonjs') const node = require('@rollup/plugin-node-resolve').nodeResolve const replace = require('rollup-plugin-replace') const version = process.env.VERSION || require('../package.json').version -const banner = -`/*! +const banner = `/*! * vue-router v${version} * (c) ${new Date().getFullYear()} Evan You * @license MIT @@ -31,27 +30,40 @@ module.exports = [ format: 'cjs' }, { + input: resolve('src/entries/esm.js'), file: resolve('dist/vue-router.esm.js'), format: 'es' }, { + input: resolve('src/entries/esm.js'), file: resolve('dist/vue-router.esm.browser.js'), format: 'es', env: 'development', transpile: false }, { + input: resolve('src/entries/esm.js'), file: resolve('dist/vue-router.esm.browser.min.js'), format: 'es', env: 'production', transpile: false + }, + { + input: resolve('src/composables/index.js'), + file: resolve('./dist/composables.mjs'), + format: 'es' + }, + { + input: resolve('src/composables/index.js'), + file: resolve('./dist/composables.js'), + format: 'cjs' } ].map(genConfig) function genConfig (opts) { const config = { input: { - input: resolve('src/index.js'), + input: opts.input || resolve('src/index.js'), plugins: [ flow(), node(), @@ -59,7 +71,8 @@ function genConfig (opts) { replace({ __VERSION__: version }) - ] + ], + external: ['vue'] }, output: { file: opts.file, @@ -70,9 +83,11 @@ function genConfig (opts) { } if (opts.env) { - config.input.plugins.unshift(replace({ - 'process.env.NODE_ENV': JSON.stringify(opts.env) - })) + config.input.plugins.unshift( + replace({ + 'process.env.NODE_ENV': JSON.stringify(opts.env) + }) + ) } if (opts.transpile !== false) { diff --git a/dist/composables.js b/dist/composables.js new file mode 100644 index 000000000..2f1c92291 --- /dev/null +++ b/dist/composables.js @@ -0,0 +1,87 @@ +/*! + * vue-router v3.5.4 + * (c) 2022 Evan You + * @license MIT + */ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var vue = require('vue'); + +function useRouter () { + var i = vue.getCurrentInstance(); + if (process.env.NODE_ENV !== 'production' && !i) { + throwNoCurrentInstance('useRouter'); + } + + return i.proxy.$root.$router +} + +function useRoute () { + var i = vue.getCurrentInstance(); + if (process.env.NODE_ENV !== 'production' && !i) { + throwNoCurrentInstance('useRoute'); + } + + var root = i.proxy.$root; + if (!root._$route) { + var route = vue.effectScope(true).run( + function () { return vue.shallowReactive(Object.assign({}, root.$router.currentRoute)); } + ); + root._$route = route; + + root.$router.afterEach(function (to) { + Object.assign(route, to); + }); + } + + return root._$route +} + +// TODO: +// export function useLink () {} + +function onBeforeRouteUpdate (guard) { + var i = vue.getCurrentInstance(); + if (process.env.NODE_ENV !== 'production' && !i) { + throwNoCurrentInstance('onBeforeRouteUpdate'); + } + + var router = useRouter(); + + var target = i.proxy; + // find the nearest routerview to know the depth + while (target && target.$vnode && target.$vnode.data && target.$vnode.data.routerViewDepth == null) { + target = target.$parent; + } + + var depth = target && target.$vnode && target.$vnode.data ? target.$vnode.data.routerViewDepth : null; + + console.log('found depth', depth); + + // TODO: allow multiple guards? + i.proxy.$options.beforeRouteUpdate = guard; + + var removeGuard = router.beforeEach(function (to, from, next) { + // TODO: check it's an update + return guard(to, from, next) + }); + + vue.onUnmounted(removeGuard); + + return removeGuard +} + +// TODO: +// export function onBeforeRouteLeave () {} + +function throwNoCurrentInstance (method) { + throw new Error( + ("[vue-router]: Missing current instance. " + method + "() must be called inside + diff --git a/examples/index.html b/examples/index.html index 5f2cd4f32..2b41a845a 100644 --- a/examples/index.html +++ b/examples/index.html @@ -30,6 +30,7 @@

Vue Router Examples

  • Keepalive View
  • Multiple Apps
  • Restart App
  • +
  • Composables
  • diff --git a/examples/webpack.config.js b/examples/webpack.config.js index 85b68fcf4..a74f22fb4 100644 --- a/examples/webpack.config.js +++ b/examples/webpack.config.js @@ -49,7 +49,8 @@ module.exports = { resolve: { alias: { vue: 'vue/dist/vue.esm.js', - 'vue-router': path.join(__dirname, '..', 'src') + 'vue-router': path.join(__dirname, '..', 'src'), + 'vue-router/composables': path.join(__dirname, '..', 'src/composables') } }, diff --git a/package.json b/package.json index c44a25fd8..35a83b92f 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,29 @@ "files": [ "src", "dist/*.js", + "dist/*.mjs", "types/*.d.ts", "vetur/tags.json", "vetur/attributes.json" ], + "exports": { + ".": { + "import": { + "node": "./dist/vue-router.mjs", + "default": "./dist/vue-router.esm.js" + }, + "require": "./dist/vue-router.common.js", + "types": "./types/index.d.ts" + }, + "./composables": { + "import": "./dist/composables.mjs", + "require": "./dist/composables.js", + "types": "./types/composables.d.ts" + }, + "./dist/*": "./dist/*", + "./types/*": "./types/*", + "./package.json": "./package.json" + }, "vetur": { "tags": "vetur/tags.json", "attributes": "vetur/attributes.json" @@ -73,7 +92,7 @@ "babel-preset-flow-vue": "^1.0.0", "browserstack-local": "^1.4.8", "buble": "^0.19.8", - "chromedriver": "^90.0.0", + "chromedriver": "^96.0.0", "conventional-changelog-cli": "^2.0.11", "cross-spawn": "^7.0.3", "css-loader": "^2.1.1", @@ -99,11 +118,11 @@ "rollup-watch": "^4.0.0", "selenium-server": "^3.141.59", "terser": "^4.2.0", - "typescript": "^3.5.2", - "vue": "^2.6.12", + "typescript": "^4.7.0", + "vue": "^2.7.0", "vue-loader": "^15.9.3", - "vue-server-renderer": "^2.6.12", - "vue-template-compiler": "^2.6.12", + "vue-server-renderer": "^2.7.0", + "vue-template-compiler": "^2.7.0", "vuepress": "^1.5.3", "vuepress-theme-vue": "^1.1.1", "webpack": "^4.35.2", diff --git a/src/components/link.js b/src/components/link.js index 9c2a25784..821b69f02 100644 --- a/src/components/link.js +++ b/src/components/link.js @@ -189,7 +189,7 @@ export default { } } -function guardEvent (e) { +export function guardEvent (e: any) { // don't redirect with control keys if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return // don't redirect when preventDefault called diff --git a/src/composables/globals.js b/src/composables/globals.js new file mode 100644 index 000000000..8331f28ec --- /dev/null +++ b/src/composables/globals.js @@ -0,0 +1,34 @@ +import { + getCurrentInstance, + shallowReactive, + effectScope +} from 'vue' +import { throwNoCurrentInstance } from './utils' + +export function useRouter () { + if (process.env.NODE_ENV !== 'production') { + throwNoCurrentInstance('useRouter') + } + + return getCurrentInstance().proxy.$root.$router +} + +export function useRoute () { + if (process.env.NODE_ENV !== 'production') { + throwNoCurrentInstance('useRoute') + } + + const root = getCurrentInstance().proxy.$root + if (!root._$route) { + const route = effectScope(true).run(() => + shallowReactive(Object.assign({}, root.$router.currentRoute)) + ) + root._$route = route + + root.$router.afterEach(to => { + Object.assign(route, to) + }) + } + + return root._$route +} diff --git a/src/composables/guards.js b/src/composables/guards.js new file mode 100644 index 000000000..6312e9401 --- /dev/null +++ b/src/composables/guards.js @@ -0,0 +1,68 @@ +import { getCurrentInstance, onUnmounted } from 'vue' +import { throwNoCurrentInstance } from './utils' +import { useRouter } from './globals' + +export function onBeforeRouteUpdate (guard) { + if (process.env.NODE_ENV !== 'production') { + throwNoCurrentInstance('onBeforeRouteUpdate') + } + + return useFilteredGuard(guard, isUpdateNavigation) +} +function isUpdateNavigation (to, from, depth) { + const toMatched = to.matched + const fromMatched = from.matched + return ( + toMatched.length >= depth && + toMatched + .slice(0, depth + 1) + .every((record, i) => record === fromMatched[i]) + ) +} + +function isLeaveNavigation (to, from, depth) { + const toMatched = to.matched + const fromMatched = from.matched + return toMatched.length < depth || toMatched[depth] !== fromMatched[depth] +} + +export function onBeforeRouteLeave (guard) { + if (process.env.NODE_ENV !== 'production') { + throwNoCurrentInstance('onBeforeRouteLeave') + } + + return useFilteredGuard(guard, isLeaveNavigation) +} + +const noop = () => {} +function useFilteredGuard (guard, fn) { + const instance = getCurrentInstance() + const router = useRouter() + + let target = instance.proxy + // find the nearest RouterView to know the depth + while ( + target && + target.$vnode && + target.$vnode.data && + target.$vnode.data.routerViewDepth == null + ) { + target = target.$parent + } + + const depth = + target && target.$vnode && target.$vnode.data + ? target.$vnode.data.routerViewDepth + : null + + if (depth != null) { + const removeGuard = router.beforeEach((to, from, next) => { + return fn(to, from, depth) ? guard(to, from, next) : next() + }) + + onUnmounted(removeGuard) + return removeGuard + } + + return noop +} diff --git a/src/composables/index.js b/src/composables/index.js new file mode 100644 index 000000000..d1cefa8cf --- /dev/null +++ b/src/composables/index.js @@ -0,0 +1,3 @@ +export * from './guards' +export * from './globals' +export * from './useLink' diff --git a/src/composables/useLink.js b/src/composables/useLink.js new file mode 100644 index 000000000..237fd3355 --- /dev/null +++ b/src/composables/useLink.js @@ -0,0 +1,113 @@ +import { computed, unref } from 'vue' +import { guardEvent } from '../components/link' +import { throwNoCurrentInstance } from './utils' +import { useRouter, useRoute } from './globals' + +function includesParams (outer, inner) { + for (const key in inner) { + const innerValue = inner[key] + const outerValue = outer[key] + if (typeof innerValue === 'string') { + if (innerValue !== outerValue) return false + } else { + if ( + !Array.isArray(outerValue) || + outerValue.length !== innerValue.length || + innerValue.some((value, i) => value !== outerValue[i]) + ) { + return false + } + } + } + + return true +} + +// helpers from vue router 4 + +function isSameRouteLocationParamsValue (a, b) { + return Array.isArray(a) + ? isEquivalentArray(a, b) + : Array.isArray(b) + ? isEquivalentArray(b, a) + : a === b +} + +function isEquivalentArray (a, b) { + return Array.isArray(b) + ? a.length === b.length && a.every((value, i) => value === b[i]) + : a.length === 1 && a[0] === b +} + +export function isSameRouteLocationParams (a, b) { + if (Object.keys(a).length !== Object.keys(b).length) return false + + for (const key in a) { + if (!isSameRouteLocationParamsValue(a[key], b[key])) return false + } + + return true +} + +export function useLink (props) { + if (process.env.NODE_ENV !== 'production') { + throwNoCurrentInstance('useLink') + } + + const router = useRouter() + const currentRoute = useRoute() + + const resolvedRoute = computed(() => router.resolve(unref(props.to), currentRoute)) + + const activeRecordIndex = computed(() => { + const route = resolvedRoute.value.route + const { matched } = route + const { length } = matched + const routeMatched = matched[length - 1] + const currentMatched = currentRoute.matched + if (!routeMatched || !currentMatched.length) return -1 + const index = currentMatched.indexOf(routeMatched) + if (index > -1) return index + // possible parent record + const parentRecord = currentMatched[currentMatched.length - 2] + + return ( + // we are dealing with nested routes + length > 1 && + // if the parent and matched route have the same path, this link is + // referring to the empty child. Or we currently are on a different + // child of the same parent + parentRecord && parentRecord === routeMatched.parent + ) + }) + + const isActive = computed( + () => + activeRecordIndex.value > -1 && + includesParams(currentRoute.params, resolvedRoute.value.route.params) + ) + const isExactActive = computed( + () => + activeRecordIndex.value > -1 && + activeRecordIndex.value === currentRoute.matched.length - 1 && + isSameRouteLocationParams(currentRoute.params, resolvedRoute.value.route.params) + ) + + const navigate = e => { + const href = resolvedRoute.value.route + if (guardEvent(e)) { + return props.replace + ? router.replace(href) + : router.push(href) + } + return Promise.resolve() + } + + return { + href: computed(() => resolvedRoute.value.href), + route: computed(() => resolvedRoute.value.route), + isExactActive, + isActive, + navigate + } +} diff --git a/src/composables/utils.js b/src/composables/utils.js new file mode 100644 index 000000000..969d0f7c0 --- /dev/null +++ b/src/composables/utils.js @@ -0,0 +1,11 @@ +import { getCurrentInstance } from 'vue' + +// dev only warn if no current instance + +export function throwNoCurrentInstance (method) { + if (!getCurrentInstance()) { + throw new Error( + `[vue-router]: Missing current instance. ${method}() must be called inside