本人技术栈偏向 vue 一些, 所以之前写小程序的时候会考虑使用 wepy, 但是期间发现用起来有很多问题, 然后又没有什么更好的替代品, 直到有 mpvue 的出现, 让我眼前一亮, 完全意义上的用 vue 的语法写小程序, 赞
踩坑之旅
起因
根据官网的文档, 可以很迅速的完成 quick start, 之后很愉快地把自己写的 tabbar 组件搬了过来, 首先先引入组件...
- // script
- import {LTabbar, LTabbarItem} from '@/components/tabbar'
- export default {
- components: {
- LTabbar,
- LTabbarItem
- },
- ...
- // file path
- components
- |----tabbar
- |----tabbar.vue
- |----tabbar-item.vue
- |----index.js
- ...
在 vue 上很常规的引入方式, 然后使用... 然后看效果... 结果没有任何东西被渲染出来, 查看 console 发现有一条警告
有问题肯定得去解决是吧, 然后就开始作死的 mpvue 源码探究之旅
定位问题
由于是基于实际问题出发的源码探究, 所以本质是为了解决问题, 那么就得先定位出该问题可能会产生的原因, 并带着这个问题去阅读源码从 warning 可以很明确的看出, 是 vue 组件转化为 wxml 时发生的问题, 而这件事应当是在 loader 的时候处理的, 所以可以把问题的原因定位到 mpvue-loader, 先看一眼 mpvue-loader 的构成
- component-normalizer.js
- loader.js // loader 入口
- mp-compiler // mp script 解析相关文件夹
- index.js
- parse.js // components & config parse babel 插件
- templates.js // vue script 部分转化成 wxml 的 template
- util.js // 一些通用方法
- parser.js // parseComponent & generateSourceMap
- selector.js
- style-compiler // 样式解析相关文件夹
- template-compiler // 模板解析相关文件夹
- utils
首先找到 loader.js 这个文件, 找到关于 script 的解析部分, 从这里看到调用了一个 compileMPScript 方法来解析 components
script 参数即为 vue 单文件的 < script></script > 包含部分
mpOptions mp 相关配置参数
moduleId 用于模块唯一标识
- moduleId = 'data-v-' + genId(filePath, context, options.hashKey)
- // line 259
- // <script>
- output += '/* script */\n'
- var script = parts.script
- if (script) {
- // for mp js
- // 需要解析组件的 components 给 wxml 生成用
- script = compileMPScript.call(this, script, mpOptions, moduleId)
- ...
接下来看一下 mp-compiler 目录下的 compileMPScript 具体做了哪些事情
- function compileMPScript (script, optioins, moduleId) {
- // 获得 babelrc 配置
- const babelrc = optioins.globalBabelrc ? optioins.globalBabelrc : path.resolve('./.babelrc')
- // 写了一个 parseComponentsDeps babel 插件来遍历组件从而获取到组件的依赖(关键)
- const { metadata } = babel.transform(script.content, { extends: babelrc, plugins: [parseComponentsDeps] })
- // metadata: importsMap, components
- const { importsMap, components: originComponents } = metadata
- // 处理子组件的信息
- const components = {}
- if (originComponents) {
- const allP = Object.keys(originComponents).map(k => {
- return new Promise((resolve, reject) => {
- // originComponents[k] 为组件依赖的路径, 格式如下: '@/components/xxx'
- // 通过 this.resolve 得到 realSrc
- this.resolve(this.context, originComponents[k], (err, realSrc) => {
- if (err) return reject(err)
- // 将组件名由驼峰转化成中横线形式
- const com = covertCCVar(k)
- // 根据真实路径获取到组件名(关键)
- const comName = getCompNameBySrc(realSrc)
- components[com] = { src: comName, name: comName }
- resolve()
- })
- })
- })
- Promise.all(allP)
- .then(res => {
- components.isCompleted = true
- })
- .catch(err => {
- console.error(err)
- components.isCompleted = true
- })
- } else {
- components.isCompleted = true
- }
- const fileInfo = resolveTarget(this.resourcePath, optioins.mpInfo)
- cacheFileInfo(this.resourcePath, fileInfo, { importsMap, components, moduleId })
- return script
- }
这段代码中有两处比较关键的部分
babel 插件的转化究竟做了些什么事儿, 组件的依赖是怎么样的形式?
组件的 realSrc 是否真的为我所需要的路径 那么首先先看一下 babel 插件究竟做了什么
parseComponentsDeps babel 插件
首先我在看这份源码的时候对于 babel 这块的知识是零基础, 所以着实废了不少功夫
在看 babel 插件之前最好可以先阅览这些资料
Babel-handbook - 这份资料里面很详细地描述了如何写一个 babel 插件
Babel-types 相关 - 这里会涉及到 AST 节点类型
接下来看一下核心的源码部分, 这里声明了一个 components 访问者:
Visitors(访问者)
当我们谈及进入一个节点, 实际上是说我们在访问它们, 之所以使用这样的术语是因为有一个访问者模式 (visitor) 的概念.
访问者是一个用于 AST 遍历的跨语言的模式 简单的说它们就是一个对象, 定义了用于在一个树状结构中获取具体节点的方法
- // components 的遍历器
- const componentsVisitor = {
- ExportDefaultDeclaration: function (path) {
- path.traverse(traverseComponentsVisitor)
- }
- }
traverseComponentsVisitor 里面主要是对结构的一个解析, 最后获取到 importsMap, 然后组装成一个 components 对象并返回
- // 解析 components
- const traverseComponentsVisitor = {
- Property: function (path) {
- // 只对类型为 components 的进行操作
- if (path.node.key.name !== 'components') {
- return
- }
- path.stop()
- const { metadata } = path.hub.file
- const { importsMap } = getImportsMap(metadata)
- // 找到所有的 imports
- const { properties } = path.node.value
- const components = {}
- properties.forEach(p => {
- const k = p.key.name || p.key.value
- const v = p.value.name || p.value.value
- components[k] = importsMap[v]
- // Example: components = { Card: '@/components/card' }
- })
- metadata.components = components
- }
- }
对于
import Card from '@/components/card'
component 就应该为
{ Card: '@/components/card' }
对于
import { LTabbar, LTabbrItem } from '@/components/tabbar'
则会被解析为
{ LTabbar: '@/components/tabbar', LTabbarItem: '@/components/tabbar' }
而我们期望的显然是
{ LTabbar: '@/components/tabbar/tabbar', LTabbarItem: '@/components/tabbar/tabbar-item' }
然后我就得到这样一个思路:
从 path 中解析出 LTabbar 和 LTabbarItem 真实的路径, 或者关联的部分
找到以后替换这里的 importsMap
感觉想法并没有错, 但是我花费了大量的精力去解析 path 最后得出一个结论... 解析不出来!!, 期间尝试了 ImportDeclaration 从中得到过最接近期望的一段 path, 然而它是被写在 LeadingComments 这个字段当中的, 除非没有办法的办法, 否则就不应该通过这个字段去进行正则匹配
然后看了一部分 Rollup 的 Module 部分的源码, 感觉这个源码写得是真的好, 非常清晰从中的确收获了一些启迪, 不过感觉这目前的解析而言没有什么帮助
既然从 babel 插件这条路走不通了, 所以想着是否可以从其他路试试, 然后就到了第二个关键点部分
组件的 realSrc
既然在 babel 组件当中的 importsMap 不是我真正想要的依赖文件, 那究竟依赖文件怎么获取到呢? 首先我再 compileMPScript 里面打印了一下 this.resourcePath, 得到了以下输出
- resource: /Users/linyiheng/Code/wechat/my-project/src/App.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/pages/counter/index.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/pages/index/index.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/pages/logs/index.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/components/card.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/components/tabbar/tabbar.vue
- resource: /Users/linyiheng/Code/wechat/my-project/src/components/tabbar/tabbar-item.vu
这个其实就是文件的一个加载顺序, 由于 LTabbarLTabbarItem 这两个组件是在 pages/index/index.vue 被引入的, 所以相应的解析操作会被放在这里进行, 但是从 babel 组件无法得到这两个组件的 realSrc, 那么是否可以从最后加载进来的两个 vue 组件着手考虑呢, 这个 resourcePath 显然就是我们想要的 realSrc
简单的给 traverseComponentsVisitor 加上这样的一个代码段
- // traverseComponentsVisitor
- if (path.node.key.name === 'component') {
- path.stop()
- const k = path.node.value.value
- const components = {}
- const { metadata } = path.hub.file
- components[k] = ''
- metadata.components = components
- return
- }
然后稍微改造一下 this.resolve 的处理
- // 如果 originComponents[k]不存在的情况下, 则使用当前的 resourcePath
- this.resolve(this.context, originComponents[k] || this.resourcePath, (err,
感觉一切就绪了, 尝试发现仍然是不行的, 虽然我的确得到了组件的 realSrc, 但是对于 pages/index/index.vue 而言, 已经完成了 wxml 模板的输出了, 而后面进行的主体是 components/tabbar/tabbar.vue 和 components/tabbar/tabbar-item.vue, 显然这个时候是无法输出 wxml 的看一下生成 Wxml 的核心代码
- function createWxml (emitWarning, emitError, emitFile, resourcePath, rootComponent, compiled, html) {
- const { pageType, moduleId, components, src } = getFileInfo(resourcePath) || {}
- // 这儿一个黑魔法, 和 webpack 约定的规范写法有点偏差!
- if (!pageType || (components && !components.isCompleted)) {
- return setTimeout(createWxml, 20, ...arguments)
- }
- let wxmlContent = ''let wxmlSrc =''
- if (rootComponent) {
- const componentName = getCompNameBySrc(rootComponent)
- wxmlContent = genPageWxml(componentName)
- wxmlSrc = src
- } else {
- // TODO, 这儿传 options 进去
- // {
- // components: {
- // 'com-a': { src: '../../components/comA$hash', name: 'comA$hash' }
- // },
- // pageType: 'component',
- // name: 'comA$hash',
- // moduleId: 'moduleId'
- // }
- // 以 resourcePath 为 key 值, 从 cache 里面获取到组件名, 组件名 + hash 形式
- const name = getCompNameBySrc(resourcePath)
- const options = { components, pageType, name, moduleId }
- // 将所有的配置相关传入并生成 Wxml Content
- wxmlContent = genComponentWxml(compiled, options, emitFile, emitError, emitWarning)
- // wxml 的路径
- wxmlSrc = `components/${name}`
- }
- // 上抛
- emitFile(`${wxmlSrc}.wxml`, wxmlContent)
- }
这部分代码主要的工作其实就是根据之前获取的组件 & 组件路径相关信息, 通过 genComponentWxml 生成对应的 wxml, 但是由于没办法一次性拿到 realSrc, 所以我觉得这里的代码存在着一些小问题, 理想的效果应该是完成所有的 components 解析以后再进行 wxml 的生成, 那么这件问题就迎刃而解了其实作者用尝试通过 components.isCompleted 来实现异步加载的问题, 但是除非是把所有的 compileMPScript 给包含在一个 Promise 里面, 否则的话感觉这步操作似乎没有起到作用(也有可能是我理解不到位)
总结
虽然这个需求并不是优先级很高的一个需求
- // 其实只要把 import { LTabbar, LTabbarItem } from '@/components/tabbar' 拆分为以下两段就可以了
- import LTabbar from '@/components/tabbar'
- import LTabbarItem from '@/components/tabbar-item'
但是从这个需求出发看源码, 的确是有发现源码中的一些瑕疵(当然换我我还写不出来... 所以还是得支持一下大佬的), 顺带也了解了一下 Babel 插件实现的原理, 了解了 loader 大概的一个实现原理, 所以还是收获颇丰的
经过了那么久时间的尝试我还是没有解决这个问题, 说实话我是心有不甘的, 我把这次经验整理出来也希望大家能够给我提供一些思路, 或是如何解析 babel 插件, 或是如何实现 wxml 的统一解析, 或是还有其他的解决方案最后希望 mpvue 能够越来越棒
来源: https://juejin.im/post/5aa9e3f86fb9a028c6756e01