不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

原始碼終結者發表於2018-12-30

共9000餘字,閱讀需要10分鐘左右。

寫在最前

對於前端來說,github 就是寶藏。做任何事情,一定要專業,很多知識都是可以找到的,尤其在前端,有很多很好的東西就擺在你的面前。好的元件原始碼,好的設計模式,好的測試方案,好的程式碼結構,你都可以觸手可及,所以不要覺得不會, coding just api ,你需要掌握的是程式設計的思想和思維。

其實這次的文章也和 ant design 彩蛋有點關係。因為有人說,誰讓你不去閱讀 npm 包原始碼的,可能很多人覺得閱讀 npm 包的原始碼是一件很困難的事情,但是我要告訴你們,npm 包對前端來說就是一座寶藏。你可以從 npm 包中看到很多東西的真相,你可以看到全世界的最優秀的 npm 包的程式設計思想。

比如你可以看到他們的程式碼結構,他們的依賴關係,他們的程式碼互動方式,以及他們的程式碼編寫規範,等等等等。那麼現在,我就通過目前最火的多端統一框架 taro 來向大家展示,如何去分析一個通過 CLI 生成的 npm 包的程式碼。一片文章做不到太細緻的分析,我就當是拋磚引玉,告訴大家,不要被 node_modules 那一串串的包嚇到了,不敢去看,怕看不懂。其實不是你們想的那樣看不懂,一般有名的 npm 包,程式碼結構都是很友好的,理解起來並不比你去閱讀你同事的程式碼(你懂的)難。而且在閱讀 npm 包的過程中,你會發現很多驚喜,找到很多靈感。是不是很激動,是不是很開心,嗯,那就牽著我的手,跟著我一起走,我帶你去解開 npm 包那神祕而又美麗的面紗。

taro init 發生了什麼

執行 taro init xxx 後,package.json 的依賴如下圖所示

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

你會發現當你初始化完一個 CLI 時,安裝了很多依賴,然後這個時候如果你去看 node_modules ,一定會很難受,因為安裝了很多很多依賴的包,這也是很多人點開 node_modules 目錄後,立馬就關上的原因,不關可能就卡住了?。那麼我們玩點輕鬆的,不搞這麼多,我們進入裸奔模式,一個一個包下載,按照 taro initpackage.json 的安裝,我們來分析一下其中的包的程式碼。

分析 @tarojs/components

node_modules 進行截圖,圖片如下:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

從圖片裡面我們可以看到安裝了很多依賴,其中和我們有著直接相關的包是 @tarojs ,開啟 @tarojs 可以看到:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

其實你會發現沒什麼東西,我們再看一下 src 目錄下有什麼:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

分析 src/index.js 檔案

index.js 檔案程式碼如下:

import 'weui'
export { default as View } from './view'
export { default as Block } from './block'
export { default as Image } from './image'
export { default as Text } from './text'
export { default as Switch } from './switch'
export { default as Button } from './button'
// 其他元件省略不寫了
複製程式碼

你會發現,這是一個集中 export 各種元件的地方,從這裡的程式碼我們可以知道,為什麼在 taro 裡面要通過下面這種形式去引入元件。

import { View, Text, Icon } from '@tarojs/components'
複製程式碼

比如為什麼要大寫,這是因為上面 export 出去的就是大寫,同時把所有元件放在了一個物件裡面。這裡再思考一下,為什麼要大寫呢?可能是因為避免和微信小程式的原生元件的命名衝突,畢竟 taro 是支援原生和 taro 混寫的,如果都是小寫,那怎麼區分呢。當你看到這裡的原始碼的時候,你對 taro 的元件引入需要大寫這個規則是不是就覺得非常的順其自然了。同時這裡我們應該多去體會一下 taro 這樣匯出一個元件的思想。越是這種頻繁但不起眼的操作,我們越應該去體會其優秀的思想。

下面我們來挑一個元件看一下結構,比如 Button 元件,結構如下:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

從上圖我們可以看到一個 taro 的基礎元件的程式碼結構,從這裡我們可以獲取到幾點資訊:

第一點:對每個元件進行了單元測試,使用的是 Jest ,目錄是 __test__

第二點:每個元件都有 index.md ,用來介紹元件的文件

第三點: 樣式單獨用了目錄 style 來存放,同時入口檔名字統一使用 index

第四點:在 types 目錄裡進行了 index.d.ts 的檔案設定,使得程式碼提示更加友好

分析 @tarojs/components 後的總結

鑑於 taro 是一個正在崛起且非常有潛力的框架,我們是不是能從 @tarojs/components 的原始碼中學到一些思想。比如我們去設計一個我們自己的元件庫時,是不是可以借鑑這種思想呢。其實這種元件的程式碼結構形式是目前很流行的,比如使用了今年最流行的框架 Jest 框架作為元件的單元測試,使用 ts 做程式碼提示。看 github 上的原始碼的話,會發現,使用了最新的 lerna 包釋出工具,使用了輕量級的 rollup 打包工具,使用 @xxx 作為 namespace 。這也是我為什麼選擇 taro 框架來分析的原因,taro 於2018年 6月多才開源,所以一定借鑑了目前前端最新的技術和最佳實踐,沒有歷史包袱。其實看 taro 的原始碼後,你會發現 taro 中的一些設計理念,已經優於其他著名框架了。

分析 @tarojs/taro

你會發現,這個還是安裝在了 @tarojs 目錄下,並沒有增加其他依賴。taro 的目錄結構如下圖所示

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

從圖中的程式碼結構我們大概可以知道:

第一: types 目錄下有一個 index.d.ts ,這個檔案是一個 ts 檔案,他的作用是編寫程式碼提示。這樣在你寫程式碼的時候,會給你非常友好的程式碼規範提示。比如 index.d.ts 裡面有段程式碼(隨便擷取了一段)如下:

  interface PageConfig {
    navigationBarBackgroundColor?: string,
    backgroundTextStyle?: 'dark' | 'light',
    enablePullDownRefresh?: boolean,
    onReachBottomDistance?: number
    disableScroll?: boolean
  }

複製程式碼

這段程式碼的目的是在你寫對應的配置時,會提示你此欄位的資料型別時什麼,給你一個友好的提示。看到這裡,其實我們想,我們自己也可以自定義的給自己的專案加上這種提示,這對專案是一種很好的優化。

第二:我們看到了 dist 目錄,基本能推測出這是通過打包工具,打包出來的輸出目錄。

第三:整個目錄很簡單,那 taro 的作用是什麼呢,其實 taro 是一個執行時。

我們來看一下 package.json ,如下圖所示:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

發現有個欄位,就是

  "peerDependencies": {
    "nervjs": "^1.2.17"
  }
複製程式碼

平常我們用到的最多的就是 dependenciesdevDependencies 。那麼 peerDependencies 表達什麼意識呢?我們去谷歌翻譯一下,如圖所示:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

拆開翻譯後,是 對等依賴 ,結合翻譯來說一下整個欄位的作用,其實就是指:

這個依賴不需要在自己的目錄下 npm install 了。只需在根目錄下 npm install 就可以了。本著不造輪子的精神,具體意識請看下面 blog

探討 npm 依賴管理之 peerDependencies

我們來看一下 index.js , 就兩行程式碼:

module.exports = require('./dist/index.js').default
module.exports.default = module.exports
複製程式碼

不過我對於這種寫法還是有點驚喜的。為什麼要寫成這樣呢,不能一行搞定麼,更加解耦? 大概是為了什麼吧。

PS: 寫完此文章,我思考了這個問題,發現這個寫法和下面介紹的的一個 index.js 中的寫法如出一轍:

export {}
export default {}
複製程式碼

瞬間明白了作者這樣寫的目的。

分析 taro/src

如圖所示:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

我們看一下 env.js

export const ENV_TYPE = {
  WEAPP: 'WEAPP',
  WEB: 'WEB',
  RN: 'RN',
  SWAN: 'SWAN',
  ALIPAY: 'ALIPAY',
  TT: 'TT'
}

export function getEnv () {
  if (typeof wx !== 'undefined' && wx.getSystemInfo) {
    return ENV_TYPE.WEAPP
  }
  if (typeof swan !== 'undefined' && swan.getSystemInfo) {
    return ENV_TYPE.SWAN
  }
  if (typeof my !== 'undefined' && my.getSystemInfo) {
    return ENV_TYPE.ALIPAY
  }
  if (typeof tt !== 'undefined' && tt.getSystemInfo) {
    return ENV_TYPE.TT
  }
  if (typeof global !== 'undefined' && global.__fbGenNativeModule) {
    return ENV_TYPE.RN
  }
  if (typeof window !== 'undefined') {
    return ENV_TYPE.WEB
  }
  return 'Unknown environment'
}

複製程式碼

從上面程式碼裡面,我們可以看到,通過 getEnv 函式來拿到我們當前專案的執行時的環境,比如是 weapp 還是 swan 還是 tt 等等。其實這時我們就應該感覺到多端統一的思想,genEnv 做了一件很重要的事情:

使用 taro 框架編寫程式碼後,如何轉換成多端?其實就是在執行時根據環境切換到對應的編譯環境,從而轉換成指定端的程式碼。這個 getEnv 函式就可以形象說明這一轉換過程。

下面我們繼續看一下 index.js , 程式碼如下:

import Component from './component'
import { get as internal_safe_get } from './internal/safe-get'
import { set as internal_safe_set } from './internal/safe-set'
import { inlineStyle as internal_inline_style } from './internal/inline-style'
import { getOriginal as internal_get_original } from './internal/get-original'
import { getEnv, ENV_TYPE } from './env'
import Events from './events'
import render from './render'
import { noPromiseApis, onAndSyncApis, otherApis, initPxTransform } from './native-apis'
const eventCenter = new Events()
export {
  Component, Events, eventCenter, getEnv, ENV_TYPE, render, internal_safe_get, internal_safe_set, internal_inline_style, internal_get_original, noPromiseApis, onAndSyncApis,
  otherApis, initPxTransform
}

export default {
  Component, Events, eventCenter, getEnv, ENV_TYPE, render, internal_safe_get, internal_safe_set, internal_inline_style, internal_get_original, noPromiseApis, onAndSyncApis,
  otherApis, initPxTransform
}
複製程式碼

可以看到,分別用 exportexport default 匯出了相同的模組集合。這樣做的原因是什麼呢,我個人認為是為了程式碼的健壯性。你可以通過一個上下文掛載所有匯出,也可以通過解構去匯入你想要的指定匯出。看到這,我們是不是也可以在自己的專案中這樣實踐呢。

馬不停蹄,我們來看一下兩個比較重要但程式碼量很少的檔案,一個是 render.js ,另一個是 component.js 。 程式碼如下:

render.js :

export default function render () {}
複製程式碼

component.js :

class Component {
  constructor (props) {
    this.state = {}
    this.props = props || {}
  }
}
export default Component
複製程式碼

程式碼量都很少,一個空的 render 函式,一個功能很少的 Componet 類,想想就知道是幹啥的了。

分析 taro 全域性訊息機制 event.js

我們看一下events.js,虛擬碼(簡寫)如下:

class Events {
  constructor() {
    // ...
  }
  on() {}
  once() {}
  off() {}
  trigger() {}
}

export default Events
複製程式碼

你會發現這個檔案完成了taro的全域性訊息通知機制。它 有on, once, off, trigger方法,events.js裡都有相應的完整程式碼實現。對應官方文件如下:

Taro訊息機制

想一想,你是不是發現API原來是這麼來的,也不是那麼的難理解了,也不用死記硬背了。

分析 internal 目錄

下面我們繼續分析,我們還要關注一下 internal 目錄,這個目錄有介紹,看 internal 目錄下的 README.md 就可以知道:其是匯出以 internal_ 開頭命名的函式,使用者不需要關心也不會使用到的內部方法,在編譯期會自動給每個使用 taro-cli 編譯的檔案加上其依賴並使用。例如:

import { Component } from 'taro'
class C extends Component {
  render () {
    const { todo } = this.state
    return (
      <TodoItem
        id={todo[0].list[123].id}
      />
    )
  }
}
複製程式碼

會被編譯成:

import { Component, internal_safe_get } from 'taro'
class C extends Component {
  $props = {
    TodoItem() {
      return {
        $name: "TodoItem",
        id: internal_safe_get(this.state, "todo[0].list[123].id"),
      }
    }
  }
  ...
}
複製程式碼

在編譯期會自動給每個使用 taro-cli 編譯的檔案加上其依賴並使用。這句話是什麼意識呢?可能是 taro-cli 在編譯的時候,需要通過這種方式對檔案進行相應的處理。目前我暫時這樣理解,暫時理解不了很正常,繼續往下面分析。

分析 tarojs/taro 的總結

tarojs/taro 已經分析的差不多了,從分析中,我們較為整體的知道了,一個執行時在巨集觀上是如何去銜接多端的,如何通過 ts 檔案給程式碼新增友好提示。既然有 internal ,那就意味著不是 internal 目錄下的檔案都可以對外提供方法,比如 events.js ,這也可以給我們啟發。如何去界定對內對外的程式碼,如何去分割。

分析幾個有意識的函式檔案

先安裝一下依賴:

yarn add @tarojs/taro-weapp && nervjs && nerv-devtools -S
複製程式碼

然後我們看一下最新的包結構

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

對應的package.json如下:

{
  "dependencies": {
    "@tarojs/components": "^1.2.1",
    "@tarojs/router": "^1.2.2",
    "@tarojs/taro": "^1.2.1",
    "@tarojs/taro-weapp": "^1.2.2",
    "nerv-devtools": "^1.3.9",
    "nervjs": "^1.3.9"
  }
}
複製程式碼

也就是我們安裝這些依賴後,node_modules 下目錄下多了這麼多東西。我們簡單的看一下間接有關的包,挑幾個說

分析 omit.js

我們看一下:omit.js

import _extends from "babel-runtime/helpers/extends";
function omit(obj, fields) {
  var shallowCopy = _extends({}, obj);
  for (var i = 0; i < fields.length; i++) {
    var key = fields[i];
    delete shallowCopy[key];
  }
  return shallowCopy;
}

export default omit;
複製程式碼

omit.jsreadme.md 中我們可以知道,它是生成一個去掉指定欄位的,並且是淺拷貝的物件。

分析 slash.js

程式碼如下:

'use strict';
module.exports = input => {
	const isExtendedLengthPath = /^\\\\\?\\/.test(input);
	const hasNonAscii = /[^\u0000-\u0080]+/.test(input);
	if (isExtendedLengthPath || hasNonAscii) {
		return input;
	}
	return input.replace(/\\/g, '/');
};
複製程式碼

slashreadme.md 中我們可以知道

This was created since the path methods in Node outputs \\ paths on Windows.

具體意識,自行分析吧,不難。

分析 value-equal.js

value-equal的主要內容如下:

var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };

function valueEqual(a, b) {
  if (a === b) return true;
  if (a == null || b == null) return false;
  if (Array.isArray(a)) {
    return Array.isArray(b) && a.length === b.length && a.every(function (item, index) {
      return valueEqual(item, b[index]);
    });
  }
  var aType = typeof a === 'undefined' ? 'undefined' : _typeof(a);
  var bType = typeof b === 'undefined' ? 'undefined' : _typeof(b);
  if (aType !== bType) return false;
  if (aType === 'object') {
    var aValue = a.valueOf();
    var bValue = b.valueOf();
    if (aValue !== a || bValue !== b) return valueEqual(aValue, bValue);
    var aKeys = Object.keys(a);
    var bKeys = Object.keys(b);
    if (aKeys.length !== bKeys.length) return false;
    return aKeys.every(function (key) {
      return valueEqual(a[key], b[key]);
    });
  }
  return false;
}
export default valueEqual;
複製程式碼

value-equalreadme.md 中我們可以知道,這個方法是:只比較每個物件的 key 對應的 value 值。仔細感受一下程式碼這樣寫的思想。

分析 prop-types.js

我們看一下 prop-types ,這裡就不列原始碼了。看 README.md ,我們知道

Runtime type checking for React props and similar objects.

它是 react 框架中的 props 型別檢查的輔助工具,也就是完成了下面這個功能

XxxComponent.propTypes = {
  xxProps: PropTypes.xxx
}
複製程式碼

分析 js-tokens

我們來看一下 js-tokens ,程式碼如下:

Object.defineProperty(exports, "__esModule", {
  value: true
})
exports.default = /((['"])(?:(?!\2|\\).|\\(?:\r\n|[\s\S]))*(\2)?|`(?:[^`\\$]|\\[\s\S]|\$(?!\{)|\$\{(?:[^{}]|\{[^}]*\}?)*\}?)*(`)?)|(\/\/.*)|(\/\*(?:[^*]|\*(?!\/))*(\*\/)?)|(\/(?!\*)(?:\[(?:(?![\]\\]).|\\.)*\]|(?![\/\]\\]).|\\.)+\/(?:(?!\s*(?:\b|[\u0080-\uFFFF$\\'"~({]|[+\-!](?!=)|\.?\d))|[gmiyus]{1,6}\b(?![\u0080-\uFFFF$\\]|\s*(?:[+\-*%&|^<>!=?({]|\/(?![\/*])))))|(0[xX][\da-fA-F]+|0[oO][0-7]+|0[bB][01]+|(?:\d*\.\d+|\d+\.?)(?:[eE][+-]?\d+)?)|((?!\d)(?:(?!\s)[$\w\u0080-\uFFFF]|\\u[\da-fA-F]{4}|\\u\{[\da-fA-F]+\})+)|(--|\+\+|&&|\|\||=>|\.{3}|(?:[+\-\/%&|^]|\*{1,2}|<{1,2}|>{1,3}|!=?|={1,2})=?|[?~.,:;[\](){}])|(\s+)|(^$|[\s\S])/g
複製程式碼

結合 README.md ,我們會發現,它使用正則來將 JS 語法變成一個個的 token , so cool

example 如下:

var jsTokens = require("js-tokens").default
var jsString = "var foo=opts.foo;\n..."
jsString.match(jsTokens)
// ["var", " ", "foo", "=", "opts", ".", "foo", ";", "\n", ...]
複製程式碼

讓你寫能寫出來這種逆天正則嗎?。

各種小函式的總結

是不是感覺這些函式檔案都挺有意識的,如果想看具體怎麼實現的,可以繼續看看原始碼,你會發現很多東西都是有具體實現的,完全不需要去死記硬背。我們再看一下上面介紹的 js-token, value-equal, prop-types omit, slash 等,其實都是很好的函式,它們可以給我們很多程式設計上的靈感,我們完全可以借鑑這些函式的思想和實現方式,從而更好的提高我們的 JS 程式設計能力,這也是在閱讀 npm 包原始碼過程中的一個很重要的收穫。

分析 @tarojs/taro-weapp

這個包是用來把 taro 編寫的程式碼編譯成微信小程式程式碼的,程式碼結構如圖所示:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

首先從 readme.md 中,我們看不到此包究竟是幹什麼的,只能看到一句話,多端解決方案小程式端基礎框架。所以我覺得這點,taro 團隊還是要對其進行相應補充的。這裡的 readme.md 寫的太簡潔了。

但是我們可以通過閱讀程式碼來分析一下 taro-weapp 是幹什麼的,首先我們看一下程式碼結構。有 distsrc 等,還有 node_modules 。這時候我們聯想到上面介紹的包後,我們發出了這樣的疑問,為什麼這裡有了 node_modules 目錄。它的目的是什麼?不能用上面的 peerDependencies 解決嗎?對此,暫時無法理解這個事情,遇到這種問題該怎麼辦呢?這時我們可以先不去深入思考這個問題,做到不要阻塞,繼續去分析其他程式碼。

我們按照慣例先看 readme.md ,但是 readme.md 的資訊就一句話,多端解決方案小程式端基礎框架。那怎麼辦,不要氣餒!八年抗戰,我們繼續分析下去。

我們看一下 package.json ,部分程式碼如下:

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "rollup -c rollup.config.js",
    "watch": "rollup -c rollup.config.js -w"
  },
  "dependencies": {
    "@tarojs/taro": "1.2.2",
    "@tarojs/utils": "1.2.2",
    "lodash": "^4.17.10",
    "prop-types": "^15.6.1"
  }
複製程式碼

package.json 中我們能發現兩個主要的事情,第一個是此包需要的依賴,可以看到依賴 @tarojs/taro, @tarojs/utils, lodash, prop-types 。 然後我們檢視 node_modules ,發現只有 @tarojs/taro 。其他的都是在外面安裝好了,比如 lodash, prop-types 可以用根目錄下的包,這裡的 @tarojs/utils 是新安裝的。在 taro 目錄下。掌握這些資訊,我們再結合上面的瞭解,再去思考幾個問題:

  1. 為什麼沒有用 peerDependencies
  2. 為什麼把 @tarojs/taro 安裝到了 taro-weapp 包的內部。
  3. 為什麼 taro-weapp 沒有 types/index.d.ts 這種檔案

問題 mark 一下,先把問題丟擲來,後續再做深入思考。記住一個事情,我們完全沒必要在閱讀原始碼的時候一定要達到完全理解的程度,不現實也沒必要。我們需要做的就是丟擲問題,然後繼續分析,現在我們閱讀一下 index.js ,程式碼如下:

module.exports = require('./dist/index.js').default
module.exports.default = module.exports
複製程式碼

很明顯 dist 目錄是經過打包生成的目錄,現在我們來分析 src 目錄,src 中的 index 檔案程式碼如下:

/* eslint-disable camelcase */
import {
  getEnv, Events, eventCenter, ENV_TYPE, render,
  internal_safe_get, internal_safe_set,
  internal_inline_style, internal_get_original
} from '@tarojs/taro'

import Component from './component'
import PureComponent from './pure-component'
import createApp from './create-app'
import createComponent from './create-component'
import initNativeApi from './native-api'
import { getElementById } from './util'

export const Taro = {
  Component, PureComponent, createApp, initNativeApi,
  Events, eventCenter, getEnv, render, ENV_TYPE,
  internal_safe_get, internal_safe_set,
  internal_inline_style, createComponent,
  internal_get_original, getElementById
}
export default Taro
initNativeApi(Taro)
複製程式碼

index.js 中,我們可以看到,匯入了 @tarojs/taro 的一些方法。而文章前面已經分析過了 @tarojs/taro 。現在我們結合起來想一下,可以發現:使用 @tarojs/taro-weapp 將用 taro 編寫的程式碼,編譯成微信小程式的時候,是需要藉助 @tarojs/taro 包來一起實現轉換的。

大致知道了 taro-weapp 的作用。現在我們來分析一下 index.js 中依賴的外部檔案,分析如下:

分析 src/components.js

把程式碼縮排去,我們看一下大致的程式碼,如圖所示:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

從圖中可以看出,匯出了 BaseComponent 類,從命名可以知道,這是一個基礎元件類,由於程式碼不是太多,我直接貼上來吧。

import { enqueueRender } from './render-queue'
import { updateComponent } from './lifecycle'
import { isFunction } from './util'
import {
  internal_safe_get as safeGet
} from '@tarojs/taro'
import { cacheDataSet, cacheDataGet } from './data-cache'
const PRELOAD_DATA_KEY = 'preload'
class BaseComponent {
  // _createData的時候生成,小程式中通過data.__createData訪問
  __computed = {}
  // this.props,小程式中通過data.__props訪問
  __props = {}
  __isReady = false
  // 會在componentDidMount後置為true
  __mounted = false
  // 刪減了一點
  $componentType = ''
  $router = {
    params: {},
    path: ''
  }
  constructor (props = {}, isPage) {
    this.state = {}
    this.props = props
    this.$componentType = isPage ? 'PAGE' : 'COMPONENT'
  }
  _constructor (props) {
    this.props = props || {}
  }
  _init (scope) {
    this.$scope = scope
  }
  setState (state, callback) {
    enqueueRender(this)
  }
  getState () {
    const { _pendingStates, state, props } = this
    const queue = _pendingStates.concat()
    queue.forEach((nextState) => {
      if (isFunction(nextState)) nextState = nextState.call(this, stateClone, props)
      Object.assign(stateClone, nextState)
    })
    return stateClone
  }
  forceUpdate (callback) {
    updateComponent(this)
  }
  $preload (key, value) { // 省略 }
  // 會被匿名函式呼叫
  __triggerPropsFn (key, args) {}
}
export default BaseComponent
複製程式碼

我們看一下上面的程式碼,從命名我們知道,這是一個元件的基類,可以理解為所有元件都要繼承 BaseComponent 。我們來分析一下上面的程式碼,首先分析第一個點,為什麼有那麼多下劃線變數?其實這些變數是給自己用的,我們看下面的程式碼:

class BaseComponent {
  // _createData的時候生成,小程式中通過data.__createData訪問
  __computed = {}
  // this.props,小程式中通過data.__props訪問
  __props = {}
  __isReady = false
  // 會在componentDidMount後置為true
  __mounted = false
  // 刪減了一點
  $componentType = ''
  $router = { params: {}, path: ''}
}
複製程式碼

首先我記得 ES6 是不支援直接在類中寫變數的,這應該是通過 babel 去支援這樣寫的。通過程式碼中的註釋,基本就知道了這個變數的作用,比如可以通過 data.__props 訪問到 __props 。也就是 this.props 的值,這裡也是用到了代理模式。就像 vue 中的訪問方式。OK,這個我們瞭解了,那麼我們繼續來看下面這段程式碼:

class BaseComponent {
  constructor (props = {}, isPage) {
    this.state = {}
    this.props = props
    this.$componentType = isPage ? 'PAGE' : 'COMPONENT'
  }
  _constructor (props) {
    this.props = props || {}
  }
}
複製程式碼

你看,我們發現了什麼,“建構函式” 有兩個,哈哈哈,騙你的,建構函式就一個,就是 constructor 。但是下面的 _constructor 函式是什麼鬼,裡面還進行了 this.props = props || {} 操作,是什麼鬼呢,如果你看了 taro 官方文件,你可能會看到這樣的提示:

就算你不寫 this.props = props ,也沒事,因為 taro 在執行的過程中,需要用到 props 做一些事情。

但是你可能不明白是為什麼,總感覺文字說明沒有程式碼來的實在,所以當你看到上面的程式碼時,是不是就感覺到實在的感覺了,因為看到程式碼了。 其實是 taro 使用自己內部的方法 _constructor 來進行了 this.props = props || {} 操作。所以文件中會提示說:不寫 props 也可以。

其他的比如 setStategetState 等自己分析一下吧,路子都是一樣的。反正只要你分析了,基本就能對其有一個更加深刻的理解。可能這一刻你把官網文件上的東西忘記了,但你不會忘記程式碼裡這一行的意義。

分析 src/native-api.js

這個檔案的程式碼很重要,為什麼叫 native-api 。如果你看了官方文件的話,你會看到這個頁面:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

其實這裡的 native-api.js 就是上圖的介紹,可以理解為 Taro 對微信小程式的原生 api 進行的封裝。

下面我們來看一下 native-api.js 的輸出是什麼,程式碼如下

export default function initNativeApi (taro) {
  processApis(taro)
  taro.request = request
  taro.getCurrentPages = getCurrentPages
  taro.getApp = getApp
  taro.requirePlugin = requirePlugin
  taro.initPxTransform = initPxTransform.bind(taro)
  taro.pxTransform = pxTransform.bind(taro)
  taro.canIUseWebp = canIUseWebp
}
複製程式碼

這裡到匯出了一個 initNativeApi 方法。看到上面程式碼,是不是知道整個入口的大概畫面了。這個匯出的方法在入口中執行,來對 taro 進行了補充。我們先從 taro-weapp 的入口檔案中, 看一下在沒有執行 initNativeApi(Taro)Taro 物件是什麼,程式碼如下:

const Taro = {
  Component, PureComponent, createApp, initNativeApi, Events,
  eventCenter, getEnv, render, ENV_TYPE, internal_safe_get,
  internal_safe_set, internal_inline_style,
  createComponent, internal_get_original, getElementById
}
複製程式碼

從上面程式碼可以知道,Taro 就好比是 koa 中的 ctx ,通過繫結上下文的形式掛載了很多方法。但是這裡,做了一個優化,就是通過 initNativeApi(Taro) 方法來給 Taro 掛載更多的方法。我們看一下在執行 initNativeApi(Taro) 後的 Taro 物件是什麼,程式碼如下:

const Taro = {
  // 上面的匯出依然存在,這裡不重複寫了
  request,
  getCurrentPages,
  getApp,
  requirePlugin,
  initPxTransform,
  pxTransform,
  canIUseWebp,
}
複製程式碼

processApis(taro) 這個先不說。

我們看上面的程式碼,發現多了很多方法,我們可以理解為通過執行 initNativeApi(Taro) ,使得 Taro 掛載了微信小程式本地的一些 API 。可是你會發現有些又不是本地 API ,但是可以先這樣理解吧,比如 request, getCurrentPages, getApp 。我個人理解作者這樣做的原因是為了解耦,將 native 和非 native 的方法分開。

分析 src/pure-component.js

import { shallowEqual } from '@tarojs/utils'
import Component from './component'
class PureComponent extends Component {
  isPureComponent = true
  shouldComponentUpdate (nextProps, nextState) {
    return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState)
  }
}
export default PureComponent
複製程式碼

我們看一下 pure-componnet.js 的程式碼。是不是發現非常好理解了,PureComponent 類繼承了 Component 。同時,自己實現了一個 shouldComponentUpdate 方法。而這個方法程式碼如下所示:

shouldComponentUpdate (nextProps, nextState) {
    return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState)
}
複製程式碼

你會發現其入參是 nextProps , nextState 。然後通過 shallowEqual 方法和 props, state 進行比較,而 shallowEqual 聽名字就知道是淺比較。 具體程式碼在 @taro/util 目錄下的 src 目錄下的 shallow-equal.js 中,程式碼如下:

Object.is = Object.is || function (x, y) {
  if (x === y) return x !== 0 || 1 / x === 1 / y
  return x !== x && y !== y
}

export default function shallowEqual (obj1, obj2) {
  if (obj1 === null && obj2 === null) return true
  if (obj1 === null || obj2 === null) return false
  if (Object.is(obj1, obj2)) return true
  const obj1Keys = obj1 ? Object.keys(obj1) : []
  const obj2Keys = obj2 ? Object.keys(obj2) : []
  if (obj1Keys.length !== obj2Keys.length) return false

  for (let i = 0; i < obj1Keys.length; i++) {
    const obj1KeyItem = obj1Keys[i]
    if (!obj2.hasOwnProperty(obj1KeyItem) || !Object.is(obj1[obj1KeyItem], obj2[obj1KeyItem])) {
      return false
    }
  }
  return true
}
複製程式碼

看看程式碼,發現是淺比較。看到這,你是不是感覺到 PureComponent 也沒有想象中的抽象難懂,類推一下, React 中的 PureComponent 也是這個理。所以不必去死記硬背一些框架的生命週期和各種專業名字什麼的。其實當你在揭去它的面紗,看到它的真相的時候,你會發現,框架並沒有多深奧。但是如果你就是沒有勇氣去揭開它的面紗,去面對它的話,那麼你就會一直處於想象之中,對真相一無所知。

分析 src/create-componnet.js

我們找一段看一下

  const weappComponentConf = {
    data: initData,
    created (options = {}) {
      this.$component = cacheDataGet(preloadInitedComponent, true)
      this.$component = new ComponentClass({}, isPage)
      this.$component._init(this)
      this.$component.render = this.$component._createData
      this.$component.__propTypes = ComponentClass.propTypes
      Object.assign(this.$component.$router.params, options)
    },
    attached () {},
    ready () {
      componentTrigger(this.$component, 'componentDidMount')
    },
    detached () {
      componentTrigger(this.$component, 'componentWillUnmount')
    }
  }
複製程式碼

從上面程式碼我們可以看出,這是將用 taro 編寫的元件,編譯成微信小程式程式裡面的原生元件例項的。這裡關注一個點,就是 attached 方法中用到了 cacheDataGetcacheDataHas ,上面有介紹這兩個方法,為什麼要在這裡用,目的是什麼,背後的意義是什麼? 需要結合微信小程式的元件生命週期的含義,來思考分析一下。同時,我們要去思考元件中這句 this.$component.render = this.$component._createData 程式碼的含義,好好理解 created 究竟發生了哪些過程。

分析 src/create-app.js

function createApp (AppClass) {
  const app = new AppClass()
  const weappAppConf = {
    onLaunch (options) {
      app.$app = this
      app.$app.$router = app.$router = {
        params: options
      }
      if (app.componentWillMount) app.componentWillMount()
      if (app.componentDidMount) app.componentDidMount()
    },
    onShow (options) {},
    onHide () {},
    onError (err) {},
  }
  return Object.assign(weappAppConf, app)
}
export default createApp
複製程式碼

上面這個一看就知道是用來生成微信小程式的小程式級別的配置,來看一下上面的 if 語句,你可以感受到其背後的目的了。再看一下 Object.assign(weappAppConf, app) 你就知道, taro 是如何遵循 react 的資料不可變的程式設計思想了。

分析 src/next-tick.js

const nextTick = (fn, ...args) => {
  fn = typeof fn === 'function' ? fn.bind(null, ...args) : fn
  const timerFunc = wx.nextTick ? wx.nextTick : setTimeout
  timerFunc(fn)
}
export default nextTick
複製程式碼

這個程式碼也好理解,通過將程式碼放在 wx.nextTick 或者 setTimeout 來達到在下一個迴圈階段再執行。

分析src/render-queue.js

import nextTick from './next-tick'
import { updateComponent } from './lifecycle'
let items = []
export function enqueueRender (component) {
  if (!component._dirty && (component._dirty = true) && items.push(component) === 1) {
    nextTick(rerender)
  }
}
export function rerender () {
  let p
  const list = items
  items = []
  while ((p = list.pop())) {
    if (p._dirty) {
      updateComponent(p, true)
    }
  }
}
複製程式碼

通過命名就知道用到了 nextTick 渲染的思想。

分析 src/lifecycle.js

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學
我們把函式縮起來,發現只匯出了 updateComponent 方法,從命名中,我們知道這是更新元件的意識。

分析 src/data-cache.js

const data = {}
export function cacheDataSet (key, val) {
  data[key] = val
}
export function cacheDataGet (key, delelteAfterGet) {
  const temp = data[key]
  delelteAfterGet && delete data[key]
  return temp
}
export function cacheDataHas (key) {
  return key in data
}
複製程式碼

從程式碼我們可以知道,這是做資料快取用的。先快取起來,然後每取一次 value ,就把這個 value 刪掉。那麼為什麼要這樣設計呢,背後的原因或者說這樣設計的優勢是什麼?可以後續去細緻思考一下,這也是一個好的程式設計思想。

分析 @tarojs/taro-weapp 後的總結

通過對 @tarojs/taro-weapp 的分析,我們具體知道了:當在執行時,taro 是通過 getEnv 將程式碼切到 taro-weapp 環境來進行編譯的。 隨後我們分析了,taro-weapp 是如何進行編譯處理的,比如如何去解決多端涉及到的API不同的問題。通過分析,我們已經較為深入的理解了 taro 的整個架構思想和部分內部實現。這些思想值得我們在平時的專案中去實踐它。其實看原始碼的目的是什麼,比如我分析 taro init 分析到現在,如果你看完,你會發現有很多很酷的思想,可能在你的世界中,寫了幾個專案都根本想不起來也可以這樣用,看原始碼的目的就是讓你去接觸世界上優秀的開源專案是如何設計出來的。從而吸收這些思想,為我所用,使我成長。

分析 rollup-plugin-alias

readme.md 中,我們可以發現,它做了一件事,就是把包的引入路徑抽象化了,這樣好處很多,可以不用關心 ../ 這種符號了,而且可以做到集中式修改。我們的啟發是什麼,其實我們可以從 rollup-plugin-alias 中學到如何去管理我們自己的 npm 包。這種思想我們要吸收。

分析 resolve-pathname

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學
它做了什麼事情呢?結合原始碼,從 readme.md 中,我們可以發現,其實它做了這麼一件事,就是提供一個方法,讓我們去處理 URL ,或者說是路由,通過這個方法,我們能對給定的路由做一些處理,比如返回一個新的路由。

關於invariant、warning都是一些處理提示的輔助工具,就不說了,自行閱讀原始碼進行分析。

分析 @tarojs/router

程式碼目錄結構截圖如下:

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學

我們會看到在 router 目錄下,有 disttypes 目錄。但是沒有 src 目錄,但是為什麼有的包有 src 呢,有的沒有呢?這是個問題,有待後續細緻分析。

如何發現更加有趣的東西

如何在 node_modules 發現更加有趣的東西。我舉個例子,比如我們來看一個 bind 在不同的包中的實現方式: 下圖是 core-jsmodules 目錄下的的 bind 實現

不敢閱讀 npm 包原始碼?帶你揭祕 taro init 背後的哲學
程式碼如下:

var aFunction = require('./_a-function');
var isObject = require('./_is-object');
var invoke = require('./_invoke');
var arraySlice = [].slice;
var factories = {};

var construct = function (F, len, args) {
  if (!(len in factories)) {
    for (var n = [], i = 0; i < len; i++) n[i] = 'a[' + i + ']';
    factories[len] = Function('F,a', 'return new F(' + n.join(',') + ')');
  } return factories[len](F, args);
};

module.exports = Function.bind || function bind(that /* , ...args */) {
  var fn = aFunction(this);
  var partArgs = arraySlice.call(arguments, 1);
  var bound = function (/* args... */) {
    var args = partArgs.concat(arraySlice.call(arguments));
    return this instanceof bound ? construct(fn, args.length, args) : invoke(fn, args, that);
  };
  if (isObject(fn.prototype)) bound.prototype = fn.prototype;
  return bound;
};
複製程式碼

下面我們再看一下 lodash 中的 bind 實現,程式碼如下:

var baseRest = require('./_baseRest'),
    createWrap = require('./_createWrap'),
    getHolder = require('./_getHolder'),
    replaceHolders = require('./_replaceHolders');
    
var WRAP_BIND_FLAG = 1,
    WRAP_PARTIAL_FLAG = 32;

var bind = baseRest(function(func, thisArg, partials) {
  var bitmask = WRAP_BIND_FLAG;
  if (partials.length) {
    var holders = replaceHolders(partials, getHolder(bind));
    bitmask |= WRAP_PARTIAL_FLAG;
  }
  return createWrap(func, bitmask, thisArg, partials, holders);
});
bind.placeholder = {};
module.exports = bind;
複製程式碼

對比兩者的程式碼,我們能發現兩者的程式碼的實現形式是不一樣的。可能大家能普遍理解的是第一種寫法,幾乎所有文章都是第一種寫法,容易看懂。但是第二種寫法就比較難理解了,相比第一種寫法,第二種寫法更加抽象和解耦。比如更加函式式,其實如果函數語言程式設計掌握的熟練的話, bind 本質上就是偏函式的一種實現,第二種寫法裡面已經在命名中就體現出來了,partials。比如在面試中,如果被問到 bind 如何實現,是不是就可以寫出兩種實現方式了(程式設計思想)呢。可能你寫完,面試官都看不懂呢?。這裡就是舉個例子,還有很多這種,自行探索吧。(順帶把 core-jslodash 包介紹了。。)

對 ant design 彩蛋事件的理解

最近 ant design 彩蛋事件,這個彩蛋足夠刺激,以至於大家反應這麼強烈。足以說明 ant design 的受歡迎程度,按照土話說,ant design 以前的身份是:大家只愛不恨,但是現在的身份是:大家又愛又恨。

出了問題,該怎麼解決,就怎麼解決,但是逼還是要撕的,誰的鍋誰背好。

故事是這樣的:

比如平常在公司工作,同事或者其他人闖禍了,把你的程式碼 reset 掉了。這肯定波及到你的工作了,這個時候你會怎麼做?你肯定不爽,肯定會 BB 。尤其遇到那種闖了禍,影響到了別人工作的還不主動背鍋道歉,擺出一副你把程式碼找回來不就行了麼的態度。遇到這種人你肯定就很不爽,要找這個人撕逼。畢竟你已經影響到我工作了,別一副好像鍋不是自己的一樣,鍋你背好,我會解決掉你給我帶來的問題,下次別再這樣了。

ant design ,就好比上面闖禍的同事,波及到了大家,但是 ant 也主動認錯了,鍋也主動背了,也立刻給出了方案。

其實對於那些因為這個事情導致失業什麼的,我個人認為還是比較難受的。但是對於那些說話比較激烈(難聽)的人,也就是嘴上難聽,有幾個會因為前端框架而上升到很大的那種怨恨的,難聽的目的無非就是隱式的鞭策 ant 團隊。我想 ant 也意識到了,後面肯定不會再這樣做類似這種事情了。

我內心還是希望大家:

既然我們從一開始就選擇了相信 ant design ,那我們就多一份包容,包容這一次 ant design 的犯錯,不要因為一次犯錯,就否定其全部。

其實你在公司裡,也是這樣的,你犯了錯,影響到了很多同事,你意識到事情的嚴重性,你很難受,很後悔,你發現自己做了一件極其愚蠢的事情,你真的很想去彌補,但是時間不能倒退,歲月不能迴流,你能做的就是保證下次不會再次犯錯,你很想得到大家的原諒和信任。雖然你是真心認錯的,希望大家可以像原來一樣信任你,可是如果大家因為你一次錯誤,就在舉止談吐之間表現的不那麼相信你了。那,此時你的心,也一定是極其的失落和灰冷吧。

所以我還是希望大家能繼續對 ant design 保持信任,包容 ant design 一次,也是包容一次 偏右 這種為開源做出很大貢獻的人。

其實,在生活中,有時候,我們會發現,包容不需要很多次的,一次包容就可以了。因為一次包容就可以讓一件事情再也不會發生第二次。是不,囉囉嗦嗦了那麼多,其實答案就在文字中。

好了,不胡謅個人看法了。

備註

關於文章有點長

因為文章確實有點長,所以我對我貼的程式碼動了些手腳,比如,刪減了一些程式碼,寫成三行的 if 語句,寫成一行。把 import, export 的東西儘可能寫在一起,不換行寫。所以如果想看沒有刪減版本的文章,可以去我的 github 上看,github 連線:https://github.com/godkun/blog/issues/30

閱讀 npm 包遇到不懂的地方怎麼辦

對於 npm 包的原始碼,我本人在看的時候,也會對一些地方不明白,這對於我們來說很正常( NB 的大佬除外),但是我不會因為某一段,某一個檔案看不懂而阻塞我對於整個包的理解,我會加入我自己的理解,哪怕是錯的,但是隻要我能流暢的把整個包按照我想的那樣理解掉就足夠了。不要試圖去完全理解,除非你和 npm 包的作者進行交流了。

你會發現這篇文章中,在分析的過程中,已經存在了一些問題,而且我也沒有一個確切的答案,就好像那些上傳 LOL 教學的視訊,只要是上傳的,都是各種經典走位,預判,風騷操作。但是現實中,可能已經跪了10幾把了。說到這,突然想到知乎上,有個帖子,好像是問程式平常寫程式碼是什麼場景,還貼出一個黑客帝國的圖片,問真的是這樣的嗎?然後有個用視訊回答的,我看完快笑噴了。其實推導一下,就知道看 npm 包原始碼的時候,是不可能一帆風順的。一定有看不懂的,而且 npm 包的原始碼和 github 上對應 npm 包的原始碼是不一樣的。npm 包就好比是 github 上的 npm 原始碼經過包管理工具,build 後的輸出。這點你從有 dist 目錄就可以看出來,比如 githubtaro 原始碼中是用 rollup 打成小包的。

遇到不懂的地方很正常,你要做的就是理解整體,忽略區域性。

文末心得總結

讀到這,你會發現,我沒有把 taro init 下載的全部依賴都分析一遍,因為真分析完的話,可能短篇小說就誕生了,而且也沒有什麼意義。我就是起個拋磚引玉的作用,希望大家閱讀我的文章後,有一些收穫,不要去害怕 npm 包,npm 包也是人寫的。

在分析的時候,我建議一個一個包下載,然後下載一個包看一下目錄。這樣有助於你去理解,很多人都是一個 npm i 或者 yarn install 甩下來,然後開啟 node_modules 目錄,然後就傻眼了,根本不知道找哪個包看。所以,當你想去了解一個東西的時候,最好的方式是一個包一個包去下載,一點一點去看,看前後的程式碼結構變化,包的變化。然後你會發現包的個數在慢慢的增加,但是你一點也不慌,因為你已經知道他們大概的作用和內容了。

最後按照小學語文老師教我的操作,搞個首尾呼應吧。

前端是 github 上最受益的一個行業,因為最先進的開源技術,原始碼都在 github 上, github 就是前端的寶藏,取之不盡,用之不完。reactvueangularwebpackbabelnoderxjsthree.jsTypeScripttaroant-designeggjestkoalodashparcelrollupd3reduxfluttercaxlernahapijsxeslint 等等等等等,寶藏就在那,你願意去解開它們的面紗看一看真相嗎?

參考連結

激萌一刻

掘金系列文章都可以在我的 github 上找到,歡迎討論,傳送地址:

https://github.com/godkun/blog

覺得不錯的,可以點個 star 和 贊贊,鼓勵鼓勵。

第一次暴露我的最神祕交友網站賬號(潛水逃)

幕後花絮

2018年快過去了,祝福大家在2019年,家庭幸福,事業有成,在前端行業,遊刃有餘。

本文裡面大概率會有寫錯的地方,但是大概率也會有很不錯的地方。

所以............

元旦快樂丫!

相關文章