Taro小程序编译流程浅析

3,402 阅读12分钟

本文基于taro 2.0.3版本,介绍taro工具从类react语法到小程序(微信小程序、头条小程序)的编译流程。

1. Taro和小程序简介

1.1 Taro

Taro 是一套遵循 React 语法规范的 多端开发 解决方案。使用Taro,可以只开发一套代码,通过Taro编译将代码转换成RN、H5、小程序、快应用多端的运行代码。 Taro的运行原理如下图,可以将其解决方案分为两层: - 编译层:通过taro工具将Taro源代码转换成目标代码 - 运行时:目标代码运行时,通过(taro\taro-tt\taro-h5)这些运行时的库去适配不同端。

image.png

对于小程序来说,就是小程序目标代码最终是什么样的,在分析目标代码之前,我们先看下小程序的基本场景。

1.2 小程序

以头条小程序为例,小程序的目录结构如下:主体由app.js、app.json、app.ttss组成,页面有index.js、index.json、index.ttml、index.ttss四个文件组成,小程序与H5的工程结构是不太一样的。

|____app.ttss
|____app.json
|____project.config.json
|____pages
|       |____index
|       |        |____index.js
|       |        |____index.json
|       |        |____index.ttml
|       |        |____index.ttss
|____app.js

小程序提供与H5不一样的“组件系统”,如view、text、image、navigator等,开发者可以使用这些组件开发小程序。 小程序是无法操作DOM、BOM的,它提供不同的生命周期钩子满足DOM事件。通过JS、TTML的分离,实现逻辑层与业务的分离,通过数据驱动视图的形式,来进行页面的渲染。 除此之外,小程序还提供了不同的API,来实现不同的功能。例如可以使用tt.request来实现HTTP请求、tt.uploadFile来实现文件的上传功能。

1.3 Taro 编译时和运行时

基于小程序、Taro多端运行的设计,我们可以将Taro编译时所做的工作如下:

  • 编译时,将项目工程目录转换为小程序模式、进行小程序组件间的适配和转换
  • 运行时,使用Taro、Taro-tt,动态的进行react语法到小程序转换及API的转换

image.png

因此,编译层产生的目标代码,是包括如下三个方面的内容。

  • 符合小程序工程要求的项目结构
  • 编译部分React语法,例如map、ref等
  • 运行时转换,包括Taro基础库、小程序生命周期、API相关的挂载和转换、类react语法的处理等

1.4 Taro库功能

Taro包功能如下表所示,本文对taro转换小程序的编译进行分析,分析@tarojs/mini-runer、@tarojs/transformer-wx、@tarojs/components这三个包如何实现从taro的类react语法转换成符合小程序语法的过程。

image.png

2. Webpack和Babel简介

Taro小程序编译主要是基于Webpack、Babel的工具,接下来简单介绍以下Webpack、Babel。

2.1 Webpack简介

webpack通过模块打包的形式,递归地构建一个依赖关系图(dependency graph),然后将所有这些模块打包成一个或多个 bundle。在分析Taro编译之前,我们先看下webpack的编译过程及Hooks的功能。

2.1.1 Webpack编译过程

如下图,为webpack的编译过程:

  • step1: 解析webpack配置
  • step2: 读取module文件(首次读取entry文件)
  • step3: 使用文件对应的loader处理,得到结果代码,如less-loader、babel-loader、url-loader等。
  • step4: 使用acorn转换得到AST
  • step5: 搜集require、import、export语法依赖module,存入Module图数据结构
  • step6: 若依赖的module还有未处理的,返回step3,否则执行step7
  • step7: 依据3-6步骤的处理,生成Module图数据结构
  • step8: 根据拆包规则,module转化为chunk图
  • step9: 将最终的结果生成结果文件

image.png

2.1.2 Webpack compiler hooks简介

Compiler是Webpack的核心模块,通过CLI、配置、API的选项,创建一个compilation实例。Compiler 提供不同的生命周期的钩子,供插件调用,实际上,你可以通过Compiler提供的钩子在编译的任意阶段去处理compilation实例。 @taro/mini-runner中的mini-plugin插件使用钩子完成工程目录转换的功能。例如mini-plugin使用的钩子run, 它在创建compilation实例前被调用,这个时间你还可以改变Webpack的配置选项,比如添加entry、添加plugin等。

2.2 Babel

Babel可以将ECMAScript 2015+ 版本的代码转换为向后兼容的 JavaScript 语法,它的实际流程是如下图(Babel 编译到打包):

image.png

Babel从ES6到ES5的转换有三步:

  • Code转换AST:使用babel-core transform, 将源代码转换成AST(抽象语法树),关于AST,可以看编译原理相关介绍。

  • AST增删改:使用babel-traverse traverse,可以增加、删除、修改AST的节点、属性,是核心的转换流程。关于节点,可以查这里的介绍。

  • 生成新的Code:使用babel-generator generate生成新的代码,完成最终的转换。

image.png

从babel的转换过程,我们可以看到,bable通过生成AST,配合定义的节点,进行增删改的操作,最终完成代码的转换。Taro小程序就是使用这套流程,完成类react语法到小程序代码的转换的。

3. Taro小程序编译流程

Taro小程序编译时,使用如下命令"taro build --type tt\taro build --type tt --watch",即可完成Taro到小程序的转换。依据taro-cli,小程序的转换流程分为如下步骤:

  • 生成小程序的webpack配置。基于type类型,生成对应的webpack配置。
  • webpack构建小程序代码。基于webpack配置,结合自定义的mini-plugin插件、自定义的babel转换,实现类react语法到小程序的转换。

3.1 生成小程序的webpack配置

taro-cli根据构建初始化的参数和工程下的config/index.js, 生成相关的webpack配置,从而在编译时,构建出H5、TT、Weixin、QuickAPP等多种类型的目标代码。 如下图,为某次TT小程序构建产生的webpack配置(对部分内容做了更改,但不影响基础功能)

{
    "mode": "development",
    "devtool": "none",
    "watch": true,
    "entry":{
        "app":[
            "./src/app.jsx"
        ]
    },
    "output":{
        "path": "dist",
        "publicPath": "/",
        "filename": "[name].js",
        "chunkFilename": "[name].js",
        "globalObject": "tt"
    },
    "module":{
        "rules": [{
            "test": /\.(s[ac]ss)$/,
            "use":[
                    {
                        "loader": "sass-loader",
                        ......
                    }
            ]
        }, {
            "test": /\.less$/,
            "use":[
                    {
                        "loader": "less-loader",
                        ......
                    }
            ]
        }, {
            "test": /\.styl$/,
            "use":[
                    {
                        "loader": "stylus-loader",
                        ......
                    }
            ]
        
        }, {
            "test": /\.(css|scss|sass|less|styl|wxss|acss)(\?.*)?$/,
            "oneOf": [
              {
                "use": [{
                    "loader": "css-loader",
                    ...
                }]
              },
              ...
            ]
        }, {
            "test": /\.(css|scss|sass|less|styl|wxss|acss)(\?.*)?$/,
            "oneOf": [
              {
                "use": [{
                    "loader": "postcss-loader",
                    ...
                }]
              },
              ...
            ]
         }, {
            "test": /\.(css|scss|sass|less|styl|wxss|acss)(\?.*)?$/,
            "use": {
                "loader": "mini-css-extract-plugin",
                ...
            }
        }, {
            "test": /\.[tj]sx?$/i,
            "use": [{
                "loader": "@bytaro-mini-runner/dist/loaders/fileParseLoader.js"",
                ...
            }, {
               
                 "loader": "@bytaro-mini-runner/dist/loaders/wxTransformerLoader.js"",
                ...
            }]
        }, {
            "test": /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
            "use": [{
                "loader": "url-loader",
                ...
            }]
        }, {
            "test": /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
            "use": [{
                "loader": "url-loader",
                ...
            }]
        
        }, {
            "test": /\.(png|jpe?g|gif|bpm|svg|webp)(\?.*)?$/
            "use": [{
                "loader": "url-loader",
                ...
            }]
        }]
    },
    "optimization":{
        "minimizer":[
        ],
        "runtimeChunk":{
            "name":"runtime"
        },
        "splitChunks":{
            "chunks":"all",
            "maxInitialRequests":null,
            "minSize":0,
            "name":"vendors",
            "cacheGroups":{
                "vendors":{

                }
            }
        }
    },
    "plugins":[
        {
            "__pluginName": "definePlugin"
            ......
        },
        {
            "__pluginName": "miniPlugin"
            ......
        },
        {
            "__pluginName": "MiniCssExtractPlugin"
            ......
        }
    ],
    "resolve":{
        "symlinks":true,
        "extensions":[
            ".js",
            ".jsx",
            ".ts",
            ".tsx"
        ],
        "mainFields":[
            "browser",
            "module",
            "main"
        ],
        "modules":[
            "/Users/nihao/work/taro/ttMiniApp/node_modules",
            "node_modules"
        ]
    },
    "resolveLoader":{
        "modules":[
            "node_modules"
        ]
    },
}

依据webpack的配置,我们可以得到loader如下表:

  • jsx/tsx:使用自定义的loader,实现语法的转换。
  • css|scss|sass|less|styl|wxss|acss:使用常用的样式loader和mini-css-extract-plugin,合成一个小程序样式文件。
  • mp4|webm|ogg|mp3|wav|flac|aac、woff2?|eot|ttf|otf、png|jpe?g|gif|bpm|svg|webp:使用url-loader,进行静态资源文件的处理。

image.png

除此之外,我们可以看到,taro小程序编译使用了definePlugin、miniPlugin、MiniCssExtractPlugin三个插件,其中miniPlugin为taro自定义的插件。 基于以上的分析,taro小程序编译的核心在于tsx/jsx的编译,基于此,接下来我们将分析如下模块:

  • @tarojs/transformer-wx: 较独立的模块,实现code到微信小程序的转换。
  • @tarojs/mini-runner: webpack流程模块,整体小程序的编译。

3.2 taro-transformer-wx

taro-transformer-wx的核心功能如下图:

  • 输入:code(源代码内容)、adapter(类型,头条小程序tt)、sourcePath(源文件绝对地址)
  • 输出:ast(抽象语法树)、code(生成新代码)、template(模版内容)

image.png

我们看一次taro-transformer-wx将taro代码转成的形式,它做了以下的功能:

  • 将jsx/tsx中的js、html分离

  • 引入taro核心方法,为组件添加一些运行时的属性

  • JSX转换,View/Text到view/text的转换、添加根结点、事件的转换等。

  • 除此之外,还有components的计算、模版的压缩等,限于篇幅,这里就不介绍了。

image.png

taro-transformer-wx的核心转换流程如下图(去除了QuickApp、TSX相关部分,不影响主流程):

  • AST转换:babel-core tranformer转换得到AST
  • AST增删改: transverse 增删改ast节点
  • 设置属性,处理兼容性问题
  • 模版处理:使用class.js的transformer转换得到小程序模版
  • 生成code:使用generaror生成新的code
  • 设置结果:code、ast、template、components等。

image.png

我们看一个关于AST ImportDeclaration处理的一小段代码:主要是引入了@tarojs/taro 的一些辅助函数、将import { component } from @tarojs/taro 替换为import { __BaseComponent } from '@tarojs/taro'。

ImportDeclaration (path) {
  const source = path.node.source.value
  if (importSources.has(source)) {
    throw codeFrameError(path.node, '无法在同一文件重复 import 相同的包。')
  } else {
    importSources.add(source)
  }
  const names: string[] = []
  if (source === TARO_PACKAGE_NAME) {
    isImportTaro = true
   /**
    * 如果文件中有import xx from '@tarojs/taro'
    * 会自动帮你多导入一些辅助函数
    * import xx, {
    *  internal_safe_get,
    *  internal_get_orignal,
    *  internal_inline_style,
    *  .....................
    * } from '@tarojs/taro'
    * 
    */
    path.node.specifiers.push(
      t.importSpecifier(t.identifier(INTERNAL_SAFE_GET), t.identifier(INTERNAL_SAFE_GET)),
      t.importSpecifier(t.identifier(INTERNAL_GET_ORIGNAL), t.identifier(INTERNAL_GET_ORIGNAL)),
      t.importSpecifier(t.identifier(INTERNAL_INLINE_STYLE), t.identifier(INTERNAL_INLINE_STYLE)),
      t.importSpecifier(t.identifier(HANDLE_LOOP_REF), t.identifier(HANDLE_LOOP_REF)),
      t.importSpecifier(t.identifier(GEN_COMP_ID), t.identifier(GEN_COMP_ID)),
      t.importSpecifier(t.identifier(GEN_LOOP_COMPID), t.identifier(GEN_LOOP_COMPID))
    )
    if (Adapter.type !== Adapters.alipay) {
      path.node.specifiers.push(
        t.importSpecifier(t.identifier(PROPS_MANAGER), t.identifier(PROPS_MANAGER))
      )
    }
  }
  ....
  /**
  * 1.遍历当前import语句收集所有导入的变量名
  * 2.将 import { Component } from '@tarojs/taro'
  * 替换成 import { __BaseComponent } from '@tarojs/taro'
  */
  path.traverse({
    ImportDefaultSpecifier (path) {
      const name = path.node.local.name
      names.push(name)
    },
    ImportSpecifier (path) {
      const name = path.node.imported.name
      names.push(name)
      if (source === TARO_PACKAGE_NAME && name === 'Component') {
        path.node.local = t.identifier('__BaseComponent')
      }
    }
  })
  componentSourceMap.set(source, names)
}

更多关于taro-transformer-wx的功能和介绍,大家可以看juejin.cn/post/684490…

3.3 webpack编译流程

taro/mini-runner通过自定义plugin、loader,结合taro-transformer-wx包,实现taro到小程序的转换。mini-plugin插件,通过定义webpack运行时的钩子,在编译前、输入前、输出后等多个阶段更改compilation(编译过程实例),完成taro编译功能。fileParseLoader/wxTransformerLoader,自定义tsx/jsx文件的loader,完成tsx/jsx小程序语法的转换。 mini-plugin插件使用注册了如下钩子,其具体功能如下表(更多的功能和钩子见webpack.docschina.org/api/compile…

image.png

taro正是使用webpack的hooks完成小程序工程目录转换、入口文件内容映射的功能。结合2.1.1中的正常的webpack构建流程,我们可以得到taro小程序的webpack构建流程:

image.png

3.3.1 compiler.hooks.run

compiler.hooks.run钩子在compilation实例化之前调用,在这个阶段,我们可以更改webpack的配置项目。taro在这里将小程序的page、component都加到了webpack的配置项entry。compiler.hooks. run实现的具体功能如下表:

image.png

获取Pages/Components

getPages从app.tsx/app.jsx获取页面文件,保存在taroFileTypeMap里。getComponents从页面文件获取compoents,递归小程序的组件。以Pages为例,我们看它的基本流程如下:

image.png

设置webpack Entry选项 小程序目录的结构形式, 需要webpack把页面、组件都加入到webpack的entry中,这样才能保证输出小程序的工程目录。taro使用compiler.hooks.make钩子,调用compilation编译实例addEntry,完成入口文件的添加。

// compilation.addEntry将页面、组件入口加到webpack entry中。
addEntry (compiler: webpack.Compiler, entryPath, entryName, entryType) { 
    compiler.hooks.make.tapAsync(PLUGIN_NAME, (compilation: webpack.compilation.Compilation, callback) => {
      const dep = new TaroSingleEntryDependency(entryPath, entryName, { name: entryName }, entryType)
      compilation.addEntry(this.sourceDir, dep, entryName, callback)
    })
  }

如example工程项目里,其工程结构在run执行之后的entry如下图: image.png

入口文件内容映射

在获取Pages/Components时,我们调用taro-transformer-wx对文件做了一次编译,taro为了复用这次转换,使用VirtualModulePlugin插件对入口文件做了映射,使loader读取的内容是编译后的新代码。

Taro通过使用compiler.hooks.normalModuleFactory的beforeResolve钩子实现内容映射的功能,具体的实现可以参考VirtualModulePlugin.ts。

...
compiler.hooks.normalModuleFactory.tap('VirtualModulePlugin', (nmf) => {
nmf.hooks.beforeResolve.tap('VirtualModulePlugin', resolverPlugin)
})
....

3.3.2 compiler.compilation实例化

实例化webpack的配置参数项,得到编译实例compilation。

3.3.3 compiler.hooks.compilation

将SingleEntryDependency、TaroSingleEntryDependency的工厂对象设置为normalModuleFactory ,在使用create dependency的时候,使用的工厂类为normalModuleFactory。

compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation, { normalModuleFactory }) => {
 compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory)
 compilation.dependencyFactories.set(TaroSingleEntryDependency, normalModuleFactory)
})

3.3.4 webpack正常构建流程

Taro在完成入口文件的添加、入口文件内容映射后,接下来就是正常的webpack构建流程。对应2.1.1中,就是步骤3-9,module图的解析和依赖的解析。样式(less/scss/stylus)、资源文件(字体、图片、视频)使用的都是我们熟悉的loader,这里就不在过多解释。taro的核心就是自定义loader实现tsx/jsx文件的解析。其loader处理流程有如下两步:

  • WxTranformerLoader
  • FileParseLoader

3.3.4.1 WxTranformerLoader

WxTranformerLoader的功能很简单,使用taro-transform-wx normal模式,编译3.3.1传递的code,得到新的code和ast。

image.png

3.3.4.2 fileParseLoader

fileParseLoader的流程如下图,经过fileParseLoader后,我们就得到了最终的小程序js代码。

image.png

3.2.5 compiler.hooks.emit

在编译目录输出之前,此时的webpack输出文件中是没有json、ttml、style、icon等文件的,我们需要在3.2.1中的template、json、style、icon添加到webpack的输出项上,这样才能生成最终的小程序工程。 编译实例compilation.assets对应output要输出的内容。我们可以通过删、增加item的形式,实现小程序的工程目录转换,其具体的流程如下:

image.png

以下截图为Taro Example 的compilation.assets对象,每个页面、组件都生成对应的四个文件(js、ttss、ttml、json)。

image.png

3.2.6 资源输出到output定义目录

将compilation.assets的内容输出到output目录。下图为Taro Example输出结果。

image.png

3.2.7 compiler.hooks.afterEmit

fileDependencies为webpack watch的依赖文件数组,文件变更时,触发webpack的watch流程。afterEmit钩子主要是将tabBar的Icons加到fileDependencies中,这样icon的变化webpack也能检测到。

addTarBarFilesToDependencies(compilation) {
    const { fileDependencies } = compilation;
    this.tabBarIcons.forEach(icon => {
        if (!fileDependencies.has(icon)) {
            fileDependencies.add(icon);
        }
    });

4. 总结

本文简单介绍了taro小程序的编译流程,taro使用babel编译、webpack构建,完成编译时的代码转换,其在编译期主要是做了如下的工作:

  • taro-transformer-wx, 完成到微信小程序的ast、template转换。
  • taro-components, 完成组件的适配。
  • taro-mini-runner
    • mini-plugin插件,定义不同的钩子,完成源文件的处理、工程目录的切换。
    • 自定义loader,tsx/jsx的编译流程,类react代码转化为小程序代码。

编译时方案相对于运行时方案,其优势在于性能,劣势在于技术方案的方案完整性,由于JSX和TTML的功能差异,编译时方案的问题都是通过CaseByCase的形式去解决问题,导致技术方案的难维护性。我们可以看到taro-transformer-wx包的代码缺少一定的设计,阅读起来也比较痛苦,这其实都是为了抹平两者差异而做的各种兼容处理。

5. 参考技术文档