13

需求

在最近的开发过程中,不同的项目、不同的页面都需要用到某种UI控件,于是很自然的将这些UI控件拆出来,单独建立了一个代码库进行维护。下面是我的组件库大致的目录结构如下:

...
- lib
    - components
        - componentA
            - index.vue
        - componentB
            - index.vue
        - componentC
            - index.vue
- index.js
...

整个组件库的出口在index.js,里面的内容差不多是下面这样的:

import A from './lib/componentA';
import B from './lib/componentB';
import C from './lib/componentC';

export {
    A,
    B,
    C
}

我的代码库的name为:kb-bi-vue-component。在项目中引用这个组件库的时候,代码如下:

import { A, B } from 'kb-bi-vue-component';
....

这个时候,问题出现了,我在页面中,仅仅使用了AB两个组件,但是页面打包后,整个组件库的代码都会被打进来,增加了产出的体积,包括了不少的冗余代码。很容易想到的一个解决方案是按照以下的方式引用组件。

import A from 'kb-bi-vue-component/lib/componentA';
import B from 'kb-bi-vue-component/lib/componentB';

这种方法虽然解决了问题,我想引用哪个组件,就引用哪个组件,不会有多余的代码。但是我总觉得这种写法看起来不太舒服。有没有还能像第一种写法一样引用组件库,并且只引用需要的组件呢?写一个babel-plugin好了,自动将第一种写法转换成第二种写法。

Babel的原理

本文只是简单介绍。想要深入理解代码编译,请学习<<编译原理>>

这里有一个不错的Babel教程:https://github.com/jamiebuild...

Babel是Javascript编译器,更确切地说是源码到源码的编译器,通常也叫做『转换编译器』。也就是说,你给Babel提供一些Javascript代码,Babel更改这下代码,然后返回给你新生成的代码。

AST

在这整个过程中,都是围绕着抽象语法树(AST)来进行的。在Javascritp中,AST,简单来说,就是一个记录着代码语法结构的Object。比如下面的代码:

function square(n) {
  return n * n;
}

转换成AST后如下,

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

AST是分层的,由一个一个的 节点(Node) 组成。如:

{
  ...
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  ...
}
{
  type: "Identifier",
  name: ...
}

每一个节点都有一个必需的 type 字段表示节点的类型。如上面的FunctionDeclaration

Identifier等等。每种类型的节点都会有自己的属性。

Babel的工作过程

Babel的处理过程主要为3个:解析(parse)转换(transform)生成(generate)

  • 解析

    解析主要包含两个过程:词法分析和语法分析,输入是代码字符串,输出是AST。

  • 转换

    处理AST。处理工具、插件等就是在这个过程中介入,将代码按照需求进行转换。

  • 生成

    遍历AST,输出代码字符串。

解析和生成过程,都有Babel都为我们处理得很好了,我们要做的就是在 转换 过程中搞事情,进行个性化的定制开发。

开发一个babel-plugin

这里有详细的介绍:https://github.com/jamiebuild...

开发方式概述

首先,需要大致了解一下babel-plugin的开发方法。

babel使用一种 访问者模式 来遍历整棵语法树,即遍历进入到每一个Node节点时,可以说我们在「访问」这个节点。访问者就是一个对象,定义了在一个树状结构中获取具体节点的方法。简单来说,我们可以在访问者中,使用Node的type来定义一个hook函数,每一次遍历到对应type的Node时,hook函数就会被触发,我们可以在这个hook函数中,修改、查看、替换、删除这个节点。说起来很抽象,直接看下面的内容吧。

开始开发吧

  • 下面,根据我们的需求,来开发一个plugin。怎么配置使用自己的babel-plugin呢?我的项目中,是使用.babelrc来配置babel的,如下:
{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ]
}

上面的配置中,只有两个预设,并没有使用插件。首先加上插件的配置。由于是在本地开发,插件直接写的本地的相对地址。

{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ],
    "plugins":["./my-import-babel-plugin"]
}

仅仅像上面这样是有问题的,因为需求是需要针对具体的library,所以肯定是需要传入参数的。改成下面这样:

{
    "presets": [
        ["es2015"],
        ["stage-0"]
    ],
    "plugins":[
        ["./my-import-babel-plugin", { "libraryName": "kb-bi-vue-component", "alias": "kb-bi-vue-component/lib/components"}]
    ]
}

我们给plugin传了一个参数,libraryName表示需要处理的library,alias表示组件在组件库内部的路径。

  • 下面是插件的代码./my-import-babel-plugin.js
module.exports = function ({ types: t }) {
    return {
        visitor: {
            ImportDeclaration(path, source){
                const { opts: { libraryName, alias } } = source;
                if (!t.isStringLiteral(path.node.source, { value: libraryName })) {
                    return;
                }
                console.log(path.node);
                // todo
            }
        }    
    }
}

函数的参数为babel对象,对象中的types是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理AST逻辑非常有用。我们单独把这个types拿出来。返回的visitor就是我们上文提到的访问者对象。这次的需求是对 import 语句的修改,所以我们在visitor中定义了import的type:ImportDeclaration。这样,当babel处理到代码里的import语句时,就会走到这个ImportDeclaration函数里面来。

这里查看Babel定义的所有的AST Node: https://github.com/babel/babe...

ImportDeclaration接受两个参数,

  1. path表示当前访问的路径,path.node就能取到当前访问的Node.
  2. source表示PluginPass,即传递给当前plugin的其他信息,包括当前编译的文件、代码字符串以及我们在.babelrc中传入的参数等。

在插件的代码中,我们首先取到了传入插件的参数。接着,判断如果不是我们需要处理的library,就直接返回了

这里可以查看babel.types的使用方法:https://babeljs.io/docs/en/ba...
  • 假设我们的业务代码中的代码如下:
...
import { A, B } from 'kb-bi-vue-component'
...

我们运行一下打包工具,输出一下path.node,可以看到,当前访问的Node如下:

Node {
    type: 'ImportDeclaration',
    start: 9,
    end: 51,
    loc: SourceLocation {
        start: Position {
            line: 10,
            column: 0
        },
        end: Position {
            line: 10,
            column: 42
        }
    },
    specifiers: [Node {
            type: 'ImportSpecifier',
            start: 18,
            end: 19,
            loc: [Object],
            imported: [Object],
            local: [Object]
        },
        Node {
            type: 'ImportSpecifier',
            start: 21,
            end: 22,
            loc: [Object],
            imported: [Object],
            local: [Object]
        }
    ],
    source: Node {
        type: 'StringLiteral',
        start: 30,
        end: 51,
        loc: SourceLocation {
            start: [Object],
            end: [Object]
        },
        extra: {
            rawValue: 'kb-bi-vue-component',
            raw: '\'kb-bi-vue-component\''
        },
        value: 'kb-bi-vue-component'
    }
}

稍微解释一下这个Node. specifiers是一个数组,包含两个Node,对应的是代码import后面的两个参数AB。这两个Node的local值都是Identifier类型的Node。source表示的是代码from后面的library。

  • 接下来,按照需求把这个ImportDeclaration类型的Node替换掉,换成我们想要的。使用path.replaceWithMultiple这个方法来替换一个Node。此方法接受一个Node数组。所以我们首先需要构造出Node,装进一个数组里,然后扔给这个path.replaceWithMultiple方法。

    查阅文档,

    t.importDeclaration(specifiers, source)
    
    specifiers: Array<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier> (required)
    source: StringLiteral (required)

    可以通过t.importDeclaration来构造importNode,参数如上所示。构造importNode,需要先构造其参数需要的Node。最终,修改插件的代码如下:

    module.exports = function ({ types: t }) {
        return {
            visitor: {
                ImportDeclaration(path, source) {
                    const { opts: { libraryName, alias } } = source;
                    if (!t.isStringLiteral(path.node.source, { value: libraryName })) {
                        return;
                    }
                    const newImports = path.node.specifiers.map( item => {
                        return t.importDeclaration([t.importDefaultSpecifier(item.local)], t.stringLiteral(`${alias}/${item.local.name}`))
                    });
                    path.replaceWithMultiple(newImports);
                }
            }
        }
    }

    开发基本结束

    好了,一个babel-plugin开发完成了。我们成功的实现了以下的编译:

    import { A, B } from 'kb-bi-vue-component';
    
    ↓ ↓ ↓ ↓ ↓ ↓
    
    import A from 'kb-bi-vue-component/lib/components/A';
    import B from 'kb-bi-vue-component/lib/components/B';

    babel在工作时,会优先执行.babelrc中的plugins,接着才会执行presets。我们优先将源代码进行了转换,再使用babel去转换为es5的代码,整个过程是没有问题的。

    当然,这是最简单的babel-plugin,还有很多其他情况没有处理,比如下面这种,转换后就不符合预期。

    import { A as aaa, B } from 'kb-bi-vue-component';
    
    ↓ ↓ ↓ ↓ ↓ ↓
    
    import aaa from 'kb-bi-vue-component/lib/components/aaa';
    import B from 'kb-bi-vue-component/lib/components/B';

    要完成一个高质量的babel-plugin,还有很多的工作要做。

    附:阿里已经开源了一个成熟的babel-plugin-import

    参考链接:

    1、https://github.com/jamiebuild...
    2、https://babeljs.io/docs/en/ba...


CoyPan
3.8k 声望3.7k 粉丝

FE