SourceMap 与前端异常监控

export default {}

{ “greeting”: “hello” }

我们使用 @vue/compiler-sfc 将该 Vue 文件编译为一个 SFCRecord,此时 SFCRecord 里实际上包含了每个 block 的 SourceMap

const { parse } = require(‘@vue/compiler-sfc’);

const fs = require(‘fs’);

const path = require(‘path’);

async function main(){

const content = fs.readFileSync(path.join(__dirname, ‘./App.vue’),‘utf-8’);

const sfcRecord = parse(content);

const map = sfcRecord.descriptor[‘styles’][0].map

console.log(‘sfc:’,map) // 打印style的SourceMap

}

main();

我们可以进一步的根据每个 block 的 tag 和 lang 来继续 transform 每个 block,如使用 Babel | Typescript 处理 Script,PostCSS来处理 Style,Pug 来处理 Template,这里每个 Transformer 也都需要处理 SourceMap。

Bundler

处理完 Vue 文件的编译后,我们希望通过一个 bundler 来处理 Vue 模块的打包,此时我们可以使用esbuild、rollup、或者 Webpack,我们这里使用 rollup-plugin-vue 来配合 rollup 给 Vue 应用进行打包。

async function bundle(){

const bundle = await rollup.rollup({

input: [path.join(__dirname, ‘./App.vue’)],

plugins:[rollupPluginVue({needMap:true})],

external: [‘vue’],

output: {

sourcemap:‘inline’

}

})

const result = await bundle.write({

output: {

file: ‘bundle.js’,

sourcemap:true

},

})

for(const chunk of result.output){

console.log(‘chunk:’,chunk.map) // SourceMap

}

}

bundle()

因此这里也需要 bundle 在进行 bundler 的过程中同时处理生成 sourcemap。

Minifier

但我们 bundler 完代码后,还需要将代码进行压缩混淆才能发布到线上,这时我们需要使用 minify 工具进行混淆压缩。我们使用 terser 进行压缩。压缩时不仅需要处理 minfy 过程生成的 SourceMap 还需要处理其和原始 bundler 生成的 SourceMap 合并的问题,否则 SourceMap 和经过压缩处理的代码对应不上了。

for(const chunk of result.output){

console.log(‘chunk:’, chunk.map)

const minifyResult = await require(‘terser’).minify(chunk.code, {

sourceMap:true,

})

console.log(‘minifyMap:’, minifyResult.map)

}

Runtime

经过一番折腾,我们的编译流程终于处理完 SourceMap 了,我们开发过程中突然发现了代码出问题了,我们希望错误的堆栈能显示源码的位置,另外能支持源码调试应用,这时候就需要用的浏览器的 SourceMap 支持和 node 的 SourceMap 支持了。

日志收集和上报

经过一番眼花缭乱的操作,我们的代码终于和 SourceMap 对应上了,我们平稳的将业务部署上线,上线前我们需要确保我们的错误能够以正确的格式上报到我们的日志平台,然而我们线上运行的平台那么多样,运行的 JS 引擎也是各式各样,我们需要将用户的错误统一成一个格式上报给平台,幸运的是 Sentry 的客户端已经帮我们做了这件事情。我们只需要考虑接入 Sentry 的客户端就行了。因为如果直接将 SourceMap 一起跟随 js 代码下发,这就导致用户可以直接窥探你的源码了,类似发生这样的事情就很尴尬了https://zhuanlan.zhihu.com/p/26033573,因此我们还需要考虑将 SourceMap 发布到内网而非公网上,这时就需要处理 SourceMap 关联的问题了。

错误日志反解

一切都妥当了,只需要等用户的错误上报上来(最好永远别来),我就可以在 Sentry 上查看用户的原始错误堆栈,帮用户排查问题了,这时候实际上 Sentry Server 端偷偷帮我们做了根据用户的错误栈和用户的 SourceMap,帮我们反解错误栈的事情了。

总结一下,一个完整的 SourceMap 流程支持包括了如下这些步骤:

  • transformer: Babel、typescript、emscripten 、 esbuild

  • minifier: esbuild ,terser

  • bundler: esbuild, webpack, rollup

  • runtime: browser & node &  deno

  • 日志上报: sentry client

  • 错误日志反解: sentry server && node-sourcemap-support

上面这些流程,基本上大多数工具都帮我们封装好了,我们只需要安心使用即可,但是当某天你需要自己开发一个自定义的 DSL 的 transformer 通过自研的 bundler 进行编译打包,运行在自研的 JS 引擎上并且使用自研的 monitor client 上报到自研的 apm 平台上,任何环节的出错都可能导致你线上的错误日志反解前功尽弃,你所能做的就是在整个链路上进行分析定位。

我们接下来就看看整个链路上有多少种出错的风险和可能,并且如何定位修复这些问题。

SourceMap 格式


首先我们需要了解下 SourceMap 的基本格式

我们将一个 .ts 文件编译为 .js 文件,看看其 SourceMap 信息是如何处理映射的。我们项目包含了原始的 ts 文件 add.ts、编译后的产物文件 add.js 和 SourceMap 文件 add.js.map,其内容如下

  • add.ts

const add = (x:number, y:number) => {

return x + y;

}

  • add.js

var add = function (x, y) {

return x + y;

};

//# sourceMappingURL=module.js.map

SourceMap 的规范本身十分精简和清晰,其本身是一个 JSON 文件,包含如下几个核心字段

{

version : 3, // SourceMap标准版本,最新的为3

file: “add.js”, // 转换后的文件名

sourceRoot : “”, // 转换前的文件所在目录,如果与转换前的文件在同一目录,该项为空

sources: [“add.ts”], // 转换前的文件,该项是一个数组,表示可能存在多个文件合并

names: [], // 转换前的所有变量名和属性名,多用于minify的场景

sourcesContent: [ // 原始文件内容

“const add = (x:number,y:number) => {\n  return x+y;\n}”

]

mappings: “AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ;IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC;AACb,CAAC,CAAA”,

}

简单介绍下 mapping 的格式,mapping 实际上是个三级结构,我们以上述的例子为例

  • line:每个 mapping 包含由 ; 分割的多行内容

lines = mappings.split(‘;’)

//  [

‘AAAA,IAAM,GAAG,GAAG,UAAC,CAAQ,EAAC,CAAQ’, // var add = function (x, y) {

‘IAC5B,OAAO,CAAC,GAAC,CAAC,CAAC’,     // return x + y;

‘AACb,CAAC,CAAA’ // };

]

其中每一行都对应生成代码的每行文件的位置映射信息,这里的三行分别对应了编译产物的三行信息

  • segment:每一行同包含由 , 分割的多个 segment 信息,其中每个 segment 都对应了产物里每一行里每一个符合所在的列的信息

const segments = lines.map(x => {

return x.split(‘,’)

})

console.log(‘segments:’,segments)

//  [

[

‘AAAA’, ‘IAAM’,

‘GAAG’, ‘GAAG’,

‘UAAC’, ‘CAAQ’,

‘EAAC’, ‘CAAQ’

],

[ ‘IAC5B’, ‘OAAO’, ‘CAAC’, ‘GAAC’, ‘CAAC’, ‘CAAC’ ],

[ ‘AACb’, ‘CAAC’, ‘CAAA’ ]

]

  • fields:每个 segment 实际上又包含了几个 field,每个 field 都编码了具体的行列映射信息,依次为

    • 第一位: 转换后代码所处的列号,如果这是当前行的第一个 segment,那么是个绝对值,否则是相对于上一个 segment 的相对值
  • 第二位:表示这个位置属于 sources 属性中的哪一个文件,相对于前一个 segment 的位置(区别于列号,下一行的第一个 segment 仍然是相对于上一行的最后一个 segment,并不会 reset)

  • 第三位:表示这个位置属于转换前代码的第几行,相对位置,同第二列

  • 第四位:表示这个位置属于转换前代码的第几列,相对位置,同第二列

  • 第五位:表示这个位置属于 names 属性中的哪一个变量,相对位置,同第二列

这里 field 存储的值并非是直接的数字值,而是将数字使用 vlq 进行了编码,根据上述这些信息我们实际上就可以实现 SourceMap 的双向映射了,即可以根据 SourceMap 和原始代码的位置信息查找到生成代码的信息,也可以根据 SourceMap 和生成代码的位置信息,查找到原始代码的信息。接下来我们就实践下如何进行代码位置的双向查找。

双向查找流程

vlq 解码

首先第一步我们需要将 vlq 编码的 SourceMap 反解为原始的数字偏移信息,我们可以直接使用封装好的 vlq 库完成这一步

function decode() {

const { decode} = require(‘vlq’)

const mappings = JSON.parse(result.sourceMapText).mappings;

console.log(‘mappings:’, mappings)

/**

* @type {string[]}

*/

const lines = mappings.split(‘;’);

const decodeLines = lines.map(line => {

const segments = line.split(‘,’);

const decodedSeg = segments.map(x => {

return decode(x)

})

return decodedSeg;

})

console.log(decodeLines)

}

此时我们得到一个解码后的位置信息

[

[

[ 0, 0, 0, 0 ],

[ 4, 0, 0, 6 ],

[ 3, 0, 0, 3 ],

[ 3, 0, 0, 3 ],

[ 10, 0, 0, 1 ],

[ 1, 0, 0, 8 ],

[ 2, 0, 0, 1 ],

[ 1, 0, 0, 8 ]

],

[

[ 4, 0, 1, -28 ],

[ 7, 0, 0, 7 ],

[ 1, 0, 0, 1 ],

[ 3, 0, 0, 1 ],

[ 1, 0, 0, 1 ],

[ 1, 0, 0, 1 ]

],

[ [ 0, 0, 1, -13 ], [ 1, 0, 0, 1 ], [ 1, 0, 0, 0 ] ]

]

还原绝对位置索引

此时的这些位置信息都是相对位置,我们需要将其还原为绝对位置

const decoded = decodeLines.map((line) => {

absSegment[0] = 0; // 每行的第一个segment的位置要重置

if (line.length == 0) {

return [];

}

const absoluteSegment = line.map((segment) => {

const result = [];

for (let i = 0; i < segment.length; i++) {

absSegment[i] += segment[i];

result.push(absSegment[i]);

}

return result;

});

return absoluteSegment;

});

console.log(‘decoded:’, decoded)

}

结果如下,此时为绝对位置映射表

[

[

[ 0, 0, 0, 0 ],

[ 4, 0, 0, 6 ],

[ 7, 0, 0, 9 ],

[ 10, 0, 0, 12 ],

[ 20, 0, 0, 13 ],

[ 21, 0, 0, 21 ],

[ 23, 0, 0, 22 ],

[ 24, 0, 0, 30 ]

],

[

[ 4, 0, 1, 2 ],

[ 11, 0, 1, 9 ],

[ 12, 0, 1, 10 ],

[ 15, 0, 1, 11 ],

[ 16, 0, 1, 12 ],

[ 17, 0, 1, 13 ]

],

[ [ 0, 0, 2, 0 ], [ 1, 0, 2, 1 ], [ 2, 0, 2, 1 ] ]

]

双向映射

有了这个绝对位置映射,我们就可以构建源码和产物的双向映射了。我们可以实现两个核心 API

originalPositionFor 用于根据产物的行列号,查找对应源码的信息,而generatedPositionFor 则是根据源码的文件名、行列号,查找产物里的位置信息。

class SourceMap {

constructor(rawMap) {

this.decode(rawMap);

this.rawMap = rawMap

}

/**

*

* @param {number} line

* @param {number} column

*/

originalPositionFor(line, column){

const lineInfo = this.decoded[line];

if(!lineInfo){

throw new Error(不存在该行信息:${line});

}

const columnInfo = lineInfo[column];

for(const seg of lineInfo){

// 列号匹配

if(seg[0] === column){

const [column, sourceIdx,origLine, origColumn] = seg;

const source = this.rawMap.sources[sourceIdx]

const sourceContent = this.rawMap.sourcesContent[sourceIdx];

const result = codeFrameColumns(sourceContent, {

start: {

line: origLine+1,

column: origColumn+1

}

}, {forceColor:true})

return {

source,

line: origLine,

column: origColumn,

frame: result

}

}

}

throw new Error(不存在该行列号信息:${line},${column})

}

decode(rawMap) {

const {mappings} = rawMap

const { decode } = require(‘vlq’);

console.log(‘mappings:’, mappings);

/**

* @type {string[]}

*/

const lines = mappings.split(‘;’);

const decodeLines = lines.map((line) => {

const segments = line.split(‘,’);

const decodedSeg = segments.map((x) => {

return decode(x);

});

return decodedSeg;

});

const absSegment = [0, 0, 0, 0, 0];

const decoded = decodeLines.map((line) => {

absSegment[0] = 0; // 每行的第一个segment的位置要重置

if (line.length == 0) {

return [];

}

const absoluteSegment = line.map((segment) => {

const result = [];

for (let i = 0; i < segment.length; i++) {

absSegment[i] += segment[i];

result.push(absSegment[i]);

}

return result;

});

return absoluteSegment;

});

this.decoded = decoded;

}

}

const consumer = new SourceMap(rawMap);

console.log(consumer.originalPositionFor(0,21).frame)

我们还可以使用 codeFrame 直接可视化查找出源码的上下文信息

generatedPositionFor 的实现原理类似,不再赘述。

事实上上面这些反解流程并不需要我们自己去实现,https://github.com/mozilla/source-map 已经帮我们提供了很多的编译方法,包括不限于

  • originalPositionFor:查找源码位置

  • generatedPositionFor:查找生成代码位置

  • eachMapping:生成每个 segment 的详细映射信息

Mapping {

generatedLine: 2,

generatedColumn: 17,

lastGeneratedColumn: null,

source: ‘add.ts’,

originalLine: 2,

originalColumn: 13,

name: null

}

Mapping {

generatedLine: 3,

generatedColumn: 0,

lastGeneratedColumn: null,

source: ‘add.ts’,

originalLine: 3,

originalColumn: 0,

name: null

}

Mapping {

generatedLine: 3,

generatedColumn: 1,

lastGeneratedColumn: null,

source: ‘add.ts’,

originalLine: 3,

originalColumn: 1,

name: null

}

Mapping {

generatedLine: 3,

generatedColumn: 2,

lastGeneratedColumn: null,

source: ‘add.ts’,

originalLine: 3,

originalColumn: 1,

name: null

}

事实上 Sentry 的 SourceMap 反解功能也是基于此实现的。

SourceMap 全链路支持

===============

前面我们已经介绍的 SourceMap 的基本格式,以及如何基于 SourceMap 的内容,来实现 SourceMap 的双向查找功能,大部分的 sourcmap 相应的工具链都是基于此设计的,但是在给整个链路做 SourceMap 支持的时候,但是链路的每一步需要解决的问题却各有不同(的坑),我们来一步步的研(踩)究(坑)吧。

给 transformer 添加 SourceMap 映射


Web 社区的主流语言的工具链都已经有了内置的 SourceMap 支持了,但是如果你自行设计一个 DSL 要怎么给其添加 SourceMap 支持呢?事实上 SourceMapGenerator 给我们提供了便捷的生成 SourceMap 内容的方法,但是当我们处理各种字符串变换的时候,直接使用其 API 仍然较为繁琐。幸运的是很多工具封装了生成 SourceMap 的操作,提供了较为上层的 api。我们自己实现 transformer 主要分为两种场景,一种是基于 AST 的变换,另一种则是对字符串(可能压根不存在 AST)的增删改查。

  • AST 变换

大部分的前端 transform 工具,都内置帮我们处理好了 SourceMap 的映射,我们只需要关心如何处理 AST 即可,以 babel 为例,并不需要我们手动的进行 SourceMap 节点的操作

import babel from ‘@babel/core’;

import fs from ‘fs’;

const result = babel.transform(‘a === b;’, {

sourceMaps: true,

filename: ‘transform.js’,

plugins: [

{

name: ‘my-plugin’,

pre: () => {

console.log(‘xx’);

},

visitor: {

BinaryExpression(path, t) {

let tmp = path.node.left;

path.node.left = path.node.right;

path.node.right = tmp;

}

}

}

]

});

console.log(result.code, result.map);

// 结果

b === a;

{

version: 3,

sources: [ ‘transform.js’ ],

names: [ ‘b’, ‘a’ ],

mappings: ‘AAAMA,CAAN,KAAAC,CAAC’,

sourcesContent: [ ‘a === b;’ ]

}

  • 但是 AST 并不能覆盖所有场景,例如我们如果需要将 c++ 或者 brainfuck 编译为 js,就很难找到便捷的工具,或者我们只需要替换代码里的部分内容,AST 分析就是大才小用了。此时我们可以使用 magic-string 来实现。

const MagicString = require(‘magic-string’);

const s = new MagicString(‘problems = 99’);

s.overwrite(0, 8, ‘answer’);

s.toString(); // ‘answer = 99’

s.overwrite(11, 13, ‘42’); // character indices always refer to the original string

s.toString(); // ‘answer = 42’

s.prepend(‘var ‘).append(’;’); // most methods are chainable

s.toString(); // ‘var answer = 42;’

const map = s.generateMap({

source: ‘source.js’,

file: ‘converted.js.map’,

includeContent: true

}); // generates a v3 SourceMap

console.log(‘code:’, s.toString());

console.log(‘map:’, map);

// 结果

code: var answer = 42;

map: SourceMap {

version: 3,

file: ‘converted.js.map’,

sources: [ ‘source.js’ ],

sourcesContent: [ ‘problems = 99’ ],

names: [],

mappings: ‘IAAA,MAAQ,GAAG’

}

我们发现对于简单的字符串处理,magic-string 比使用 AST 的方式要方便和高效很多。

SourceMap 验证

当我们给我们的 transformer 加了 SourceMap 支持后,我们怎么验证我们的 SourceMap 是正确的呢?你除了可以使用上面提到的 SourceMap 库的双向反解功能进行验证外,一个可视化的验证工具将大大简化我们的工作。esbuild 作者就开发了一个 SourceMap 可视化验证的网站 https://evanw.github.io/source-map-visualization/ 来帮我们简化 SourceMap 的验证工作。

SourceMap 合并


当我们处理好 transformer 的 SourceMap 生成之后,接下来就需要将 transformer 接入到 bundler 了,一定意义上 bundler 也可以视为一种 transformer,只是此时其输入不再是单个源文件而是多个源文件。但这里牵扯到的一个问题是将 A 进行编译生成了 B with SourceMap1  接着又将 B 进一步进行编译生成了 C with SourceMap2,那么我们如何根据 C 反解到 A 呢?很明显使用 SourceMap2 只能帮助我们将 C 反解到 B,并不能反解到 A,大部分的反解工具也不支持自动级联反解,因此当我们将 B 生成 C 的时候,还需要考虑将 SourceMap1 和 SourceMap2 进行合并,不幸的是很多 transformer 并不会自动的处理这种合并,如 TypeScript,但是大部分的 bundler 都是支持自动的 SourceMap 合并的。

如在 Rollup 里,你可以在 load 和 transform 里返回 code 的同时,返回 mapping。Rollup 会自动将该 mapping 和 builder 变换的 mapping 进行合并,vite 和 esbuild 也支持类似功能。如果我们需要自己处理 SourceMap 合并该如何操作,社区上已经有库帮我们处理这个事情。我们简单看下

import ts from ‘typescript’;

import { minify } from ‘terser’;

import babel from ‘@babel/core’;

import fs from ‘fs’;

import remapping from ‘@ampproject/remapping’;

const code = `

const add = (a,b) => {

return a+b;

}

`;

const transformed = babel.transformSync(code, {

filename: ‘origin.js’,

sourceMaps: true,

plugins: [‘@babel/plugin-transform-arrow-functions’]

});

console.log(‘transformed code:’, transformed.code);

console.log(‘transformed map:’, transformed.map);

const minified = await minify(

{

‘transformed.js’: transformed.code

},

{

sourceMap: {

includeSources: true

}

}

);

console.log(‘minified code:’, minified.code);

console.log(‘minified map’, minified.map);

const mergeMapping = remapping(minified.map, (file) => {

if (file === ‘transformed.js’) {

return transformed.map;

} else {

return null;

}

});

fs.writeFileSync(‘remapping.js’, minified.code);

fs.writeFileSync(‘remapping.js.map’, minified.map);

//fs.writeFileSync(‘remapping.js.map’, JSON.stringify(mergeMapping));

我们来简单验证下效果

  • 使用 mergeMapping 之前

  • 使用 mergeMapping 之后

我们可以看出做了 mergeSourcemap 后可以成功的还原出最初的源码

性能 matters

我们支持好了上面的 SourceMap 生成和 SourceMap 合并了,迫不及待的在业务中加以使用了,但是却“惊喜”的发现整个构建流程的速度直线下降,因为 SourceMap 操作的开销实际上是非常可观的,在不需要 SourceMap 的情况下或者在对性能极其敏感的场景下(服务端构建),实际是不建议默认开启 SourceMap 的,事实上 SourceMap 对性能极其敏感,以至于 source-map 库的作者们重新用 rust 实现了 source-map,并将其编译到了 webassembly。

错误日志上报和反解


当我们处理好 SourceMap 的生成后,就可以进行日志上报了

Sentry

错误上报需要解决的一个问题就是统一上报格式问题,我们生产环境遇到的错误并非直接将原始的 Error 信息上报给服务端的,而是需要先进行格式化处理,如下面这种错误

function inner() {

myUndefinedFunction();

}

function outer() {

inner();

}

setTimeout(() => {

outer();

}, 1000);

原始的错误堆栈如下

Sentry Client 会将其先进行格式化处理,Sentry 发送给后端的错误堆栈格式下面这种格式化数据

问题来了,为啥 Sentry 要经过这样一番格式化处理,以及格式化处理中可能会发生什么问题呢。

V8 StackTrace API

按理来讲 Error 对象作为标准里规定的 Ordinary Object ,其在不同的 JS 引擎上表现行为应该一致,但是很不幸,标准里虽然规定了 Error 对象是个 Ordinary Object,但也只规定了 name 和 message 两个属性的行为,对于最广泛使用的 stack 属性,并没有加以定义,这导致了 JS 引擎在 stack 属性的表现差别很大(目前已经有一个标准化 stack 的 proposal),甚至有的引擎实现已经突破了标准的限定,使得 Error 表现的更像一个 Exotic Object。我们来具体看看各引擎对于 Error 对象的实现差异。

V8 支持了 stack 属性,并且给 stack 属性提供了丰富的配置,如下是一个基本的 stack 信息。

function inner() {

myUndefinedFunction();

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
](https://i-blog.csdnimg.cn/blog_migrate/267ae840d32f4df435c79a4f1e19eda3.png)

Sentry Client 会将其先进行格式化处理,Sentry 发送给后端的错误堆栈格式下面这种格式化数据

问题来了,为啥 Sentry 要经过这样一番格式化处理,以及格式化处理中可能会发生什么问题呢。

V8 StackTrace API

按理来讲 Error 对象作为标准里规定的 Ordinary Object ,其在不同的 JS 引擎上表现行为应该一致,但是很不幸,标准里虽然规定了 Error 对象是个 Ordinary Object,但也只规定了 name 和 message 两个属性的行为,对于最广泛使用的 stack 属性,并没有加以定义,这导致了 JS 引擎在 stack 属性的表现差别很大(目前已经有一个标准化 stack 的 proposal),甚至有的引擎实现已经突破了标准的限定,使得 Error 表现的更像一个 Exotic Object。我们来具体看看各引擎对于 Error 对象的实现差异。

V8 支持了 stack 属性,并且给 stack 属性提供了丰富的配置,如下是一个基本的 stack 信息。

function inner() {

myUndefinedFunction();

最后

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,自己不成体系的自学效果低效漫长且无助。

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-toFjtVek-1715592101153)]

[外链图片转存中…(img-fskdAAHz-1715592101153)]

[外链图片转存中…(img-7sm8cjjf-1715592101154)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点!不论你是刚入门Android开发的新手,还是希望在技术上不断提升的资深开发者,这些资料都将为你打开新的学习之门!

如果你觉得这些内容对你有帮助,需要这份全套学习资料的朋友可以戳我获取!!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值