阅读 260

[性能优化] 7个DEMO教你写Babel Import按需加载

前言

这并不是一篇深入babel的文章,相反这是一篇适合初学babel的demos;本demos不会介绍一大堆babel各种牛逼特性(ps:因为这我也不会,有待深入研究),相反这里提供一大堆demos来解释如何从零开启babel plugin之路,然后开发一个乞丐乞丐版BabelPluginImport,并接入webpack中应用

五分钟阅读,五分钟Demo Coding你能学会什么?

  • 编写你的第一个babel plugin
  • 使用babel plugin实现webpack resolve alias功能
  • 实现乞丐乞丐版BabelPluginImport
  • 把自己的插件接入webpack

STEP 1 | 冥想

先来试想下babel的实现,大概分几个步骤:

  1. js文件应该是作为字符串传递给babel
  2. babel对字符串进行解析,出AST
  3. AST应该大概是个json,这时候啥es6转es5啊都发生了,叫做转换
  4. 转换完的AST还得输出为String,这叫生成

STEP 2 | 小试牛刀

编写你的第一个babel plugin

babel的插件开发可以参考 Babel插件手册

先上一个最简单的demo

根据STEP 1的思路

// babel.js

var babel = require('babel-core');

const _code = `a`;

const visitor = {
    Identifier(path){
        console.log(path);
        console.log(path.node.name);
    }
};

babel.transform(_code, {
	plugins: [{
		visitor: visitor
	}]
});

复制代码
看完这个demo是不是有几个问题?
  • 问题1. plugins传入[{ visitor: {} }]格式
  • 问题2. 钩子函数为啥叫Identifier,而不叫Id?name?
  • 问题3. 其实类似问题2,钩子函数怎么定义,如何定义,什么规范?

问题解答

  • 问题1
    这个babel plugin定义要求如此,我们不纠结
  • 问题2
    所谓钩子函数当然是跟生命周期之类的有关了,这里的钩子函数其实是babel的在解析过程中的钩子函数,比如Identifier,当解析到标识符时就会进这个钩子函数
  • 问题3
    钩子函数的定义可以参考babel官网 @babel/types[API],不过需要注意Api的首字母大写,不然会提示你没有此钩子函数

ok,对这个简单的demo没有问题之后来执行下这个demo:node babel.js,输出如下path AST:

// 因为光是一个"a",AST文件也长达284行,所以就不全部放出来了。只放出AST对象下的表示当前Identifier节点数据信息的node来看下

node: Node {
	type: 'Identifier',
	start: 0,
	end: 1,
	loc: SourceLocation {
		start: [Position],
		end: [Position],
		identifierName: 'a'
	},
	name: 'a'
},
复制代码

从这个AST node,对AST有个初步的认识,node节点会存储当前的loc信息,还有标识符的name,这一节小试牛刀的目的就达到了

STEP 3 | 实现resolve alias

前言

经过小试牛刀的阶段,然后自己熟悉下@babel/types的api,熟悉几个api之后就可以进行简单的开发了,这一节要讲的是ImportDeclaration

使用babel plugin实现webpack resolve alias功能

先思考下要实现resolve alias的步骤:

  1. 造数据_code="import homePage from '@/views/homePage';";
  2. 造数据const alias = {'@': './'};
  3. 把'@/views/homePage'变成'./views/homePage'输出

总结好我们要实现的功能,下面用demo来实现一遍

// babel.js

const babel = require('babel-core');
const _code = `import homePage from '@/views/homePage';`;
const alias = {
    '@': './'
};

const visitor = {
    ImportDeclaration(path){
        for(let prop in alias){
            if(alias.hasOwnProperty(prop)){
                let reg = new RegExp(`${prop}/`);
                path.node.source.value = path.node.source.value.replace(reg, alias[prop]);
            }
        }
    }
};

const result = babel.transform(_code, {
	plugins: [{
		visitor: visitor
	}]
});

console.log(result.code);
复制代码

这个demo的主要作用是当进入到ImportDeclaration钩子函数时把path.node.source.value里面的@替换成了./,来node babel.js看下效果:

发现log输出了import homePage from "./views/homePage";
说明我们的alias生效了

STEP 4 | 乞丐乞丐版BabelPluginImport is coming

问题:

还是一样的步骤,先试想下实现一个BabelPluginImport的难点在哪?
复制代码

我在 React性能优化之代码分割 中介绍过BalbelPluginImport,其实这个插件的一个功能是把 import { Button } from 'antd' 转换为 import { Button } from 'antd/lib/button';

-> 我们这个乞丐版BabelPluginImport就简单实现下这个功能

// babel.js

var babel = require('@babel/core');
var types = require('babel-types');
// Babel helper functions for inserting module loads
var healperImport = require("@babel/helper-module-imports");

const _code = `import { Button } from 'antd';`;

const ImportPlugin = {
    // 库名
    libraryName: 'antd',
    // 库所在文件夹
    libraryDirectory: 'lib',
    // 这个队列其实是为了存储待helperModuleImports addNamed的组件的队列,不过remove和import都在ImportDeclaration完成,所以这个队列在这个demo无意义
    toImportQueue: {},
    // 使用helperModuleImports addNamed导入正确路径的组件
    import: function(file){
        for(let prop in this.toImportQueue){
            if(this.toImportQueue.hasOwnProperty(prop)){
                return healperImport.addNamed(file.path, prop, `./main/${this.libraryDirectory}/index.js`);
            }
        }
    }
};

const visitor = {
    ImportDeclaration(path, state) {
        const { node, hub: { file } } = path;
        if (!node) return;
        const { value } = node.source;
        // 判断当前解析到的import source是否是antd,是的话进行替换
        if (value === ImportPlugin.libraryName) {
            node.specifiers.forEach(spec => {
                if (types.isImportSpecifier(spec)) {
                    ImportPlugin.toImportQueue[spec.local.name] = spec.imported.name;
                }
            });
            // path.remove是移除import { Button } from 'antd';
            path.remove();
            // import是往代码中加入import _index from './main/lib/index.js';
            ImportPlugin.import(file);
        }
    }
};

const result = babel.transform(_code, {
	plugins: [
        {
		    visitor: visitor
        },
        // 这里除了自定义的visitor,还加入了第三方的transform-es2015-modules-commonjs来把import转化为require
        "transform-es2015-modules-commonjs"
    ]
});

console.log(result.code);
复制代码

输出结果:

可以发现:
import { Button } from 'antd';
->
"use strict"; var _index = require("./main/lib/index.js");

原代码被转换成了下面的代码

STEP 5 | Demo Coding高光时刻

高光时刻来了,说了这么久理论知识,可以来上手自己写一个了。

5.1 create-react-app先来搭起一个项目

npx create-react-app babel-demo
复制代码

5.2 简单的开发下项目,一个入口组件App.js,一个Button组件

目录结构是:
    src
        - App.js
        - firefly-ui文件夹
            - lib文件夹
                - Button.js
代码很简单,如下:

// App.js
import React from 'react';
import Button from 'firefly-ui';
function App() {
	return (
		<div className="App">
			<Button />
		</div>
	);
}
export default App;

// Button.js
import React, { Component } from 'react';
class Button extends Component{
    render(){
        return <div>我是button啊</div>
    }
}
export default Button;
复制代码

ok,代码写完了,一运行,崩了
这没问题,没崩就奇怪了,因为你没装firefly-ui啊,可是firefly-ui是个啥?
有这个疑问说明你跟上节奏了,我可以告诉你,firefly-ui就是你src目录的firefly-ui目录,那么下面我们就要写一个babel plugin来解决这个问题,大致思路如下:

  • 当解析到import { Button } from 'firefly-ui'时对这个import进行转换
  • 当解析到jsx中Button时用上面转换后的import

那下面从这两个入手写babel import

5.3 npm run eject来eject出webpack配置

好的,为啥要eject出配置,因为你要配置babel-loader的plugins啊大佬。   
ok,来配置一把

// 找到webpack.config.js -> 找到babel-loader -> 找到plugins

// 注意点:
// 在plugins里面加入咱们的import插件
// tips:import插件放在src的兄弟文件夹babel-plugins的import.js
// 所以这里的路径是../babel-plugins/import,因为默认是从node_modules开始

//还有个timestamp,这是因为webpackDevServer的缓存,为了重启清缓存加了时间戳

[
	require.resolve('../babel-plugins/import'),
	{
		libName: 'firefly-ui',
		libDir: 'lib',
		timestamp: +new Date
	},
]
以上是balbel-loader的plugins配置,请看下注意点,其他的没什么难点
复制代码

5.4 import plugin开发

所有配置都完成了,那么还差实现../babel-plugins/import.js

const healperImport = require("@babel/helper-module-imports");

let ImportPlugin = {
    // 从webpack配置进Program钩子函数读取libName和libDir
    libName: '',
    libDir: '',
    // helper-module-imports待引入的组件都放在这里
    toImportQueue: [],
    // helper-module-imports引入过的组件都放在这里
    importedQueue: {},
    // helper-module-imports替换原始import
    import: function(path, file){
        for(let prop in this.toImportQueue){
            if(this.toImportQueue.hasOwnProperty(prop)){
                // return healperImport.addNamed(file.path, prop, `./${this.libName}/${this.libDir}/${prop}.js`);
                let imported = healperImport.addDefault(file.path, `./${this.libName}/${this.libDir}/${prop}.js`);
                this.importedQueue[prop] = imported;
                return imported;
            }
        }
    }
};

module.exports = function ({ types }) {
    return {
        visitor: {
            // Program钩子函数主要接收webpack的配置
            Program: {
                enter(path, { opts = {} }) {
                    ImportPlugin.libName = opts.libName;
                    ImportPlugin.libDir = opts.libDir;
                }
            },
            // ImportDeclaration钩子函数主要处理import之类的源码
            ImportDeclaration: {
                enter(path, state){
                    const { node, hub: { file } } = path;
                    if (!node) return;
                    const { value } = node.source;
            
                    if (value === ImportPlugin.libName) {
                        node.specifiers.forEach(spec => {
                            ImportPlugin.toImportQueue[spec.local.name] = spec.local.name;
                        });
                        path.remove();
                        ImportPlugin.import(path, file);
                    }
                }
            },
            // Identifier主要是为了解析jsx里面的Button,并转换为helper-module-imports引入的新节点
            Identifier(path){
                if(ImportPlugin.importedQueue[path.node.name]){
                    path.replaceWith(ImportPlugin.importedQueue[path.node.name]);
                }
            }
        }
    }
}
复制代码

这个plugin的实现,我探索了几个小时才实现的。 如果只是实现ImportDeclaration钩子函数,而不实现Identifier钩子函数的话,可以发现import的Button已被转换,而jsx里面还是Button。所以会提示Button is not defined。如下图:

好的,按照我的demo完整实现之后,发现import和jsx里全部被转换了。并且程序正常运行。如下图:

到这里差不多就结束了,认真的同学可能还会发现有很多问题没有给出解答,后面有时间再继续写babel,因为感觉这篇文章的知识点对于初学者来说已经挺多了,如果环境搭建有问题,或者自己无法写出plugin示例的效果,可以看我的 babel-demo源码,有问题可以咨询我

关注下面的标签,发现更多相似文章
评论