前言
看到过有人在vite+vue
的项目中实现约定式路由,但是还没看到有人在webpakc+vue
的项目中实现约定式路由,前几天尝试了一些,基本的使用已经没有问题,代码比较简单,但是实现出来还是有完美的地方,希望能与各位共同优化代码。
正文
先来看看实际使用的效果吧:
目录结构:
├─about
│ index.js
│ index.vue
│ [id].vue
│
└─home
│ index.js
│ index.vue
│
└─news
index.js
index.vue
_newsid.js
_newsid.vue
输出的routes:
[
{
"children": [
{
"children": [],
"path": "/about/:id",
"name": "about-:id"
},
{
"children": [],
"path": "/about/",
"name": "about-index",
"meta": {
"name": "123"
}
}
],
"path": "/about",
"name": "about"
},
{
"children": [
{
"children": [],
"path": "/home/",
"name": "home-index",
"meta": {}
},
{
"children": [
{
"children": [],
"path": "/home/news/:newsid?",
"name": "home-news-:newsid?",
"meta": {}
},
{
"children": [],
"path": "/home/news/",
"name": "home-news-index",
"meta": {}
}
],
"path": "/home/news",
"name": "home-news"
}
],
"path": "/home",
"name": "home"
}
]
在vite
项目中实现约定式路由主要是通过vite
提供的import.meta.glob
方法,其实webpack也提供了有类似的办法,就是require.context
,这个方法用的不多,代码都不带提示的,我们先来了解一下这个方法:
// 方法执行后,返回一个 context 函数,同时它也是一个对象
const context = require.context(
directory: string, // 必填,一个目录路径,用于创建上下文环境
includeSubdirs?: boolean = true, // 可选,是否包含子目录,默认值为 true
filter?: RegExp = /^./.*$/, // 可选,过滤结果的正则表达式,默认值为 /^./.*$/ 表示所有文件
mode?: string = 'sync', // 可选, 加载模式,可选值为 'sync' | 'eager' | 'weak' | 'lazy' | 'lazy-once', 默认值为 'sync'
)
这个方法可以根据提供的路径去加载指定的模块,与import.meta.blob
相类似,但是功能可以比后者差点。
要注意的是,webpack官方文档提到了:传递给 require.context 的参数必须是字面量(literal)!
也就是不能使用变量。
const path = "../pages"
const moduleList = require.context(path, true, /\.vue$/) //TypeError: __webpack_require__(...).context is not a function
第三个选项mode决定了怎么加载模块,mode的可选值解释:
'sync'
直接打包到当前文件,同步加载并执行'lazy'
,为每个导入的模块生成一个单独的可延迟加载(lazy-loadable)的 chunk ,模块将被异步加载。'lazy-once'
为所有的import()
只生成一个满足所有的延迟加载模块,第一个import()
语句加载这个模块后,之后的import()
语句就只需在内存中读取了。'eager'
不会分离出单独的 chunk ,有的模块都被当前的 chunk 引入,并且没有额外的网络请求。但是仍会返回一个 resolved 状态的 Promise。与静态导入相比,只有访问了这个 Promise 才会执行代码,相当于先加载代码,但暂不执行这部分代码。'weak'
,尝试加载模块,如果该模块函数已经以其他方式加载,(即另一个 chunk 导入过此模块,或包含模块的脚本被加载)。仍会返回 Promise, 但是只有在客户端上已经有该 chunk 时才会成功解析。如果该模块不可用,则返回 rejected 状态的 Promise,且网络请求永远都不会执行。当需要的 chunks 始终在(嵌入在页面中的)初始请求中手动提供,而不是在应用程序导航在最初没有提供的模块导入的情况下触发,这对于通用渲染(SSR)是非常有用的。
mode
选项可以帮助我们实现异步加载。
这个函数返回一个context
对象,同时也是一个函数,它有三个属性,分别是 resolve
, keys
, id
。
context.keys
是一个函数,返回匹配到的所有模块路径字符串组成的数组,如['./a.js', './b.js']
,将返回数组的任一元素传回给context()
则可以得到这个文件的 ES Module ,访问这个 ES Module 的default
就可以访问模块的默认导出。其他命名导出也按对应方法访问。context.resolve
也是一个函数,返回解析后得到的模块 id 。传入context.keys()
返回的某个文件的 key ,可以得到这个文件相对于项目启动目录的一个相对路径。context.id
是上下文模块的模块 id 。这可能对module.hot.accept
有用。
例子:
const moduleList = require.context("../pages", true, /\.vue$/) //TypeError: __webpack_require__(...).context is not a function
moduleList.keys().forEach(key => {
console.log('moduleList.id', moduleList.id); //oduleList.id ./src/pages sync recursive \.vue$
console.log('moduleList(key)', moduleList.resolve(key)) // moduleList(key) ./src/pages/about/about.vue
});
介绍完这个关键的函数,我们接着看具体实现。
我对路由的约定类似小程序或者nuxt3
,首先把所有的页面组件都放在一个文件下,
每个对应一个文件夹,pages文件作为根目录,.vue
文件的路径就是路由的路径,.js文件就是对应的.vue
文件的meta
属性。
- 假设
pages
的目录结构如下:
pages/
--| user/
-----| index.vue
-----| one.vue
--| index.vue
那么,生成的路由配置如下:
router: {
routes: [
{
name: 'index',
path: '/',
component: 'pages/index.vue'
},
{
name: 'user',
path: '/user',
component: 'pages/user/index.vue'
},
{
name: 'user-one',
path: '/user/one',
component: 'pages/user/one.vue'
}
]
}
- 文件名以
_
开头,那么这个路由携带一个参数。
例如,文件结构:
home/
│ index.js
│ index.vue
│
└─news
index.js
index.vue
_newsid.js
_newsid.vue
生成的路由:
{
"children": [
{
"children": [],
"path": "/home/",
"name": "home-index",
"meta": {}
},
{
"children": [
{
"children": [],
"path": "/home/news/:newsid?",
"name": "home-news-:newsid?",
"meta": {}
},
{
"children": [],
"path": "/home/news/",
"name": "home-news-index",
"meta": {}
}
],
"path": "/home/news",
"name": "home-news"
}
],
"path": "/home",
"name": "home"
}
- 文件名如果是被
[]
包裹的,那么就表示这个路由是可选参数。
假设 pages
的目录结构如下:
pages/
--| user/
-----| index.vue
-----| [id].vue
--| index.vue
那么,生成的路由配置如下:
router: {
routes: [
{
name: 'index',
path: '/',
component: 'pages/index.vue'
},
{
name: 'user',
path: '/user',
component: 'pages/user/index.vue'
},
{
name: 'user-:id?',
path: '/user/:id?',
component: 'pages/user/[id].vue'
}
]
}
了解完大致规则就来着手实现吧。
这是实现的目录结构,这个文件在/src/pages下
├─about
│ index.js
│ index.vue
│ [id].vue
│
└─home
│ index.js
│ index.vue
│
└─news
index.js
index.vue
_newsid.js
_newsid.vue
首先我们要通过require.context
获取到所有的路由配置文件和路由组件,那么我这里的做法是先获取所有的.js文件(也就是路由的meta配置),再获取.vue组件,我这样做也就是为了逻辑更加好懂而已,其实也可以一起获取处理。
- 获取meta配置文件
代码:
// 获取meta
let rawMetas = require.context("../pages", true, /\.js$/);
const metas = {}
rawMetas.keys().forEach(key => {
const metaVal = rawMetas(key)
key = key.replace(/\.js$/, "").replace(/\./, '')
metas[key] = metaVal ? (metaVal.default || metaVal) : {}; // 这里是为了适应不同的导出格式
})
看的出来逻辑很简单,就是通过require.context
获取了所有的.js文件以后,以路径为key,获取导出的模块为值,生成了一个映射表metas
,这里去除.js 后缀是为了后面处理组件的时候方便取值。
- 处理路由组件
// 处理组件
const moduleList = require.context("../pages", true, /\.vue$/, 'eager' /*懒加载*/)
const rawRoutes = moduleList.keys().map(key => {
const component = () => moduleList(key);
const path = key.replace(/\.vue$/, '').replace(/\./, '');
return {
path,
component,
meta: metas[path],
}
});
这里使用require.context
要使用到第四个参数,实现懒加载,这里我选择了eager
,其实其他几个也差不多,在添加了这第四个属性以后,moduleList(key)
返回的就是一个被promise
包裹的路由组件,这就和import()
比较相似了,但是区别在于,这里导入组件是不能指定chunkName
,因为require.context
并不会把组件单独打包为一个chunk,而是和入口文件一起打包。
这里获取meta
属性就是通过组件路径名去获取的,所以在写配置文件的时候要注意配置文件和路由要是同一个名字,同一个文件夹下。
来看看这个rawRoutes
的结构:
[
{
"path": "/about/[id]"
},
{
"path": "/about/index",
"meta": {
"name": "123"
}
},
{
"path": "/home/index",
"meta": {}
},
{
"path": "/home/news/_newsid",
"meta": {}
},
{
"path": "/home/news/index",
"meta": {}
}
]
看的出来已经和结果很解接近了,现在要做的就是把这个结构处理为树结构以及完善约定。
- 构建路由树
// 转化为树
function buildTree(rawTree) {
const root = {
path: "/",
children: []
}; // 根节点
rawTree.forEach(item => {
const eachPaths = item.path.split("/").slice(1); // 对每个路径分割处理,slice(1) 是去除空格
let parent = root // 默认父节点是根节点
let path = ""; // 每次循环都会添加path
eachPaths.forEach((p) => {
let nameSuffix = '' // 这个name的作用是为之后的命名添加index
// 处理path
if (p.startsWith("_")) {
// 当路由以_开头,就处理为可选参数
p = `:${p.replace(/^_/, '')}?`;
} else if (p.startsWith("[") && p.endsWith("]")) {
// 当路由被[]包裹,就处理为必选参数
p = `:${p.replace(/^\[/, '').replace(/\]$/, '')}`;
} else if (p === "index") {
console.log('path', path)
// 如果路由是index,就说明是默认组件,把index转化为''空字符串就好了
p = '';
nameSuffix = "index" // 由于index变成空字符串了,所以要给组件的名字末尾加上index,这里可以优化。
}
path += "/" + p; // 拼接组件path
let node = parent.children.find(child => child.path === path) // 查看当前节点是否存在
if (!node) {
// 不存在节点
// 组件的名字
const name = (path.split("/").slice(1).join("-") + nameSuffix).replace(/\?/, '').replace(/:/, '')
if (p === '') {
// 如果是默认组件,就直接赋值给parent
node = parent;
node.name = name;
} else {
node = {
children: [],
path,
name
}
// 不是就添加节点给父组件
parent.children.push(node)
}
}
// 给下一次的循环使用
parent = node;
})
// 添加其他属性
Object.keys(item).forEach(key => key !== "children" && key !== 'path' && (parent[key] = item[key]))
});
return root.children;
}
那么buildTree
返回的结果就可以作为路由了,这个转化为树结构的过程有点复杂,还有很大的优化空间。但是没有使用递归,可能使用递归会简单一点。
结语
这个方法有不少缺陷,
由于require.context
是没法使用变量的,所以上面的代码没法封装为函数,最多只能提取到另一个文件里面,但是路径是写死的,还有就是逻辑比较复杂,这个办法只为大家做个参考,有建议可以一起讨论。
参考
webpack-require.context
文档:webpack.docschina.org/guides/depe…
代码干燥计划require.context
介绍:drylint.com/Webpack/req…
原文链接:https://juejin.cn/post/7227750114281390138 作者:jqm_