如何实现 Vue 转小程序 (3)

小程序
首页

这部分实现的是处理 vue 中 script 标签内 js 代码,将其转换为小程序可以解析的 js 文件代码

babel

我借助了 babel 将代码转为 ast 树,然后对 ast 树进行一些处理,最后再转换为代码

主要用了以下几个库:

  • @babel/parser:将代码转为 ast 树
  • @babel/traverse:用来遍历 ast 树
  • @babel/types:可以用作验证、构造 ast 节点
  • @babel/template:以模版的形式生成 ast 节点,适用于生成复杂 ast 节点
  • @babel/generator:将 ast 树再转换为代码

更多关于 babel 的使用,可以查看官方的babel 手册,在编写代码的时候,可以使用https://astexplorer.net/这个网站,能清楚地明白代码的 ast 树构成

开始转换

首先,我们要明确我们要怎样改造 vue 的 script 代码,才能让它在小程序上运行:

  • vue 中的 data 是一个函数,返回一个对象,而小程序中则直接是一个对象
  • vue 中是在 component 属性中引入子组件,小程序是将子组件和路径写在 json 配置的 useComponents 中
  • vue 中 props 的默认值关键字为 value,小程序为 default
  • vue 和小程序的生命周期勾子函数名不一样
  • vue 中的 name 属性,小程序并不存在,需要删除
  • vue 中的代码使用 export default 输出,而小程序中则不需要,小程序使用的是直接用 compoent/page 包裹组件属性

由于,微信存在 json 配置文件,而在 vue 中并不存在,我在 vue 中自定义了一个 wx 属性,用来接收配置

下面就是一个简单的单文件 vue 中 script 标签内的代码:

import ComponentA from "../componentA";
export default {
  name: "Hello Vue",
  wx: {},
  components: {
    "component-a": ComponentA
  },
  props: {
    propA: Number,
    propB: {
      type: Number,
      default: 100
    }
  },
  data: function() {
    return {
      name: "bowen"
    };
  },
  mounted() {}
};

我们拿到这段代码的第一步当然是将它转化为 ast:

const parser = require("@babel/parser");
// script就是上文的代码
const ast = parser.parse(script, {
  sourceType: "module"
});

我们可以直接通过上文提到的网站,清楚地看到它的 ast 结构:

ast结构

再对 ast 进行遍历,先获取到 export default 这个语句,通过 t.isObjectExpression()方法判断 export default 出去的是否为一个对象:

const traverse = require("@babel/traverse")["default"];
const t = require("@babel/types");
const result = { wxConfig: {}, useComponents: {} };
traverse(ast, {
  ExportDefaultDeclaration(rootPath) {
    let declarationPath = rootPath.get("declaration");
    if (t.isObjectExpression(declarationPath)) {
    }
  }
});

接下来就是对 vue 中定义的一些属性的处理:

const prop2handler = {
    data: dataHandler,
    props: propHandler,
    wx: wxHandler,
    components: componentsHandler,
    name: nameHandler
};
...
if (t.isObjectExpression(declarationPath)) {
  const properties = declarationPath.get("properties");
  properties.forEach(property => {
    const key = property.node.key.name;
    const handler = prop2handler[key];
    if (handler) {
      handler(property, rootPath, result);
    } else {
      lifetimesHandler(property, rootPath);
    }
  });
}

我们拿到 export default 出去的这个对象的属性,并遍历它们,获取到 key(也就是 data、props 等等),匹配对应的 handler,如果有对应的 handler,传入这个属性的 path,export default 语句的 path 和声明的 result 对象;如果没找到对应的 handler,试着用生命周期的 handler 去处理它

各类 handler

dataHandler:
const DATA_ID = "__v2mp__data__";
handlers.dataHandler = (path, rootPath) => {
  const dataFun = path.get("value").node;
  const buildDataDec = template(`const %%id%% = (%%fun%%)()`);
  const dataDec = buildDataDec({
    id: t.identifier(DATA_ID),
    fun: dataFun
  });
  rootPath.insertBefore(dataDec);
  path.node.value = t.identifier(DATA_ID);
};

由于 vue 中 data 是一个方法,在小程序中我在 export default 语句前直接执行了这个方法,拿到返回值并作为 data 属性的值

propsHandler
handlers.propHandler = path => {
  const props = path.get("value").get("properties");
  props.forEach(prop => {
    const propVal = prop.get("value");
    if (t.isObjectExpression(propVal)) {
      const props = propVal.get("properties");
      props.forEach(prop => {
        const keyName = prop.get("key").node.name;
        if (keyName === "default") {
          prop.get("key").node.name = "value";
        }
      });
    }
  });
};

将 vue 中的默认值属性 value 改为小程序中的 default

lifetimesHandler
const lifetimes = {
  created: "created",
  beforeMount: "attached",
  mounted: "ready",
  beforeDestroy: "detached"
};
handlers.lifetimesHandler = path => {
  const lifetimesName = path.node.key.name;
  wxLifetimes = lifetimes[lifetimesName];
  if (wxLifetimes) {
    path.get("key").replaceWith(t.identifier(wxLifetimes));
  }
};

vue 的生命周期于小程序不同,我在转换的时候只处理了部分生命周期,更多的生命周期你可以自己尝试模拟

nameHandler
handlers.nameHandler = path => {
  path.remove();
};

多余的属性直接删除

exportDefaultHanlder

我们在处理完各类属性后要将 vue 的 export default xxx 语句要改成小程序的 Component(xxx):

traverse(ast, {
  ExportDefaultDeclaration(rootPath) {
    let declarationPath = rootPath.get("declaration");
    if (t.isObjectExpression(declarationPath)) {
      ...
    }
    exportDefaultHanlder(rootPath);
  }
});

handlers.exportDefaultHanlder = path => {
  const componentTemplate = template("Component(%%obj%%);");
  let declarationPath = path.get("declaration");
  if (t.isObjectExpression(declarationPath)) {
    const component = componentTemplate({
      obj: declarationPath.node
    });
    path.replaceWith(component);
  }
};
componentsHandler
handlers.componentsHandler = (path, rootPath, result) => {
  const components = path.get("value").get("properties");
  const useComponents = {};
  components.forEach(component => {
    const key = component.node.key;
    const componentName = key.value || key.name;
    const componentPath = component.node.value.name;
    useComponents[componentPath] = componentName;
  });
  result.useComponents = useComponents;
  path.remove();
};

拿到 components 对象里的组件名和组件路径,并保存在 result 的 useComponents 属性中(是以组件路径为键名)

通常,组件路径是在之前就通过 import/require 语句引入的。在执行完这次 ast 树遍历后,result 里保存着我们的 useComponents 信息,这时,我们进行第二次遍历,处理 import 和 require 语句,判断其是否为引入组件,如果是,则删除该 import/requier 语句:

traverse(ast, {
  ImportDeclaration(path) {
    importHandler(path, result);
  },
  VariableDeclaration(path) {
    variableHandler(path, result);
  }
});

handlers.importHandler = (path, result) => {
  let id, importPath;
  path.traverse({
    Identifier(path) {
      id = path.node.name;
    }
  });
  importPath = path.get("source").node.value;
  if (result.useComponents[id]) {
    result.useComponents[id] = importPath;
    path.remove();
  }
};

handlers.variableHandler = (path, result) => {
  const declarations = path.get("declarations.0");
  if (!t.isVariableDeclarator(declarations)) {
    return;
  }
  const init = declarations.get("init");
  if (!t.isCallExpression(init)) {
    return;
  }
  const callee = init.get("callee");
  if (callee.node.name !== "require") {
    return;
  }
  let id, importPath;
  id = declarations.get("id").node.name;
  importPath = init.get("arguments.0").node.value;
  if (result.useComponents[id]) {
    result.useComponents[id] = importPath;
    path.remove();
  }
};

转换结果

上文的样例结果转换,输出结果为下:

const __v2mp__data__ = (function() {
  return {
    name: "bowen"
  };
})();

Component({
  props: {
    propA: Number,
    propB: {
      type: Number,
      value: 100
    }
  },
  data: __v2mp__data__,

  ready() {}
});