This is the 8th day of my participation in the genwen Challenge
This article will continue with the vue-Router4 analysis of the routers attributes and the installation of the VueRouter instance
Routes properties
Back in the createRouter method, you can see that options.routes is used in only one place in the method. It serves as the createRouterMatcher parameter and returns an object of type RouterMatcher
export function createRouter(options: RouterOptions) :Router {
const matcher = createRouterMatcher(options.routes, options)
/ /...
}
Copy the code
The entry to the matcher module is SRC /matcher/index.ts. This module provides routing configuration related properties and methods. The matcher interface is defined as follows
// src/matcher/index.ts
interface RouterMatcher {
addRoute: (record: RouteRecordRaw, parent? : RouteRecordMatcher) = > () = > void
removeRoute: {
(matcher: RouteRecordMatcher): void
(name: RouteRecordName): void
}
getRoutes: () = > RouteRecordMatcher[]
getRecordMatcher: (name: RouteRecordName) = > RouteRecordMatcher | undefined
resolve: (location: MatcherLocationRaw, currentLocation: MatcherLocation) = > MatcherLocation
}
Copy the code
The basic logic of the createRouterMatcher function is simplified as follows
function createRouterMatcher(routes: RouteRecordRaw[], globalOptions: PathParserOptions) :RouterMatcher {
const matchers: RouteRecordMatcher[] = []
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
globalOptions = mergeOptions(
{ strict: false.end: true.sensitive: false } as PathParserOptions,
globalOptions
)
function getRecordMatcher(name: RouteRecordName) {
// ...
}
function addRoute(record: RouteRecordRaw, parent? : RouteRecordMatcher, originalRecord? : RouteRecordMatcher) {
// ...
}
function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
// ...
}
function getRoutes() {
// ...
}
function insertMatcher(matcher: RouteRecordMatcher) {
// ...
}
function resolve(location: Readonly
, currentLocation: Readonly
) :MatcherLocation {
// ...
}
// add initial routes
routes.forEach(route= > addRoute(route))
return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}
Copy the code
This function takes two arguments, the first being the route configuration array and the second being the options passed in when VueRouter was initialized. We then declare two variables matchers and matcherMap. We then declare a series of methods. Before returning, we traverse routes and convert the route configuration to matcher by using the addRoute method
Let’s look at each of these methods one by one
- AddRoute method
function addRoute(record: RouteRecordRaw, parent? : RouteRecordMatcher, originalRecord? : RouteRecordMatcher) {
letisRootAdd = ! originalRecordlet mainNormalizedRecord = normalizeRouteRecord(record)
mainNormalizedRecord.aliasOf = originalRecord && originalRecord.record
const options: PathParserOptions = mergeOptions(globalOptions, record)
const normalizedRecords: typeof mainNormalizedRecord[] = [
mainNormalizedRecord,
]
if ('alias' in record) {
const aliases =
typeof record.alias === 'string' ? [record.alias] : record.alias!
for (const alias of aliases) {
normalizedRecords.push(
assign({}, mainNormalizedRecord, {
components: originalRecord
? originalRecord.record.components
: mainNormalizedRecord.components,
path: alias,
aliasOf: originalRecord
? originalRecord.record
: mainNormalizedRecord,
}) as typeof mainNormalizedRecord
)
}
}
let matcher: RouteRecordMatcher
let originalMatcher: RouteRecordMatcher | undefined
for (const normalizedRecord of normalizedRecords) {
let { path } = normalizedRecord
if (parent && path[0]! = ='/') {
let parentPath = parent.record.path
let connectingSlash =
parentPath[parentPath.length - 1= = ='/' ? ' ' : '/'
normalizedRecord.path =
parent.record.path + (path && connectingSlash + path)
}
matcher = createRouteRecordMatcher(normalizedRecord, parent, options)
if (originalRecord) {
originalRecord.alias.push(matcher)
} else {
originalMatcher = originalMatcher || matcher
if(originalMatcher ! == matcher) originalMatcher.alias.push(matcher)if(isRootAdd && record.name && ! isAliasRecord(matcher)) removeRoute(record.name) }if ('children' in mainNormalizedRecord) {
let children = mainNormalizedRecord.children
for (let i = 0; i < children.length; i++) {
addRoute(
children[i],
matcher,
originalRecord && originalRecord.children[i]
)
}
}
originalRecord = originalRecord || matcher
insertMatcher(matcher)
}
return originalMatcher
? () = > {
removeRoute(originalMatcher!)
}
: noop
}
Copy the code
All this does is create a matcher object based on the route configuration object, add it to the Matchers array, and return a remove route method or noop based on the originalMatcher condition (let noop = () => {}). The route configuration transmitted in the application is incomplete. Therefore, you need to format the route configuration using the normalizeRouteRecord method to generate a complete route configuration object. The props properties are formatted using the normalizeRecordProps function. The formatting object is generated based on the Component or components of the route configuration object. If there is a Component attribute, the props object contains a default attribute and is assigned to the props in the configuration. Otherwise, the key of the Components object is used. And take the corresponding value from the route configuration property props
function normalizeRouteRecord(
record: RouteRecordRaw
) :RouteRecordNormalized {
return {
path: record.path,
redirect: record.redirect,
name: record.name,
meta: record.meta || {},
aliasOf: undefined.beforeEnter: record.beforeEnter,
props: normalizeRecordProps(record),
children: record.children || [],
instances: {},
leaveGuards: new Set(),
updateGuards: new Set(),
enterCallbacks: {},
components:
'components' in record
? record.components || {}
: { default: record.component! }}},function normalizeRecordProps(
record: RouteRecordRaw
) :Record<string._RouteRecordProps> {
const propsObject = {} as Record<string, _RouteRecordProps>
const props = (record as any).props || false
if ('component' in record) {
propsObject.default = props
} else {
for (let name in record.components)
propsObject[name] = typeof props === 'boolean' ? props : props[name]
}
return propsObject
}
Copy the code
After the normalizeRouteRecord method is called to format the route configuration object, the processed mainNormalizedRecord object is added to the normalizedRecords array
If there is an alias, add the record to the normalizedRecords array. The basic logic is to copy the mainNormalizedRecord and reset the components, path, aliasOf properties. In other words, Aliasing works by copying records and adjusting some properties to get a new record
The above code is a preparation for the creation of a matcher. Continue to analyze the code by first preparing two variables: matcher and originalMatcher, and then iterating through normalizedRecords
Inside the traversal, according to the route configuration object, create matcher, and insert into matchers, divided into the following steps:
-
1. If a child route is configured and path does not start with a slash (/), add the path of the parent route and the path of the child route to generate the complete path
-
2. Call createRouteRecordMatcher to create a matcher object. If parent exists, add the current matcher object to parent-children
function createRouteRecordMatcher(
record: Readonly<RouteRecord>,
parent: RouteRecordMatcher | undefined, options? : PathParserOptions) :RouteRecordMatcher {
const parser = tokensToParser(tokenizePath(record.path), options)
const matcher: RouteRecordMatcher = assign(parser, {
record,
parent,
children: [].alias: [],})if (parent) {
if(! matcher.record.aliasOf === ! parent.record.aliasOf) parent.children.push(matcher) }return matcher
}
Copy the code
The matcher object is of type RouteRecordMatcher, which inherits from the PathParser interface, so a matcher object should contain the following properties and methods, The first five properties or methods are created by tokensToParser(tokenizePath(Record.path), options). The implementation logic of these properties or methods will be analyzed in the method below
-re: RegExp
- score: Array<number[]>
- keys: PathParserParamKey[]
- parse(path: string): PathParams | null
- stringify(params: PathParams): string
- Record: RouteRecord stores formatted route configuration records
- Parent: RouteRecordMatcher | undefined parent route matcher object
- Children: RouteRecordMatcher[] child route, initialized to an empty array
- Alias: RouteRecordMatcher[] alias, initialized as an empty array
Before analyzing tokensToParser, we need to take a look at tokenizePath(Record.path), which converts path to a token array.
export const enum TokenType {
Static,
Param,
Group,
}
const enum TokenizerState {
Static,
Param,
ParamRegExp, // custom re for a param
ParamRegExpEnd, // check if there is any ? + *
EscapeNext,
}
interface TokenStatic {
type: TokenType.Static
value: string
}
interface TokenParam {
type: TokenType.Param regexp? : stringvalue: string
optional: boolean
repeatable: boolean
}
interface TokenGroup {
type: TokenType.Group
value: Exclude<Token, TokenGroup>[]
}
export type Token = TokenStatic | TokenParam | TokenGroup
const ROOT_TOKEN: Token = {
type: TokenType.Static,
value: ' ',}const VALID_PARAM_RE = /[a-zA-Z0-9_]/
// After some profiling, the cache seems to be unnecessary because tokenizePath
// (the slowest part of adding a route) is very fast
// const tokenCache = new Map<string, Token[][]>()
export function tokenizePath(path: string) :Array<Token[] >{
if(! path)return [[]]
if (path === '/') return [[ROOT_TOKEN]]
if(! path.startsWith('/')) {
throw new Error(
__DEV__
? `Route paths should start with a "/": "${path}" should be "/${path}". `
: `Invalid path "${path}"`)}// if (tokenCache.has(path)) return tokenCache.get(path)!
function crash(message: string) {
throw new Error(`ERR (${state})/"${buffer}": ${message}`)}let state: TokenizerState = TokenizerState.Static
let previousState: TokenizerState = state
const tokens: Array<Token[]> = []
// the segment will always be valid because we get into the initial state
// with the leading /
letsegment! : Token[]function finalizeSegment() {
if (segment) tokens.push(segment)
segment = []
}
// index on the path
let i = 0
// char at index
let char: string
// buffer of the value read
let buffer: string = ' '
// custom regexp for a param
let customRe: string = ' '
function consumeBuffer() {
if(! buffer)return
if (state === TokenizerState.Static) {
segment.push({
type: TokenType.Static,
value: buffer,
})
} else if (
state === TokenizerState.Param ||
state === TokenizerState.ParamRegExp ||
state === TokenizerState.ParamRegExpEnd
) {
if (segment.length > 1 && (char === The '*' || char === '+'))
crash(
`A repeatable param (${buffer}) must be alone in its segment. eg: '/:ids+.`
)
segment.push({
type: TokenType.Param,
value: buffer,
regexp: customRe,
repeatable: char === The '*' || char === '+'.optional: char === The '*' || char === '? '})},else {
crash('Invalid state to consume buffer')
}
buffer = ' '
}
function addCharToBuffer() {
buffer += char
}
while (i < path.length) {
char = path[i++]
if (char === '\ \'&& state ! == TokenizerState.ParamRegExp) { previousState = state state = TokenizerState.EscapeNextcontinue
}
switch (state) {
case TokenizerState.Static:
if (char === '/') {
if (buffer) {
consumeBuffer()
}
finalizeSegment()
} else if (char === ':') {
consumeBuffer()
state = TokenizerState.Param
} else {
addCharToBuffer()
}
break
case TokenizerState.EscapeNext:
addCharToBuffer()
state = previousState
break
case TokenizerState.Param:
if (char === '(') {
state = TokenizerState.ParamRegExp
} else if (VALID_PARAM_RE.test(char)) {
addCharToBuffer()
} else {
consumeBuffer()
state = TokenizerState.Static
// go back one character if we were not modifying
if(char ! = =The '*'&& char ! = ='? '&& char ! = ='+') i--
}
break
case TokenizerState.ParamRegExp:
// TODO:is it worth handling nested regexp? like :p(? :prefix_([^/]+)_suffix)
// it already works by escaping the closing )
// https://paths.esm.dev/?p=AAMeJbiAwQEcDKbAoAAkP60PG2R6QAvgNaA6AFACM2ABuQBB#
// is this really something people need since you can also write
// /prefix_:p()_suffix
if (char === ') ') {
// handle the escaped )
if (customRe[customRe.length - 1] = ='\ \')
customRe = customRe.slice(0, -1) + char
else state = TokenizerState.ParamRegExpEnd
} else {
customRe += char
}
break
case TokenizerState.ParamRegExpEnd:
// same as finalizing a param
consumeBuffer()
state = TokenizerState.Static
// go back one character if we were not modifying
if(char ! = =The '*'&& char ! = ='? '&& char ! = ='+') i--
customRe = ' '
break
default:
crash('Unknown state')
break}}if (state === TokenizerState.ParamRegExp)
crash(`Unfinished custom RegExp for param "${buffer}"`)
consumeBuffer()
finalizeSegment()
// tokenCache.set(path, tokens)
return tokens
}
Copy the code
The purpose of this function is to convert the path string to an array for subsequent processing. For example, /user will be converted to [[{type: 0, value: ‘user’}]] and /user/:id will be converted to:
[[{type: 0.value: "user"}],
[{type: 1.value: "id".regexp: "".repeatable: false.optional: false}]]Copy the code
Let’s go back to tokensToParser and see how PathParser is generated
- re
A regular expression that converts tokens into regular expressions that match the path using tokens passed in from parameters and a list of criteria
const BASE_PATH_PARSER_OPTIONS: Required<_PathParserOptions> = {
sensitive: false.strict: false.start: true.end: true,}function tokensToParser(
segments: Array<Token[]>, extraOptions? : _PathParserOptions) :PathParser {
const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions)
let pattern = options.start ? A '^' : ' '
for (const segment of segments) {
// Iterate through tokens to improve regular expressions
// TODO: Dig a hole here and analyze how to generate regular expressions later
}
if(! options.strict) pattern +='/? '
if (options.end) pattern += '$'
else if (options.strict) pattern += '(? : / | $) '
const re = new RegExp(pattern, options.sensitive ? ' ' : 'i')
// ...
}
Copy the code
- score
Calculate a score for the current path, and use the score value to compare subsequent paths, which is equivalent to comparing weights
let score: Array<number[]> = []
for (const segment of segments) {
const segmentScores: number[] = segment.length ? [] : [PathScore.Root]
// ...
score.push(segmentScores)
}
if (options.strict && options.end) {
const i = score.length - 1
score[i][score[i].length - 1] += PathScore.BonusStrict
}
Copy the code
- keys
Saves the dynamic parameters of a route
const keys: PathParserParamKey[] = []
for (const segment of segments) {
// ...
if (token.type === TokenType.Param) {
const { value, repeatable, optional, regexp } = token
keys.push({
name: value,
repeatable,
optional,
})
}
// ...
}
Copy the code
- parse
Pass in the path argument, then get the dynamic argument object based on the RE, and then iterate over the result
function parse(path: string) :PathParams | null {
const match = path.match(re)
const params: PathParams = {}
if(! match)return null
for (let i = 1; i < match.length; i++) {
const value: string = match[i] || ' '
const key = keys[i - 1]
params[key.name] = value && key.repeatable ? value.split('/') : value
}
return params
}
Copy the code
- stringify
This method passes in the params object and returns the path of the parameter object combined with path instead of the parameter value
function stringify(params: PathParams) :string {
let path = ' '
// for optional parameters to allow to be empty
let avoidDuplicatedSlash: boolean = false
for (const segment of segments) {
if(! avoidDuplicatedSlash || ! path.endsWith('/')) path += '/'
avoidDuplicatedSlash = false
for (const token of segment) {
if (token.type === TokenType.Static) {
path += token.value
} else if (token.type === TokenType.Param) {
const { value, repeatable, optional } = token
const param: string | string[] = value in params ? params[value] : ' '
if (Array.isArray(param) && ! repeatable)throw new Error(
`Provided param "${value}" is an array but it is not repeatable (* or + modifiers)`
)
const text: string = Array.isArray(param) ? param.join('/') : param
if(! text) {if (optional) {
// if we have more than one optional param like /:a? -static we
// don't need to care about the optional param
if (segment.length < 2) {
// remove the last slash as we could be at the end
if (path.endsWith('/')) path = path.slice(0, -1)
// do not append a slash on the next iteration
else avoidDuplicatedSlash = true}}else throw new Error(`Missing required param "${value}"`)
}
path += text
}
}
}
return path
}
Copy the code
It’s not easy to go through these complicated steps and get a complete Matcher object
-
The originalMatcher property is then assigned to the originalMatcher as matcher if it is the first assignment. The matcher is not reassigned, but added to the OriginalRecord. alias array
-
4, then according to the ‘children’ in mainNormalizedRecord conditions determine whether zi lu by, if there is zi lu by the traversal mainNormalizedRecord. Children array, and call addRoute method, the parameters are: children[i], matcher, originalRecord && originalRecord.children[i]
-
5. Finally call insertMatcher(matcher) to add matcher to matchers and update the matcherMap
function insertMatcher(matcher: RouteRecordMatcher) {
let i = 0
while (
i < matchers.length &&
comparePathParserScore(matcher, matchers[i]) >= 0
)
i++
matchers.splice(i, 0, matcher)
if(matcher.record.name && ! isAliasRecord(matcher)) matcherMap.set(matcher.record.name, matcher) }Copy the code
The addRoute method is completed
- Resolve method
The resolve method returns the MatcherLocation object, which contains the following attributes: name, path, Params, matched, meta. The function is to match the route based on the passed location and find the route information corresponding to the matcher corresponding to the location
function resolve(location: Readonly
, currentLocation: Readonly
) :MatcherLocation {
let matcher: RouteRecordMatcher | undefined
let params: PathParams = {}
let path: MatcherLocation['path']
let name: MatcherLocation['name']
if ('name' in location && location.name) {
matcher = matcherMap.get(location.name)
if(! matcher)throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
location,
})
name = matcher.record.name
params = assign(
paramsFromLocation(
currentLocation.params,
matcher.keys.filter(k= >! k.optional).map(k= > k.name)
),
location.params
)
path = matcher.stringify(params)
} else if ('path' in location) {
path = location.path
matcher = matchers.find(m= > m.re.test(path))
if (matcher) {
params = matcher.parse(path)!
name = matcher.record.name
}
} else {
matcher = currentLocation.name
? matcherMap.get(currentLocation.name)
: matchers.find(m= > m.re.test(currentLocation.path))
if(! matcher)throw createRouterError<MatcherError>(ErrorTypes.MATCHER_NOT_FOUND, {
location,
currentLocation,
})
name = matcher.record.name
params = assign({}, currentLocation.params, location.params)
path = matcher.stringify(params)
}
const matched: MatcherLocation['matched'] = []
let parentMatcher: RouteRecordMatcher | undefined = matcher
while (parentMatcher) {
matched.unshift(parentMatcher.record)
parentMatcher = parentMatcher.parent
}
return {
name,
path,
params,
matched,
meta: mergeMetaFields(matched),
}
}
Copy the code
- RemoveRoute method
The method takes a parameter matcherRef, the parameter type can be passed in the route name attribute or matcher object, and then find the corresponding matcher or matcher index through the matcherRef, delete the corresponding matcher in the matcherMap and matchers. It then recursively removes references to the matcher object in matcher. Children and matcher. Alias
function removeRoute(matcherRef: RouteRecordName | RouteRecordMatcher) {
if (isRouteName(matcherRef)) {
const matcher = matcherMap.get(matcherRef)
if (matcher) {
matcherMap.delete(matcherRef)
matchers.splice(matchers.indexOf(matcher), 1)
matcher.children.forEach(removeRoute)
matcher.alias.forEach(removeRoute)
}
} else {
let index = matchers.indexOf(matcherRef)
if (index > -1) {
matchers.splice(index, 1)
if (matcherRef.record.name) matcherMap.delete(matcherRef.record.name)
matcherRef.children.forEach(removeRoute)
matcherRef.alias.forEach(removeRoute)
}
}
}
Copy the code
- GetRoutes method
This method returns the Matchers array directly
function getRoutes() {
return matchers
}
Copy the code
- GetRecordMatcher method
This method is very simple, just get the corresponding matcher object from the matcherMap by route name
function getRecordMatcher(name: RouteRecordName) {
return matcherMap.get(name)
}
Copy the code
Install the VueRouter instance
Through the above analysis, we know that:
Create a VueRouter object using the createRouter(options) method. This method takes a configuration object that must provide two properties: History and Routes History You can create different types of History objects as needed using the three methods provided by VueRouter, which provide route properties and route jump methods
VueRouter creates a matcher object based on the Routes configuration. With the matcher object, VueRouter provides attributes and methods related to route configuration, such as adding routes, matching routes, and removing routes
Now that we have created the VueRouter instance object, how do we associate the VueRouter instance with the Vue instance
In the document sample code, add the VueRouter instance object to the Vue instance object via app.use
Create and mount the root instance
const app = Vue.createApp({})
// Ensure that the _use_ routing instance enables the entire application to support routing.
app.use(router)
Copy the code
When app.use(router) executes, it actually calls the install method of the VueRouter instance
export const START_LOCATION_NORMALIZED: RouteLocationNormalizedLoaded = {
path: '/'.name: undefined.params: {},
query: {},
hash: ' '.fullPath: '/'.matched: [].meta: {},
redirectedFrom: undefined,}export function createRouter(options: RouterOptions) :Router {
// shallowRef: Creates a ref that tracks its own.value changes but does not make its value responsive.
const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
START_LOCATION_NORMALIZED
)
let routerHistory = options.history
const router: Router = {
install(app: App) {
const router = this
// In the vUE instance, register the global routing components RouterLink and RouterView
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// Assign config.globalProperties.$router to the current VueRouter instance in the vue instance
app.config.globalProperties.$router = router
/ * * * when reading app. Config. GlobalProperties. $route, * return unref (currentRoute), namely the current routing information, the initial value for the path for ` / ` object * /
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true.get: () = > unref(currentRoute),
})
if (
isBrowser &&
// Avoid multiple pushes when using the router in multiple applications. This is false only if it is stated at first install! started && currentRoute.value === START_LOCATION_NORMALIZED ) { started =true
// Jump to the corresponding route in the browser URL
push(routerHistory.location)
}
// Copy the currentRoute object and convert it to the reactive object reactiveRoute, which can be retrieved in the component via Inject routeLocationKey
const reactiveRoute = {} as {
[k in keyof RouteLocationNormalizedLoaded]: ComputedRef<
RouteLocationNormalizedLoaded[k]
>
}
for (let key in START_LOCATION_NORMALIZED) {
// @ts-ignore: the key matches
reactiveRoute[key] = computed(() = > currentRoute.value[key])
}
// Inject router providers into the vue instance. The component uses Inject to receive these values
SRC /injectionSymbols.ts; // This Symbol is a Symbol
app.provide(routerKey, router)
app.provide(routeLocationKey, reactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
// Intercepts the vue instance unmount method, resets some properties and events unbind when the vue instance is unmounted, and then executes the vue instance unmount method
let unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
if (installedApps.size < 1) {
removeHistoryListener()
currentRoute.value = START_LOCATION_NORMALIZED
started = false
ready = false
}
unmountApp()
}
},
}
}
Copy the code
In summary, the install method does a few things:
- Register two routing components as VUE global components
- In app. Config. GlobalProperties add the router and the router and the router and the route attributes, the router is VueRouter instance object itself, the router is VueRouter instance object itself, Router is the VueRouter instance object itself, and route is the route object corresponding to the current location
- If this is the first install, the push method is used to jump to the route corresponding to the URL
- Inject three VueRouter related providers
- Intercept the unmount method of the Vue instance and perform the VueRouter related unmount before the unmount method is called
conclusion
Typescript makes it relatively easy to read source code, and provides a good understanding of what each variable does through type definitions. Starting from the creation of VueRouter instance object, we have a certain understanding of the basic implementation principle of routing, but also realize that it is not a very simple thing to achieve a complete routing function, and need to consider a lot of boundary problems. Due to my limited ability, THERE are still many details I cannot understand. Reading the source code is always a good thing, no matter how much you can understand.