左琳,微医前端技术部前端开发工程师。身处互联网浪潮之中,热爱生活与技术。

Tip:本文所用 rollup 打包工具版本为 rollup v2.47.0。

从 Webpack2.x 通过插件逐步实现 tree-shaking,到最近炙手可热的 Vite 构建工具也借助了 rollup 的打包能力,众所周知 Vue 和 React 也是使用 rollup 进行打包的,尤其当我们创建函数库、工具库等库的打包时,首选也是 rollup!那么到底是什么魔力让 rollup 经久不衰呢?答案也许就在 tree-shaking!

一、 了解 Tree-shaking

1. 什么是 Tree-shaking?

tree-shaking 这个概念早就有,但却是在 rollup 中实现后才开始被重视,本着寻根究源好奇的心理,我们就先从 rollup 入手 tree-shaking 一探究竟吧~~

那么,先让我们来康康 tree-shaking 是干啥的?

打包工具中的 tree-shaking, 较早时候由 Rich_Harris 的 rollup 实现,官方标准说法:本质上消除无用的 JS 代码。就是说,当引入一个模块时,并不引入整个模块的所有代码,而是只引入我需要的代码,那些我不需要的无用代码就会被”摇“掉。

后面从 Webpack2 开始 Webpack 也实现了 tree-shaking 功能,具体来说,在 Webpack 项目中,有一个入口文件,相当于一棵树的主干,入口文件有很多依赖的模块,相当于树的枝杈。而在实际情况中,虽然我们的功能文件依赖了某个模块,但其实只使用其中的某些功能而非全部。通过 tree-shaking,将没有使用的模块摇掉,这样就可以达到删除无用代码的目的。

由此我们就知道了,tree-shaking 是一种消除无用代码的方式!

但要注意的是,tree-shaking 虽然能够消除无用代码,但仅针对 ES6 模块语法,因为 ES6 模块采用的是静态分析,从字面量对代码进行分析。对于必须执行到才知道引用什么模块的 CommonJS 动态分析模块他就束手无策了,不过我们可以通过插件支持 CommonJS 转 ES6 然后实现 tree-shaking,只要思想不滑坡,办法总比困难多。

总之,rollup.js 默认采用 ES 模块标准,但可以通过 rollup-plugin-commonjs 插件使之支持 CommonJS 标准,目前来说,在压缩打包体积方面,rollup 的优势相当明显!

2. 为什么需要 Tree-shaking?

今天的 Web 网页应用可以体积很大,尤其是 JavaScript 代码,但浏览器处理 JavaScript 是非常耗资源的,如果我们能将其中的无用代码去掉,仅提供有效代码给浏览器处理,无疑会大大减小浏览器的负担,而 tree-shaking 帮我们做到了这一点。

从这个角度看,tree-shaking 功能属于性能优化的范畴。

毕竟,减少 web 项目中 JavaScript 的无用代码,就是减小文件体积,加载文件资源的时间也就减少了,从而通过减少用户打开页面所需的等待时间,来增强用户体验。

二、深入理解 Tree-shaking

我们已经了解了 tree-shaking 的本质是消除无用的 js 代码。那么什么是无用代码?怎么消除无用代码?接下来让我们从 DCE 开始揭开它神秘的面纱,一探究竟吧~

1. DCE(dead code elimination)

无用代码在我们的代码中其实十分常见,消除无用代码也就拥有了自己的专业术语 - dead code elimination(DCE)。实际上,编译器可以判断出哪些代码并不影响输出,然后消除这些代码。

tree-shaking 是 DCE 的一种新的实现,Javascript 同传统的编程语言不同的是,javascript 绝大多数情况需要通过网络进行加载,然后执行,加载的文件大小越小,整体执行时间更短,所以去除无用代码以减少文件体积,对 javascript 来说更有意义。tree-shaking 和传统的 DCE 的方法又不太一样,传统的 DCE 消灭不可能执行的代码,而 tree-shaking 更关注消除没有用到的代码。

DCE

  • 代码不会被执行,不可到达

  • 代码执行的结果不会被用到

  • 代码只会影响死变量,只写不读

传统编译型的预言都是由编译器将 Dead Code 从 AST (抽象语法树)中删除,了解即可。那么 tree-shaking 是如何 消除 javascript 无用代码的呢?

tree-shaking 更关注于消除那些引用了但并没有被使用的模块,这种消除原理依赖于 ES6 的模块特性。所以先来了解一下 ES6 模块特性:

ES6 Module

  • 只能作为模块顶层的语句出现

  • import 的模块名只能是字符串常量

  • import binding 是 immutable 的

了解了这些前提,让我们动手用代码来验证下吧!

2. Tree-shaking 消除

tree-shaking 的使用前面已经介绍过,接下来的实验中,创建了 index.js 作为入口文件,打包生成代码到 bundle.js 中,除此之外的 a.js、util.js 等文件均作为被引用的依赖模块。

1) 消除变量


从上图中可以看到,我们定义的变量 b 和变量 c 都没有使用到,它们并没有出现在打包后的文件中。

2) 消除函数


从上图中可以看到,仅引入但未使用到的 util1()和 util2()函数方法并没有打包进来。

3) 消除类

仅增加引用但不调用时


只引用类文件 mixer.js 但实际代码中并未用到 menu 的任何方法和变量时,我们通过实验可以看到,在新版本的 rollup 中消除类方法已经被实现了!

4) 副作用

但是,并不是说所有的副作用都被 rollup 解决了。参考相关文章,相对于 Webpack,rollup 在消除副作用方面有很大优势。但对于下列情况下的副作用,rollup 也无能为力:

1)模块中类的方法未被引用 2)模块中定义的变量影响了全局变量

参考下图,可以很清晰看到结果,大家也可以自己到rollup 官网提供的平台动手实践一下,:

小结

从上述打包结果我们可以看到,rollup 工具用于打包是非常轻量简洁的,从入口文件导入依赖模块到输出打包后的 bundle 文件,只保留了需要的代码。也就是说,在 rollup 打包中无需增加额外配置,只要你的代码符合 ES6 语法规范,就能实现 tree-shaking。Nice!

那么,这个打包过程中的 tree-shaking 大概可以理解为必须具备以下两个关键实现:

  • ES6 的模块引入是静态分析的,可以在编译时正确判断到底加载了什么代码。

  • 分析程序流,判断哪些变量被使用、引用,打包这些代码。

而 tree-shaking 的核心就包含在这个分析程序流的过程中:基于作用域,在 AST 过程中对函数或全局对象形成对象记录,然后在整个形成的作用域链对象中进行匹配 import 导入的标识,最后只打包匹配的代码,而删除那些未被匹配使用的代码。

但同时,我们也要注意两点:

  • 尽可能少写包含副作用的代码,比如影响全局变量的这种操作尽可能避免;

  • 引用类实例化后,也会产生 rollup 处理不了的副作用。

那么这个生成记录、匹配标识在程序流分析过程是如何实现的呢?

接下来带你走进源码,一探究竟!

三、 Tree-shaking 实现流程

在解析流程中的 tree-shaking 实现之前,我们首先要了解两点前置知识:

  • rollup 中的 tree-shaking 使用 acorn 实现 AST 抽象语法树的遍历解析,acorn 和 babel 功能相同,但 acorn 更加轻量,在此之前 AST 工作流也是必须要了解的;

  • rollup 使用 magic-string 工具操作字符串和生成 source-map。

流程图.png

让我们从源码出发根据 tree-shaking 的核心原理详细地描述一下具体流程:

  • rollup()阶段,解析源码,生成 AST tree,对 AST tree 上的每个节点进行遍历,判断出是否 include(标记避免重复打包),是的话标记,然后生成 chunks,最后导出。

  • generate()/write()阶段,根据 rollup()阶段做的标记,进行代码收集,最后生成真正用到的代码。

拿到源码 debug 起来~

// perf-debug.js
loadConfig().then(async config => // 获取收集配置(await rollup.rollup(config)).generate( Array.isArray(config.output) ? config.output[0] : config.output)
);

debug 时可能最为关注的就是这一段代码了,一句话就是将输入打包为输出,也正对应上述流程。

export async function rollupInternal(rawInputOptions: GenericConfigObject, // 传入参数配置watcher: RollupWatcher | null
): Promise<RollupBuild> {const { options: inputOptions, unsetOptions: unsetInputOptions } = await getInputOptions(rawInputOptions,watcher !== null);initialiseTimers(inputOptions);const graph = new Graph(inputOptions, watcher); // graph 包含入口以及各种依赖的相互关系,操作方法,缓存等,在实例内部实现 AST 转换,是 rollup 的核心const useCache = rawInputOptions.cache !== false; // 从配置中取是否使用缓存delete inputOptions.cache;delete rawInputOptions.cache;timeStart('BUILD', 1);try {// 调用插件驱动器方法,调用插件和提供插件环境上下文等await graph.pluginDriver.hookParallel('buildStart', [inputOptions]); await graph.build();} catch (err) {const watchFiles = Object.keys(graph.watchFiles);if (watchFiles.length > 0) {err.watchFiles = watchFiles;}await graph.pluginDriver.hookParallel('buildEnd', [err]);await graph.pluginDriver.hookParallel('closeBundle', []);throw err;}await graph.pluginDriver.hookParallel('buildEnd', []);timeEnd('BUILD', 1);const result: RollupBuild = {cache: useCache ? graph.getCache() : undefined,closed: false,async close() {if (result.closed) return;result.closed = true;await graph.pluginDriver.hookParallel('closeBundle', []);},// generate - 将遍历标记处理过作为输出的抽象语法树生成新的代码async generate(rawOutputOptions: OutputOptions) {if (result.closed) return error(errAlreadyClosed());// 第一个参数 isWrite 为 falsereturn handleGenerateWrite(false,inputOptions,unsetInputOptions,rawOutputOptions as GenericConfigObject,graph);},watchFiles: Object.keys(graph.watchFiles),// write - 将遍历标记处理过作为输出的抽象语法树生成新的代码async write(rawOutputOptions: OutputOptions) {if (result.closed) return error(errAlreadyClosed());// 第一个参数 isWrite 为 truereturn handleGenerateWrite(true,inputOptions,unsetInputOptions,rawOutputOptions as GenericConfigObject,graph);}};if (inputOptions.perf) result.getTimings = getTimings;return result;
}

单从这一段代码当然看不出来什么,下面我们一起解读源码来梳理 rollup 打包流程并探究 tree-shaing 的具体实现,为了更简单粗暴直接地看懂打包流程,我们对于源码中的插件配置中一律略过,只分析功能过程实现的核心流程。

1. 模块解析

获取文件绝对路径

通过 resolveId()方法解析文件地址,拿到文件绝对路径,拿到绝对路径是我们的主要目的,更为细节的处理此处不作分析。

export async function resolveId(source: string,importer: string | undefined,preserveSymlinks: boolean,) {// 不是以 . 或 / 开头的非入口模块在此步骤被跳过if (importer !== undefined && !isAbsolute(source) && source[0] !== '.') return null;// 调用 path.resolve,将合法文件路径转为绝对路径return addJsExtensionIfNecessary(importer ? resolve(dirname(importer), source) : resolve(source),preserveSymlinks);
}// addJsExtensionIfNecessary() 实现
function addJsExtensionIfNecessary(file: string, preserveSymlinks: boolean) {let found = findFile(file, preserveSymlinks);if (found) return found;found = findFile(file + '.mjs', preserveSymlinks);if (found) return found;found = findFile(file + '.js', preserveSymlinks);return found;
}// findFile() 实现
function findFile(file: string, preserveSymlinks: boolean): string | undefined {try {const stats = lstatSync(file);if (!preserveSymlinks && stats.isSymbolicLink())return findFile(realpathSync(file), preserveSymlinks);if ((preserveSymlinks && stats.isSymbolicLink()) || stats.isFile()) {const name = basename(file);const files = readdirSync(dirname(file));if (files.indexOf(name) !== -1) return file;}} catch {// suppress}
}

rollup()阶段

rollup() 阶段做了很多工作,包括收集配置并标准化、分析文件并编译源码生成 AST、生成模块并解析依赖,最后生成 chunks。为了搞清楚 tree-shaking 作用的具体位置,我们需要解析更内层处理的代码。

首先,通过从入口文件的绝对路径出发找到它的模块定义,并获取这个入口模块所有的依赖语句并返回所有内容。

private async fetchModule({ id, meta, moduleSideEffects, syntheticNamedExports }: ResolvedId,importer: string | undefined, // 导入此模块的引用模块isEntry: boolean // 是否入口路径
): Promise<Module> { ...// 创建 Module 实例const module: Module = new Module(this.graph, // Graph 是全局唯一的图,包含入口以及各种依赖的相互关系,操作方法,缓存等id,this.options,isEntry,moduleSideEffects, // 模块副作用syntheticNamedExports,meta);this.modulesById.set(id, module);this.graph.watchFiles[id] = true;await this.addModuleSource(id, importer, module);await this.pluginDriver.hookParallel('moduleParsed', [module.info]);await Promise.all([// 处理静态依赖this.fetchStaticDependencies(module),// 处理动态依赖this.fetchDynamicDependencies(module)]);module.linkImports();// 返回当前模块return module;
}

分别在fetchStaticDependencies(module),fetchDynamicDependencies(module)中进一步处理依赖模块,并返回依赖模块的内容。

private fetchResolvedDependency(source: string,importer: string,resolvedId: ResolvedId
): Promise<Module | ExternalModule> {if (resolvedId.external) {const { external, id, moduleSideEffects, meta } = resolvedId;if (!this.modulesById.has(id)) {this.modulesById.set(id,new ExternalModule( // 新建外部 Module 实例this.options,id,moduleSideEffects,meta,external !== 'absolute' && isAbsolute(id)));}const externalModule = this.modulesById.get(id);if (!(externalModule instanceof ExternalModule)) {return error(errInternalIdCannotBeExternal(source, importer));}// 返回依赖的模块内容return Promise.resolve(externalModule);} else {// 存在导入此模块的外部引用,则递归获取这个入口模块所有的依赖语句return this.fetchModule(resolvedId, importer, false);}
}

每个文件都是一个模块,每个模块都会有一个 Module 实例。在 Module 实例中,模块文件的代码通过 acorn 的 parse 方法遍历解析为 AST 语法树。

const ast = this.acornParser.parse(code, {...(this.options.acorn as acorn.Options),...options
});

最后将 source 解析并设置到当前 module 上,完成从文件到模块的转换,并解析出 ES tree node 以及其内部包含的各类型的语法树。

setSource({alwaysRemovedCode,ast,code,customTransformCache,originalCode,originalSourcemap,resolvedIds,sourcemapChain,transformDependencies,transformFiles,...moduleOptions
}: TransformModuleJSON & {alwaysRemovedCode?: [number, number][];transformFiles?: EmittedFile[] | undefined;
}) {this.info.code = code;this.originalCode = originalCode;this.originalSourcemap = originalSourcemap;this.sourcemapChain = sourcemapChain;if (transformFiles) {this.transformFiles = transformFiles;}this.transformDependencies = transformDependencies;this.customTransformCache = customTransformCache;this.updateOptions(moduleOptions);timeStart('generate ast', 3);this.alwaysRemovedCode = alwaysRemovedCode || [];if (!ast) {ast = this.tryParse();}this.alwaysRemovedCode.push(...findSourceMappingURLComments(ast, this.info.code));timeEnd('generate ast', 3);this.resolvedIds = resolvedIds || Object.create(null);this.magicString = new MagicString(code, {filename: (this.excludeFromSourcemap ? null : fileName)!, // 不包括 sourcemap 中的辅助插件indentExclusionRanges: []});for (const [start, end] of this.alwaysRemovedCode) {this.magicString.remove(start, end);}timeStart('analyse ast', 3);// ast 上下文环境,包装一些方法,比如动态导入、导出等,东西很多,大致看一看this.astContext = {addDynamicImport: this.addDynamicImport.bind(this), // 动态导入addExport: this.addExport.bind(this),addImport: this.addImport.bind(this),addImportMeta: this.addImportMeta.bind(this),code,deoptimizationTracker: this.graph.deoptimizationTracker,error: this.error.bind(this),fileName,getExports: this.getExports.bind(this),getModuleExecIndex: () => this.execIndex,getModuleName: this.basename.bind(this),getReexports: this.getReexports.bind(this),importDescriptions: this.importDescriptions,includeAllExports: () => this.includeAllExports(true), // include 相关方法标记决定是否 tree-shakingincludeDynamicImport: this.includeDynamicImport.bind(this), // include...includeVariableInModule: this.includeVariableInModule.bind(this), // include...magicString: this.magicString,module: this,moduleContext: this.context,nodeConstructors,options: this.options,traceExport: this.getVariableForExportName.bind(this),traceVariable: this.traceVariable.bind(this),usesTopLevelAwait: false,warn: this.warn.bind(this)};this.scope = new ModuleScope(this.graph.scope, this.astContext);this.namespace = new NamespaceVariable(this.astContext, this.info.syntheticNamedExports);// 实例化 Program,将 ast 上下文环境赋给当前模块的 ast 属性上this.ast = new Program(ast, { type: 'Module', context: this.astContext }, this.scope);this.info.ast = ast;timeEnd('analyse ast', 3);
}

2. 标记模块是否可 Tree-shaking

继续处理当前 module,根据 isExecuted 的状态及 treeshakingy 相关配置进行模块以及 es tree node 的引入,isExecuted 为 true 意味着这个模块已被添加入结果,以后不需要重复添加,最后也是根据 isExecuted 收集所有需要的模块从而实现 tree-shaking。

// 以标记声明语句为例,includeVariable()、includeAllExports()方法不一一列出
private includeStatements() {for (const module of [...this.entryModules, ...this.implicitEntryModules]) {if (module.preserveSignature !== false) {module.includeAllExports(false);} else {markModuleAndImpureDependenciesAsExecuted(module);}}if (this.options.treeshake) {let treeshakingPass = 1;do {timeStart(`treeshaking pass ${treeshakingPass}`, 3);this.needsTreeshakingPass = false;for (const module of this.modules) {// 根据 isExecuted 进行标记if (module.isExecuted) {if (module.info.hasModuleSideEffects === 'no-treeshake') {module.includeAllInBundle();} else {module.include(); // 标记}}}timeEnd(`treeshaking pass ${treeshakingPass++}`, 3);} while (this.needsTreeshakingPass);} else {for (const module of this.modules) module.includeAllInBundle();}for (const externalModule of this.externalModules) externalModule.warnUnusedImports();for (const module of this.implicitEntryModules) {for (const dependant of module.implicitlyLoadedAfter) {if (!(dependant.info.isEntry || dependant.isIncluded())) {error(errImplicitDependantIsNotIncluded(dependant));}}}
}

module.include 内部涉及到 ES tree node 了,由于 NodeBase 初始 include 为 false,所以还有第二个判断条件:当前 node 是否有副作用 side effects。这个是否有副作用是继承于 NodeBase 的各类 node 子类自身的实现,以及是否影响全局。rollup 内部不同类型的 es node 实现了不同的 hasEffects 实现,在不断优化过程中,对类引用的副作用进行了处理,消除引用却未使用的类,此处可结合第二章节中的 tree-shaking 消除进一步理解。

include(): void { /  include()实现const context = createInclusionContext();if (this.ast!.shouldBeIncluded(context)) this.ast!.include(context, false);
}

3. treeshakeNode()方法

在源码中有 treeshakeNode()这样一个方法去除无用代码,调用的时候也清楚地备注了 ---  防止重复声明相同的变量/节点,通过 included 标记节点代码是否已被包含,是的情况下 tree-shaking,同时还提供 removeAnnotations()方法删除多余注释代码。

// 消除无用节点
export function treeshakeNode(node: Node, code: MagicString, start: number, end: number) {code.remove(start, end);if (node.annotations) {for (const annotation of node.annotations) {if (!annotation.comment) {continue;}if (annotation.comment.start < start) {code.remove(annotation.comment.start, annotation.comment.end);} else {return;}}}
}
// 消除注释节点
export function removeAnnotations(node: Node, code: MagicString) {if (!node.annotations && node.parent.type === NodeType.ExpressionStatement) {node = node.parent as Node;}if (node.annotations) {for (const annotation of node.annotations.filter((a) => a.comment)) {code.remove(annotation.comment!.start, annotation.comment!.end);}}
}

调用 treeshakeNode()方法的时机很重要!在渲染前 tree-shaking 并递归地去渲染。

render(code: MagicString, options: RenderOptions, nodeRenderOptions?: NodeRenderOptions) {const { start, end } = nodeRenderOptions as { end: number; start: number };const declarationStart = getDeclarationStart(code.original, this.start);if (this.declaration instanceof FunctionDeclaration) {this.renderNamedDeclaration(code,declarationStart,'function','(',this.declaration.id === null,options);} else if (this.declaration instanceof ClassDeclaration) {this.renderNamedDeclaration(code,declarationStart,'class','{',this.declaration.id === null,options);} else if (this.variable.getOriginalVariable() !== this.variable) {// tree-shaking 以防止重复声明变量treeshakeNode(this, code, start, end);return;// included 标识做 tree-shaking} else if (this.variable.included) {this.renderVariableDeclaration(code, declarationStart, options);} else {code.remove(this.start, declarationStart);this.declaration.render(code, options, {isCalleeOfRenderedParent: false,renderedParentType: NodeType.ExpressionStatement});if (code.original[this.end - 1] !== ';') {code.appendLeft(this.end, ';');}return;}this.declaration.render(code, options);}

类似的地方还有几处,tree-shaking 就是在这些地方发光发热的!

// 果然我们又看到了 included
...
if (!node.included) {treeshakeNode(node, code, start, end);continue;
}
...
if (currentNode.included) {currentNodeNeedsBoundaries? currentNode.render(code, options, {end: nextNodeStart,start: currentNodeStart}): currentNode.render(code, options);
} else {treeshakeNode(currentNode, code, currentNodeStart!, nextNodeStart);
}
...

4. 通过 chunks 生成代码(字符串)并写入文件

在 generate()/write()阶段,将经处理生成后的代码写入文件,handleGenerateWrite()方法内部生成了 bundle 实例进行处理。

async function handleGenerateWrite(...) {...// 生成 Bundle 实例,这是一个打包对象,包含所有的模块信息const bundle = new Bundle(outputOptions, unsetOptions, inputOptions, outputPluginDriver, graph);// 调用实例 bundle 的 generate 方法生成代码const generated = await bundle.generate(isWrite);if (isWrite) {if (!outputOptions.dir && !outputOptions.file) {return error({code: 'MISSING_OPTION',message: 'You must specify "output.file" or "output.dir" for the build.'});}await Promise.all(// 这里是关键:通过 chunkId 生成代码并写入文件Object.keys(generated).map(chunkId => writeOutputFile(generated[chunkId], outputOptions)));await outputPluginDriver.hookParallel('writeBundle', [outputOptions, generated]);}return createOutput(generated);
}

小结

一句话概括来说就是:从入口文件出发,找出所有它读取的变量,找一下这个变量是在哪里定义的,把定义语句包含进来,而无关的代码一律抛弃,得到的即为我们想要的结果。

总结

本文基于对 rollup 源码对其打包过程中的 tree-shaking 原理进行解读,其实可以发现,针对简单的打包流程而言,源码中并未对代码做额外的神秘操作,只是做了遍历标记使用收集并对收集到的代码打包输出以及 included 标记节点 treeshakeNode 以避免重复声明而已。

当然最关键的还是内部静态分析并收集依赖,这个过程处理起来比较复杂,但核心其实还是针对遍历节点:找到当前节点依赖的变量,访问的变量以及这些变量的声明语句。

作为一个轻量快捷的打包工具,rollup 在打包函数工具库方便具有很大优势。归功于其偏向于代码处理的优势,源码体量相较于 Webpack 也是轻量得多,但菜鸡本菜如我依然觉得读源码是一个枯燥的过程...

但是!如果仅仅是本着弄懂原理的目的,不妨先只关注核心代码流程,边边角角的细节放在后面,也许能增强阅读愉悦体验、加快攻略源码的步伐!

参考资料

  • Tree-Shaking 与无效代码消除

  • Tree-Shaking 性能优化实践 - 原理篇

  • 你的 Tree-Shaking 并没什么卵用

  • 原来 rollup 这么简单之 tree shaking 篇

无用代码去哪了?项目减重之 rollup 的 Tree shaking相关推荐

  1. 前端基础建设与架构11 Tree Shaking:移除 JavaScript 上下文中的未引用代码

    时至今日,Tree Shaking 对于前端工程师来说,已经不是一个陌生的名词了.顾名思义:Tree Shaking 译为"摇树",它通常用于描述移除 JavaScript 上下文 ...

  2. 学好Java去哪里找项目练手?

    学好Java去哪里找项目练手? 去那些培训机构的官网找项目视频,自己照着做一遍,不过一般能发出来的都不是最新的,但是肯定是有帮助的,当然这些项目做了你的项目经验也是虚假的,要想要真实的项目经验,那就得 ...

  3. 白色flash模块代码_适用于MCU项目的代码框架BabyOS,工程师的好助手!

    来源:码云+嵌入式云IOT技术圈 一个好的代码架构直接影响项目的质量,今天为大家分享的是一个管理功能模块和外设驱动的框架:BabyOS. BabyOS是什么? BabyOS适用于MCU项目,它是一套管 ...

  4. idea修改代码后不重启项目_使用DevTool实现SpringBoot项目热部署

    前言 最近在开发的时候,每次改动代码都需要启动项目,因为有的时候改动的服务比较多,所以重启的次数也就比较多了,想着每次重启等待也挺麻烦的,就打算使用DevTools工具实现项目的热部署 热部署是什么 ...

  5. 后端代码之服务端 - 项目工程化创建目录启动服务 -讲解篇

    文章目录 前言 一. 目录创建 与 应用启动 A. 步骤如下: B. 具体cmd命令执行流,截图如下:(`部分无效,可忽略`) 二. 查看Express的欢迎页 1. 查看欢迎页的 浏览器url地址: ...

  6. 和无用代码说再见!阿里文娱无损代码覆盖率统计方案

    作者 | 阿里巴巴文娱高级无线开发工程师 孙珑达 责编 | 屠敏 背景 为了适应产品的快速迭代,通常大量的研发资源会投入在新功能的开发上,而针对无用功能的治理却很少被关注.随着时间的推移,线上应用会积 ...

  7. [GCN] 增加可视化+代码注释 of GitHub项目:Graph Convolutional Networks in PyTorch

    增加可视化+代码注释 of GitHub项目:Graph Convolutional Networks in PyTorch 更详细的,强烈推荐另一篇博客:[GCN] 代码解析 of GitHub:G ...

  8. Python3,仅仅2段代码,就实现项目代码自动上传及部署,再也不需要Jenkins了。

    代码自动上传及部署 1.引言 2.代码实战 2.1 模块安装 2.2 实现思路 2.3 代码示例 2.3.1 创建监听器 2.3.2 创建事件处理对象 2.3.3 启动事件监听 2.4 启动运行 3. ...

  9. 软件测试找工作去外包大厂项目好还是自研公司好?

    相信很多人在找工作的时候都遇到这个问题!感觉很难选! 大多数程序员一听到"外包"两个字,就避之不及. 到底什么是外包呢? 目前的外包大概有2种.一种是人力外包,工作环境和正式员工一 ...

最新文章

  1. Python-logging报错解决:UnicodeEncodeError: 'gbk' codec can't encode character '\u' in position: illegal
  2. mysql 西安_MySQL分区维护
  3. 今日上午,清华大学发布中国首个高校自研深度学习训练框架—计图Jittor
  4. 轻量高效!清华智能计算实验室开源基于PyTorch的视频 (图片) 去模糊框架SimDeblur
  5. Step By Step_Java通过JNI调C程序执行
  6. __dopostback的用法
  7. 运算符--位移运算符和一些其他运算符
  8. Qt TCP协议 传输简单字符串实例
  9. 关于学习Python的一些心得
  10. IOS开发 ios7适配
  11. comsol如何定义狄利克雷边界_COMSOL与Visual C++三维电阻抗有限元联合建模与仿真研究...
  12. 《Linux From Scratch》第三部分:构建LFS系统 第六章:安装基本的系统软件- 6.40. Expat-2.1.0...
  13. 阿里开源代码质量检测工具!
  14. Oralce/MySQL 默认隔离级别对比
  15. 为什么要切换IP地址?
  16. 如何下载B站(哔哩哔哩)高清视频?
  17. 仿雷速体育app踢足球tab
  18. 使用React.js和appbase.io构建类似Twitter的Search Feed
  19. 怎么加载网页背景图随浏览器等比例缩放(css)
  20. 用C语言求平均数的四种方法

热门文章

  1. InsecureRequestWarning: Unverified HTTPS request is being made to host ‘xx.xx.cn‘. Adding certificat
  2. CatBoost 原理及应用
  3. python 基础知识点
  4. Android开发实战讲解!这么香的技术还不快点学起来,已拿offer
  5. fzu 2090 旅行社的烦恼
  6. ubuntu----VMware 鼠标自由切换问题及主机虚拟机共享剪切板问题
  7. MySQL function方法(中文转首字母大写)
  8. window.print()打印样式不生效的问题
  9. 魅蓝note3联通卡显示无服务器,魅蓝note3用什么SIM卡?魅蓝note3手机SIM卡类型
  10. ESRI公司研发GIS产品集合