一文帶你詳細(xì)理解uni-app如何構(gòu)建小程序
前言
uni-app是一個(gè)基于Vue.js語(yǔ)法開(kāi)發(fā)小程序的前端框架,開(kāi)發(fā)者通過(guò)編寫(xiě)一套代碼,可發(fā)布到iOS、Android、Web以及各種小程序平臺(tái)。今天,我們通過(guò)相關(guān)案例分析uni-app是怎樣把Vue.js構(gòu)建成原生小程序的。
Vue是template、script、style三段式的SFC,uni-app是怎么把SFC拆分成小程序的ttml、ttss、js、json四段式?帶著問(wèn)題,本文將從webpack、編譯器、運(yùn)行時(shí)三方面帶你了解uni-app是如何構(gòu)建小程序的。
一.用法
uni-app是基于vue-cli腳手架開(kāi)發(fā),集成一個(gè)遠(yuǎn)程的Vue Preset
npm install -g @vue/cli vue create -p dcloudio/uni-preset-vue my-project
uni-app目前集成了很多不同的項(xiàng)目模版,可以根據(jù)不同的需要,選擇不同的模版
運(yùn)行、發(fā)布uni-app,以字節(jié)小程序?yàn)槔?/p>
npm run dev:mp-toutiao npm run build:mp-toutiao
二.原理
uni-app是一個(gè)比較傳統(tǒng)的小程序框架,包括編譯器+運(yùn)行時(shí)。 小程序是視圖和邏輯層分開(kāi)的雙線程架構(gòu),視圖和邏輯的加載和運(yùn)行互不阻塞,同時(shí),邏輯層數(shù)據(jù)更新會(huì)驅(qū)動(dòng)視圖層的更新,視圖的事件響應(yīng),會(huì)觸發(fā)邏輯層的交互。 uni-app的源碼主要包括三方面:
- webpack。webpack是前端常用的一個(gè)模塊打包器,uni-app構(gòu)建過(guò)程中,會(huì)將Vue SFC的template、script、style三段式的結(jié)構(gòu),編譯成小程序四段式結(jié)構(gòu),以字節(jié)小程序?yàn)槔瑫?huì)得到ttml、ttss、js、json四種文件。
- 編譯器。uni-app的編譯器本質(zhì)是把Vue 的視圖編譯成小程序的視圖,即把template語(yǔ)法編譯成小程序的ttml語(yǔ)法,之后,uni-app不會(huì)維護(hù)視圖層,視圖層的更新完全交給小程序自身維護(hù)。但是uni-app是使用Vue進(jìn)行開(kāi)發(fā)的,那Vue跟小程序是怎么交互的呢?這就依賴于uni-app的運(yùn)行時(shí)。
- 運(yùn)行時(shí)。運(yùn)行時(shí)相當(dāng)于一個(gè)橋梁,打通了Vue和小程序。小程序視圖層的更新,比如事件點(diǎn)擊、觸摸等操作,會(huì)經(jīng)過(guò)運(yùn)行時(shí)的事件代理機(jī)制,然后到達(dá)Vue的事件函數(shù)。而Vue的事件函數(shù)觸發(fā)了數(shù)據(jù)更新,又會(huì)重新經(jīng)過(guò)運(yùn)行時(shí),觸發(fā)setData,進(jìn)一步更新小程序的視圖層。 備注:本文章閱讀的源碼是
uni-app ^2.0.0-30720210122002
版本。
三.webpack
1. package.json
先看package.json scripts
命令:
- 注入NODE_ENV和UNI_PLATFORM命令
- 調(diào)用
vue-cli-service
命令,執(zhí)行uni-build
命令
"dev:mp-toutiao": "cross-env NODE_ENV=development UNI_PLATFORM=mp-toutiao vue-cli-service uni-build --watch",
2. 入口
當(dāng)我們?cè)陧?xiàng)目?jī)?nèi)部運(yùn)行 vue-cli-service
命令時(shí),它會(huì)自動(dòng)解析并加載 package.json 中列出的所有 CLI 插件,Vue CLI 插件的命名遵循 vue-cli-plugin- 或者 @scope/vue-cli-plugin-的規(guī)范,這里主要的插件是@dcloudio/vue-cli-plugin-uni,相關(guān)源碼:
module.exports = (api, options) => { api.registerCommand('uni-build', { description: 'build for production', usage: 'vue-cli-service uni-build [options]', options: { '--watch': 'watch for changes', '--minimize': 'Tell webpack to minimize the bundle using the TerserPlugin.', '--auto-host': 'specify automator host', '--auto-port': 'specify automator port' } }, async (args) => { for (const key in defaults) { if (args[key] == null) { args[key] = defaults[key] } } require('./util').initAutomator(args) args.entry = args.entry || args._[0] process.env.VUE_CLI_BUILD_TARGET = args.target // build函數(shù)會(huì)去獲取webpack配置并執(zhí)行 await build(args, api, options) delete process.env.VUE_CLI_BUILD_TARGET }) }
當(dāng)我們執(zhí)行UNI_PLATFORM=mp-toutiao vue-cli-service uni-build
時(shí),@dcloudio/vue-cli-plugin-uni
無(wú)非做了兩件事:
- 獲取小程序的
webpack
配置。 - 執(zhí)行
uni-build
命令時(shí),然后執(zhí)行webpack
。 所以,入口文件其實(shí)就是執(zhí)行webpack
,uni-app
的webpack
配置主要位于@dcloudio/vue-cli-plugin-uni/lib/mp/index.js
,接下來(lái)我們通過(guò)entry、output、loader、plugin來(lái)看看uni-app是怎么把Vue SFC轉(zhuǎn)換成小程序的。
3. Entry
uni-app會(huì)調(diào)用parseEntry
去解析pages.json,然后放在process.UNI_ENTRY
webpackConfig () { parseEntry(); return { entry () { return process.UNI_ENTRY } } }
我們看下parseEntry
主要代碼:
function parseEntry (pagesJson) { // 默認(rèn)有一個(gè)入口 process.UNI_ENTRY = { 'common/main': path.resolve(process.env.UNI_INPUT_DIR, getMainEntry()) } if (!pagesJson) { pagesJson = getPagesJson() } // 添加pages入口 pagesJson.pages.forEach(page => { process.UNI_ENTRY[page.path] = getMainJsPath(page.path) }) } function getPagesJson () { // 獲取pages.json進(jìn)行解析 return processPagesJson(getJson('pages.json', true)) } const pagesJsonJsFileName = 'pages.js' function processPagesJson (pagesJson) { const pagesJsonJsPath = path.resolve(process.env.UNI_INPUT_DIR, pagesJsonJsFileName) if (fs.existsSync(pagesJsonJsPath)) { const pagesJsonJsFn = require(pagesJsonJsPath) if (typeof pagesJsonJsFn === 'function') { pagesJson = pagesJsonJsFn(pagesJson, loader) if (!pagesJson) { console.error(`${pagesJsonJsFileName} 必須返回一個(gè) json 對(duì)象`) } } else { console.error(`${pagesJsonJsFileName} 必須導(dǎo)出 function`) } } // 檢查配置是否合法 filterPages(pagesJson.pages) return pagesJson } function getMainJsPath (page) { // 將main.js和page參數(shù)組合成出新的入口 return path.resolve(process.env.UNI_INPUT_DIR, getMainEntry() + '?' + JSON.stringify({ page: encodeURIComponent(page) })) }
parseEntry
的主要工作:
- 配置默認(rèn)入口main.js
- 解析pages.json,將page作為參數(shù),和main.js組成新的入口 比如,我們的pages.json內(nèi)容如下:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app" } } ], "globalStyle": { "navigationBarTextStyle": "black", "navigationBarTitleText": "uni-app", "navigationBarBackgroundColor": "#F8F8F8", "backgroundColor": "#F8F8F8" } }
然后我們看下輸出的enrty,可以發(fā)現(xiàn)其實(shí)就是通過(guò)在main.js帶上響應(yīng)參數(shù)來(lái)區(qū)分page的,這跟vue-loader
區(qū)分template、script、style其實(shí)很像,后面可以通過(guò)判斷參數(shù),調(diào)用不同loader進(jìn)行處理。
{ 'common/main': '/Users/src/main.js', 'pages/index/index': '/Users/src/main.js?{"page":"pages%2Findex%2Findex"}' }
4. Output
對(duì)于輸出比較簡(jiǎn)單,dev
和build
分別打包到dist/dev/mp-toutiao
和dist/build/mp-toutiao
Object.assign(options, { outputDir: process.env.UNI_OUTPUT_TMP_DIR || process.env.UNI_OUTPUT_DIR, assetsDir }, vueConfig) webpackConfig () { return { output: { filename: '[name].js', chunkFilename: '[id].js', } }
5. Alias
uni-app有兩個(gè)主要的alias
配置
vue$
是把vue替換成來(lái)uni-app的mp-vueuni-pages
表示pages.json文件
resolve: { alias: { vue$: getPlatformVue(vueOptions), 'uni-pages': path.resolve(process.env.UNI_INPUT_DIR, 'pages.json'), }, modules: [ process.env.UNI_INPUT_DIR, path.resolve(process.env.UNI_INPUT_DIR, 'node_modules') ] }, getPlatformVue (vueOptions) { if (uniPluginOptions.vue) { return uniPluginOptions.vue } if (process.env.UNI_USING_VUE3) { return '@dcloudio/uni-mp-vue' } return '@dcloudio/vue-cli-plugin-uni/packages/mp-vue' },
6. Loader
從上面我們看出entry
都是main.js,只不過(guò)會(huì)帶上page的參數(shù),我們從入口開(kāi)始,看下uni-app是怎么一步步處理文件的,先看下處理main.js的兩個(gè)loader:lib/main
和wrap-loader
module: { rules: [{ test: path.resolve(process.env.UNI_INPUT_DIR, getMainEntry()), use: [{ loader: path.resolve(__dirname, '../../packages/wrap-loader'), options: { before: [ 'import \'uni-pages\';' ] } }, { loader: '@dcloudio/webpack-uni-mp-loader/lib/main' }] }] }
a. lib/main:
我們看下核心代碼,根據(jù)resourceQuery參數(shù)進(jìn)行劃分,我們主要看下有query的情況,會(huì)在這里引入Vue和pages/index/index.vue,同時(shí)調(diào)用createPage進(jìn)行初始化,createPage是運(yùn)行時(shí),后面會(huì)講到。由于引入了.vue,所以之后的解析就交給了vue-loader
。
module.exports = function (source, map) { this.cacheable && this.cacheable() if (this.resourceQuery) { const params = loaderUtils.parseQuery(this.resourceQuery) if (params && params.page) { params.page = decodeURIComponent(params.page) // import Vue from 'vue'是為了觸發(fā) vendor 合并 let ext = '.vue' return this.callback(null, ` import Vue from 'vue' import Page from './${normalizePath(params.page)}${ext}' createPage(Page) `, map) } } else {......} }
b. wrap-loader:
引入了uni-pages,從alias可知道就是import pages.json,對(duì)于pages.json,uni-app也有專門(mén)的webpack-uni-pages-loader
進(jìn)行處理。
module.exports = function (source, map) { this.cacheable() const opts = utils.getOptions(this) || {} this.callback(null, [].concat(opts.before, source, opts.after).join('').trim(), map) }
c. webpack-uni-pages-loader:
代碼比較多,我們貼下大體的核心代碼,看看主要完成的事項(xiàng)
module.exports = function (content, map) { // 獲取mainfest.json文件 const manifestJsonPath = path.resolve(process.env.UNI_INPUT_DIR, 'manifest.json') const manifestJson = parseManifestJson(fs.readFileSync(manifestJsonPath, 'utf8')) // 解析pages.json let pagesJson = parsePagesJson(content, { addDependency: (file) => { (process.UNI_PAGES_DEPS || (process.UNI_PAGES_DEPS = new Set())).add(normalizePath(file)) this.addDependency(file) } }) const jsonFiles = require('./platforms/' + process.env.UNI_PLATFORM)(pagesJson, manifestJson, isAppView) if (jsonFiles && jsonFiles.length) { jsonFiles.forEach(jsonFile => { if (jsonFile) { // 對(duì)解析到的app.json和project.config.json進(jìn)行緩存 if (jsonFile.name === 'app') { // updateAppJson和updateProjectJson其實(shí)就是調(diào)用updateComponentJson updateAppJson(jsonFile.name, renameUsingComponents(jsonFile.content)) } else { updateProjectJson(jsonFile.name, jsonFile.content) } } }) } this.callback(null, '', map) } function updateAppJson (name, jsonObj) { updateComponentJson(name, jsonObj, true, 'App') } function updateProjectJson (name, jsonObj) { updateComponentJson(name, jsonObj, false, 'Project') } // 更新json文件 function updateComponentJson (name, jsonObj, usingComponents = true, type = 'Component') { if (type === 'Component') { jsonObj.component = true } if (type === 'Page') { if (process.env.UNI_PLATFORM === 'mp-baidu') { jsonObj.component = true } } const oldJsonStr = getJsonFile(name) if (oldJsonStr) { // update if (usingComponents) { // merge usingComponents // 其實(shí)直接拿新的 merge 到舊的應(yīng)該就行 const oldJsonObj = JSON.parse(oldJsonStr) jsonObj.usingComponents = oldJsonObj.usingComponents || {} jsonObj.usingAutoImportComponents = oldJsonObj.usingAutoImportComponents || {} if (oldJsonObj.usingGlobalComponents) { // 復(fù)制 global components(針對(duì)不支持全局 usingComponents 的平臺(tái)) jsonObj.usingGlobalComponents = oldJsonObj.usingGlobalComponents } } const newJsonStr = JSON.stringify(jsonObj, null, 2) if (newJsonStr !== oldJsonStr) { updateJsonFile(name, newJsonStr) } } else { // add updateJsonFile(name, jsonObj) } } let jsonFileMap = new Map() function updateJsonFile (name, jsonStr) { if (typeof jsonStr !== 'string') { jsonStr = JSON.stringify(jsonStr, null, 2) } jsonFileMap.set(name, jsonStr) }
我們通過(guò)分步來(lái)了解webpack-uni-pages-loader
的作用:
- 獲取
mainfest.json
和pages.json
的內(nèi)容 - 分別調(diào)用
updateAppJson
和updateProjectJson
處理mainfest.json
和page.json
updateAppJson
和updateProjectJson
本質(zhì)都是調(diào)用了updateComponentJson
,updateComponentJson
會(huì)更新json
文件,最終調(diào)用updateJsonFileupdateJsonFile
是json
文件生成的關(guān)鍵點(diǎn)。首先會(huì)定義一個(gè)共享的jsonFileMap
鍵值對(duì)象,然后這里并沒(méi)有直接生成相應(yīng)的json
文件,而是把mainfest.json
和page.json
處理成project.config
和app
,然后緩存在jsonFileMap
中。- 這里為什么不直接生成?因?yàn)楹罄m(xù)
pages/index/index.vue
里也會(huì)有json
文件的生成,所以所有的json
文件都是暫時(shí)緩存在jsonFileMap
中,后續(xù)由plugin
統(tǒng)一生成。 通俗的說(shuō),webpack-uni-pages-loader
實(shí)現(xiàn)的功能就是json
語(yǔ)法的轉(zhuǎn)換,還有就是緩存,語(yǔ)法轉(zhuǎn)換很簡(jiǎn)單,只是對(duì)象key value的更改,我們可以直觀的對(duì)比下mainfest.json
和page.json
構(gòu)建前后差異。
// 轉(zhuǎn)換前的page.json { "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app" } } ], "globalStyle": { "navigationBarTextStyle": "black", "navigationBarTitleText": "uni-app", "navigationBarBackgroundColor": "#F8F8F8", "backgroundColor": "#F8F8F8" } } // 轉(zhuǎn)換后得到的app.json { "pages": [ "pages/index/index" ], "subPackages": [], "window": { "navigationBarTextStyle": "black", "navigationBarTitleText": "uni-app", "navigationBarBackgroundColor": "#F8F8F8", "backgroundColor": "#F8F8F8" }, "usingComponents": {} } // 轉(zhuǎn)換前的mainfest.json { "name": "", "appid": "", "description": "", "versionName": "1.0.0", "versionCode": "100", "transformPx": true } // 轉(zhuǎn)換后得到的project.config.json { "setting": { "urlCheck": true, "es6": false, "postcss": false, "minified": false, "newFeature": true }, "appid": "體驗(yàn)appId", "projectname": "uniapp-analysis" }
d. vue-loader:
處理完js和json文件,接下來(lái)就到了vue文件的處理,vue-loader
會(huì)把vue拆分成template、style、script。 對(duì)于style,其實(shí)就是css,會(huì)經(jīng)過(guò)less-loader
、sass-loader
、postcss-loader
、css-loader
的處理,最后由mini-css-extract-plugin
生成對(duì)應(yīng)的.ttss文件。 對(duì)于script,uni-app主要配置了script loader進(jìn)行處理,該過(guò)程主要是將index.vue中引入的組件抽離成index.json,然后也是跟app.json一樣,緩存在jsonFileMap
數(shù)組中。
{ resourceQuery: /vue&type=script/, use: [{ loader: '@dcloudio/webpack-uni-mp-loader/lib/script' }] }
對(duì)于template,這是比較核心的模塊,uni-app更改了vue-loader
的compiler,將vue-template-compiler
替換成了uni-template-compiler
,uni-template-compiler
是用來(lái)把vue語(yǔ)法轉(zhuǎn)換為小程序語(yǔ)法的,這里我們可以先記著,后面會(huì)講到是如何編譯的。這里我們關(guān)注的處理template的loader lib/template 。
{ resourceQuery: /vue&type=template/, use: [{ loader: '@dcloudio/webpack-uni-mp-loader/lib/template' }, { loader: '@dcloudio/vue-cli-plugin-uni/packages/webpack-uni-app-loader/page-meta' }] }
loader lib/template首先會(huì)去獲取vueLoaderOptions,然后添加新的options,小程序這里有一個(gè)關(guān)鍵是emitFile
,因?yàn)関ue-loader本身是沒(méi)有往compiler注入emitFile的,所以compiler編譯出來(lái)的語(yǔ)法要生成ttml需要有emitFile。
module.exports = function (content, map) { this.cacheable && this.cacheable() const vueLoaderOptions = this.loaders.find(loader => loader.ident === 'vue-loader-options') Object.assign(vueLoaderOptions.options.compilerOptions, { mp: { platform: process.env.UNI_PLATFORM }, filterModules, filterTagName, resourcePath, emitFile: this.emitFile, wxComponents, getJsonFile, getShadowTemplate, updateSpecialMethods, globalUsingComponents, updateGenericComponents, updateComponentGenerics, updateUsingGlobalComponents }) }
7. plugin
uni-app主要的plugin是createUniMPPlugin
,該過(guò)程對(duì)應(yīng)了我們loader處理json時(shí)生成的jsonFileMap
對(duì)象,本質(zhì)就是把jsonFileMap
里的json生成真實(shí)的文件。
class WebpackUniMPPlugin { apply (compiler) { if (!process.env.UNI_USING_NATIVE && !process.env.UNI_USING_V3_NATIVE) { compiler.hooks.emit.tapPromise('webpack-uni-mp-emit', compilation => { return new Promise((resolve, reject) => { // 生成.json generateJson(compilation) // 生成app.json、project.config.json generateApp(compilation) .forEach(({ file, source }) => emitFile(file, source, compilation)) resolve() }) }) }
相關(guān)的全局配置變量
plugins: [ new webpack.ProvidePlugin({ uni: [ '/Users/luojincheng/source code/uniapp-analysis/node_modules/@dcloudio/uni-mp-toutiao/dist/index.js', 'default' ], createPage: [ '/Users/luojincheng/source code/uniapp-analysis/node_modules/@dcloudio/uni-mp-toutiao/dist/index.js', 'createPage' ] }) ]
四. 編譯器知一二
編譯器的原理其實(shí)就是通過(guò)ast
的語(yǔ)法分析,把vue的template語(yǔ)法轉(zhuǎn)換為小程序的ttml語(yǔ)法。但這樣說(shuō)其實(shí)很抽象,具體是怎么通過(guò)ast語(yǔ)法來(lái)轉(zhuǎn)換的?接下來(lái),我們通過(guò)構(gòu)建簡(jiǎn)單版的template=>ttml的編譯器,實(shí)現(xiàn)div=>view的標(biāo)簽轉(zhuǎn)換,來(lái)了解uni-app的編譯流程。
<div style="height: 100px;"><text>hello world!</text></div>
上面這個(gè)template經(jīng)過(guò)uni-app編譯后會(huì)變成下面的代碼,看這里只是div => view的替換,但其實(shí)中間是走了很多流程的。
<view style="height: 100px;"><text>hello world!</text></view>
1. vue-template-compiler
首先,template會(huì)經(jīng)過(guò)vue的編譯器,得到渲染函數(shù)render
。
const {compile} = require('vue-template-compiler'); const {render} = compile(state.vueTemplate); // 生成的render: // with(this){return _c('div',{staticStyle:{"height":"100px"}},[_c('text',[_v("hello world!")])])}
2. @babel/parser
這一步是利用parser
將render函數(shù)轉(zhuǎn)化為ast。ast是Abstract syntax tree的縮寫(xiě),即抽象語(yǔ)法樹(shù)。
const parser = require('@babel/parser'); const ast = parser.parse(render);
這里我們過(guò)濾掉了一些start、end、loc、errors等會(huì)影響我們閱讀的字段(完整ast可以通過(guò) astexplorer.net網(wǎng)站查看),看看轉(zhuǎn)譯后的ast對(duì)象,該json對(duì)象我們重點(diǎn)關(guān)注program.body[0].expression
。 1.type的類型在這里有四種:
CallExpression
(調(diào)用表達(dá)式):_c()StringLiteral
(字符串字面量):'div'ObjectExpression
(對(duì)象表達(dá)式):'{}'ArrayExpression
(數(shù)組表達(dá)式):[_v("hello world!")] 2.callee.name
是調(diào)用表達(dá)式的名稱:這里有_c、_v兩種 3.arguments.*.value
是參數(shù)的值:這里有div、text、hello world! 我們把a(bǔ)st對(duì)象和render函數(shù)對(duì)比,不難發(fā)現(xiàn)這兩個(gè)其實(shí)是一一對(duì)應(yīng)可逆的關(guān)系。
{ "type": "File", "program": { "type": "Program", }, "sourceType": "module", "interpreter": null, "body": [ { "type": "ExpressionStatement", "expression": { "callee": { "type": "Identifier", "name": "_c" }, "arguments": [ { "type": "StringLiteral", "value": "div" }, { "type": "ObjectExpression", "properties": [ { "type": "ObjectProperty", "method": false, "key": { "type": "Identifier", "name": "staticStyle" }, "computed": false, "shorthand": false, "value": { "type": "ObjectExpression", "properties": [ { "type": "ObjectProperty", "method": false, "key": { "type": "StringLiteral", "value": "height" }, "computed": false, "shorthand": false, "value": { "type": "StringLiteral", "value": "100px" } } ] } } ] }, { "type": "ArrayExpression", "elements": [ { "type": "CallExpression", "callee": { "name": "_c" }, "arguments": [ { "type": "StringLiteral", "value": "text" }, { "type": "ArrayExpression", "elements": [ { "type": "CallExpression", "callee": { "type": "Identifier", "name": "_v" }, "arguments": [ { "type": "CallExpression", "callee": { "type": "Identifier", "name": "_s" }, "arguments": [ { "type": "Identifier", "name": "hello" } ] } ] } ] } ] } ] } ] } } ], "directives": [] }, "comments": [] }
3. @babel/traverse和@babel/types
這一步主要是利用traverse
對(duì)生成的ast對(duì)象進(jìn)行遍歷,然后利用types判斷和修改ast的語(yǔ)法。 traverse(ast, visitor)主要有兩個(gè)參數(shù):
parser
解析出來(lái)的astvisitor
:visitor是一個(gè)由各種type或者是enter和exit組成的對(duì)象。這里我們指定了CallExpression類型,遍歷ast時(shí)遇到CallExpression類型會(huì)執(zhí)行該函數(shù),把對(duì)應(yīng)的div、img轉(zhuǎn)換為view、image。 其它類型可看文檔:babeljs.io/docs/en/bab…
const t = require('@babel/types') const babelTraverse = require('@babel/traverse').default const tagMap = { 'div': 'view', 'img': 'image', 'p': 'text' }; const visitor = { CallExpression (path) { const callee = path.node.callee; const methodName = callee.name switch (methodName) { case '_c': { const tagNode = path.node.arguments[0]; if (t.isStringLiteral(tagNode)) { const tagName = tagMap[tagNode.value]; tagNode.value = tagName; } } } } }; traverse(ast, visitor);
4. Generate vnode
uni-app生成小程序的ttml需要先把修改后的ast生成類似vNode
的對(duì)象,然后再遍歷vNode
生成ttml。
const traverse = require('@babel/traverse').default; traverse(ast, { WithStatement(path) { state.vNode = traverseExpr(path.node.body.body[0].argument); }, }); // 不同的element走不同的創(chuàng)建函數(shù) function traverseExpr(exprNode) { if (t.isCallExpression(exprNode)) { const traverses = { _c: traverseCreateElement, _v: traverseCreateTextVNode, }; return traverses[exprNode.callee.name](exprNode); } else if (t.isArrayExpression(exprNode)) { return exprNode.elements.reduce((nodes, exprNodeItem) => { return nodes.concat(traverseExpr(exprNodeItem, state)); }, []); } } // 轉(zhuǎn)換style屬性 function traverseDataNode(dataNode) { const ret = {}; dataNode.properties.forEach((property) => { switch (property.key.name) { case 'staticStyle': ret.style = property.value.properties.reduce((pre, {key, value}) => { return (pre += `${key.value}: ${value.value};`); }, ''); break; } }); return ret; } // 創(chuàng)建Text文本節(jié)點(diǎn) function traverseCreateTextVNode(callExprNode) { const arg = callExprNode.arguments[0]; if (t.isStringLiteral(arg)) { return arg.value; } } // 創(chuàng)建element節(jié)點(diǎn) function traverseCreateElement(callExprNode) { const args = callExprNode.arguments; const tagNode = args[0]; const node = { type: tagNode.value, attr: {}, children: [], }; if (args.length < 2) { return node; } const dataNodeOrChildNodes = args[1]; if (t.isObjectExpression(dataNodeOrChildNodes)) { Object.assign(node.attr, traverseDataNode(dataNodeOrChildNodes)); } else { node.children = traverseExpr(dataNodeOrChildNodes); } if (args.length < 3) { return node; } const childNodes = args[2]; if (node.children && node.children.length) { node.children = node.children.concat(traverseExpr(childNodes)); } else { node.children = traverseExpr(childNodes, state); } return node; }
這里之所以沒(méi)有使用@babel/generator
,是因?yàn)槭褂胓enerator生成的還是render函數(shù),雖然語(yǔ)法已經(jīng)修改了,但要根據(jù)render是沒(méi)辦法直接生成小程序的ttml,還是得轉(zhuǎn)換成vNode。 最好,我們看下生成的VNode對(duì)象。
{ "type": "view", "attr": { "style": "height: 100px;" }, "children": [{ "type": "text", "attr": {}, "children": ["hello world!"] }] }
5. Generate code
遍歷VNode
,遞歸生成小程序代碼
function generate(vNode) { if (!vNode) { return ''; } if (typeof vNode === 'string') { return vNode; } const names = Object.keys(vNode.attr); const props = names.length ? ' ' + names .map((name) => { const value = vNode.attr[name]; return `${name}="${value}"`; }) .join(' ') : ''; const children = vNode.children .map((child) => { return generate(child); }) .join(''); return `<${vNode.type}${props}>${children}</${vNode.type}>`; }
6. 總體流程:
這里列出了uni-template-compiler
大致轉(zhuǎn)換的流程和關(guān)鍵代碼,uni-template-compiler
的ast語(yǔ)法轉(zhuǎn)換工作都是在traverse
這個(gè)過(guò)程完成的。
const {compile} = require('vue-template-compiler'); const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const t = require('@babel/types'); const state = { vueTemplate: '<div style="height: 100px;"><text>hello world!</text></div>', mpTemplate: '', vNode: '', }; const tagMap = { div: 'view', }; // 1.vue template => vue render const {render} = compile(state.vueTemplate); // 2.vue render => code ast const ast = parser.parse(`function render(){${render}}`); // 3.map code ast, modify syntax traverse(ast, getVisitor()); // 4.code ast => mp vNode traverse(ast, { WithStatement(path) { state.vNode = traverseExpr(path.node.body.body[0].argument); }, }); // 5.mp vNode => ttml state.mpTemplate = generate(state.vNode); console.log('vue template:', state.vueTemplate); console.log('mp template:', state.mpTemplate);
五.運(yùn)行時(shí)的原理
uni-app提供了一個(gè)運(yùn)行時(shí)uni-app runtime,打包到最終運(yùn)行的小程序發(fā)行代碼中,該運(yùn)行時(shí)實(shí)現(xiàn)了Vue.js 和小程序兩系統(tǒng)之間的數(shù)據(jù)、事件同步。
1.事件代理
我們以一個(gè)數(shù)字增加為例,看看uni-app是怎樣把小程序的數(shù)據(jù)、事件跟vue整合起來(lái)的。
<template> <div @click="add(); subtract(2)" @touchstart="mixin($event)">{{ num }}</div> </template> <script> export default { data() { return { num1: 0, num2: 0, } }, methods: { add () { this.num1++; }, subtract (num) { console.log(num) }, mixin (e) { console.log(e) } } } </script>
a. 編譯后的ttml,這里編譯出來(lái)data-event-opts、bindtap跟前面的編譯器div => view的原理是差不多,也是在traverse做的ast轉(zhuǎn)換,我們直接看編譯后生成的ttml:
<view data-event-opts="{{ [ ['tap',[['add'],['subtract',[2]]]], ['touchstart',[['mixin',['$event']]]] ] }}" bindtap="__e" bindtouchstart="__e" class="_div"> {{num}} </view>
這里我們先解析一下data-event-opts
數(shù)組: data-event-opts
是一個(gè)二維數(shù)組,每個(gè)子數(shù)組代表一個(gè)事件類型。子數(shù)組有兩個(gè)值,第一個(gè)表示事件類型名稱,第二個(gè)表示觸發(fā)事件函數(shù)的個(gè)數(shù)。事件函數(shù)又是一個(gè)數(shù)組,第一個(gè)值表述事件函數(shù)名稱,第二個(gè)是參數(shù)個(gè)數(shù)。 ['tap',[['add'],['subtract',[2]]]]
表示事件類型為tap
,觸發(fā)函數(shù)有兩個(gè),一個(gè)為add
函數(shù)且無(wú)參數(shù),一個(gè)為subtract
且參數(shù)為2。 ['touchstart',[['mixin',['$event']]]]
表示事件類型為touchstart
,觸發(fā)函數(shù)有一個(gè)為mixin
,參數(shù)為$event
對(duì)象。
b. 編譯后的js的代碼:
import Vue from 'vue' import Page from './index/index.vue' createPage(Page)
這里其實(shí)就是后調(diào)用uni-mp-toutiao
里的createPage
對(duì)vue的script部分進(jìn)行了初始化。 createPage返回小程序的Component構(gòu)造器,之后是一層層的調(diào)用parsePage
、parseBasePage
、parseComponent
、parseBaseComponent
,parseBaseComponent
最后返回一個(gè)Component構(gòu)造器
function createPage (vuePageOptions) { { return Component(parsePage(vuePageOptions)) } } function parsePage (vuePageOptions) { const pageOptions = parseBasePage(vuePageOptions, { isPage, initRelation }); return pageOptions } function parseBasePage (vuePageOptions, { isPage, initRelation }) { const pageOptions = parseComponent(vuePageOptions); return pageOptions } function parseComponent (vueOptions) { const [componentOptions, VueComponent] = parseBaseComponent(vueOptions); return componentOptions }
我們直接對(duì)比轉(zhuǎn)換前后的vue和mp參數(shù)差異,本身vue的語(yǔ)法和mp Component的語(yǔ)法就很像。這里,uni-app會(huì)把vue的data屬性和methods方法copy到mp的data,而且mp的methods主要有__e
方法。
回到編譯器生成ttml代碼,發(fā)現(xiàn)所有的事件都會(huì)調(diào)用__e
事件,而__e
對(duì)應(yīng)的就是handleEvent
事件,我們看下handleEvent
:
- 拿到點(diǎn)擊元素上的
data-event-opts
屬性:[['tap',[['add'],['subtract',[2]]]]
,['touchstart',[['mixin',['$event']]]]]
- 根據(jù)點(diǎn)擊類型獲取相應(yīng)數(shù)組,比如
bindTap
就取['tap',[['add'],['subtract',[2]]]]
,bindtouchstart
就取['touchstart',[['mixin',['$event']]]]
- 依次調(diào)用相應(yīng)事件類型的函數(shù),并傳入?yún)?shù),比如
tap
調(diào)用this.add();this.subtract(2)
function handleEvent (event) { event = wrapper$1(event); // [['tap',[['handle',[1,2,a]],['handle1',[1,2,a]]]]] const dataset = (event.currentTarget || event.target).dataset; const eventOpts = dataset.eventOpts || dataset['event-opts']; // 支付寶 web-view 組件 dataset 非駝峰 // [['handle',[1,2,a]],['handle1',[1,2,a]]] const eventType = event.type; const ret = []; eventOpts.forEach(eventOpt => { let type = eventOpt[0]; const eventsArray = eventOpt[1]; if (eventsArray && isMatchEventType(eventType, type)) { eventsArray.forEach(eventArray => { const methodName = eventArray[0]; if (methodName) { let handlerCtx = this.$vm; if (handlerCtx.$options.generic) { // mp-weixin,mp-toutiao 抽象節(jié)點(diǎn)模擬 scoped slots handlerCtx = getContextVm(handlerCtx) || handlerCtx; } if (methodName === '$emit') { handlerCtx.$emit.apply(handlerCtx, processEventArgs( this.$vm, event, eventArray[1], eventArray[2], isCustom, methodName )); return } const handler = handlerCtx[methodName]; const params = processEventArgs( this.$vm, event, eventArray[1], eventArray[2], isCustom, methodName ); ret.push(handler.apply(handlerCtx, (Array.isArray(params) ? params : []).concat([, , , , , , , , , , event]))); } }); } }); }
2. 數(shù)據(jù)同步機(jī)制
小程序視圖層事件響應(yīng),會(huì)觸發(fā)小程序邏輯事件,邏輯層會(huì)調(diào)用vue相應(yīng)的事件,觸發(fā)數(shù)據(jù)更新。那Vue數(shù)據(jù)更新之后,又是怎樣觸發(fā)小程序視圖層更新的呢?
小程序數(shù)據(jù)更新必須要調(diào)用小程序的setData
函數(shù),而Vue數(shù)據(jù)更新的時(shí)候會(huì)觸發(fā)Vue.prototype._update
方法,所以,只要在_update
里調(diào)用setData
函數(shù)就可以了。 uni-app在Vue里新增了patch
函數(shù),該函數(shù)會(huì)在_update
時(shí)被調(diào)用。
// install platform patch function Vue.prototype.__patch__ = patch; var patch = function(oldVnode, vnode) { var this$1 = this; if (vnode === null) { //destroy return } if (this.mpType === 'page' || this.mpType === 'component') { var mpInstance = this.$scope; var data = Object.create(null); try { data = cloneWithData(this); } catch (err) { console.error(err); } data.__webviewId__ = mpInstance.data.__webviewId__; var mpData = Object.create(null); Object.keys(data).forEach(function (key) { //僅同步 data 中有的數(shù)據(jù) mpData[key] = mpInstance.data[key]; }); var diffData = this.$shouldDiffData === false ? data : diff(data, mpData); if (Object.keys(diffData).length) { if (process.env.VUE_APP_DEBUG) { console.log('[' + (+new Date) + '][' + (mpInstance.is || mpInstance.route) + '][' + this._uid + ']差量更新', JSON.stringify(diffData)); } this.__next_tick_pending = true mpInstance.setData(diffData, function () { this$1.__next_tick_pending = false; flushCallbacks$1(this$1); }); } else { flushCallbacks$1(this); } } };
源代碼比較簡(jiǎn)單,就是比對(duì)更新前后的數(shù)據(jù),然后獲得diffData
,最后批量調(diào)用setData
更新數(shù)據(jù)。
3. diff算法
小程序數(shù)據(jù)更新有三種情況
- 類型改變
- 減量更新
- 增量更新
page({ data:{ list:['item1','item2','item3','item4'] }, change(){ // 1.類型改變 this.setData({ list: 'list' }) }, cut(){ // 2.減量更新 let newData = ['item5', 'item6']; this.setData({ list: newData }) }, add(){ // 3.增量更新 let newData = ['item5','item6','item7','item8']; this.data.list.push(...newData); //列表項(xiàng)新增記錄 this.setData({ list:this.data.list }) } })
對(duì)于類型替換或者減量更新,我們只要直接替換數(shù)據(jù)即可,但對(duì)于增量更新,如果進(jìn)行直接數(shù)據(jù)替換,會(huì)有一定的性能問(wèn)題,比如上面的例子,將item1~item4更新為了item1~item8,這個(gè)過(guò)程我們需要8個(gè)數(shù)據(jù)全部傳遞過(guò)去,但是實(shí)踐上只更新了item5~item8。在這種情況下,為了優(yōu)化性能,我們必須要采用如下寫(xiě)法,手動(dòng)進(jìn)行增量更新:
this.setData({ list[4]: 'item5', list[5]: 'item6', list[6]: 'item7', list[7]: 'item8', })
這種寫(xiě)法的開(kāi)發(fā)體驗(yàn)極差,而且不便于維護(hù),所以u(píng)ni-app借鑒了westore JSON Diff
的原理,在setData
時(shí)進(jìn)行了差量更新,下面,讓我們通過(guò)源碼,來(lái)了解diff
的原理吧。
function setResult(result, k, v) { result[k] = v; } function _diff(current, pre, path, result) { if (current === pre) { // 更新前后無(wú)改變 return; } var rootCurrentType = type(current); var rootPreType = type(pre); if (rootCurrentType == OBJECTTYPE) { // 1.對(duì)象類型 if (rootPreType != OBJECTTYPE || Object.keys(current).length < Object.keys(pre).length) { // 1.1數(shù)據(jù)類型不一致或者減量更新,直接替換 setResult(result, path, current); } else { var loop = function (key) { var currentValue = current[key]; var preValue = pre[key]; var currentType = type(currentValue); var preType = type(preValue); if (currentType != ARRAYTYPE && currentType != OBJECTTYPE) { // 1.2.1 處理基礎(chǔ)類型 if (currentValue != pre[key]) { setResult(result, (path == '' ? '' : path + '.') + key, currentValue); } } else if (currentType == ARRAYTYPE) { // 1.2.2 處理數(shù)組類型 if (preType != ARRAYTYPE) { // 類型不一致 setResult(result, (path == '' ? '' : path + '.') + key, currentValue); } else { if (currentValue.length < preValue.length) { // 減量更新 setResult(result, (path == '' ? '' : path + '.') + key, currentValue); } else { // 增量更新則遞歸 currentValue.forEach(function (item, index) { _diff(item, preValue[index], (path == '' ? '' : path + '.') + key + '[' + index + ']', result); }); } } } else if (currentType == OBJECTTYPE) { // 1.2.3 處理對(duì)象類型 if (preType != OBJECTTYPE || Object.keys(currentValue).length < Object.keys(preValue).length) { // 類型不一致/減量更新 setResult(result, (path == '' ? '' : path + '.') + key, currentValue); } else { // 增量更新則遞歸 for (var subKey in currentValue) { _diff( currentValue[subKey], preValue[subKey], (path == '' ? '' : path + '.') + key + '.' + subKey, result ); } } } }; // 1.2遍歷對(duì)象/數(shù)據(jù)類型 for (var key in current) loop(key); } } else if (rootCurrentType == ARRAYTYPE) { // 2.數(shù)組類型 if (rootPreType != ARRAYTYPE) { // 類型不一致 setResult(result, path, current); } else { if (current.length < pre.length) { // 減量更新 setResult(result, path, current); } else { // 增量更新則遞歸 current.forEach(function (item, index) { _diff(item, pre[index], path + '[' + index + ']', result); }); } } } else { // 3.基本類型 setResult(result, path, current); } },
- 當(dāng)數(shù)據(jù)發(fā)生改變時(shí),uni-app會(huì)將新舊數(shù)據(jù)進(jìn)行比對(duì),然后獲得差量更新的數(shù)據(jù),調(diào)用setData更新。
- 通過(guò)
cur === pre
進(jìn)行判斷,相同則直接返回。 - 通過(guò)
type(cur) === OBJECTTYPE
進(jìn)行對(duì)象判斷:- 若
pre
不是OBJECTTYPE
或者cur
長(zhǎng)度少于pre
,則是類型改變或者減量更新,調(diào)用setResult
直接添加新數(shù)據(jù)。 - 否則執(zhí)行增量更新邏輯:
- 遍歷
cur
,對(duì)每個(gè)key批量調(diào)用loop
函數(shù)進(jìn)行處理。 - 若
currentType
不是ARRAYTYPE
或者OBJECTTYPE
,則是類型改變,調(diào)用setResult
直接添加新數(shù)據(jù)。 - 若
currentType
是ARRAYTYPE
:- 若
preType
不是ARRAYTYPE
,或者currentValue
長(zhǎng)度少于preValue
,則是類型改變或者減量更新,調(diào)用setResult
直接添加新數(shù)據(jù)。 - 否則執(zhí)行增量更新邏輯,遍歷
currentValue
,執(zhí)行_diff
進(jìn)行遞歸。
- 若
- 若
currentType
是OBJECTTYPE
:- 若
preType
不是OBJECTTYPE
或者currentValue
長(zhǎng)度少于preValue
,則是類型改變或者減量更新,調(diào)用setResult
直接添加新數(shù)據(jù)。 - 否則執(zhí)行增量更新邏輯,遍歷
currentValue
,執(zhí)行_diff
進(jìn)行遞歸。
- 若
- 遍歷
- 若
- 通過(guò)
type(cur) === ARRAYTYPE
進(jìn)行數(shù)組判斷:- 若
preType
不是OBJECTTYPE
或者currentValue
長(zhǎng)度少于preValue
,則是類型改變或者減量更新,調(diào)用setResult
直接添加新數(shù)據(jù)。 - 否則執(zhí)行增量更新邏輯,遍歷
currentValue
,執(zhí)行_diff
進(jìn)行遞歸。
- 若
- 若以上三個(gè)判斷居不成立,則判斷是基礎(chǔ)類型,調(diào)用setResult添加新數(shù)據(jù)。 小結(jié):_diff的過(guò)程,主要進(jìn)行對(duì)象、數(shù)組和基礎(chǔ)類型的判斷。只有基本類型、類型改變、減量更新會(huì)進(jìn)行
setResult
,否則進(jìn)行遍歷遞歸_diff。
六.對(duì)比
uni-app
是編譯型的框架,雖然目前市面上運(yùn)行型的框架層出不窮,比如Rax 運(yùn)行時(shí)
/Remax
/Taro Next
。對(duì)比這些,uni-app這類編譯型的框架的劣勢(shì)在于語(yǔ)法支持,運(yùn)行型的框架幾乎沒(méi)有語(yǔ)法限制,而uni-app因?yàn)閍st的復(fù)雜度和可轉(zhuǎn)換性,導(dǎo)致部分語(yǔ)法無(wú)法支持。但是運(yùn)行時(shí)也有缺點(diǎn),運(yùn)行型用的是小程序的模版語(yǔ)法template
,而uni-app采用Component
構(gòu)造器,使用Component的好處就是原生框架可以知道頁(yè)面的大體結(jié)構(gòu),而template模版渲染無(wú)法做到,同時(shí),運(yùn)行型框架數(shù)據(jù)傳輸量大,需要將數(shù)據(jù)轉(zhuǎn)換成VNode傳遞個(gè)視圖層,這也是運(yùn)行型性能損耗的原因。
七.總結(jié)
七.參考資料
前端搞跨端跨棧|保哥-如何打磨 uni-app 跨端框架的高性能和易用性 · 語(yǔ)雀
總結(jié)
到此這篇關(guān)于uni-app如何構(gòu)建小程序的文章就介紹到這了,更多相關(guān)uni-app構(gòu)建小程序內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
詳解vue.js根據(jù)不同環(huán)境(正式、測(cè)試)打包到不同目錄
這篇文章主要介紹了詳解vue.js根據(jù)不同環(huán)境(正式、測(cè)試)打包到不同目錄,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2018-07-07JavaScript 對(duì)象成員的可見(jiàn)性說(shuō)明
與java等基于類的面向?qū)ο笳Z(yǔ)言的private、protected、public等關(guān)鍵字的用途類似,基于對(duì)象的JavaScript語(yǔ)言,在對(duì)象構(gòu)造上也存在類似的成員可見(jiàn)性問(wèn)題。2009-10-10微信小程序?qū)崿F(xiàn)藍(lán)牙設(shè)備搜索及連接功能示例詳解
這篇文章主要介紹了微信小程序?qū)崿F(xiàn)藍(lán)牙設(shè)備搜索及連接功能,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2023-06-06使用Promise封裝小程序wx.request的實(shí)現(xiàn)方法
這篇文章主要介紹了使用Promise封裝小程序wx.request的實(shí)現(xiàn)方法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-11-11