基于babel,尝试将你的uni小程序转为vue-cli的h5版

1,531 阅读4分钟

前言

image.png

1)说明

  • 该篇重点不是如何转为h5,而是了解babel如何使用。
  • 该项目案例场景,适合一些正在重构项目,或者快速转换不同终端的场景。
  • 本章仅是个人提供的基础入门,转换点下边已说明。
  • 本项目需要借助服务端实现,基于koa开发,需要简单了解koa2与babel的基础。此外,需要适当了解一下相关知识点:正则表达式的抒写,babylon解析器的执行原理,htmlToH5Compiler转换等。
  • 案例通过babel格式化之后将无格式,需要自己借助eslint格式化。

2)github源码

因时间原因,github源码可能稍后处理,对应地址:github.com/zhuangweizh…

image.png

3)参考工具

步骤

1)koa项目搭建

koa只列举基本的创建过程,如有需要直接拷贝笔者github

npm install -g koa-generator
koa2 webTool

需要安装koa相关插件,babel相关插件等,这里不再描述,可查看package.json:

{
  "name": "test_koa",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "start": "node bin/www",
    "start2": "node bin/www",
    "dev": "./node_modules/.bin/nodemon bin/www LIMIT=8192 increase-memory-limit",
    "prd": "node bin/www",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "@babel/core": "^7.4.5",
    "@babel/generator": "^7.10.5",
    "@babel/preset-env": "^7.4.5",
    "@babel/traverse": "^7.10.5",
    "babel-extract-comments": "^1.0.0",
    "babel-loader": "^8.2.2",
    "babylon": "^6.18.0",
    "connect-timeout": "^1.9.0",
    "debug": "^2.6.3",
    "escodegen": "^2.0.0",
    "esprima": "^4.0.1",
    "estraverse": "^5.2.0",
    "htmlparser2": "^6.1.0",
    "koa": "^2.2.0",
    "koa-bodyparser": "^3.2.0",
    "koa-convert": "^1.2.0",
    "koa-json": "^2.0.2",
    "koa-logger": "^2.0.1",
    "koa-onerror": "^1.2.1",
    "koa-router": "^7.1.1",
    "koa-static": "^3.0.0",
    "koa-views": "^5.2.1",
    "request": "^2.88.2",
    "sync-request": "^6.1.0",
    "template-parser": "^1.0.0"
  },
  "devDependencies": {
    "increase-memory-limit": "^1.0.6",
    "nodemon": "^1.8.1"
  }
}

2)切割html, js, css

前端拿到需要转换的字符串后,post到服务端。此时,我们需要分成html, js, css切割开分别进行处理。将输入字符串,最外层template模块的内容为html, script的代码模块内容为js, style模块内容为css

笔者的思路是利用正则:

  async parse(source) {
      const htmlExp = /(?<=template)(?![\w\W]*template\>)[\w\W]+/gi;
      const styleExp = /<style([\s\S]*?)<\/style>/gi;
      const jsExp = /<script([\s\S]*?)<\/script>/gi;
      
      // 获取到的html
      const htmlStr = source
          .replace(htmlExp, '>')
          .toString()
          .replace(/\<template\>/g, '')
          .replace(/(.*)<\/template>/, '$1');
      
     // 获取到的css
     const styleStr = source.match(styleExp, '$1').toString()
      
     // 获取到的js
     const jsStr = source.match(jsExp);
     const jsContent = jsStr[0]
          .toString()
          .replace(/\<script\>/g, '')
          .replace(/(.*)<\/script>/, '$1');

     ...
  }

此时,我们可以分别拿到对应的html, js, css。

3)html与ast树的转换

下来重点描述html与ast树的转换,笔者目前找到比较适合的插件是htmlparser2。

此时定义一个专门转换html的类库TemplateParser.js,重点包含两个方法:HTML文本转AST方法, AST转文本方法。

HTML文本转AST方法,相对比较简单,我们只需要转换为Node Tree即可。而AST转文本方法是核心,我们要替换成我们想要的结果,再返回到客户端,完成我们的html替换过程。

    const htmlparser = require('htmlparser2')   //html的AST类库
    class TemplateParser {
      constructor(){
      }
      /**
       * HTML文本转AST方法
       * @param scriptText
       * @returns {Promise}
       */
      parse(scriptText){
        return new Promise((resolve, reject) => {
          //先初始化一个domHandler
          const handler = new htmlparser.DomHandler((error, dom)=>{
            if (error) {
                console.log(`reject.error`, error);
              reject(error);
            } else {
              //在回调里拿到AST对象  
              resolve(dom);
            }
          });
          //再初始化一个解析器
          const parser = new htmlparser.Parser(handler);
          //再通过write方法进行解析
          parser.write(scriptText);
          parser.end();
        });
      }
      /**
       * AST转文本方法
       * @param ast
       * @returns {string}
       */
       astToString (ast) {
            let str = '';
            ast.forEach(item => {
              if (item.type === 'text') {
                str += item.data;
              } else if (item.type === 'tag') {
                str += '<' + item.name;
                if (item.attribs) {
                  Object.keys(item.attribs).forEach(attr => {
                    str += ` ${attr}="${item.attribs[attr]}"`;
                  });
                }
                str += '>';
                if (item.children && item.children.length) {
                  str += this.astToString(item.children);
                }
                str += `</${item.name}>`;
              }
            });
            return str;
          }
    }

    module.exports = TemplateParser;
    

此时,html转ast, ast转html,都已经完成。

4)html标签的转换

此时,html转ast, ast转html,都已经完成。那么,这里中间还少一个最核心的步骤,就是ast,如何在转为html之前,转为成我们想要的ast树。

我们接着进行html的转换。这时候我们需要一个能解析html的类库。笔者目前找到比较适合的是htmlparser2。 但htmlparser2通过实践,发现一个比较严重的bug,就是需要关闭标签。如<input type="text" />, htmlparser2识别不到标签,需要转换为<input type="text" ></input>。

此时,笔者想到的替换思路,依然是正则:

   const pattern = /<(\w+)\s*(.*?)\/>/ig
   htmlStr = htmlStr.replace( pattern, '<$1 $2></$1>');
   

通过文档查阅,可以了解到node的节点,有type类型。我们当前需要替换标签,识别node.type = 'tag',即说明为标签节点。

我们先定义好我们需要替换的标签,如uni的text标签需要转换为span, 如view标签需要转换为div标签。

// 需要替换的标签对象
const tagConverterConfig = {
  text: 'span',
  view: 'div',
  image: 'img',
  ...
};

//替换入口方法
const templateConverter = function (ast) {
  for (let i = 0; i < ast.length; i++) {
    let node = ast[i];
    //检测到是html节点
    if (node.type === 'tag') {
      //进行标签替换
      if (tagConverterConfig[node.name]) {
        node.name = tagConverterConfig[node.name];
      }
      node.attribs = attrs;
    }
    //因为是树状结构,所以需要进行递归
    if (node.children) {
      templateConverter(node.children);
    }
  }
  return ast;
};

此时,即完成整个ast标签替换。我们只需要将传入的字符串,执行一遍,即可返回我们想要的html结果:

 let templateParser = new TemplateParser();
 const templateAst = await templateParser.parse(htmlStr);
 //进行上述目标的转换
 const convertedTemplate = templateConverter(templateAst);
 //把语法树转成文本
 let templateConvertedString = templateParser.astToString(convertedTemplate);

5)html标签属性的转换

上述,我们已经将html标签替换为想要的标签结果,此时,需要替换属性的话,同样的原理。属性在node.attribs,我们只需要替换成我们最终想要的node.attribs。即可完成需求。

改造一下templateConverter方法:

const attrConverterConfig = {
  '@tap': {
    key: '@click',
    value: str => {
      return str;
    },
  },
  ':nodes': {
    key: 'v-html',
  },
};

//替换入口方法
const templateConverter = function (ast) {
  for (let i = 0; i < ast.length; i++) {
    let node = ast[i];
    //检测到是html节点
    if (node.type === 'tag') {
      //进行标签替换
      if (tagConverterConfig[node.name]) {
        node.name = tagConverterConfig[node.name];
      }
      //进行属性替换
      let attrs = {};
      for (let k in node.attribs) {
        let target = attrConverterConfig[k];
        // 先看替换规则有没有
        if (target) {
          //分别替换属性名和属性值
          attrs[target['key']] = target['value'] ? target['value'](node.attribs[k]) : node.attribs[k];
        } else {
          attrs[k] = node.attribs[k];
        }
      }
      node.attribs = attrs;
    }
    //因为是树状结构,所以需要进行递归
    if (node.children) {
      templateConverter(node.children);
    }
  }
  return ast;
};

此时,我们已经可以通过配置tagConverterConfig, attrConverterConfig, 来达到我们产出html的目的。

6)单位的替换

如果是sass,less转化为css,该部分复杂一点。由于两边都支持sass,我们只需要转换一下单位即可。

转换单位的方案,当前案例没有使用转义,用的是最简单的正则替换:

const styleResult = styleStr.replace(/(\d+)rpx/g,function(val,group){
   return group + 'px'
})

7)js与ast树的转换

该章节,需要了解什么是 ast树,什么是babel。我们直接看他们的整个转换过程:

image.png

我们借助babylon来实践一次该过程:

// babylon  将源码转成ast Babylon 是 Babel 中使用的 JavaScript 解析器。
// @babel/traverse 对ast解析遍历语法树
// @babel/types 用于AST节点的Lodash-esque实用程序库
// @babel/generator 结果生成
const types = require('@babel/types');
const babylon = require('babylon');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;

let jsAst = babylon.parse(jsContent, { sourceType: 'module' });

traverse(jsAst, {
    ...转换规则
    
});

jsContent = generator(jsAst).code;

上述,parse 与 traverse 与 generator,即是整个ast树的转换过程。其核心,还是在traverse转换为我们想要的结果。下述将开启实践。

8)import的删除与替换

这里以vuex,做一个栗子。假设转换前需要引入vuex,转换后不需要,我们只需要在ImportDeclaration监听删除即可。

 traverse(jsAst, {
     ...
     
     // 引入部分处理
      ImportDeclaration(path) {
        const importUrl = path.node.source.value;
        if (importUrl.includes('vuex')) {
          path.remove();
          return;
        }
      },
}

如果你好奇为什么是ImportDeclaration,这里就需要阅读整个node的节点变量的含义。可参考github.com/babel/babel… 查看所有的node节点对应的意义。

同理,要是所有的import的路径发生变化,如原来的@需要替换为@/src

traverse(jsAst, {
     ...
     // 引入部分处理
      ImportDeclaration(path) {
          ...
        path.node.source.value = path.node.source.value.replace('@', '@/src);
      }
});

9)自动引用公用文件

由于uni或小程序提供的一些内置方法,如上拉onReachBottom等函数,都是vue-cli本身不具备的。

此时,如果通过上拉监听,再调用onReachBottom,即可完成转换。

问题来了,这样上拉监听的方法执行,需要一个页面一个页面去补充。

笔者想到的方案,就是在转换的过程中,通过mixin补充:

1) 首先,我们在整个ast树中,补充多一个import所需要的mixin文件,PageMixins。 2)在暴露的对象中,插多一个mixins属性到对应的文件。

  traverse(jsAst, {
      ...
      // 监听整个ast节点
      Program(path) {
        // 添加import导入
        const importDefaultSpecifier = [types.ImportDefaultSpecifier(types.Identifier('PageMixins'))];
        const importDeclaration = types.ImportDeclaration(importDefaultSpecifier, types.StringLiteral('@/mixins/PageMixins'));
        path.get('body')[0].insertBefore(importDeclaration);
      },
      // export的监听
      ExportDefaultDeclaration(path) {
        const obj = types.ObjectProperty(types.Identifier('mixins'), types.ArrayExpression([types.identifier('PageMixins')]));
        path.node.declaration.properties.unshift(obj);
     }
})

这样,转换后,代码将自动引入mixins。

10)修改生命周期名称

在uni中,生命周期,用到onLoad等。而我们想要的vue-cli代码,是没有onLoad的方法,我们需要替换成created.

同样,我们需要监听整个export对象,替换掉原来的onLoad:

  traverse(jsAst, {
      ...,
      ExportDefaultDeclaration(path) {
         ...,
         path.node.declaration.properties.forEach( (item, index)=> {
             if( item.key.name === 'onLoad' ){
                 item.key.name = "created"
             }
         });
      }
  });
  

或者,是不是还有一种思路,写死created调用methods中的onLoad方法。

我们要完成三个步骤:

1)页面写死created调用onLoad发放,可以利用上述"引入公用文件"的方案引入。

2)将onLoad方法移到methods中。

步骤1)在上个节点已描述,我们来模拟2)的代码:

traverse(jsAst, {
      ...,
      ExportDefaultDeclaration(path) {
         ...,
         
        let onLoadIndex = -1;
        let methodsIndex = -1;
        path.node.declaration.properties.forEach( (item, index)=> {
          if( item.key.name === 'onLoad' ){
            onLoadIndex = index;
          }else if(  item.key.name === 'methods' ){
            methodsIndex = index;
          }
        })
        const onLoadObj = path.node.declaration.properties[onLoadIndex];
        onLoadIndex > -1 && path.node.declaration.properties[methodsIndex].value.properties.unshift( onLoadObj )
        path.node.declaration.properties.forEach( (item, index)=> {
          if( item.key.name === 'onLoad' ){
            path.node.declaration.properties.splice(index , 1 );
          }
        })
     }
})

此时,完成生命周期的转换

11)uni对象的替换

在uni中,很多方法通过uni对象,或者wx对象去替换。而在vue-cli中,并没有该对象。

笔者想到友好的方案,就是将对应的方法,嵌入到vue的原型中。

即将uni.navigateTo 替换为 this.utils.goPage, 将uni.navigateBack 替换为 this.utils.goBack。

那么通过babel如何处理?我们可以通过CallExpression对方法的监听:

traverse(jsAst, {
    ...,
      if (name === 'uni') {
        // 需要转换完内部封装api
        const changeMyApi = ['navigateTo','navigateBack'];
        if (changeMyApi.includes(propertyName)) {
          // 将uni转成this.utils
          path.node.callee.object.name = 'this.utils';
          switch (propertyName) {
            case 'navigateTo':
              path.node.callee.property.name = 'goPage';
              break;
            case 'navigateBack':
              path.node.callee.property.name = 'goBack';
              break;
          }
        }
      } 
})

此时,uni.navigateTo将会替换为 this.utils.goPage。我们可以通过同样的原理,来完成整个项目的方法替换。

12)返回完整的字符串

上述已经将html, js, css分别转换。此时,只要拼接返回客户端即可。

如果中间再嵌入一个自动格式化(有推荐没?)