探索Vue小程序框架的底层原理
最近晚上有时间复盘之前研究小程序框架的相关内容,总结文章记录一下。
本篇文章主要介绍百度19年开源的Mars小程序开发框架,和Taro、mpvue、uniapp类似,都是编译型小程序框架,都是通过将 Vue 或 React 源码直接编译为小程序源码,实现小程序快速开发。我们来介绍一下其设计思路与原理。
Mars是什么?
Mars 是由 Vue 驱动的多端开发框架,其语法规范完全遵循 Vue,支持一套代码同时运行到百度小程序、微信小程序以及 H5 Web 端。Mars 诞生于搜索垂类产品对于小程序和 H5 Web 端相同的产品业务需求的场景下。从框架设计研发之初,就定位于解决小程序以及 H5 Web 端的复用和同构开发能力。
设计思路
Mars 框架的设计思路是将跨多端的应用拆分为逻辑层和视图层,逻辑层采用同一套核心运行时进行数据驱动以及生命周期管理,视图层使用同一套模板语法,经过编译转换为特定平台的视图语言。
考虑到学习成本、生态完善程度以及在多端上的扩展性、业务场景等原因,我们选择了 Vue 技术栈,采用 Vue 单文件组件和模板语法来书写组件代码,引入标准基础组件和 API 规范和标准生命周期规范。
在此开发规范之上,基于 Vue 的模板语法和基础组件来构建视图层,基于 Vue 数据驱动及标准生命周期规范来构建逻辑层,实现多端运行。框架总体原理图如下:
我们分别从模板、逻辑和数据来聊聊是如何编译的。
模板
我们先看一下Vue模板和小程序模板是如何书写的:
Vue模版如下:
<!--index.wxml-->
<template class="container">
<view class="userinfo">
<button v-if="!hasUserInfo&&canIUse" open-type="getUserInfo">获取头像昵称</button>
<block v-else>
<image @tap="bindViewTap" class="userinfo-avatar" src=""/>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
</template>
小程序的模版内容如下:
<!--index.wxml-->
<view class="container">
<view class="userinfo">
<button wx:if="{{!hasUserInfo&&canIUse}}" open-type="getUserInfo">获取头像昵称</button>
<block wx:else>
<image bindtap="bindViewTap" class="userinfo-avatar" src=""/>
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
</block>
</view>
<view class="usermotto">
<text class="user-motto">{{motto}}</text>
</view>
</view>
如果我们对比小程序和Vue模版,会发现他们与html语法是十分相似的。区别只在于标签上属性值的写法,相互之间通过编译是可以转化的。我们可以在编译阶段由Vue模版编译到小程序模版。
逻辑
我们先看一下小程序的逻辑和Vue的逻辑部分:
Vue逻辑如下:
<script>
export default{
data(){},
methods:{
bindViewTap(){},
getUserInfo(){}
},
mounted(){}
}
</script>
小程序的逻辑如下:
Page({
data:{},
bindViewTap: function(){},
onLoad: function(){},
getUserInfo: function(){}
})
逻辑部分,小程序与Vue在书写方式上有很大差异,他们的逻辑代码在各自的运行时中执行。并且逻辑部分用户书写的灵活度是很大的,没有办法通过编译将Vue的逻辑编译成小程序的逻辑去执行。
那该怎么办呢,我们不如换一种思路。Vue运行时和Vue组件的逻辑在生产中都是以JS代码执行的,在小程序提供的环境中是可以执行的。我们可以让Vue运行时也可以在小程序中执行,这样开发者编写的Vue逻辑代码也可以在小程序中执行了。
数据
数据部分是最简单的,因为数据是以JS对象的形式存在的,在小程序和Vue中是相同的。
原理
通过对视图、逻辑和数据这三个部分的分析,我们可以使用以下思路来使用Vue开发小程序。
首先将Vue template部分编译成小程序的模版,之后在小程序逻辑部分运行整个Vue的运行时,以及开发者编写的逻辑代码。最后Vue数据发生变化时同步给小程序,触发视图刷新。
我们需要在编译阶段产出 .wxml、.css、.js以及.json文件,在template部分需要将v-bind等语法转换成小程序使用的格式。样式内容则可以直接提取出来作为css文件。我们会在Vue中规定一个字段作为配置,这部分配置会提取出来作为.json文件。
而对于js部分,由于我们的逻辑执行在Vue中,因此只需要用到小程序的生命周期,在生命周期中执行Vue运行时以及业务逻辑代码就可以了。
例如,Vue单文件组件内容如下:
<template>
<view class="home-wrap">
<navigator :url="item.bookApi" v-for="(item,index) in bookList">
<book :poster="item.poster"></book>
</navigator>
</view>
</template>
<script>
import Book from 'components/Book/index';
export default{
config:{
navigationBarTitleText: "标题"
},
data(){},
components:{
book:Book
}
}
</script>
<style>
.home-wrap{
width:100vw;
height:100vh;
}
</style>
编译成小程序的组件内容如下:
<!--wxml模板内容-->
<view class="home-wrap">
<navigator url="{{item.bookApi}}" v-for="(item,index) in bookList">
<book poster="{{item.poster}}" compId="{{ (compId ? compId : '$root') + ',0' }}"></book>
</navigator>
</view>
/*wxss样式内容*/
.home-wrap{
width:100vw;
height:100vh;
}
/*json配置内容*/
{
"navigationBarTitleText": "标题",
"usingComponents":{"book":"../../components/Book/index"}
},
//js逻辑内容
import {createPage} from "../../mars-core/index"
import Book from "../../components/Book/index.vue"
Page(createPage({
data(){},
components:{
book:Book
}
}))
为了执行Vue运行时以及业务逻辑代码,我们需要在小程序中创建Vue实例,Vue在生产环境中是以JS代码来运行的。因此我们可以直接将Vue引入,然后在小程序onLoad阶段new一个Vue实例出来。
import Vue from 'vue'
Page({
onLoad(){
const vm = new Vue(options)
this.$vue = vm
}
})
但是要注意,Vue正常是要执行在浏览器中的,在执行时会进行DOM操作完成页面渲染,在小程序中我们需要将Vue进行DOM操作的部分删掉。做到这里模版已经有了,样式也有了,创建了Vue实例后逻辑也可以执行了,但到目前为止,小程序与Vue也没有真正联系上。
通过之前的分析我们了解到,小程序与Vue之间是通过数据来联系的,Vue中执行逻辑,修改数据,将数据变化同步给小程序,触发试图更新。因此,我们现在要做的就是在每次Vue中更新视图时,把数据修改同步给小程序,那么如何知道Vue中的逻辑执行造成了视图刷新了呢?
我们可以使用Vue的updated钩子函数。
const vueMixin = {
updated(){
setData(vm,this)
}
}
updated钩子函数会在数据发生变化导致视图刷新后触发。我们可以在其中调用小程序的setData方法,来将变化后的数据同步给小程序,现在我们在Vue和小程序之间建立了联系。但这个联系还是单向的,Vue的数据变化可以修改小程序的视图。但小程序中用户的操作还不能传递给Vue进行处理。用户的操作体现在tap等事件中,由于我们所有的逻辑都在Vue中,因此需要让Vue接管小程序的事件处理。
我们可以在小程序的模版中去设置一个代理函数handleProxy,在这个事件代理函数中,调用Vue实例中的事件处理函数,触发开发者编写的业务处理逻辑。这样用户的操作通过事件代理传递给Vue进行处理,Vue处理过程中会修改数据,触发VirtualDom的更新,VirtualDom更新后会触发updated钩子函数,我们在updated钩子函数中将数据变化同步给小程序,使得小程序视图更新,完成了整个用户操作响应流程。
现在我们已经完成了Vue与小程序结合的整体结构,视图绘制发生在小程序中,业务逻辑运行在Vue中,小程序与Vue用事件和数据来进行通信。
组件机制原理也是一样的,视图依旧由小程序组件来绘制,业务逻辑运行在Vue组件中,小程序组件与Vue组件通过事件和数据来进行通信。
但是这么做的前提是我们需要将小程序组件与Vue组件关联起来,在我们创建Vue实例时有两种选择,一种是我们只在小程序根组件也就是Page中去创建Vue实例,Vue会继续创建组件实例。
在这种情况下小程序组件和Vue组件的创建分别是同时进行的,那么我们就需要将小程序组件与Vue组件之间进行关联匹配,否则他们之间的通信也就无从谈起了。
那么如何匹配呢?我们可以给每个组件都标记一个唯一的ID,然后通过ID来进行匹配。标记的方法就是从根组件开始,将根组件标记为 r o o t ,那么它的子组件就是 root,那么它的子组件就是 root,那么它的子组件就是root.0,$root.1等,不同层级间使用.来分割,其中列表循环特殊对待,我们使用横线来标记循环项。
例如root.1中有一个循环列表,这个循环列表中渲染了一个组件这个组件自身ID位root.1.0,然后循环产生的第一个子组件就是root.1.0-0,第二个就是root.1.0-1,这样我们通过ID给每个组件增加了标记,将相同ID的小程序组件与Vue组件匹配在一起。
另一种是我们去掉Vue创建组件实例的逻辑,自己在每个小程序组件创建时new一个Vue实例,但如果这么做,我们需要自己维护Vue各个实例间的父子关系。