xinpianchang / fe-weekly

weekly for fe-team
10 stars 2 forks source link

nuxt约定式路由实现 #42

Open tyz98 opened 3 years ago

tyz98 commented 3 years ago

目录结构: pages/ --| a.vue --| b.vue --| b/ -----| c.vue -----| c/ -------| d.vue 生成的router:

  router: {
    routes: [
      {
        name: 'a',
        path: '/a',
        component: 'pages/a.vue'
      },
      {
        name: 'b',
        path: '/b',
        component: 'pages/b.vue',
        children: [
          {
            name: 'b-c',
            path: '/b/c',
            component: 'pages/b/c.vue',
            children: [
              {
                name: 'b-c-d',
                path: '/b/c/d',
                component: 'pages/b/c/d.vue',
              }
            ]
          },
        ]
      },
    ]
  }

@nuxt/builder/dist/builder.js Builder的build方法

async build () {
  ...
  // Generate routes and interpret the template files
  await this.generateRoutesAndFiles();
  ...
}

generateRoutesAndFiles方法

async generateRoutesAndFiles () {
  ...
  await Promise.all([
    ...
    this.resolveRoutes(templateContext),
    ...
  ]);
  ...
}

resolveRoutes方法

async resolveRoutes ({ templateVars }) {
  ...
  else if (this._nuxtPages) {
    // Use nuxt.js createRoutes bases on pages/
    const files = {};
    const ext = new RegExp(`\\.(${this.supportedExtensions.join('|')})$`);
    for (const page of await this.resolveFiles(this.options.dir.pages)) {
      const key = page.replace(ext, '');
      // .vue file takes precedence over other extensions
      if (/\.vue$/.test(page) || !files[key]) {
        files[key] = page.replace(/(['"])/g, '\\$1');
      }
    }
    templateVars.router.routes = utils.createRoutes({
      files: Object.values(files),
      srcDir: this.options.srcDir,
      pagesDir: this.options.dir.pages,
      routeNameSplitter,
      supportedExtensions: this.supportedExtensions,
      trailingSlash
    });
  } 
  ...
}

resolveFiles使用glob库返回an array of filenames

@nuxt/utils/dist/utils.js

const createRoutes = function createRoutes ({
  files,
  srcDir,
  pagesDir = '',
  routeNameSplitter = '-',
  supportedExtensions = ['vue', 'js'],
  trailingSlash
}) {
  const routes = [];
  files.forEach((file) => {
    const keys = file
      .replace(new RegExp(`^${pagesDir}`), '')
      .replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '')
      .replace(/\/{2,}/g, '/')
      .split('/')
      .slice(1);
    const route = { name: '', path: '', component: r(srcDir, file) };
    let parent = routes;
    keys.forEach((key, i) => {
      // remove underscore only, if its the prefix
      const sanitizedKey = key.startsWith('_') ? key.substr(1) : key;

      route.name = route.name
        ? route.name + routeNameSplitter + sanitizedKey
        : sanitizedKey;
      route.name += key === '_' ? 'all' : '';
      route.chunkName = file.replace(new RegExp(`\\.(${supportedExtensions.join('|')})$`), '');
      const child = parent.find(parentRoute => parentRoute.name === route.name);

      if (child) {
        child.children = child.children || [];
        parent = child.children;
        route.path = '';
      } else if (key === 'index' && i + 1 === keys.length) {
        route.path += i > 0 ? '' : '/';
      } else {
        route.path += '/' + getRoutePathExtension(key);

        if (key.startsWith('_') && key.length > 1) {
          route.path += '?';
        }
      }
    });
    if (trailingSlash !== undefined) {
      route.pathToRegexpOptions = { ...route.pathToRegexpOptions, strict: true };
      route.path = route.path.replace(/\/+$/, '') + (trailingSlash ? '/' : '') || '/';
    }

    parent.push(route);
  });

  sortRoutes(routes);
  return cleanChildrenRoutes(routes, false, routeNameSplitter)
};

举例说明createRoutes方法:

files: 
[
  'pages/a.vue',
  'pages/b.vue',
 ' pages/b/c.vue',
 ' pages/b/c/d.vue'
]
//初始化routes为[]
routes: []

遍历files,每个file最终会得到一个route对象,并且会按照嵌套关系放在正确的“parent”中(可能是routes列表本身,或者某一个route的children列表)  每个file生成一个keys列表,keys即每个.vue文件的“路径”, 之后在遍历keys的过程中要更新route对象并更新最后要插入其中的parent对象。

 遍历keys前初始化route为一个name、path均为空的对象,parent为最外层的routes: route = { name: '', path: '', component: r(srcDir, file) }; parent = routes

后面的例子中route中省略path和component

  file:"pages/a.vue"
  keys: ["a"]
  route = { name: '' };
  parent = routes
  //开始遍历keys
    key: "a"
    route = { name: 'a' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    child = undefined
    //parent不变
  //keys遍历结束,把route对象push到最终的parent中
  routes: [{ name: 'a' }]
  file:"pages/b.vue"
  keys: ["b"]
  route = { name: '' };
  parent = routes
  //开始遍历keys
    key: "b"
    route = { name: 'b' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    child = undefined
    //parent不变
  //keys遍历结束,把route对象push到最终的parent中
  routes: [{ name: 'a' }, { name: 'b' }]
  file:"pages/b/c.vue"
  keys: ["b", "c"]
  route = { name: '' };
  parent = routes
  //开始遍历keys
    key: "b"
    route = { name: 'b' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    //找到child但没有children属性,给child增加children属性并初始化为[]
    child = { name: 'b', children: [] }
    parent =  []

    key: "c"
    route = { name: 'b-c' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    child = undefined
    //parent不变

  //keys遍历结束,把route对象push到最终的parent中
  {name: 'b', children: [{ name: 'b-c' }]}
  //此时routes为:
  routes: [ { name: 'a' }, { name: 'b', children: [{ name: 'b-c' }] }]
  file:"pages/b/c/d.vue"
  keys: ["b", "c", "d"]
  route = { name: '' };
  parent = routes
  //开始遍历keys
    key: "b"
    route = { name: 'b' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    child = { name: 'b', children: [{ name: 'b-c' }] }
    parent = [{ name: 'b-c' }]

    key: "c"
    route = { name: 'b-c' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    //找到child但没有children属性,给child增加children属性并初始化为[]
    child = { name: 'b-c', chilren: [] }
    parent = []

    key: "d"
    route = { name: 'b-c-d' };
    //在parent中检索与route.name同名的route对象,若有则更新parent
    //找到child但没有children属性,给child增加children属性并初始化为[]
    child = undefined
    //parent不变

  //keys遍历结束,把route对象push到最终的parent中
  { name: 'b-c', chilren: [{ name: 'b-c-d' }] }
  //此时routes为:
  routes: [{ name: 'a' }, { name: 'b', children: [{ name: 'b-c', chilren: [{ name: 'b-c-d' }] }] }]

所以最终生成的routes为[{ name: 'a' }, { name: 'b', children: [{ name: 'b-c', chilren: [{ name: 'b-c-d' }] }] }]

tyz98 commented 3 years ago

关于生成的routes的顺序:

之前忽略了createRoutes最后的sortRoutes方法:

const DYNAMIC_ROUTE_REGEX = /^\/([:*])/;

const sortRoutes = function sortRoutes (routes) {
  routes.sort((a, b) => {
    if (!a.path.length) {
      return -1
    }
    if (!b.path.length) {
      return 1
    }
    // Order: /static, /index, /:dynamic
    // Match exact route before index: /login before /index/_slug
    if (a.path === '/') {
      return DYNAMIC_ROUTE_REGEX.test(b.path) ? -1 : 1
    }
    if (b.path === '/') {
      return DYNAMIC_ROUTE_REGEX.test(a.path) ? 1 : -1
    }

    let i;
    let res = 0;
    let y = 0;
    let z = 0;
    const _a = a.path.split('/');
    const _b = b.path.split('/');
    for (i = 0; i < _a.length; i++) {
      if (res !== 0) {
        break
      }
      y = _a[i] === '*' ? 2 : _a[i].includes(':') ? 1 : 0;
      z = _b[i] === '*' ? 2 : _b[i].includes(':') ? 1 : 0;
      res = y - z;
      // If a.length >= b.length
      if (i === _b.length - 1 && res === 0) {
        // unless * found sort by level, then alphabetically
        res = _a[i] === '*' ? -1 : (
          _a.length === _b.length ? a.path.localeCompare(b.path) : (_a.length - _b.length)
        );
      }
    }

    if (res === 0) {
      // unless * found sort by level, then alphabetically
      res = _a[i - 1] === '*' && _b[i] ? 1 : (
        _a.length === _b.length ? a.path.localeCompare(b.path) : (_a.length - _b.length)
      );
    }
    return res
  });

  routes.forEach((route) => {
    if (route.children) {
      sortRoutes(route.children);
    }
  });

  return routes
};

使用了sort方法,依据route.path进行排序。

两个同级路由对象a,b的排序按下面的规则:

注: 关于如何命名文件来代表dynamic routesdynamic nested routesunknown dynamic routes

举例:

(仅作为例子,实际上不会这样写,这里并不是所有路由都能被访问到) 目录结构:

pages/
--| index.vue
--| b.vue
--| detail/
-----| _.vue
-----| _a.vue
-----| _a/
-------| _b.vue
-----| _id.vue
-----| c.vue
-----| d/
-------| e.vue
--| u/
-----| _id.vue

生成routes:

[
  {
    name: 'detail',
    path: '/detail',
    component: '/pages/detail/index.vue',
    chunkName: 'pages/detail/index'
  },
  {
    name: 'detail-c',
    path: '/detail/c',
    component: '/pages/detail/c.vue',
    chunkName: 'pages/detail/c'
  },
  {
    name: 'detail-d-e',
    path: '/detail/d/e',
    component: '/pages/detail/d/e.vue',
    chunkName: 'pages/detail/d/e'
  },
  {
    name: 'detail-a',
    path: '/detail/:a',
    component: '/pages/detail/_a.vue',
    chunkName: 'pages/detail/_a',
    children: [ [Object] ]
  },
  {
    name: 'detail-id',
    path: '/detail/:id',
    component: '/pages/detail/_id.vue',
    chunkName: 'pages/detail/_id'
  },
  {
    name: 'u-id',
    path: '/u/:id?',
    component: '/pages/u/_id.vue',
    chunkName: 'pages/u/_id'
  },
  {
    name: 'detail-all',
    path: '/detail/*',
    component: '/pages/detail/_.vue',
    chunkName: 'pages/detail/_'
  },
  {
    name: 'index',
    path: '/',
    component: '/pages/index.vue',
    chunkName: 'pages/index'
  }
]
dailynodejs commented 3 years ago

关于 .nuxt/router.js 的文件是如何生成的

vue-app/template 中有一个对应的 router.js

routerOptions

export const routerOptions = {
  mode: '<%= router.mode %>',
  base: '<%= router.base %>',
  linkActiveClass: '<%= router.linkActiveClass %>',
  linkExactActiveClass: '<%= router.linkExactActiveClass %>',
  scrollBehavior,
  <%= isTest ? '/* eslint-disable array-bracket-spacing, quotes, quote-props, object-curly-spacing, key-spacing */' : '' %>
  routes: [<%= _routes %>],
  <%= isTest ? '/* eslint-enable array-bracket-spacing, quotes, quote-props, object-curly-spacing, key-spacing */' : '' %>
  <% if (router.parseQuery) { %>parseQuery: <%= serializeFunction(router.parseQuery) %>,<% } %>
  <% if (router.stringifyQuery) { %>stringifyQuery: <%= serializeFunction(router.stringifyQuery) %>,<% } %>
  fallback: <%= router.fallback %>
}

转换后的:

import scrollBehavior from './router.scrollBehavior.js'

export const routerOptions = {
  mode: 'history',
  base: decodeURI('/'),
  linkActiveClass: 'nuxt-link-active',
  linkExactActiveClass: 'nuxt-link-exact-active',
  scrollBehavior,
  routes: [],
  fallback: false
}

核心

packages/builder/src/builder.js,依赖了 lodash/template

import template from 'lodash/template'
import TemplateContext from './context/template'

export default class Builder {
  constructor (nuxt, bundleBuilder) {
  }

  async build () {}
}

compileTemplates

import fsExtra from 'fs-extra'

async compileTemplates (templateContext) {
  await Promise.all(
    templateFiles.map(async (templateFile) => {
      const { src, dst, custom } = templateFile
      // ...
      const fileContent = await fsExtra.readFile(src, 'utf8')
      let content
      try {
        const templateFunction = template(fileContent, templateOptions)
      } catch (err) {
        // ...
      }
    })
 )
}

packages/builder/src/context/template

export default class TemplateContext {
  constructor (builder, options) {
    this.templateFiles = Array.from(builder.template.files)
    this.templateVars = {}
  }

  get templateOptions () {
  }
}

generateRoutesAndFiles

async generateRoutesAndFiles () {
  const templateContext = this.createTemplateContext()
}

createTemplateContext

import TemplateContext from './context/template'

createTemplateContext () {
  return new TemplateContext(this, this.options)
}

.nuxt 目录如何生成

import {
  r
} from '@nuxt/utils'

async build () {
  await fsExtra.emptyDir(r(this.options.buildDir))
}

依赖了一个 lodash

import uniqBy from 'lodash/uniqBy'

循环生成:

import { interopDefault } from './utils'

<%= uniqBy(_components, '_name').map((route) => {
  if (!route.component) return ''
  const path = relativeToBuild(route.component)
  const chunkName = wChunk(route.chunkName)
  const name = route._name

  if (splitChunks.pages) {
    return `const ${name} = () => interopDefault(import('${path}' /* webpackChunkName: "${chunkName}" */))`
  } else {
    return `import ${name} from '${path}'`
  }
}).join('\n')%>

输出的:

import { interopDefault } from './utils'

const _1bdf586c = () => interopDefault(import('../pages/a.vue' /* webpackChunkName: "pages/a" */))
const _f963b7de = () => interopDefault(import('../pages/a/b.vue' /* webpackChunkName: "pages/a/b" */))
const _bef819ea = () => interopDefault(import('../pages/a/c.vue' /* webpackChunkName: "pages/a/c" */))

interopDefault

文件:/packages/vue-app/template/utils.js

export function interopDefault (promise) {
  return promise.then(m => m.default || m)
}

hash

import hash from 'hash-sum'

packages/builder 定义了依赖:

"hash-sum": "^2.0.0"

模板里面的

packages/builder/src/context.js

导入几个方法:hashserializeuniqBydevaluewChunk

import hash from 'hash-sum'
import serialize from 'serialize-javascript'
import uniqBy from 'lodash/uniqBy'
import devalue from '@nuxt/devalue'

import { r, wp, wChunk, serializeFunction, isFullStatic } from '@nuxt/utils'

export default class TemplateContext {
  get templateOptions () {
    return {
      imports: {
        uniqBy,
        serialize,
        devalue,
        hash,
        wChunk,
        ...
      },
      interpolate: /<%=([\s\S]+?)%>/g
    }
  }
}

recursiveRoutes

<% function recursiveRoutes(routes, tab, components, indentCount) {
  let res = ''
  const baseIndent = tab.repeat(indentCount)
  const firstIndent = '\n' + tab.repeat(indentCount + 1)
  const nextIndent = ',' + firstIndent
  routes.forEach((route, i) => {

  })
  return res
}
const _components = []
const _routes = recursiveRoutes(router.routes, '  ', _components, 1)
%>

循环 routes,这里面就会用到 hash

routes.forEach((route, i) => {
  let resMap = ''
  // If need to handle named views
  if (route.components) {
    let _name = '_' + hash(route.components.default)
    if (splitChunks.pages) {
      resMap += `${firstIndent}${tab}default: ${_name}`
    } else {
      resMap += `${firstIndent}${tab}default: () => ${_name}.default || ${_name}`
    }
    for (const k in route.components) {
      _name = '_' + hash(route.components[k])
      const component = { _name, component: route.components[k] }
      if (k === 'default') {
        components.push({
          ...component,
          name: route.name,
          chunkName: route.chunkName
        })
      } else {
        components.push({
          ...component,
          name: `${route.name}-${k}`,
          chunkName: route.chunkNames[k]
        })
        if (splitChunks.pages) {
          resMap += `${nextIndent}${tab}${k}: ${_name}`
        } else {
          resMap += `${nextIndent}${tab}${k}: () => ${_name}.default || ${_name}`
        }
      }
    }
    route.component = false
  } else {
    route._name = '_' + hash(route.component)
    components.push({ _name: route._name, component: route.component, name: route.name, chunkName: route.chunkName })
  }
  // @see: https://router.vuejs.org/api/#router-construction-options
  res += '{'
  res += firstIndent + 'path: ' + JSON.stringify(route.path)
  res += (route.components) ? nextIndent + 'components: {' + resMap + '\n' + baseIndent + tab + '}' : ''
  res += (route.component) ? nextIndent + 'component: ' + route._name : ''
  res += (route.redirect) ? nextIndent + 'redirect: ' + JSON.stringify(route.redirect) : ''
  res += (route.meta) ? nextIndent + 'meta: ' + JSON.stringify(route.meta) : ''
  res += (typeof route.props !== 'undefined') ? nextIndent + 'props: ' + (typeof route.props === 'function' ? serialize(route.props) : JSON.stringify(route.props)) : ''
  res += (typeof route.caseSensitive !== 'undefined') ? nextIndent + 'caseSensitive: ' + JSON.stringify(route.caseSensitive) : ''
  res += (route.alias) ? nextIndent + 'alias: ' + JSON.stringify(route.alias) : ''
  res += (route.pathToRegexpOptions) ? nextIndent + 'pathToRegexpOptions: ' + JSON.stringify(route.pathToRegexpOptions) : ''
  res += (route.name) ? nextIndent + 'name: ' + JSON.stringify(route.name) : ''
  if (route.beforeEnter) {
    if(isTest) { res += ',\n/* eslint-disable indent, semi */' }
    res += (isTest ? firstIndent : nextIndent) + 'beforeEnter: ' + serialize(route.beforeEnter)
    if(isTest) { res += firstIndent + '/* eslint-enable indent, semi */' }
  }
  res += (route.children) ? nextIndent + 'children: [' + recursiveRoutes(routes[i].children, tab, components, indentCount + 1) + ']' : ''
  res += '\n' + baseIndent + '}' + (i + 1 === routes.length ? '' : ', ')
})

@nuxt/utils

wChunk

export const wChunk = function wChunk (p = '') {
  return p
}