快应用开发框架vue-hap-tools实现原理

2,430 阅读4分钟
原文链接: zhuanlan.zhihu.com

前期爝神大大

@小爝 已经对vue-hap-tools做了简单介绍,参考使用vue编写快应用解决方案。现在这篇文章主要说说实现思路,也算总结一下,如果有实现得不合理的地方,欢迎大家指正。

要让vue代码运行在快应用平台,一种实现思路是像mpvue一样,js部分使用vue.runtime接管,模板部分直接转换,另一种思路是直接将vue的语法转换为快应用的语法,js部分添加少量hack代码。由于快应用与vue语法本身就比较接近,因此我们选择了成本相对较低的第二种实现方式。当然,可能还有更底层的实现方式,还望知道的大佬指点指点😁。

快应用打包过程

在开始之前,我们需要了解快应用官方脚手架hap-toolkit的打包过程。整体来说,hap-toolkit基于webpack的多入口打包,最终在用户端,快应用解析执行的是webpack的打包结果。hap-toolkit首先会解析出应用的主入口和各个页面入口,然后把这些入口作为webpack的entry。比如下面的例子:

// Webpack配置
const webpackConf = {
  entry: {
    'app.js': '/path-to-project/src/app.ux',
    'page1/index.js': '/path-to-project/src/pages/page1/index.ux',
    'page2/index.js': '/path-to-project/src/pages/page2/index.ux'
  },
  rules: [
    {
      test: /\.ux$/,
      use: ['hap-toolkit-loader']
    }
    // 其他loader
  ]
  // 其他配置
  ...
}

并且配置webpack的rules,使ux后缀的文件(对应于.vue文件)都会进入hap-toolkit-loader。看到这里,我们本以为只需要在hap-toolkit-loader之前加一层我们的loader,在编译之前先进行我们的转换逻辑就行了,比如下面这样,但实际却行不通。

rules: [
    {
      test: /\.ux$/,
      use: ['hap-toolkit-loader', 'vue-hap-tools-loader']
    }
    // 其他loader
  ]

实际上,hap-toolkit-loader内部并没有直接编译ux文件,而是根据ux文件内容生成一个js文件返回给webpack(步骤一),这个js文件说明了component、template、style、script的编译方式,比如下面的形式:

// 最终编译时,源文件会在require中的loader之间流转
// 注意,require的最后就是要处理的ux文件的路径
// 因此webpack根据这个js文件继续编译时,会再读取一次原始ux文件
// 而不是复用 步骤一 中的文件流
var $app_template$ = require('!!./json-loader.js!./template-loader.js!./fragment-loader.js?index=0&type=templates!./src/pages/page1/index.ux');
var $app_style$ = require('!!./json-loader.js!./style-loader.js?index=0&type=styles!./fragment-loader.js?index=0&type=styles!./src/pages/page1/index.ux');
var $app_script$ = require('!!./script-loader.js!babel-loader?presets[]=./node_modules/babel-preset-env&presets=./babel-preset-env&plugins[]=./lib/jsx-loader.js&plugins=./lib/jsx-loader.js&comments=false!./access-loader.js!./fragment-loader.js?index=0&type=scripts!./src/pages/page1/index.ux');

webpack会继续解析这个js文件,从而进入真正的编译流程(步骤二)。因此,如果我们直接在hap-toolkit-loader前加一层我们的loader,只能影响生成js文件的步骤一,步骤二中webpack又会重新读取一遍原始文件,这时我们的loader就干预不到了。

为了不过多地侵入hap-toolkit-loader的编译逻辑,最终我们的做法是,在步骤二中生成的js中添加我们的loader,比如:

// 在require的最后添加我们的vue-hap-tools-loader
var $app_template$ = require('!!./json-loader.js!./template-loader.js!./fragment-loader.js?index=0&type=templates!./vue-hap-tools/index.js?type=templates!./src/pages/page1/index.ux');

从而使我们的loader在真正的编译过程中生效,并且不介入后续逻辑。最后只需要修改webpack的rules就可以编译vue文件了:

rules: [
    {
      test: /\.vue$/,
      use: ['hap-toolkit-loader']
    }
    // 其他loader
  ]

一个简化的打包流程如下图所示:

语法转换

现在我们能够在hap-toolkit真正打包之前做一层loader了,因此我们只需要在该loader中实现语法转换就可以了。和vue文件的划分类似,我们的转换也分为template、script、style三部分,但三者并不是孤立的,会有一定联系。

template转换

先将template解析为html的语法树,然后遍历处理就行。

标签转换

建立标签转换的映射关系,直接修改标签名,并处理部分特异性,比如button需要转换为type=button的input,button的文本需要放在input的value属性中。

指令转换

这部分也只需简单地替换,比如v-for -> for、v-if -> if、v-show -> show、v-bind:class -> class,指令的值需要用双大括号包裹,如v-if="ifRender" -> if="{{ifRender}}"。为了支持对象形式的class,需要特异性处理,并且需要合并class和:class :

<!-- 转换前 -->
<div class="staticClass" :class="{class1: useClass1===true, class2: useClass2===true}"></div>
<!-- 转换后 -->
<div class="{{'staticClass'}} {{useClass1===true?'class1':''}} {{useClass2===true?'class2':''}}"></div>

另外v-model是vue提供的语法糖,快应用没有提供,我们需要实现:

<template>
  <!-- 转换前 -->
  <input type="text" v-model="inputVal">
  <!-- 转换后 -->
  <!-- 快应用中input的change事件对应web input的input事件 -->
  <input type="text" value="{{inputVal}}" onchange="_qa_v_model_inputVal">  
</template>
<script>
export default {
  // 其它代码
  ...
  methods: {
    // 在methods中添加事件回调
    _qa_v_model_inputVal(e){
      // 快应用中获取input value的方式与web不同
      // 这里用赋值的方式抹平
      e.target.value = e.value;
      this.inputVal = e.target.value;
    }
  }
}
</script>

script转换

同样的,首先将js转换为语法树,所有操作都基于语法树进行。

提取组件

这里贴一段提取组件的伪代码:提取组件

快应用的组件引入形式为:

<import name="comp-part1" src="./part1"></import>
<template>
  <comp-part1></comp-part1>
</template>
...

因此需要根据js提取组件的名字及组件路径,再拼接回快应用支持的形式:

<script>
import utils from './utils'
import compPart1 from './part1'
export default {
  // 其它代码
  ...
  // 用components字段中的变量名与import的变量名对应
  // 从而获得组件路径
  components: {
    compPart1
  }
}
</script>

处理methods

快应用没有methods字段,所有methods里的方法都提升为与data、mounted等字段同一级。

实现computed

computed我们暂时使用的Object.defineProperty来实现,比如下面的例子:

转换前

<script>
export default {
  computed: {
    showTip () {
      return this.tipList.length > 0
    }
  }
}
</script>

转换后:

<script>
export default {
  data(){
    showTip: ''
  }
  created() {
    Object.defineProperty(this, 'showTip', {
      get: function(){
        return this.tipList.length > 0
      }
    });
  }
}
</script>

watch转换

watch直接基于快应用的$watch来实现:

转换前:

<script>
export default {
  watch: {
    showTip () {
      console.log('tip changed')
    }
  }
}
</script>

转换后:

<script>
export default {
  created() {
    this.$watch('showTip', '_qa_watch_showTip')
  }
  methods: {
    _qa_watch_showTip(){
      console.log('tip changed')
    }
  }
}
</script>

生命周期映射

暂时只支持vue与快应用能够对得上的生命周期,这几个生命周期钩子基本能满足大多数需求,后期考虑在快应用中模拟更多的vue生命周期钩子。

{
  'created': 'onInit',
  'mounted': 'onReady',
  'beforeDestroy': 'onDestroy'
}

事件回调

快应用与web事件的event参数有一定差异,比如输入框input事件的回调中,获取输入框值:

<script>
export default {
  methods: {
    inputEventCallback(e){
      // 快应用需要通过e.value获取输入框的值
      // 为了在快应用中也能像web一样获取输入框的值
      // 这里做一个赋值
      e.target.value = e.value;
      this.inputVal = e.target.value;
    }
  }
}
</script>

vue-router转换

vue-router直接借助快应用的router实现,但需要抹平差异性:

转换前:

<script>
export default {
  methods: {
    gotoTodoMVC () {
      this.$router.push({
        path: '/TodoMVC',
        query: { useInfo: {name: 'John', id: 100} }
      })
    }
  }
}
</script>

// 下一个页面获取参数
<script>
export default {
  created() {
    console.log(this.$route.query.userInfo.name)
  }
}
</script>

转换后:

<script>
// 引入快应用的router
import _qa_router from '@system.router'
export default {
  created(){
    this.$router=_qa_router;
  }
  methods: {
    gotoTodoMVC () {
      this.$router.push({
        uri: '/TodoMVC',
        params: { useInfo: {name: 'John', id: 100} }
      })
    }
  }
}
</script>

// 下一个页面获取参数
<script>
export default {
  created() {
    this.$route={
      query: {
        // 快应用会将上个页面传递的参数全部挂载到this上
        // 并且会把参数转为字符串,因此这里需要将字符串还原
        userInfo: new Function(`return ${this.useInfo}`)()
      }
    };
    // 获取参数
    console.log(this.$route.query.userInfo.name)
  }
}
</script>

style转换

快应用样式是web样式的子集,对于快应用不支持,而web支持的样式,实在没想到比较好的转换方式😂,暂时的做法是,尽量在编译阶段就对不支持的样式抛出警告。从新浪这边的情况来看,使用快应用支持的样式来实现设计稿,问题不大。

rem转换

快应用只支持px、百分比尺寸,css中的rem会按照manifest.json中的基准宽度转为px。

标签选择器

由于快应用的标签比web标签少得多,比如p、h1、nav、section等都转会为div,从而针对上述标签的标签选择器都会失效。一个可行的方式是,为转换过的标签添加私有class,并在css中将标签选择器修改为私有class的选择器。但这样做有个问题是,选择器权重变了:

转换前

<template>
  <div class="class1">
    <h1></h1>
  <div>
<template>
<style>
  .class1 h1{}
<style>

转换后:

<template>
  <div class="class1">
    <h1 class="_qa_h1"></h1>
  <div>
<template>
<style>
  .class1 ._qa_h1{}
<style>

因此,我们暂时的做法是,仅支持快应用具有的标签的选择器,不支持的标签选择器会抛出警告。

总结

由于篇幅有限,这里只是大概说明了一下实现过程。大概思路就是,在快应用官方脚手架hap-toolkit编译之前,加一层我们的loader,实现语法转换,并hack部分vue特性。可以看到,整个过程几乎都是基于语法树的遍历、修改,大多都是体力活。另外,毕竟是用快应用的特性去hack vue的特性,可能部分实现前后的等价性有待商榷,望大佬指正😁。