关于在webpack+vue的项目中实现约定式路由

前言

看到过有人在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"
    }
]

关于在webpack+vue的项目中实现约定式路由

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

关于在webpack+vue的项目中实现约定式路由
第三个选项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对象,同时也是一个函数,它有三个属性,分别是 resolvekeysid 。

  • 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的结构:

关于在webpack+vue的项目中实现约定式路由

[
    {
        "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_

(0)
上一篇 2023年5月1日 上午10:10
下一篇 2023年5月1日 上午10:20

相关推荐

发表评论

登录后才能评论