前言
這是
學習源碼整體架構系列
第七篇。整體架構這詞語好像有點大,姑且就算是源碼整體結構吧,主要就是學習是代碼整體結構,不深究其他不是主線的具體函數的實現。本篇文章學習的是實際倉庫的代碼。
學習源碼整體架構系列
文章如下:
1.學習 jQuery 源碼整體架構,打造屬于自己的 js 類庫
2.學習 underscore 源碼整體架構,打造屬于自己的函數式編程類庫
3.學習 lodash 源碼整體架構,打造屬于自己的函數式編程類庫
4.學習 sentry 源碼整體架構,打造屬于自己的前端異常監控SDK
5.學習 vuex 源碼整體架構,打造屬于自己的狀態管理庫
6.學習 axios 源碼整體架構,打造屬于自己的請求庫
感興趣的讀者可以點擊閱讀。
其他源碼計劃中的有:express
、vue-rotuer
、redux
、 ?react-redux
等源碼,不知何時能寫完(哭泣),歡迎持續關注我(若川)。
源碼類文章,一般閱讀量不高。已經有能力看懂的,自己就看了。不想看,不敢看的就不會去看源碼。
所以我的文章,盡量寫得讓想看源碼又不知道怎么看的讀者能看懂。
如果你簡歷上一不小心寫了熟悉koa
,面試官大概率會問:
1、
koa
洋蔥模型怎么實現的。
2、如果中間件中的next()
方法報錯了怎么辦。
3、co
的原理是怎樣的。
等等問題
導讀
文章通過例子調試koa
,梳理koa
的主流程,來理解koa-compose
洋蔥模型原理和co
庫的原理,相信看完一定會有所收獲。
本文學習的koa
版本是v2.11.0
。克隆的官方倉庫的master
分支。截至目前(2020年3月11日),最新一次commit
是2020-01-04 07:41 Olle Jonsson
eda27608
,build: Drop unused Travis sudo: false directive (#1416)
。
本文倉庫在這里若川的 koa-analysis github 倉庫 https://github.com/lxchuan12/koa-analysis。求個star
呀。
本文閱讀最佳方式
先star
一下我的倉庫,再把它git clone https://github.com/lxchuan12/koa-analysis.git
克隆下來。不用管你是否用過nodejs
。會一點點promise、generator、async、await
等知識即可看懂。如果一點點也不會,可以邊看阮一峰老師的《ES6標準入門》相關章節。跟著文章節奏調試和示例代碼調試,動手調試(用vscode
或者chrome
)印象更加深刻。文章長段代碼不用細看,可以調試時再細看。看這類源碼文章百遍,可能不如自己多調試幾遍。也歡迎加我微信交流lxchuan12
。
# 克隆我的這個倉庫
git clone https://github.com/lxchuan12/koa-analysis.git
# chrome 調試:
# 全局安裝 http-server
npm i -g http-server
hs koa/examples/
# 可以指定端口 -p 3001
# hs -p 3001 koa/examples/
# 瀏覽器中打開
# 然后在瀏覽器中打開localhost:8080,開心的把代碼調試起來
這里把這個examples
文件夾做個簡單介紹。
middleware
文件夾是用來vscode
調試整體流程的。simpleKoa
文件夾是koa
簡化版,為了調試koa-compose
洋蔥模型如何串聯起來各個中間件的。koa-convert
文件夾是用來調試koa-convert
和co
源碼的。co-generator
文件夾是模擬實現co
的示例代碼。
vscode 調試 koa 源碼方法
之前,我在知乎回答了一個問題一年內的前端看不懂前端框架源碼怎么辦?推薦了一些資料,閱讀量還不錯,大家有興趣可以看看。主要有四點:
1.借助調試
2.搜索查閱相關高贊文章
3.把不懂的地方記錄下來,查閱相關文檔
4.總結
看源碼,調試很重要,所以我詳細寫下 koa
源碼調試方法,幫助一些可能不知道如何調試的讀者。
# 我已經克隆到我的koa-analysis倉庫了
git clone https://github.com/koajs/koa.git
// package.json
{"name": "koa","version": "2.11.0","description": "Koa web app framework","main": "lib/application.js",
}
克隆源碼后,看package.json
找到main
,就知道入口文件是lib/application.js
了。
大概看完項目結構后發現沒有examples
文件夾(一般項目都會有這個文件夾,告知用戶如何使用該項目),這時仔細看README.md
。如果看英文README.md
有些吃力,會發現在Community
標題下有一個中文文檔 v2.x。同時也有一個examples
倉庫。
# 我已經克隆下來到我的倉庫了
git clone https://github.com/koajs/examples.git
這時再開心的把examples
克隆到自己電腦。可以安裝好依賴,逐個研究學習下這里的例子,然后可能就一不小心掌握了koa
的基本用法。當然,我這里不詳細寫這一塊了,我是自己手寫一些例子來調試。
繼續看文檔會發現使用指南講述編寫中間件
。
使用文檔中的中間件koa-compose
例子來調試
學習 koa-compose
前,先看兩張圖。
在koa
中,請求響應都放在中間件的第一個參數context
對象中了。
再引用Koa中文文檔中的一段:
如果您是前端開發人員,您可以將 next()
; 之前的任意代碼視為“捕獲”階段,這個簡易的 gif
說明了 async
函數如何使我們能夠恰當地利用堆棧流來實現請求和響應流:
創建一個跟蹤響應時間的日期
等待下一個中間件的控制
創建另一個日期跟蹤持續時間
等待下一個中間件的控制
將響應主體設置為“Hello World”
計算持續時間
輸出日志行
計算響應時間
設置
X-Response-Time
頭字段交給 Koa 處理響應
讀者們看完這個gif圖,也可以思考下如何實現的。根據表現,可以猜測是next
是一個函數,而且返回的可能是一個promise
,被await
調用。
看到這個gif
圖,我把之前寫的examples/koa-compose
的調試方法含淚刪除了。默默寫上gif
圖上的這些代碼,想著這個讀者們更容易讀懂。我把這段代碼寫在這里 koa/examples/middleware/app.js
便于調試。
在項目路徑下配置新建.vscode/launch.json文件,program
配置為自己寫的koa/examples/middleware/app.js
文件。
按F5鍵
開始調試,調試時先走主流程,必要的地方打上斷點,不用一開始就關心細枝末節。
斷點調試要領:
賦值語句可以一步跳過,看返回值即可,后續詳細再看。
函數執行需要斷點跟著看,也可以結合注釋和上下文倒推這個函數做了什么。
上述比較啰嗦的寫了一堆調試方法。主要是想著授人予魚不如授人予漁
,這樣換成其他源碼也會調試了。
簡單說下chrome
調試nodejs
,chrome
瀏覽器打開chrome://inspect
,點擊配置**configure...**配置127.0.0.1:端口號
(端口號在Vscode
調試控制臺顯示了)。
更多可以查看English Debugging Guide
中文調試指南
喜歡看視頻的讀者也可以看慕課網這個視頻node.js調試入門,講得還是比較詳細的。
不過我感覺在chrome
調試nodejs
項目體驗不是很好(可能是我方式不對),所以我大部分具體的代碼時都放在html
文件script
形式,在chrome
調試了。
先看看 new Koa()
結果app
是什么
看源碼我習慣性看它的實例對象結構,一般所有屬性和方法都放在實例對象上了,而且會通過原型鏈查找形式查找最頂端的屬性和方法。
用koa/examples/middleware/app.js
文件調試時,先看下執行new Koa()
之后,app
是什么,有個初步印象。
// 文件 koa/examples/middleware/app.js
const Koa = require('../../lib/application');// const Koa = require('koa');
// 這里打個斷點
const app = new Koa();
// x-response-time// 這里打個斷點
app.use(async (ctx, next) => {});
在調試控制臺ctrl + 反引號鍵(一般在
Tab上方的按鍵)喚起
,輸入app
,按enter
鍵打印app
。會有一張這樣的圖。
VScode
也有一個代碼調試神器插件Debug Visualizer
。
安裝好后插件后,按ctrl + shift + p
,輸入Open a new Debug Visualizer View
,來使用,輸入app
,顯示是這樣的。
不過目前體驗來看,相對還比較雞肋,只能顯示一級,而且只能顯示對象,相信以后會更好。更多玩法可以查看它的文檔。
我把koa實例對象比較完整的用xmind
畫出來了,大概看看就好,有個初步印象。
接著,我們可以看下app 實例、context、request、request
的官方文檔。
app 實例、context、request、request 官方API文檔
index API | context API | request API | response API
可以真正使用的時候再去仔細看文檔。
koa 主流程梳理簡化
通過F5啟動調試(直接跳到下一個斷點處)
、F10單步跳過
、F11單步調試
等,配合重要的地方斷點,調試完整體代碼,其實比較容易整理出如下主流程的代碼。
class Emitter{// node 內置模塊constructor(){}
}
class Koa extends Emitter{constructor(options){super();options = options || {};this.middleware = [];this.context = {method: 'GET',url: '/url',body: undefined,set: function(key, val){console.log('context.set', key, val);},};}use(fn){this.middleware.push(fn);return this;}listen(){const fnMiddleware = compose(this.middleware);const ctx = this.context;const handleResponse = () => respond(ctx);const onerror = function(){console.log('onerror');};fnMiddleware(ctx).then(handleResponse).catch(onerror);}
}
function respond(ctx){console.log('handleResponse');console.log('response.end', ctx.body);
}
重點就在listen
函數里的compose
這個函數,接下來我們就詳細來欣賞下這個函數。
koa-compose 源碼(洋蔥模型實現)
通過app.use()
添加了若干函數,但是要把它們串起來執行呀。像上文的gif
圖一樣。
compose
函數,傳入一個數組,返回一個函數。對入參是不是數組和校驗數組每一項是不是函數。
function compose (middleware) {if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')for (const fn of middleware) {if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')}// 傳入對象 context 返回Promisereturn function (context, next) {// last called middleware #let index = -1return dispatch(0)function dispatch (i) {if (i <= index) return Promise.reject(new Error('next() called multiple times'))index = ilet fn = middleware[i]if (i === middleware.length) fn = nextif (!fn) return Promise.resolve()try {return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));} catch (err) {return Promise.reject(err)}}}
}
把簡化的代碼和koa-compose
代碼寫在了一個文件中。koa/examples/simpleKoa/koa-compose.js
hs koa/examples/
# 然后可以打開localhost:8080/simpleKoa,開心的把代碼調試起來
不過這樣好像還是有點麻煩,我還把這些代碼放在codepen
https://codepen.io/lxchuan12/pen/wvarPEb中,直接可以在線調試啦。是不是覺得很貼心^_^,自己多調試幾遍便于消化理解。
你會發現compose
就是類似這樣的結構(移除一些判斷)。
// 這樣就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
const fnMiddleware = function(context){return Promise.resolve(fn1(context, function next(){return Promise.resolve(fn2(context, function next(){return Promise.resolve(fn3(context, function next(){return Promise.resolve();}))}))}));
};
fnMiddleware(ctx).then(handleResponse).catch(onerror);
也就是說
koa-compose
返回的是一個Promise
,Promise
中取出第一個函數(app.use
添加的中間件),傳入context
和第一個next
函數來執行。
第一個next
函數里也是返回的是一個Promise
,Promise
中取出第二個函數(app.use
添加的中間件),傳入context
和第二個next
函數來執行。
第二個next
函數里也是返回的是一個Promise
,Promise
中取出第三個函數(app.use
添加的中間件),傳入context
和第三個next
函數來執行。
第三個...
以此類推。最后一個中間件中有調用next
函數,則返回Promise.resolve
。如果沒有,則不執行next
函數。這樣就把所有中間件串聯起來了。這也就是我們常說的洋蔥模型。
不得不說非常驚艷,“玩還是大神會玩”。
這種把函數存儲下來的方式,在很多源碼中都有看到。比如lodash
源碼的惰性求值,vuex
也是把action
等函數存儲下,最后才去調用。
搞懂了koa-compose
洋蔥模型實現的代碼,其他代碼就不在話下了。
錯誤處理
中文文檔 錯誤處理
仔細看文檔,文檔中寫了三種捕獲錯誤的方式。
ctx.onerror
中間件中的錯誤捕獲app.on('error', (err) => {})
最外層實例事件監聽形式 也可以看看例子koajs/examples/errors/app.js 文件app.onerror = (err) => {}
重寫onerror
自定義形式 也可以看測試用例 onerror
// application.js 文件
class Application extends Emitter {// 代碼有簡化組合listen(){const fnMiddleware = compose(this.middleware);if (!this.listenerCount('error')) this.on('error', this.onerror);const onerror = err => ctx.onerror(err);fnMiddleware(ctx).then(handleResponse).catch(onerror);}onerror(err) {// 代碼省略// ...}
}
ctx.onerror
lib/context.js
文件中,有一個函數onerror
,而且有這么一行代碼this.app.emit('error', err, this)
。
module.exports = {onerror(){// delegate// app 是在new Koa() 實例this.app.emit('error', err, this);}
}
app.use(async (ctx, next) => {try {await next();} catch (err) {err.status = err.statusCode || err.status || 500;throw err;}
});
try catch
錯誤或被fnMiddleware(ctx).then(handleResponse).catch(onerror);
,這里的onerror
是ctx.onerror
而ctx.onerror
函數中又調用了this.app.emit('error', err, this)
,所以在最外圍app.on('error',err => {})
可以捕獲中間件鏈中的錯誤。因為koa
繼承自events模塊
,所以有'emit'和on
等方法)
koa2 和 koa1 的簡單對比
中文文檔中描述了 koa2 和 koa1 的區別
koa1
中主要是generator
函數。koa2
中會自動轉換generator
函數。
// Koa 將轉換
app.use(function *(next) {const start = Date.now();yield next;const ms = Date.now() - start;console.log(`${this.method} ${this.url} - ${ms}ms`);
});
koa-convert 源碼
在vscode/launch.json
文件,找到這個program
字段,修改為"program": "${workspaceFolder}/koa/examples/koa-convert/app.js"
。
通過F5啟動調試(直接跳到下一個斷點處)
、F10單步跳過
、F11單步調試
調試走一遍流程。重要地方斷點調試。
app.use
時有一層判斷,是否是generator
函數,如果是則用koa-convert
暴露的方法convert
來轉換重新賦值,再存入middleware
,后續再使用。
class Koa extends Emitter{use(fn) {if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');if (isGeneratorFunction(fn)) {deprecate('Support for generators will be removed in v3. ' +'See the documentation for examples of how to convert old middleware ' +'https://github.com/koajs/koa/blob/master/docs/migration.md');fn = convert(fn);}debug('use %s', fn._name || fn.name || '-');this.middleware.push(fn);return this;}
}
koa-convert
源碼挺多,核心代碼其實是這樣的。
function convert(){return function (ctx, next) {return co.call(ctx, mw.call(ctx, createGenerator(next)))}function * createGenerator (next) {return yield next()}
}
最后還是通過co
來轉換的。所以接下來看co
的源碼。
co 源碼
tj大神寫的co 倉庫
本小節的示例代碼都在這個文件夾koa/examples/co-generator
中,hs koa/example
,可以自行打開https://localhost:8080/co-generator
調試查看。
看co
源碼前,先看幾段簡單代碼。
// 寫一個請求簡版請求
function request(ms= 1000) {return new Promise((resolve) => {setTimeout(() => {resolve({name: '若川'});}, ms);});
}
// 獲取generator的值
function* generatorFunc(){const res = yield request();console.log(res, 'generatorFunc-res');
}
generatorFunc(); // 報告,我不會輸出你想要的結果的
簡單來說co
,就是把generator
自動執行,再返回一個promise
。generator
函數這玩意它不自動執行呀,還要一步步調用next()
,也就是叫它走一步才走一步。
所以有了async、await
函數。
// await 函數 自動執行
async function asyncFunc(){const res = await request();console.log(res, 'asyncFunc-res await 函數 自動執行');
}
asyncFunc(); // 輸出結果
也就是說co
需要做的事情,是讓generator
向async、await
函數一樣自動執行。
模擬實現簡版 co(第一版)
這時,我們來模擬實現第一版的co
。根據generator
的特性,其實容易寫出如下代碼。
// 獲取generator的值
function* generatorFunc(){const res = yield request();console.log(res, 'generatorFunc-res');
}function coSimple(gen){gen = gen();console.log(gen, 'gen');const ret = gen.next();const promise = ret.value;promise.then(res => {gen.next(res);});
}
coSimple(generatorFunc);
// 輸出了想要的結果
// {name: "若川"}"generatorFunc-res"
模擬實現簡版 co(第二版)
但是實際上,不會上面那么簡單的。有可能是多個yield
和傳參數的情況。傳參可以通過這如下兩行代碼來解決。
const args = Array.prototype.slice.call(arguments, 1);
gen = gen.apply(ctx, args);
兩個yield
,我大不了重新調用一下promise.then
,搞定。
// 多個yeild,傳參情況
function* generatorFunc(suffix = ''){const res = yield request();console.log(res, 'generatorFunc-res' + suffix);const res2 = yield request();console.log(res2, 'generatorFunc-res-2' + suffix);
}function coSimple(gen){const ctx = this;const args = Array.prototype.slice.call(arguments, 1);gen = gen.apply(ctx, args);console.log(gen, 'gen');const ret = gen.next();const promise = ret.value;promise.then(res => {const ret = gen.next(res);const promise = ret.value;promise.then(res => {gen.next(res);});});
}coSimple(generatorFunc, ' 哎呀,我真的是后綴');
模擬實現簡版 co(第三版)
問題是肯定不止兩次,無限次的yield
的呢,這時肯定要把重復的封裝起來。而且返回是promise
,這就實現了如下版本的代碼。
function* generatorFunc(suffix = ''){const res = yield request();console.log(res, 'generatorFunc-res' + suffix);const res2 = yield request();console.log(res2, 'generatorFunc-res-2' + suffix);const res3 = yield request();console.log(res3, 'generatorFunc-res-3' + suffix);const res4 = yield request();console.log(res4, 'generatorFunc-res-4' + suffix);
}function coSimple(gen){const ctx = this;const args = Array.prototype.slice.call(arguments, 1);gen = gen.apply(ctx, args);console.log(gen, 'gen');return new Promise((resolve, reject) => {onFulfilled();function onFulfilled(res){const ret = gen.next(res);next(ret);}function next(ret) {const promise = ret.value;promise && promise.then(onFulfilled);}});
}coSimple(generatorFunc, ' 哎呀,我真的是后綴');
但第三版的模擬實現簡版co
中,還沒有考慮報錯和一些參數合法的情況。
最終來看下co
源碼
這時來看看co
的源碼,報錯和錯誤的情況,錯誤時調用reject
,是不是就好理解了一些呢。
function co(gen) {var ctx = this;var args = slice.call(arguments, 1)// we wrap everything in a promise to avoid promise chaining,// which leads to memory leak errors.// see https://github.com/tj/co/issues/180return new Promise(function(resolve, reject) {// 把參數傳遞給gen函數并執行if (typeof gen === 'function') gen = gen.apply(ctx, args);// 如果不是函數 直接返回if (!gen || typeof gen.next !== 'function') return resolve(gen);onFulfilled();/*** @param {Mixed} res* @return {Promise}* @api private*/function onFulfilled(res) {var ret;try {ret = gen.next(res);} catch (e) {return reject(e);}next(ret);}/*** @param {Error} err* @return {Promise}* @api private*/function onRejected(err) {var ret;try {ret = gen.throw(err);} catch (e) {return reject(e);}next(ret);}/*** Get the next value in the generator,* return a promise.** @param {Object} ret* @return {Promise}* @api private*/// 反復執行調用自己function next(ret) {// 檢查當前是否為 Generator 函數的最后一步,如果是就返回if (ret.done) return resolve(ret.value);// 確保返回值是promise對象。var value = toPromise.call(ctx, ret.value);// 使用 then 方法,為返回值加上回調函數,然后通過 onFulfilled 函數再次調用 next 函數。if (value && isPromise(value)) return value.then(onFulfilled, onRejected);// 在參數不符合要求的情況下(參數非 Thunk 函數和 Promise 對象),將 Promise 對象的狀態改為 rejected,從而終止執行。return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '+ 'but the following object was passed: "' + String(ret.value) + '"'));}});
}
koa 和 express 簡單對比
中文文檔 koa 和 express 對比
文檔里寫的挺全面的。簡單來說koa2
語法更先進,更容易深度定制(egg.js
、think.js
、底層框架都是koa
)。
總結
文章通過授人予魚不如授人予魚
的方式,告知如何調試源碼,看完了koa-compose
洋蔥模型實現,koa-convert
和co
等源碼。
koa-compose
是將app.use
添加到middleware
數組中的中間件(函數),通過使用Promise
串聯起來,next()
返回的是一個promise
。
koa-convert
判斷app.use
傳入的函數是否是generator
函數,如果是則用koa-convert
來轉換,最終還是調用的co
來轉換。
co
源碼實現原理:其實就是通過不斷的調用generator
函數的next()
函數,來達到自動執行generator
函數的效果(類似async、await函數的自動自行
)。
koa
框架總結:主要就是四個核心概念,洋蔥模型(把中間件串聯起來),http
請求上下文(context
)、http
請求對象、http
響應對象。
本文倉庫在這里若川的 koa-analysis github 倉庫 https://github.com/lxchuan12/koa-analysis。求個star
呀。
git clone https://github.com/lxchuan12/koa-analysis.git
再強烈建議下按照本文閱讀最佳方式,克隆代碼下來,動手調試代碼學習更加深刻。
如果讀者發現有不妥或可改善之處,再或者哪里沒寫明白的地方,歡迎評論指出,也歡迎加我微信交流
lxchuan12
。另外覺得寫得不錯,對您有些許幫助,可以點贊、評論、轉發分享,也是對筆者的一種支持,萬分感謝。
解答下開頭的提問
僅供參考
1、
koa
洋蔥模型怎么實現的。
可以參考上文整理的簡版koa-compose
作答。
// 這樣就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
const fnMiddleware = function(context){return Promise.resolve(fn1(context, function next(){return Promise.resolve(fn2(context, function next(){return Promise.resolve(fn3(context, function next(){return Promise.resolve();}))}))}));
};
fnMiddleware(ctx).then(handleResponse).catch(onerror);
答:app.use() 把中間件函數存儲在middleware
數組中,最終會調用koa-compose
導出的函數compose
返回一個promise
,中間函數的第一個參數ctx
是包含響應和請求的一個對象,會不斷傳遞給下一個中間件。next
是一個函數,返回的是一個promise
。
2、如果中間件中的
next()
方法報錯了怎么辦。
可參考上文整理的錯誤處理作答。
ctx.onerror = function {this.app.emit('error', err, this);
};listen(){const fnMiddleware = compose(this.middleware);if (!this.listenerCount('error')) this.on('error', this.onerror);const onerror = err => ctx.onerror(err);fnMiddleware(ctx).then(handleResponse).catch(onerror);}onerror(err) {// 代碼省略// ...}
答:中間件鏈錯誤會由ctx.onerror
捕獲,該函數中會調用this.app.emit('error', err, this)
(因為koa
繼承自events模塊
,所以有'emit'和on
等方法),可以使用app.on('error', (err) => {})
,或者app.onerror = (err) => {}
進行捕獲。
3、
co
的原理是怎樣的。
答:co
的原理是通過不斷調用generator
函數的next
方法來達到自動執行generator
函數的,類似async、await
函數自動執行。
答完,面試官可能覺得小伙子還是蠻懂koa
的啊。當然也可能繼續追問,直到答不出...
還能做些什么 ?
學完了整體流程,koa-compose
、koa-convert
和co
的源碼。
還能仔細看看看http
請求上下文(context
)、http
請求對象、http
響應對象的具體實現。
還能根據我文章說的調試方式調試koa 組織中的各種中間件,比如koa-bodyparser
, koa-router
,koa-jwt
,koa-session
、koa-cors
等等。
還能把examples
倉庫克隆下來,我的這個倉庫已經克隆了,挨個調試學習下源碼。
web
框架有很多,比如Express.js
,Koa.js
、Egg.js
、Nest.js
、Next.js
、Fastify.js
、Hapi.js
、Restify.js
、Loopback.io
、Sails.js
、Midway.js
等等。
還能把這些框架的優勢劣勢、設計思想等學習下。
還能繼續學習HTTP
協議、TCP/IP
協議網絡相關,雖然不屬于koa
的知識,但需深入學習掌握。
學無止境~~~
歷史非技術精選文章
如何制定有價值的目標
若川的2019年度總結,波瀾不驚
高考七年后、工作三年后的感悟
工作一年后,我有些感悟(寫于2017年)
知乎問答:你寫過什么自認為驚艷的詩?
歡迎加微信交流 微信公眾號
作者:常以若川為名混跡于江湖。前端路上 | PPT 愛好者 | 所知甚少,唯善學。博客:https://lxchuan12.cn
,閱讀體驗可能更好些。
主要發布
前端 | PPT | 生活 | 效率
相關的文章,長按掃碼關注。歡迎加我微信lxchuan12
(注明來源,基本來者不拒),拉您進【前端視野交流群】,長期交流學習~
小提醒:若川視野公眾號原創文章合集在菜單欄中間【原創精選】按鈕,歡迎點擊閱讀。
另外回復 pdf 可以獲取前端優質書籍pdf。
由于公眾號限制外鏈,點擊閱讀原文,或許閱讀體驗更佳,覺得文章不錯,可以點個在看呀^_^另外歡迎留言交流~