vue转小程序
29 October 2017

vue转小程序

背景

在普通移动端h5页面和小程序端页面内容因业务相同有大量相同内容时,需要一个工具能够将h5端的代码转成小程序端的,来提高开发效率,也便于日后的维护。这个转换其实是语法的映射,最终确定了h5端选择vue框架的的代码,一方面是因为可行性,特别是js部分,结构类似,处理起来比较方便;另一方面vue目前比较通用。

方案

针对vue的sfc结构组件,将template中的模板部分转化到小程序的wxml,style里的css部分转化为小程序的wxss,script里的js部分转化为小程序的js。

模板层是比较复杂的,需要对vue进行语法分析得到抽象语法树,然后再一一映射到小程序的语法,就是一次把字符串拆成ast,再转化成另一种语法的字符串的过程

css部分主要是单位转化,把px、rem单位转成小程序里的rpx。具体一点的实施方案:利用postcss拿到css的ast,拿到decl、comment等节点信息。转化:1rem -> 100rpx; 1px -> 1rpx; 有 /* no2rem */标识的px单位不转化。import文件的处理:对于import引入的公用文件,为了体积不能像less处理器那样合成到所引入的文件里,所以使用postcss-less处理器分析ast,去除import语句后处理剩下的语句,并记录import的文件,将原文件补上import wxss文件语句信息,将import的文件转成wxss并引入到小程序目录

js部分是核心,虽然不需要语法上的转化,但是需要实现一套标准,在vue和小程序端都能跑起来,其实是实现一个框架。但是为了方便,我们并没有重新构造一套框架,而是将vue的核心部分除了模板渲染搬到了小程序里,模板渲染依旧使用小程序内置的。

当然,这不是万能的,不是每个vue语句都能被转成小程序语句,譬如小程序里不允许在模板里使用函数。这种属于小程序本身的限制我们是绕不开的。只能提供一份说明文档,让大家开发时写的是有一定限制的vue子集。

而且,这不仅仅是开发个转化工具就能无痕的实现vue转小程序的,还需要在h5端和小程序端都封装一套基础库,以实现js里的很多功能,下面会详细介绍

难点&亮点

模板的转化

模板部分的处理主要是转化,从vue的语法映射到小程序的语法。

获取sfc

vue源码里就有将template字符串转化成ast的部分,在compiler/parser部分,我们可以直接搬过来使用。稍微做了点改动,譬如在ast中记录行号,以抛出错误时精确到具体的行数。

如果不改动vue源码,获取ast可以直接这么做:

const compiler = require('vue-template-compiler')
const getsfc = function(content) {
  let output = compiler.parseComponent(content)
  return output
}
const compileTpl = function(tpl) {
  let output = compiler.compile(tpl, { comments: true, preserveWhitespace: false, shouldDecodeNewlines: true })
  return output
}
const sfc = getsfc(vueFileContent)
const astRes = compileTpl(sfc.template.content).ast

vue里的这部分将template进行compileToFunctions是为了得到render以及staticRenderFns。生成的ast结构是为了这个目的设计的,所以有些地方不一定跟我的需求完全一致,还有一些地方在我的角度看上去会觉得很怪异,譬如else不是一个独立的ast节点,而是存在if的ast节点信息的ifCondition里。不过虽然这种结构对于我们来说不是最方便的,但是所有信息都是完整够用的(行号这种没有的信息就只能自己改改源码记录下了),考虑到以后升级维护,vue自己解析的ast才是最准确的,所以还是利用了vue源码中的这部分,得到ast。

我们来看下vue解析出来的ast结构:

譬如这样的template:

<template>
  <section class="app" :class="dynCls"> 
    <h1 @click="handleClick">vue转小程序<span :style="style.headerTip">(test demo)</span></h1>
  </section>
</template>

生成的ast结构是:

vue ast转化为小程序语句

不断遍历ast,取出有意义的部分,再分别做映射。

ast的子元素放在父元素的children里需要遍历取出处理。

如上图的结构,对于我比较有意义的几个数据是:

  • tag: 标签
  • type: 类型,1-标签;2-表达式节点(Mustache);3-纯文本节点和comment节点
  • attrsMap: 标签上的属性集合
  • children: 元素的子元素,需要递归遍历处理

还有一些特定场景会出现的有意义的信息

  • classBinding、styleBinding: 当元素上有动态绑定的class、style时,会将信息存在这里
  • if、elseif、else: 条件语句中的条件
  • ifConditions: 条件语句的else、elseif的节点信息都放在ifConditions的block里了
  • for、alias、iterator1: v-for中的相关信息
  • text: 文本节点的文本内容
  • isComment:是否是注释(注释是不转化的)

首先针对转化的内容的不同,分成3种处理的方式:

  • 注释: 不转化,直接拼装成字符串。我们还支持注释的block,特定的注释里的(<!-- xcx-begin -->...<!-- xcx-end -->)内容,会在h5中移除,直接不转化送到小程序里。
  • 组件元素:需要分析生成组件注册的js语句。需要将props、event、控制语句都分析出来;模板转化成小程序template的引用,并将跟js关联的data传进去,详情下面有介绍。组件元素相对于普通元素还有区别,譬如事件就不会去将@、v-on转成bind、catch;属性会进行中划线转驼峰的处理
  • 普通元素,就是分析ast的有用信息,转化后再拼接成字符串。

映射这部分需要细致的对比vue的api和小程序的api。举个v-for的例子。

譬如vue中:

拿到的ast结构是:

我们先要将标签做映射,从第一层开始,ul转成view。再取出ul这层的children,是个数组,里面只有一项,就是li这一层,li先转成view,再分析含有ast.for,需要处理循环语句,循环的对象是ast.for,循环赋值的个体是ast.alias,循环的索引是ast.iterator1,这样需要的信息都有了,我们就能映射到小程序端的语法了,小程序里循环对象语法是wx:for=,循环个体是wx:for-item="xx",循环索引是wx:for-index="x",为了代码最佳,我们加上key值,即wx:key="x",如果指定了索引变量,则wx:key使用索引变量,否则由系统根据时间戳+递增变量值,生成一个唯一值。 在看li这一层的children,是个type为2的数据绑定表达式,表达式内容是ast.text,vue中的数据绑定语法与小程序的一致,都是使用 Mustache 语法,直接搬过去就好,所以最终结果是:

组件的支持

我们在开发这一套时,微信是还没有对外开放小程序的组件化方案的,但是对组件化已经在开发阶段预计将在4个月后支持了。

但是如果要h5端跟小程序一样不支持组件,这是接受不了的事情。所以我们实现了一套模板转化和js基础库配合的组件化方案。

组件实现的难点在于要让模板中的组件和js中的组件实例建立联系。所以我们的方案是在解析模板,分析ast时,抓取到组件,生成组件注册函数,然后在js实例化时运行这个函数,将组件挂在到页面或者父组件上去。(小程序端实际组件的联系都是将子组件的数据挂载在了父组件上,然后利用小程序的template去渲染)

譬如这样一段template:

<template>
  <demo-component :parent-msg="demoComponentData.parentMsgData" :img-class="classObj.imgClass" data-name="demo-component" @parentFnEvent="demoComponentFn" @parentFnEvent2="demoComponentFn2"></demo-component>
</template>

解析ast时我们会生成这样的js语句:

export default function render(renderComponent) {
renderComponent('demo-component', { "parentMsg": this.demoComponentData.parentMsgData, "imgClass": this.classObj.imgClass, "dataName": "demo-component" }, { "parentFnEvent": this.demoComponentFn, "parentFnEvent2": this.demoComponentFn2 }, '15075388741760');
}

然后在小程序端运行时基础库会去调用这个函数注册组件,采用跟最外层模块相同的方式注册这个组件。

这里的renderComponnet函数有模板分析出来的4个参数:组件名、组件的props数据、组件上传递的事件event,组件的唯一key(防止在for循环里同名组件出现问题)。

模板wxml生成的结构是:

<template is="demo-component" data="{{ $parent:$parent['$'+label],...$parent['$'+label]['$demo-component_15075388741760']}}"></template></template>

label是当前组件的组件名标识,$parent['$'+label]['$demo-component_15075373766070']其实就是将相应子组件的数据传递进去了。可以在微信端看到数据结构是这样的:

这里面比较复杂的是条件语句、循环语句和组件混合的情况,分析模板时不仅要关注组件是否出现,还要关注其对应的上下文环境,是否在逻辑语句中,需要有个栈去记录其上下文信息,当走到某个ast分支的最底层时如果存在组件,则将这一条线路的语句和组件都记录下来生成相应的js语句,然后再将这个分支对应的上下文逻辑语句信息出栈;如果不存在组件,则将该分支记录的上下文信息都出栈。循环往复,直到分析完整个模板。

譬如某段含有逻辑语句上下文的模板:

生成的wxml:

生成的组件注册函数:

npm包的支持

小程序不支持npm包,这给我们的开发带来了很多不便,既然支持了组件,就要支持组件对应的npm包,方便大家复用代码。

对npm包的支持,处理了以下几个方面

  1. 读取模块中的package.json,将需要的npm包下载到小程序项目的特定目录下。某些内置默认加载的npm包也会被下载
  2. 由于微信的import是不会分析npm包的,对于import xxx form 'npmlocation'这种是分析不出来位置会抛出错误的,所以我们要将js代码在转化时,处理掉这个路径,变成小程序端下载的npm包文件相对于转化后小程序端的js文件的相对路径
  3. 小程序包大小限制,原始下载的npm包目录是做ignore处理的。会在分析模块js文件时,记录下依赖的文件,只将引用到的文件移出到工作目录下。做到按需引入

价值

vue转小程序只是个场景,这套方案背后更大的价值是在掌握了js\css\html的ast后,能够将一种语法转化成另一种语法的能力。这对于一些迁移改造可以节省很大人力成本。