基于小程序的AST实践

6,222 阅读12分钟

背景

现在前端对移动端和小程序的开发热情很高,各种多端解决方案百花齐放。例如很火的Taro和mpvue,还有后来居上的uni-app等等。

因公司业务需要,本人最近也在忙活各种小程序,例如:之前开发的小程序的业务逻辑需要在其他平台复用,我们不可能把业务再重写一遍,所以需要研究下小程序之间的差异和转换,因此花了不少精力,有点心得体会,写点东西和大家交流交流。

这篇总结文章主要是对转换工具 github.com/xujie-phper… 的介绍,想进一步研究的同学可以带着问题看看代码,这样你就会更疑惑了~~

小程序的比较

类型 微信小程序 百度小程序 支付宝小程序
api wx.* swan.* my.*
视图模版 循环: wx:for条件: wx:if 循环: s-for 条件:s-if条件判断中不需要使用插值语法 循环: a:for条件: a:if
事件处理 bindtap bindtap onTap
过滤器 wxs语法 filter语法 无
原生组件 除canvas,基本一致 除canvas,基本一致 更强大组件库系统
登陆流程 指定scope授权 指定scope,获取code,换token 授权获取code,获取token
支付 微信支付 聚合收银台 支付宝
配置信息 project.config.json project.swan.json和pkginfo.json 无
生命周期和样式 一致(理论) 一致(理论) 一致(理论)

设计图

图片

流程图

图片

架构图

生成项目基本文件目录

使用recursive-copy库,完成文件的整体拷贝,替换文件名后缀,例:.wxml===> .swan

转换json文件,去掉组件驼峰

1、找到josn文件里“usingComponents“包含的值,将组件引用中的驼峰改为kebabCase 2、若包含抽象节点“componentGenerics“字段,手百中不支持,存放在错误日志中

⚠️将修改后的组件名的映射关系记录在全局的contextStore中,属性值为“renamedComponents“,视图层中转换中需要使用新的组件名

AST实战讲解

以下的转换逻辑会大量依赖babel,进行AST的代码转换,所以我们先巩固下抽象语法树相关的知识。

可能刚接触AST的人会感觉无从下手,毕竟ast相关的知识点确实比较繁杂,而且相关的入门指导比较少。这里我们以一个完整的例子,过一下AST常用基本语法,方便大家入门,虽说是入门,但如果熟练掌握,已经可以应用于实际开发了。

  1. 打开在线AST工具,发现新大陆长这样

高亮的是对应的代码段,左边是一个对象的属性,右边对应ast中的节点信息。

注意:js中不同的数据类型,对应的ast节点信息也不竟相同。以图中为例,externalClasses对象的节点信息中类型(type)是ObjectProperty,包含key ,value等关键属性(其他类型节点可能就没有)

  1. 打开transform开关,选择转换引擎,又发现了新大陆

图片
这里我们选择babel和配套的acorn,可以根据实际需要自己选择,这只是推荐。

注意选择最新的babel7版本,不然下面例子中的类型会匹配不上,

  1. 现在的界面结构展示如下图,接下来就开始进行转换逻辑的代码编写

假设我们的目标是要把properties属性中key为‘current’的属性改为myCurrent。let's go!

原始代码:

/*eslint-disable*/
/*globals Page, getApp, App, wx,Component,getCurrentPages*/
Component({
  externalClasses: ['u-class'],

  relations: {
    '../tab/index': {
      type: 'child',
      linked() {
        this.changeCurrent();
      },
      linkChanged() {
        this.changeCurrent();
      },
      unlinked() {
        this.changeCurrent();
      }
    }
  },

  properties: {
    current: {
      type: String,
      value: '',
      observer: 'changeCurrent'
    }
  },

  methods: {
    changeCurrent(val = this.data.current) {
      let items = this.getRelationNodes('../tab/index');
      const len = items.length;

      if (len > 0) {
        items.forEach(item => {
          item.changeScroll(this.data.scroll);
          item.changeCurrent(item.data.key === val);
          item.changeCurrentColor(this.data.color);
        });
      }
    },
    emitEvent(key) {
      this.triggerEvent('change', { key });
    }
  }
});

首先在原始代码中选中'current',查看右边ast的节点结构,如图:

这是一个对象属性(ObjectProperty),关键节点信息为key和value,key本身也是一个ast节点,类型为Identifier(准确的应该是StringIdentifer,常用的还有NumberIdentifer等),'curent'是里面的name属性。所以我们的第一步就是找到改节点,然后修改它。

查找

export default function (babel) {
  const { types: t } = babel;
  
  return {
    name: "ast-transform", // not required
    visitor: {
      Identifier(path) {
        //path.node.name = path.node.name.split('').reverse().join('');
      },
       ObjectProperty(path) {
         if (path.node.key.type === 'StringIdentifier' && 
             path.node.key.name === 'current') {
         	console.log(path,'StringIdentifier')
         }
  	   }
    }
  };
}

这里需要用到@babel/typesbabeljs.io/docs/en/bab…来辅助我们进行类型判断,开发中会非常依赖这个字典进行查找

在控制台会看见,path下面的节点信息很多,关键字段为node和parentPath,node记录了该节点下数据信息,例如之前提到过的key和value。parentPath代表父级节点,此例中表示ObjectExpression中properties节点信息,有时我们需要修改父节点的数据,例如常见的节点移除操作。接下来我们修改该节点信息。

修改

在@babel/types中找到该ObjectProperty的节点信息如下,我们需要需要构造一个新的同类型节点(ObjectProperty)来替换它。

可以看到关键信息是key和value,其他使用默认就好。value里面的信息我们可以照搬,从原有的path里面获取,我们更改的只是key里面的标识符'current'。因为key本身也是一个ast节点,所以我们还需要查看字典,看看生成Identifier节点需要什么参数,步骤一样。修改代码如下:

ObjectProperty(path) {
         console.log(path,'ObjectProperty--')
         if (path.node.key.type === 'Identifier' && 
             path.node.key.name === 'current') {
            //替换节点
           path.replaceWith(t.objectProperty(t.identifier('myCurrent'), path.node.value));
         }
  	   }

其中我们用到了replaceWith方法,这个方法表示用一个ast节点来替换当前节点。 还有一个常用的replaceWithSourceString方法,表示用一个字符串来代替该ast节点,参数为一串代码字符串,如:'current : {type:String};',感兴趣的,可以自己试试。

最后查看转换后的代码,发现'current'已经被我们替换成了'myCurrent'。

到这里,一个完整的例子就演示完了。这里补充说明一下,在实际中可能会遇到嵌套结构比较深的ast结构。我们需要嵌套类型判断,比如:

ObjectProperty(path) {
     console.log(path,'ObjectProperty--')
      MemberExpression(memberPath) {
          console.log(path,'memberPath--')
      }
 }

因为遍历中的path指定的是当前匹配的节点信息。所以可以为不同的类型遍历指定不同的path参数,来获取当前遍历的节点信息,避免path覆盖,例如上面的path和memberPath。

到这里,babel的基本用法就差不多介绍完了,想要熟练掌握,还需要你在项目中反复练习和实践。想系统学习babel,并在实际项目中使用的同学可以先看看这篇babel的介绍文档,边写边查,巩固学习

逻辑层转换

借助babel的三剑客:@babel/parser、@babel/traverse、@babel/generator。

js的转换规则较复杂,会大量依赖babel/types做类型判断,并借助在线AST工具辅助测试。

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+
  1. 名称不同,功能相同的api,需要做映射,例: navigateToMiniProgram ===> navigateToSmartProgram

  2. 自定义组件的处理: 百度小程序构造器不支持的属性: moved,relations, observers 内置behaviors的处理:

    `wx://form-field` ===>  `swan://form-field`
    `wx://component-export` ===>  `swan://component-export`
    

    relations中若有使用link回调函数,则对应到百度的attached生命周期中执行, 配套使用的getRelationNodes,则对应百度的selectComponent方法。

    为解决页面多组件实例的问题,引入swanId做为唯一标识,我们会为有依赖关系的组件添加swanId属性,同一组的父子组件共用一个swanId。

    所有的父子组件的依赖关系存在在全局的contextStore中,供视图层添加swanId时使用

  3. 独有api无法自动匹配,存放到转换日志中,需手动删除或替换对应逻辑

  4. 关键词替换:wx ===> swan

视图层转换

视图层的转换也是使用的AST,借助stricter-htmlparser2将html转化为节点树,遍历,替换指定节点,最后生成新的html结构。

<view wx:='aaa'>test</view>

"parseHtml": {
        "type": "tag",
        "name": "view",
        "attribs": {
            "wx:": "aaa"
        },
        "children": [
            {
                "data": "test",
                "type": "text"
            }
        ],
        "singleQuoteAttribs": {},
        "selfclose": false
    }

循环和条件判断

微信:

//循环
<view wx:for="{{array}}" wx:for-index="idx" wx:for-item="itemName">
  {{idx}}: {{itemName.message}}
</view>
//条件
<view wx:if="{{view == 'WEBVIEW'}}"> WEBVIEW </view>
<view wx:elif="{{view == 'APP'}}"> APP </view>
<view wx:else="{{view == 'MINA'}}"> MINA </view>

百度

//循环
<view>
    <view s-for="p,index in persons">
        {{index}}: {{p.name}}
    </view>
</view>
//条件
<view s-if="is4G">4G</view>
<view s-elif="isWifi">Wifi</view>
<view s-else>Other</view>

转换逻辑为:

  1. 将wx:替换为s-,例:wx:if =====> s-if
  2. 去掉插值语法(花括号)
  3. wx:for, wx:for-index,wx:for-item合并为s-for="p,index in persons"

模版的转换

<template name="msgItem">
  <view>
    <text> {{index}}: {{msg}} </text>
    <text> Time: {{time}} </text>
  </view>
</template>

//**微信**:
<template is="msgItem" data="{{...item}}"/>
//**百度**:
<template is="msg-item" data="{{ {...item} }}" />

转换逻辑为:

  1. data属性外增加一个大括号
  2. 名称改为小写字母与中划线“-”的组合

for和if作用于同一标签

微信可以使用,手百禁止, 编译会报错

注意: s-if 与 s-for 不可在同一标签下同时使用。

将微信中的if标签,借助虚拟组件block,分成父子组件。 例:

 <view wx:for="{{list}}" wx:if="{{item}}">test</view>

转化为

 <view s-for="item, index in list">
     <block s-if="item">test</block>
 </view>

双向绑定

//**微信**:
<scroll-view scroll-into-view="{{toView}}" scroll-top="{{scrollTop}}">
    <view id="green" class="scroll-view-item bc_green"></view>
</scroll-view>

//**百度**:
<scroll-view scroll-into-view="{=toView=}" scroll-top="{=scrollTop=}">
    <view id="green" class="scroll-view-item bc_green"></view>
</scroll-view>

转换逻辑为:

将插值语法变换为{= * =}

wxs语法

微信使用wxs来进行数据处理,定义共用函数段;对应的百度的filter语法

//**微信**:
<wxs module="test">
    var some_msg = "hello world";
    module.exports = {
        setPosition: function (position) {
            return 'transform: translateX(' + position.pageX + 'px);';
        }
    }
</wxs>

//**百度**:
<filter module="test">
    var some_msg = "hello world";
    export default {
        setPosition: function (position) {
            return 'transform: translateX(' + position.pageX + 'px);';
        }
    }
</filter>

转换逻辑为:

将module.exports替换为export default

注:百度的filter中不支持导出变量,但是微信是支持的,所有这部分需要开发者手动处理下逻辑

样式文件的转换

小程序间的样式完全一样,只是文件后缀名不同,只需要替换引入的样式文件后缀wxss ===> css。但是微信中不支持相对路径图片路径,所以需要使用postcss插件处理下,将相对路径变为绝对的网络路径使用

例:

@import "header.wxss";

转化为:

@import "header.css";

转换日志

转换日志分为'info'、'warning'、'error'三种类型,转换过程中产生的日志信息都存放在统一logStore中,结束时会借助mkdirp和fs 能力把logStore存储的所有信息,写入到日志文件中。

注:小程序独有能力和私有能力,无法转化(目前),需要手动进行逻辑替换或删除。转换中不涉及项目依赖文件的替换,例:project.swan.json和pkginfo.json,可以使用百度开发者工具自动生成

重点回顾和梳理

  • 微信中的表示组件间关系的relations,在百度小程序中不支持
//微信中的使用例子
Component({
  relations: {
    './custom-li': {
      type: 'child', // 关联的目标节点应为子节点
      linked: function(target) {
        // 每次有custom-li被插入时执行,target是该节点实例对象,触发在该节点attached生命周期之后
      }
    }
  },
  methods: {
    _getAllLi: function(){
      // 使用getRelationNodes可以获得nodes数组,包含所有已关联的custom-li,且是有序的
      var nodes = this.getRelationNodes('path/to/custom-li')
    }
  },
}

在百度小程序中,我们也需要获取到依赖的父子组件节点,所以我们使用selectCompoennt(s)来替代。步骤如下:

  1. 拿到relations中linked的用户代码A,移除relations代码片段,添加attached生命周期,将A作为其函数主体。并将依赖关系记录,保存到context中,供view层使用
  2. 将getRelationNodes替换为selectCompoennt(s),其中(s)由父子关系决定,参数为传入的系统添加的props(swanIdForSystem),例:
const parentNode = getCurrentPages()[getCurrentPages().length - 1].selectComponent('#' + this.data.swanIdForSystem)
  1. view中根据context中存储的父子依赖表,给记录的指定节点添加唯一的swanIdForSystem属性
  • 登录流程(内部小程序鉴权,不同于OAuth的第三方登录)

微信中使用的是passport(百度统一登录系统)给微信小程序封装的工具SDK,将BDUSS和SToken逻辑封装在里面。在百度小程序中,我们直接使用swan.getBDUSS和swan.getSToken获取,所以我们需要拦截微信中所有的请求方法,在cookie中注入BDUSS和SToken

  1. 找到opne-type的回调函数(getuserinfo),在回调函数中执行登录逻辑,调取swan.getBDUSS和swan.getSToken,获取到BDUSS和SToken并保存起来。其中getuserinfo可由用户自定义配置
  2. 替换SDK封装的登录后的回调方法(bdWxLogin)方法,其中bdWxLogin也可由用户自定义配置
  3. 匹配所有wx.request方法,在header字段里,添加cookie,注入BDUSS和SToken
  4. 用户需要把pass相关的SDK包手动删除一下
  • 微信的WXS语法

百度小程序不识别这种结构,需要转换为等价的SJS语法

  1. 视图层,逻辑层中将WXS标签换成SJS标签
  2. 自定义组件中使用SJS,需要父组件中也必须使用SJS

总结

工具中设计到的AST转换分为三个部分:view(html),js,css。使用的解析引擎分别为

  • babel:解析js为js类型的ast
  • stricter-htmlparser2:解析view为html类型的ast
  • postcss:解析css文件为css类型的ast

这三点就是本文的核心知识点。以上所讨论的都是最近写的一个微信转百度小程序工具的详细介绍和具体实现,对小程序和babel感兴趣的可以去看看代码,应该会有所收获,并能发现其中还存在的一些问题,欢迎讨论,一起学习。