本人技術棧偏向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
複製程式碼
這個其實就是檔案的一個載入順序,由於LTabbar、LTabbarItem這兩個元件是在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能夠越來越棒?