拉勾教育大前端高薪訓練營 心得體會+學習筆記

文章內容輸出來源:拉勾教育大前端高薪訓練營

拉勾教育大前端高薪訓練營 心得體會+學習筆記

心得體會

已經學習了兩週半拉鉤教育大前端課程了,課程質量真的是好得沒話說,我看過很多前端的課程,但從沒有哪家課程能將前端的知識體系劃分的如此全面細緻,還能保證每一個知識點還都能講得如此透徹,在講知識點的基礎上還能開篇幅去講思想,更是難得。比如下面的函數式編程,這種編程範式我之前從來都沒使用過,更不知道柯里化、函數組合爲何物。直到在拉鉤大前端課程中,每一個知識點的學習,都讓我有種重獲新生的感覺,彷彿以前學習的東西都白學了,只知道簡單的用法,不瞭解核心原理,更不會用高級特性。現在每學習完一個模塊,就期待着解鎖下一個模塊,迫不及待地想去知道下一個模塊可以讓自己get到哪些技能。

課程的主講老師,汪磊老師,我看過他的webpack專欄,那時我就非常佩服他能夠把webpack這樣一個大而繁瑣的工具,講得如此細微易懂,讓我懂了webpack的插件機制和loader機制。在大前端課程中,汪磊老師更是讓我敬佩,我感覺他的知識面非常廣,說他什麼都懂也不爲過,他還總是把我們在學習中會遇到的問題演示出來,或者是提出來讓我們注意。最感謝汪磊老師的地方,就是在JS異步章節,直播課中的補充中,老師演示了各種function會影響this問題,道出了this取決於調用而不是定義,讓我醍醐灌頂,也徹底搞懂了JS的this的取值,那天晚上令我激動地睡不着覺。

除此之外,兩位助教老師還整天在羣內答疑,只要遇到不懂的地方,就可以立馬去羣裏問助教老師,老師會看到問題就會立馬回覆,如果是代碼執行問題,還會把你的代碼下載下來親自運行排查,真的是太貼心了。班主任老師會在羣裏每天督促同學們交作業,遇到軟件問題、作業提交問題、聽課問題都可以找班主任老師。

一個人學習或許會太孤獨,但是在拉鉤教育大前端課程裏,每天和幾百人一起學習,羣裏還有專業的助教老師答疑,其他同學很多都是前端大佬,在你遇到問題的時候,無論是什麼問題,只要是前端問題,總會有人給你解答或者提供思路。

在拉鉤教育大前端課程中,一起學習,使彼此共同成長。


函數式編程範式

一、高階函數

使用高階函數的意義:抽象可以幫我們屏蔽細節,只需要關注於我們的目標。高階函數是用來抽象通用的問題。

1. 函數作爲參數

function forEach (array, fn) {
  for (let i = 0; i < array.length; i++) {
    fn(array[i])
  }
}

function filter (array, fn) {
  const res = []
  for (let i = 0; i < array.length; i++) {
    if(fn(array[i])) {
      res.push(array[i])
    }
  }
  return res
}

const arr = [1, 2, 4, 5, 2]

forEach(arr, console.log)
console.log(filter(arr, function (item) {
  return item % 2 === 0
}))

2. 函數作爲返回值

function makeFn () {
  let msg = 'hello function'
  return function () {
    console.log(msg)
  }
}

const fn = makeFn()
fn() // hello function

makeFn()() // hello function

應用:once函數 只執行一次的函數,比如說支付情況,無論用戶點多少次,這個函數都只執行一次

function once(fn) {
  let done = false
  return function () {
    if(!done) {
      done = true
      fn.apply(this, arguments)
    }
  }
}

let pay = once(function (money) {
  console.log(`支付了${money}元`)
})

pay(1) // 支付了1元
pay(2)
pay(3)

常用的高階函數:

forEach/map/filter/every/some/find/findIndex/reduce/sort

// 模擬常用的高階函數:map every some
const arr = [1, 2, 3, 4]

// map 
const map = (arr, fn) => {
  let result = []
  for(let item of arr) {
    result.push(fn(item))
  }
  return result
}
console.log(map(arr, val => val * val)) // [ 1, 4, 9, 16 ]

// every
const every = (arr, fn) => {
  for(let item of arr) {
    if(!fn(item))return false
  }
  return true
}
console.log(every(arr, v => v > 0)) // true

// some
const some = (arr, fn) => {
  for(let item of arr) {
    if(fn(item))return true
  }
  return false
}
console.log(some(arr, v => v % 2 == 0)) // true

二、閉包

函數和其周圍的狀態(詞法環境)的引用捆綁在一起形成閉包。可以在一個作用域中調用一個函數的內部函數並訪問到該函數的作用域中的成員。

本質:函數在執行的時候會放到一個執行棧上,當函數執行完畢之後會從執行棧移除,但是堆上的作用域成員因爲外部引用不能釋放,因此內部函數依然可以訪問外部函數的成員

function makeFn () {
  let msg = 'hello function'
  return function () {
    console.log(msg)
  }
}

const fn = makeFn()
fn() // hello function

閉包的應用:

function makePower(power) {
  return function (num) {
    return Math.pow(num, power)
  }
}

// 求平方
let power2 = makePower(2)
let power3 = makePower(3)

console.log(power2(4))
console.log(power2(5))
console.log(power3(4))

三、純函數

  • 相同的輸入永遠會得到相同的輸出

  • 沒有任何可觀察的副作用

  • 類似數學中的函數

  • lodash是一個純函數的功能庫,提供了對數組、數字、對象、字符串、函數等操作的一些方法

  • 數組的slice和splice分別是純函數和不純的函數

    • slice返回數組中的指定部分,不改變原數組
    • splice對數組進行操作返回該數組,會改變原數組
    // 純函數slice和不純函數splice
    
    let arr = [1, 2, 3, 4, 5]
    
    console.log(arr.slice(0, 3))
    console.log(arr.slice(0, 3))
    console.log(arr.slice(0, 3))
    
    console.log(arr.splice(0, 3))
    console.log(arr.splice(0, 3))
    console.log(arr.splice(0, 3))
    
    function getSum(n1, n2) {
      return n1 + n2
    }
    
    console.log(1, 2)
    console.log(1, 2)
    console.log(1, 2)
    
  • 函數式編程不會保留計算中間的結果,所以變量是不可變的(無狀態的)

  • 我們可以把一個函數的執行結果交給另一個函數去處理

1. lodash 純函數庫

// 演示 lodash
// first  last toUpper reverse each includes find findIndx
const _ = require('lodash')
const arr = ['jal', 'cathy', 'yibo', 'lucy']

console.log(_.first(arr))
console.log(_.last(arr))

console.log(_.toUpper(_.first(arr)))

console.log(_.reverse(arr))

const r = _.each(arr, (item, index) => {
  console.log(item, index)
})
console.log(r)

緩存純函數結果案例:

// 記憶函數
const _ = require('lodash')

function getArea (r) {
  console.log(r)
  return Math.PI * r * r * r
}

// let getAreaWithMemory = _.memoize(getArea)
// console.log(getAreaWithMemory(4))
// console.log(getAreaWithMemory(4))
// console.log(getAreaWithMemory(4))
// console.log(getAreaWithMemory(4))

// 模擬memoize的實現
function memoize(fn) {
  const cache = {}
  return function () {
    let key = JSON.stringify(arguments)
    cache[key] = cache[key] || fn.apply(fn, arguments)
    return cache[key]
  }
}

let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))
console.log(getAreaWithMemory(4))

2. 純函數的好處

  • 可緩存
    • 因爲純函數對相同的輸入始終有相同的結果,所以可以把純函數的結果緩存起來
  • 可測試
    • 純函數讓測試更加方便
  • 並行處理
    • 在多線程環境並行操作共享的內存數據可能會出現意外情況
    • 純函數不需要訪問共享的內存數據,所以在並行環境下可以任意運行純函數(Web Worker)

3. 沒有任何可觀察的副作用

  • 純函數對於相同的輸入永遠會得到相同的輸出,而且沒有任何可觀察的副作用
// 不純的,函數的返回值依賴外部的變量
let mini = 18
function checkAge (age) {
  return age >= mini
}

// 純的(有硬編碼,後續可以通過柯里化解決)
function checkAge2 (age) {
  let mini = 18
  return age >= mini
}

副作用讓一個函數變得不純(如上例的checkAge中的mini是全局的),純函數的根據相同的輸入返回相同的輸出,如果函數依賴於外部的狀態就無法保證輸出相同,就會帶來副作用。

副作用的來源:

  • 配置文件

  • 數據庫

  • 獲取用戶的輸入

    所有的外部交互都有可能代理副作用,副作用也是的方法通用性下降不適合擴展和可重用性,同時副作用會給程序中帶來安全隱患給程序員帶來不確定性,但是副作用不可能完全禁止,儘可能控制他們在可控範圍內發生。

四、柯里化

  • 當一個函數有多個參數的時候,先傳遞一部分參數調用它(這部分參數以後永遠不變)
  • 然後返回一個新的函數接受剩餘的參數,返回結果

1. 使用柯里化解決上一個案例中的硬編碼的問題:

// 柯里化演示
// function checkAge (age) {
//   let mini = 18
//   return age >= mini
// }

// 普通的純函數
function checkAge (mini, age) {
  return age >= mini
}

console.log(checkAge(18, 20))
console.log(checkAge(18, 24))
console.log(checkAge(22, 24))

// 閉包,高階函數,函數的柯里化
function saveMini (mini) {
  return function (age) {
    return age >= mini
  }
}

// ES6 寫法, 同上
// const saveMini = mini => age => age >= mini

const checkAge18 = saveMini(18)
const checkAge22 = saveMini(22)
console.log(checkAge18(20))
console.log(checkAge18(24))
console.log(checkAge22(24))

2. lodash中的柯里化函數

_.curry(func)

  • 功能:創建一個函數,該函數接受一個或多個func的參數,如果func所需要的參數都被提供則執行func並返回執行的結果,否則繼續返回改函數並等待接受剩餘的參數。
  • 參數:需要柯里化的函數
  • 返回值:柯里化後的函數
// _.curry(func)
const _ = require('lodash')

function getSum (a, b, c) {
  return a + b + c
}

const curried = _.curry(getSum)
console.log(curried(1, 2, 3)) // 6

console.log(curried(1)(2, 3))// 6

console.log(curried(1, 2)(3))// 6

案例:

// 柯里化案例
// ''.match(/\s+/g)
// ''.match(/\d+/g)

const _ = require('lodash')

const match = _.curry(function (reg, str) {
  return str.match(reg)
})

const haveSpace = match(/\s+/g)

console.log(haveSpace('hello world')) // [ ' ' ]
console.log(haveSpace('hello')) // null

const haveNumber = match(/\d+/g)

console.log(haveNumber('123abc456def789')) // [ '123', '456', '789' ]
console.log(haveNumber('jal')) // null


const filter = _.curry(function (func, arr) {
  return arr.filter(func)
})

console.log(filter(haveSpace, ['hello world', 'Ji Ailing', 'cathy', 'yibo', 'Wang Yibo']))
// [ 'hello world', 'Ji Ailing', 'Wang Yibo' ]

const findSpace = filter(haveSpace)
console.log(findSpace(['hello world', 'Ji Ailing', 'cathy', 'yibo', 'Wang Yibo']))
// [ 'hello world', 'Ji Ailing', 'Wang Yibo' ]

3. 模擬實現柯里化

function getSum (a, b, c) {
  return a + b + c
}

const myCurried = curry(getSum)
console.log(myCurried(1, 2, 3)) // 6
console.log(myCurried(1)(2, 3))// 6
console.log(myCurried(1, 2)(3))// 6

function curry(fn) {
  return function curriedFn (...args) {
    if(args.length < fn.length) {
      return function () {
        return curriedFn(...args.concat(Array.from(arguments)))
        // return fn(...args, ...arguments) // 這樣寫也是一樣的
      }
    }else {
      return fn(...args)
    }
    
  }
}

4. 總結

  • 柯里化可以讓我們給一個函數傳遞較少的參數得到一個已經記住了某些固定參數的新函數
  • 這是一種對函數參數的‘緩存’
  • 讓函數變得更靈活,讓函數的粒度更細
  • 可以把多元函數轉換成一元函數,可以組合使用函數產生強大的功能

五、函數組合

如果一個函數要經過多個函數處理才能得到最終值,這個時候可以把中間過程的函數合併成一個函數。

  • 函數就像是數據的管道,函數組合就是把這些管道連接起來,讓數據穿過多個管道形成最終結果
  • 函數組合默認是從右到左執行
function compose(f, g) {
  return function (value) {
    return f(g(value))
  }
}

function reverse (arr) {
  return arr.reverse()
}

function first (arr) {
  return arr[0]
}

const last = compose(first, reverse)

console.log(last([1, 2, 3, 4])) // 4

1. lodash中的組合函數flow、flowRight

  • lodash中組合函數flow()或者flowRight(),他們都可以組合多個函數
  • flow()是從左到右運行
  • flowRight()是從右到左運行,使用的更多一些
const _ = require('lodash')

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = str => str.toUpperCase()

const f = _.flowRight(toUpper, first, reverse)

console.log(f(['one', 'two', 'three'])) // THREE

模擬實現flowRight:

// function compose (...args) {
//   return function (value) {
//     return args.reverse().reduce(function (acc, fn) {
//       return fn(acc)
//     }, value)
//   }
// }

// 將上面的寫法修改成箭頭函數
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = str => str.toUpperCase()

const f = compose(toUpper, first, reverse)

console.log(f(['one', 'two', 'three'])) // THREE

2. 函數組合要滿足結合律:

  • 我們既可以把g和h組合,還可以把f和g組合,結果都是一樣的

    const _ = require('lodash')
    
    const f = _.flowRight(_.toUpper, _.first, _.reverse)
    console.log(f(['one', 'two', 'three'])) // THREE
    
    const f2 = _.flowRight(_.flowRight(_.toUpper, _.first), _.reverse)
    console.log(f2(['one', 'two', 'three'])) // THREE
    
    const f3 = _.flowRight(_.toUpper, _.flowRight(_.first, _.reverse))
    console.log(f3(['one', 'two', 'three'])) // THREE
    

3. 調試

// NEVER SAY DIE --> never-say-die

const _ = require('lodash')

const split = _.curry((sep, str)=>_.split(str, sep))
// 爲什麼要調換兩個參數的位置?因爲要保證函數只有一個參數的函數,那就要通過柯里化實現。
// 而柯里化想要保留一個參數,那就只能保留最後一個參數,所以要把str放到最後

const join = _.curry((sep, arr) => _.join(arr, sep))

const map = _.curry((fn, arr) => _.map(arr, fn))

const log = v => {
  console.log(v)
  return v
}

const trace = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})

// const f = _.flowRight(join('-'), log, _.toLower, split(' ')) // n-e-v-e-r-,-s-a-y-,-d-i-e
// const f = _.flowRight(join('-'), log, split(' '), _.toLower) // never-say-die
const f = _.flowRight(join('-'), trace('map之後'), map(_.toLower), trace('split之後'), split(' ')) // never-say-die
console.log(f('NEVER SAY DIE'))

4. lodash-fp模塊

  • lodash的fp模塊提供了實用的對函數式編程友好的方法, 函數優先,數據在後
  • 提供了不可變auto-curried iteratee-first data-last的方法
// lodash的fp模塊
// NEVER SAY DIE --> never-say-die

const fp = require('lodash/fp')

const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))
console.log(f('NEVER SAY DIE')) // never-say-die
  • lodash與lodash-fp中map的區別

    const _ = require('lodash')
    const fp = require('lodash/fp')
    
    // lodash中的map中的函數的參數有三個:(item, index, array)
    console.log(_.map(['23', '8', '10'], parseInt)) // [ 23, NaN, 2 ]
    // parseInt('23', 0, array) 第二個參數是0,則是10進制
    // parseInt('8', 1, array) 第二個參數是1,不合法,輸出NaN
    // parseInt('10', 2, array) 第二個參數是2,表示2進制,輸出2
    
    // lodashFp中的map中的函數的參數有1個:(item)
    console.log(fp.map(parseInt, ['23', '8', '10'])) // [ 23, 8, 10 ]
    

5. Pointfree模式

我們可以在數據處理的過程中定義成與數據無關的合成運算,不需要用到代表數據的那個參數,只要把簡單的運算步驟合成到一起,在使用這種模式之前我們需要定義一些輔助的基本運算函數。

  • 不需要指明處理的函數
  • 只需要合成運算過程
  • 需要定義一些輔助的基本運算函數
// point free 函數的組合
// Hello   World => hello_world

const fp = require('lodash/fp')

const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(f('Hello   World')) // hello_world

案例:把一個字符串中的首字母提取並轉換成大寫,使用. 作爲分隔符

// world wild web ==> W. W. W
const fp = require('lodash/fp')
// const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) // W. W. W

六、Functor(函子)

1. 什麼是Functor:

  • 容器:包含值和值的變形關係(這個變性關係就是函數)
  • 函子:是一個特殊的容器,通過一個普通的對象來實現,該對象具有map方法,map方法可以運行一個函數對值進行處理(變性關係)
class Container {
  constructor(value) {
    this._value = value
  }
  map (fn) {
    return new Container(fn(this._value))
  }
}

let r = new Container(5)
.map(x=>x+1)
.map(x=>x*x)
// .map(console.log)
console.log(r) // Container { _value: 36 }

使用靜態方法創建對象:

class Container {
  static of (value) {
    return new Container(value)
  }
  constructor(value) {
    this._value = value
  }
  map (fn) {
    return Container.of(fn(this._value))
  }
}

let r = Container.of(5)
.map(x => x+2)
.map(x => x*x)

console.log(r) // Container { _value: 49 }

2. 總結

  • 函數式編程的運算不直接操作值,而是由函子完成
  • 函子就是一個實現了map契約的對象
  • 我們可以把函子想象成一個盒子,這個盒子封裝了一個值
  • 想要處理盒子中的值,我們需要給盒子的map方法傳遞一個處理值的函數(純函數),由這個番薯來對值進行處理
  • 最終map方法返回一個包含新值的盒子(函子)

3. MayBe函子

普通函子出現異常會變得不純,MayBe可以處理異常值

class MayBe {
  static of (value) {
    return new MayBe(value)
  }

  constructor(value) {
    this._value = value
  }
  map (fn) {
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

  isNothing () {
    return this._value === null || this._value === undefined
  }
}

let r = MayBe.of('hello')
.map(x => x.toUpperCase())
console.log(r) // MayBe { _value: 'HELLO' }

let r2 = MayBe.of(null)
.map(x => x.toUpperCase())
console.log(r2) // MayBe { _value: null }

let r3 = MayBe.of('hello world')
.map(x => x.toUpperCase())
.map(x => null)
.map(x => x.split(' '))
console.log(r3) // MayBe { _value: null } 無法知道null是哪裏發生的

4. Either函子

  • Either 兩者中的任何一個,類似於if…else…的處理
  • 異常會讓函數變得不純,Either函子可以用來做異常處理

Left存異常信息,Right存正常信息

class Left {
  static of (value) {
    return new Left(value)
  }

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return this
  }
}

class Right {
  static of (value) {
    return new Right(value)
  }

  constructor (value) {
    this._value = value
  }

  map (fn) {
    return Right.of(fn(this._value))
  }
}

let r1 = Right.of(12)
.map(x => x + 2)
console.log(r1)
let r2 = Left.of(12) // Right { _value: 14 }
.map(x => x + 2)
console.log(r2) // Left { _value: 12 }

function parseJSON (str) {
  try {
    return Right.of(JSON.parse(str))
  } catch (e) {
    return Left.of({error: e.message})
  }
}

let r3 = parseJSON('{name: jal}')
console.log(r3) // Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let r4 = parseJSON('{"name": "jal"}')
console.log(r4) // Right { _value: { name: 'jal' } }
let r5 = r4.map(x => x.name.toUpperCase())
console.log(r5) // Right { _value: 'JAL' }

5. IO函子

  • IO函子中的_value是一個函數,這裏是把函數作爲值來處理
  • IO函子可以把不純的動作存儲到_value中,延遲執行這個不純的操作(惰性執行),包裝當前的操作純
  • 把不純的操作交給調用者處理
const fp = require('lodash')

class IO {
  static of (value) {
    return new IO(function () {
      return value
    })
  }

  constructor (fn) {
    this._value = fn
  }

  map (fn) {
    return new IO(fp.flowRight(fn, this._value))
  }
}

const r = IO.of(process).map(p => p.execPath)
console.log(r) // IO { _value: [Function (anonymous)] }
console.log(r._value()) // /usr/local/Cellar/node/13.6.0/bin/node

5. folktale Task函子

Task函子處理異步執行

  • 異步任務的實現過於複雜,我們使用folktale中的Task來演示
  • folktale:一個標準的函數式編程庫
    • 和lodash、ramda不同的是,他沒有提供很多功能函數
    • 只提供了一些函數式處理的操作,例如:compose、curry等,一些函子Task、Either、MayBe等
// folktale的使用
const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
// curry 第一個參數寫上參數的個數
const f = curry(2, (x, y) => x + y)
console.log(f(1, 2)) // 3
console.log(f(1)(2)) // 3

// folktale中的compose相當於lodash中的flowRight
const f2 = compose(toUpper, first)
console.log(f2(['one', 'two'])) // ONE
  • folktale(2.3.2)2.x中的Task和1.0中的Task區別很大,1.0這種的用法更接近我們現在演示的函子
  • 這裏以2.3.2來演示
// Task 處理異步任務
const fs = require('fs')
const { task } = require('folktale/concurrency/task')
// 柯里化的方法:split、find
const { split, find } = require('lodash/fp')
function readFile (filename) {
  // 返回一個函子
  return task(resolver => {
    fs.readFile(filename, 'utf-8', (err, data) => {
      if (err) {
        resolver.reject(err)
      }
      resolver.resolve(data)
    })
  })
}

readFile('package.json')
.map(split('\n'))
.map(find(x => x.includes('version')))
.run()
.listen({
  onRejected: err => {
    console.log(err)
  },
  onResolved: value => {
    console.log(value) //   "version": "1.0.0",
  }
})

6. Pointed函子

  • Pointed函子是實現了of靜態方法的函子
  • of方法是爲了避免使用new來創建對象,更深層的含義是of方法用來把值放到上下文Context(把值放到容器中,使用map來處理值)
class Container {
  static of (value) {
    return new Container(value)
  }
  constructor(value) {
    this._value = value
  }
  map (fn) {
    return Container.of(fn(this._value))
  }
}

7. Monad(函子)

  • Monad函子是可以變扁的Pointed函子,IO(IO(x))
  • 一個函子如果具有join和of兩個方法並遵守一些定律就是一個Monad
const fp = require('lodash')
const fs = require('fs')
class IO {
  static of (value) {
    return new IO(function () {
      return value
    })
  }

  constructor (fn) {
    this._value = fn
  }

  map (fn) {
    return new IO(fp.flowRight(fn, this._value))
  }

  join () {
    return this._value()
  }

  // 當fn返回一個函子的時候,用flatMap拍平
  flatMap (fn) {
    return this.map(fn).join()
  }
}

const readFile = function (filename) {
  return new IO(function () {
    return fs.readFileSync(filename, 'utf-8')
  })
}

const print = function (x) {
  return new IO(function () {
    console.log(x)
    return x
  })
}

// const cat = fp.flowRight(print, readFile)
// // IO(IO(x))
// // const r = cat('package.json')._value() // IO { _value: [Function (anonymous)] }
// const r = cat('package.json')._value()._value()

const r = readFile('package.json')
.map(fp.toUpper)
.flatMap(print)
.join()

console.log(r)

文章內容輸出來源:拉勾教育大前端高薪訓練營

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章