原来rollup这么简单之 tree shaking篇

作者 : 开心源码 本文共11515个字,预计阅读时间需要29分钟 发布时间: 2022-05-13 共174人阅读

大家好,我是小雨小雨,致力于分享有趣的、实用的技术文章。
内容分为翻译和原创,假如有问题,欢迎随时评论或者私信,希望和大家一起进步。
分享不易,希望能够得到大家的支持和关注。

计划

rollup系列打算一章一章的放出,内容更精简更专一更易于了解

目前打算分为以下几章:

  • rollup.rollup
  • rollup.generate + rollup.write
  • rollup.watch
  • tree shaking <==== 当前文章
  • plugins

TL;DR

es node: 各种语法块的类,比方if块,箭头函数块,函数调用块等等

rollup()阶段,分析源码,生成ast tree,对ast tree上的每个节点进行遍历,判断出能否include,是的话标记,而后生成chunks,最后导出。
generate()或者者write()阶段根据rollup()阶段做的标记,进行代码收集,最后生成真正用到的代码,这就是tree shaking的基本原理。

一句话就是,根据side effects的定义,设定es node的include 与 不同的es node生成不同的渲染(引入到magic string实例)函数,有magic string进行收集,最后写入。

本文没有具体分析各es node渲染方法和include设定的具体实现,不过有问题欢迎探讨,拍砖~

注意点

!!!版本 => 笔者阅读的rollup版本为: 1.32.0

!!!提醒 => 标有TODO为具体实现细节,会视情况分析。

!!!注意 => 每一个子标题都是父标题(函数)内部实现

!!!强调 => rollup中模块(文件)的id就是文件地址,所以相似resolveID这种就是解析文件地址的意思,我们可以返回我们想返回的文件id(也就是地址,相对路径、决定路径)来让rollup加载

rollup是一个核心,只做最基础的事情,比方提供默认模块(文件)加载机制, 比方打包成不同风格的内容,我们的插件中提供了加载文件路径,解析文件内容(解决ts,sass等)等操作,是一种插拔式的设计,和webpack相似
插拔式是一种非常灵活且可长期迭代升级的设计,这也是一个中大型框架的核心,人多力量大嘛~

主要通用模块以及含义

  1. Graph: 全局唯一的图,包含入口以及各种依赖的相互关系,操作方法,缓存等。是rollup的核心
  2. PathTracker: 引用(调用)追踪器
  3. PluginDriver: 插件驱动器,调用插件和提供插件环境上下文等
  4. FileEmitter: 资源操作器
  5. GlobalScope: 全局作用局,相对的还有局部的
  6. ModuleLoader: 模块加载器
  7. NodeBase: ast各语法(ArrayExpression、AwaitExpression等)的构造基类

流程解析

这次就不全流程解析了,咱们举个最简单的例子分析一下,更有助于了解。

比方我们有这么一段简单的代码:

function test() {    var name = 'test';    console.log(123);}const name = '测试测试';function fn() {    console.log(name);}fn();

如你所见,打包的结果应该是不包含test函数的,像下面这样:

'use strict';const name = '测试测试';function fn() {    console.log(name);}fn();

那rollup是怎样解决这段代码的呢?

  • 模块解析

还得回到了rollup()流程,根据例子,我们可以把对importexportre-export等相关的都干掉,暂时不需要关注,理解最基本的流程后会水到渠成的。
关于插件,我也不会使用任何插件,只使用rollup内置的默认插件。

对于这个例子来说,首先会根据解析文件地址,获取文件真正的路径:

function createResolveId(preserveSymlinks: boolean) {    return function(source: string, importer: string) {        if (importer !== undefined && !isAbsolute(source) && source[0] !== '.') return null;        // 最终调用path.resolve,将合法路径片段转为绝对路径        return addJsExtensionIfNecessary(            resolve(importer ? dirname(importer) : resolve(), source),            preserveSymlinks        );    };}

而后创立rollup模块,设置缓存等:

const module: Module = new Module(    this.graph,    id,    moduleSideEffects,    syntheticNamedExports,    isEntry);

之后通过内置的load钩子获取文件内容,当然咱也可以自己设置该行为:

// 第二个参数是 传给load钩子函数的 参数,内部使用的applyreturn Promise.resolve(this.pluginDriver.hookFirst('load', [id]))

之后经过transform(transform这里可以了解为webpack的各种loader,解决不同类型文件的)解决生成一段标准化的结构:

const source = { ast: undefined,    code:     'function test() {\n    var name = \'test\';\n    console.log(123);\n}\nconst name = \'测试测试\';\nfunction fn() {\n    console.log(name);\n}\n\nfn();\n',    customTransformCache: false,    moduleSideEffects: null,    originalCode:     'function test() {\n    var name = \'test\';\n    console.log(123);\n}\nconst name = \'测试测试\';\nfunction fn() {\n    console.log(name);\n}\n\nfn();\n',    originalSourcemap: null,    sourcemapChain: [],    syntheticNamedExports: null,    transformDependencies: [] }

而后就到了比较关键的一步,将source解析并设置到当前module上:

// 生成 es tree astthis.esTreeAst = ast || tryParse(this, this.graph.acornParser, this.graph.acornOptions);// 调用 magic string,超方便操作字符串的工具库this.magicString = new MagicString(code, options).// 搞一个ast上下文环境,包装少量方法,比方动态导入、导出等等吧,之后会将分析到的模块或者内容填充到当前module中,bind的this指向当前modulethis.astContext = {    addDynamicImport: this.addDynamicImport.bind(this), // 动态导入    addExport: this.addExport.bind(this), // 导出    addImport: this.addImport.bind(this), // 导入    addImportMeta: this.addImportMeta.bind(this), // importmeta    annotations: (this.graph.treeshakingOptions && this.graph.treeshakingOptions.annotations)!,    code, // Only needed for debugging    deoptimizationTracker: this.graph.deoptimizationTracker,    error: this.error.bind(this),    fileName, // Needed for warnings    getExports: this.getExports.bind(this),    getModuleExecIndex: () => this.execIndex,    getModuleName: this.basename.bind(this),    getReexports: this.getReexports.bind(this),    importDescriptions: this.importDescriptions,    includeDynamicImport: this.includeDynamicImport.bind(this),    includeVariable: this.includeVariable.bind(this),    isCrossChunkImport: importDescription =>        (importDescription.module as Module).chunk !== this.chunk,    magicString: this.magicString,    module: this,    moduleContext: this.context,    nodeConstructors,    preserveModules: this.graph.preserveModules,    propertyReadSideEffects: (!this.graph.treeshakingOptions ||        this.graph.treeshakingOptions.propertyReadSideEffects)!,    traceExport: this.getVariableForExportName.bind(this),    traceVariable: this.traceVariable.bind(this),    treeshake: !!this.graph.treeshakingOptions,    tryCatchDeoptimization: (!this.graph.treeshakingOptions ||        this.graph.treeshakingOptions.tryCatchDeoptimization)!,    unknownGlobalSideEffects: (!this.graph.treeshakingOptions ||        this.graph.treeshakingOptions.unknownGlobalSideEffects)!,    usesTopLevelAwait: false,    warn: this.warn.bind(this),    warnDeprecation: this.graph.warnDeprecation.bind(this.graph)};// 实例化Program,将结果赋给当前模块的ast属性上以供后续使用// !!! 注意实例中有一个included属性,用于能否打包到最终输出文件中,也就是tree shaking。默认为false。!!!this.ast = new Program(    this.esTreeAst,    { type: 'Module', context: this.astContext }, // astContext里包含了当前module和当前module的相关信息,使用bind绑定当前上下文    this.scope);// Program内部会将各种不同类型的 estree node type 的实例增加到实例上,以供后续遍历使用// 不同的node type继承同一个NodeBase父类,比方箭头函数表达式(ArrayExpression类),详见[nodes目录]( FoxDaxian/rollup-analysis/tree/master/src/ast/nodes)function parseNode(esTreeNode: GenericEsTreeNode) {        // 就是遍历,而后new nodeType,而后挂载到实例上    for (const key of Object.keys(esTreeNode)) {        // That way, we can override this function to add custom initialisation and then call super.parseNode        // this 指向 Program构造类,通过new创立的        // 假如program上有的话,那么跳过        if (this.hasOwnProperty(key)) continue;        // ast tree上的每一个属性        const value = esTreeNode[key];        // 不等于对象或者者null或者者key是annotations        // annotations是type        if (typeof value !== 'object' || value === null || key === 'annotations') {            (this as GenericEsTreeNode)[key] = value;        } else if (Array.isArray(value)) {            // 假如是数组,那么创立数组并遍历上去            (this as GenericEsTreeNode)[key] = [];            // this.context.nodeConstructors 针对不同的语法书类型,进行不同的操作,比方挂载依赖等等            for (const child of value) {                // 循环而后各种new 各种类型的node,都是继成的NodeBase                (this as GenericEsTreeNode)[key].push(                    child === null                        ? null                        : new (this.context.nodeConstructors[child.type] ||                                this.context.nodeConstructors.UnknownNode)(child, this, this.scope) // 解决各种ast类型                );            }        } else {            // 以上都不是的情况下,直接new            (this as GenericEsTreeNode)[key] = new (this.context.nodeConstructors[value.type] ||                this.context.nodeConstructors.UnknownNode)(value, this, this.scope);        }    }}

后面解决相关依赖模块,直接跳过咯~

return this.fetchAllDependencies(module).then();

到目前为止,我们将文件转换成了模块,并解析出 es tree node 以及其内部包含的各类型的语法树

  • 使用PathTracker追踪上下文关系
for (const module of this.modules) {    // 每个一个节点自己的实现,不是全都有    module.bindReferences();}

比方我们有箭头函数,因为没有this指向所以默认设置UNKONW

// ArrayExpression类,继承与NodeBasebind() {    super.bind();    for (const element of this.elements) {        if (element !== null) element.deoptimizePath(UNKNOWN_PATH);    }}

假如有外包裹函数,就会加深一层path,最后会根据层级关系,进行代码的wrap

  • 标记模块能否可shaking

其中核心为根据isExecuted的状态进行模块以及es tree node的引入,再次之前我们要知道includeMarked方式是获取入口之后调用的。
也就是所有的入口模块(客户定义的、动态引入、入口文件依赖、入口文件依赖的依赖..)都会module.isExecuted为true
之后才会调用下面的includeMarked方法,这时候module.isExecuted已经为true,就可调用include方法

function includeMarked(modules: Module[]) {    // 假如有treeshaking不为空    if (this.treeshakingOptions) {        // 第一个tree shaking        let treeshakingPass = 1;        do {            timeStart(`treeshaking pass ${treeshakingPass}`, 3);            this.needsTreeshakingPass = false;            for (const module of modules) {                // 给ast node标记上include                if (module.isExecuted) module.include();            }            timeEnd(`treeshaking pass ${treeshakingPass++}`, 3);        } while (this.needsTreeshakingPass);    } else {        // Necessary to properly replace namespace imports        for (const module of modules) module.includeAllInBundle();    }}// 上面module.include()的实现。include(context: InclusionContext, includeChildrenRecursively: IncludeChildren) {    // 将当然程序块的included设为true,再去遍历当前程序块中的所有es node,根据不同条件进行include的设定    this.included = true;    for (const node of this.body) {        if (includeChildrenRecursively || node.shouldBeIncluded(context)) {            node.include(context, includeChildrenRecursively);        }    }}

module.include内部就涉及到es tree node了,因为NodeBase初始include为false,所以还有第二个判断条件:当前node能否有反作用side effects。
这个能否有反作用是继承与NodeBase的各类node子类自身的实现。目前就我看来,反作用也是有自身的协议规定的,比方修改了全局变量这类就算是反作用,当然也有些是一定无反作用的,比方export语句,rollup中就写死为false了。
rollup内部不同类型的es node 实现了不同的hasEffects实现,可自身观摩学习。可以通过该篇文章,简单理解少量 side effects。

  • chunks的生成

后面就是通过模块,生成chunks,当然其中还包含多chunk,少chunks等配置选项的区别,这里不再赘述,有兴趣的朋友可以参考本系列第一篇文章或者者直接查看带注释的源码

  • 通过chunks生成代码(字符串)

调用rollup方法后,会返回一个对象,其中包括了代码生成和写入操作的write方法(已去掉少量warn等):

return {    write: ((rawOutputOptions: GenericConfigObject) => {        const { outputOptions, outputPluginDriver } = getOutputOptionsAndPluginDriver(            rawOutputOptions        );        // 这里是关键        return generate(outputOptions, true, outputPluginDriver).then(async bundle => {            await Promise.all(                Object.keys(bundle).map(chunkId =>                    writeOutputFile(result, bundle[chunkId], outputOptions, outputPluginDriver) // => 写入操作                )            );            // 修改生成后的代码            await outputPluginDriver.hookParallel('writeBundle', [bundle]);            // 目前看来是供之后缓存用,提高构建速度            return createOutput(bundle);        });    }) as any}

generate是就相对简单些了,就是少量钩子和方法的调用,比方:

preRender方法将es node渲染为字符串,调用的是各es node自身实现的render方法,具体参考代码哈。规则很多,这里不赘述,我也没细看~~

那哪些需要渲染,哪些不需要渲染呢?

没错,就用到了之前定义的include字段,做了一个简单的判断,比方:

// label node类中function render(code: MagicString, options: RenderOptions) {    // 诺~    if (this.label.included) {        this.label.render(code, options);    } else {        code.remove(            this.start,            findFirstOccurrenceOutsideComment(code.original, ':', this.label.end) + 1        );    }    this.body.render(code, options);}

之后增加到chunks中,这样chunks中不仅有ast,还有生成后的可执行代码。

之后根据format字段获取不同的wrapper,对代码字符串进行解决,而后传递给renderChunk方法,该方法主要为了调用renderChunktransformChunktransformBundle三个钩子函数,对结果进行进一步解决。不过因为我分析的版本不是最新的,所以会与当前2.x有出入,改动详见changlog

对了,还有sourceMap,这个能力是magic string提供的,可自行查阅

这样我们就得到了最终想要的结果:

chunks.map(chunk => {    // 通过id获取之前设置到outputBundleWithPlaceholders上的少量属性    const outputChunk = outputBundleWithPlaceholders[chunk.id!] as OutputChunk;    return chunk        .render(outputOptions, addons, outputChunk, outputPluginDriver)        .then(rendered => {            // 引用类型,outputBundleWithPlaceholders上的也变化了,所以outputBundle也变化了,最后返回outputBundle            // 在这里给outputBundle挂载上了code和map,后面直接返回 outputBundle 了            outputChunk.code = rendered.code;            outputChunk.map = rendered.map;            // 调用生成的钩子函数            return outputPluginDriver.hookParallel('ongenerate', [                { bundle: outputChunk, ...outputOptions },                outputChunk            ]);        });})

上面函数解决的是引用类型,所以最后可以直接返回结果。不在赘述。

  • 文件写入

这部分没啥好说的,大家自己看下下面的代码吧。其中writeFile方法调用的node fs模块提供的能力。

function writeOutputFile(    build: RollupBuild,    outputFile: OutputAsset | OutputChunk,    outputOptions: OutputOptions,    outputPluginDriver: PluginDriver): Promise<void> {    const fileName = resolve(outputOptions.dir || dirname(outputOptions.file!), outputFile.fileName);    let writeSourceMapPromise: Promise<void>;    let source: string | Buffer;    if (outputFile.type === 'asset') {        source = outputFile.source;    } else {        source = outputFile.code;        if (outputOptions.sourcemap && outputFile.map) {            let url: string;            if (outputOptions.sourcemap === 'inline') {                url = outputFile.map.toUrl();            } else {                url = `${basename(outputFile.fileName)}.map`;                writeSourceMapPromise = writeFile(`${fileName}.map`, outputFile.map.toString());            }            if (outputOptions.sourcemap !== 'hidden') {                source += `//# ${SOURCEMAPPING_URL}=${url}\n`;            }        }    }    return writeFile(fileName, source)        .then(() => writeSourceMapPromise)        .then(            (): any =>                outputFile.type === 'chunk' &&                outputPluginDriver.hookSeq('onwrite', [                    {                        bundle: build,                        ...outputOptions                    },                    outputFile                ])        )        .then(() => {});}
  • 其余
    对于importexportre-export这类ast node,rollup会解析生成的ast tree,获取其中的value,也就是模块名,组合有用的信息备用。而后就和上述流程相似了。

推荐使用ast explorer解析一段code,而后看看里面的结构,理解后会更容易了解。

function addImport(node: ImportDeclaration) {    // 比方引入了path模块    // source: {    //  type: 'Literal',    //  start: 'xx',    //  end: 'xx',    //  value: 'path',    //  raw: '"path"'    // }    const source = node.source.value;    this.sources.add(source);    for (const specifier of node.specifiers) {        const localName = specifier.local.name;        // 重复引入了        if (this.importDescriptions[localName]) {            return this.error(                {                    code: 'DUPLICATE_IMPORT',                    message: `Duplicated import '${localName}'`                },                specifier.start            );        }        const isDefault = specifier.type === NodeType.ImportDefaultSpecifier;        const isNamespace = specifier.type === NodeType.ImportNamespaceSpecifier;        const name = isDefault            ? 'default'            : isNamespace            ? '*'            : (specifier as ImportSpecifier).imported.name;        // 导入的模块的相关形容        this.importDescriptions[localName] = {            module: null as any, // filled in later            name,            source,            start: specifier.start        };    }}

总结

感觉这次写的不好,看下来可能会觉得只是标记与收集的这么一个过程,但是其内部细节是非常复杂的。以至于你需要深入理解side effects的定义与影响。日后也许会专门整理一下。

rollup系列也快接近尾声了,尽管一直在自嗨,但是也蛮爽的。

学习使我快乐,哈哈~~

image

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » 原来rollup这么简单之 tree shaking篇

发表回复