不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

对于前端来说, github 就是宝藏。做任何事情,一定要专业,很多知识都是可以找到的,尤其在前端,有很多很好的东西就摆在你的面前。好的组件源代码,好的设计模式,好的测试方案,好的代码结构,你都可以触手可及,所以不要觉得不会,其实 coding just api ,你需要掌握的是编程的思想和思维。

多啰嗦几句

最近 ant design 彩蛋事件,这个彩蛋足够刺激,以至于大家反应这么强烈。足以说明 ant design 的受欢迎程度,按照土话说, ant design 以前的身份是:大家只爱不恨,但是现在的身份是:大家又爱又恨。想一下,有个故事是这样说的:

出了问题,该怎么解决,就怎么解决。但是逼还是要撕的,谁的锅谁背好。

故事是这样的:

比如平常在公司工作,同事或者其他人闯祸了,把你的代码 reset 掉了。这肯定波及到你的工作了,这个时候你会怎么做?你肯定不爽,肯定会BB。尤其遇到那种闯了祸,影响到了别人工作的还不主动背锅道歉,摆出一副你把代码找回来不就行了么的态度。遇到这种人你肯定就很不爽,要找这个人撕逼。毕竟你已经影响到我工作了,别一副好像锅不是自己的一样,锅你背好,我会解决掉你给我带来的问题,下次别再这样了。

ant design ,就好比上面闯祸的同事,波及到了大家,但是 ant 也主动认错了,锅也主动背了,也立刻给出了方案。

其实对于那些因为这个事情导致失业什么的,我个人认为还是比较难受的。但是对于那些说话比较激烈(难听)的人,也就是嘴上难听,有几个会因为前端框架而上升到很大的那种怨恨的,难听的目的无非就是隐式的鞭策 ant 团队。我想 ant 也意识到了,后面肯定不会再这样做类似这种事情了。

所以说了一个插曲,其实内心还是希望大家:

既然我们从一开始就选择了相信 ant design ,那我们就多一份包容,包容这一次 ant design 的犯错,不要因为一次犯错,就否定其全部。

其实你在公司里,也是这样的,你犯了错,影响到了很多同事,你意识到事情的严重性,你很难受,很后悔,你发现自己做了一件极其愚蠢的事情,你真的很想去弥补,但是时间不能倒退,岁月不能回流,你能做的就是保证下次不会再次犯错,你很想得到大家的原谅和信任。虽然你是真心认错的,希望大家可以像原来一样信任你,可是如果大家因为你一次错误,就在举止谈吐之间表现的不那么相信你了。那,此时你的心,也一定是极其的失落和灰冷吧。

所以我还是希望大家能继续对 ant design 保持信任,包容 ant design 一次,也是包容一次 偏右 这种为开源做出很大贡献的人。

其实,在生活中,有时候,我们会发现,包容不需要很多次的,一次包容就可以了。因为一次包容就可以让一件事情再也不会发生第二次。是不,啰啰嗦嗦了那么多,其实答案就在文字中。

好了,不胡诌个人看法了。下面进入正题,其实这次的文章也和 ant 彩蛋有点关系。因为有人说,谁让你不去阅读 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 目录后,立马就关上的原因,不关可能就卡主了:joy:。那么我们玩点轻松的,不搞这么多,我们进入裸奔模式,一个一个包下载,按照 taro initpackage.json 的安装,我们来分析一下其中的包的代码。

先 yarn add @tarojs/components

对node_modules进行截图,图片如下:

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

从图片里面我们可以看到安装了很多依赖,其中和我们有着直接相关的包是 @tarojs ,打开 @tarojs 可以看到:

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

其实你会发现没什么东西,我们再看一下src目录下有什么:

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

我们看一下, 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 { default as Icon } from './icon'
export { default as Radio } from './radio'
export { default as Input } from './input'
export { default as ScrollView } from './scroll-view'
export { Swiper, SwiperItem } from './swiper'
export { default as Checkbox } from './checkbox'
export { default as Picker } from './picker'
export { default as Label } from './label'
export { default as Textarea } from './textarea'
export { default as Slider } from './slider'
export { default as Video } from './video'
export { default as Audio } from './audio'
export { default as Camera } from './camera'
export { default as Progress } from './progress'
export { default as RichText } from './rich-text'
export { default as Form } from './form'
export { default as RadioGroup } from './radio/radio-group'
export { default as CheckboxGroup } from './checkbox/checkbox-group'
export { default as Tabbar } from './tabbar'
export { default as TabbarContainer } from './tabbar/container'
export { default as TabbarPanel } from './tabbar/panel'
// export { default as Navigator } from './navigator'

复制代码

你会发现,这是一个集中 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

鉴于 taro 是一个最新的正在崛起的非常有潜力的框架,我们是不是能从 taro 的源码中学到一些思想。比如我们去设计一个我们自己的组件库时,是不是可以借鉴这种思想。其实这种组件的代码结构形式时目前很流行的,使用了今年最流行的框架 Jest 框架作为组件的单元测试。

总结:

现在和我们有着直接关系的包已经简单介绍了一遍。具体组件代码内容就不介绍了。主要介绍为什么要这样设计。下面我来介绍一下,在 yarn add @tarojs/components 后,还安装了哪些依赖。

yarn add @tarojs/taro

你会发现,这个还是安装在了 @tarojs 目录下,并没有增加其他依赖。 taro 的目录结构如下图所示

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

从图中的代码结构我们大概可以知道东西:

第一: types 目录下有一个 index.d.ts ,这个文件是一个 ts 文件,他的作用是编写代码提示。这样在你写代码的时候,会给你非常友好的代码规范提示。比如 index.d.ts 里面有段代码(随便截取了一段)如下:

interface PageConfig {
    /**
     * 导航栏背景颜色,HexColor
     * default: #000000
     */
    navigationBarBackgroundColor?: string,

    backgroundTextStyle?: 'dark' | 'light',
    /**
     * 是否开启下拉刷新
     * default: false
     */
    enablePullDownRefresh?: boolean,
    /**
     * 页面上拉触底事件触发时距页面底部距离,单位为px
     * default: 50
     */
    onReachBottomDistance?: number
    /**
     * 设置为 true 则页面整体不能上下滚动;只在页面配置中有效,无法在 app.json 中设置该项
     * default: false
     */
    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
复制代码

不过我对于这种写法还是有点惊喜的。为什么要写成这样呢,不能一行搞定么,更加解耦? 大概是为了什么吧。

我们看一下 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 ,很有趣的~,代码如下:

/* eslint-disable camelcase */
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 类,想想就知道是干啥的了。

我们再看一下 events.js ,伪代码(简写)如下:

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

export default Events
复制代码

你会发现这个文件完成了 taro 的全局消息通知机制。它 有 on , once , off , trigger 方法, events.js 里都有相应的完整代码实现。对应官方文档如下:

Taro消息机制

想一想,你是不是发现 API 原来是这么来的,也不是那么的难理解了,也不用死记硬背了。

下面我们继续分析,我们还要关注一下 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 已经分析的差不多了,从分析中,我们较为整体的知道了,一个运行时是在宏观上是如何去衔接多端的,如何通过 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

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

'use strict';
module.exports = input => {
	const isExtendedLengthPath = /^/////?///.test(input);
	const hasNonAscii = /[^/u0000-/u0080]+/.test(input); // eslint-disable-line no-control-regex

	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 的主要内容如下

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 ,这这里不列源码了。看 README.md ,我们知道 Runtime type checking for React props and similar objects.

它是 react 框架中的 props 类型检查的辅助工具,也就是完成了下面这个功能

XxxComponent.propTypes = {
  xxProps: PropTypes.xxx
}
复制代码

如果想看具体怎么实现的,可以继续看看源码,你会发现其实很多东西都是有具体的实现的,完全不需要去死记硬背。

我们来看一下, js-tokens 。这里只列出了 default 的内容

// Copyright 2014, 2015, 2016, 2017, 2018 Simon Lydell
// License: MIT. (See LICENSE.)

Object.defineProperty(exports, "__esModule", {
  value: true
})

// This regex comes from regex.coffee, and is inserted here by generate-index.js
// (run `npm run build`).
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 语法变成一个个的 tokenso cool

效果如下:

var jsTokens = require("js-tokens").default

var jsString = "var foo=opts.foo;/n..."

jsString.match(jsTokens)
// ["var", " ", "foo", "=", "opts", ".", "foo", ";", "/n", ...]
复制代码

让你写能写出来这种逆天正则吗:joy:

看一下 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呢。这是为什么呢,这是个问题。

看一下@tarojs/taro-weapp

这是将用 taro 编写的代码编译成微信小程序代码所需要的一些工具,代码结构如图所示:

![]( user-gold-cdn.xitu.io/2018/12/27/

首先从 readme.md 中,我们看不到此包究竟是干什么的,只能看到一句话,多端解决方案小程序端基础框架。所以我觉得这点, taro 团队还是要对其进行相应补充的。至少通过对比其他包,这里的 readme.md 写的太简洁了。

但是我们可以通过阅读代码来分析一下 taro-weapp 是干什么的,首先我们看一下代码结构。有 distsrc 等,还有 node_modules 。这时候我们联想到上面介绍的包后,我们发出了这样的疑问,为什么这里有了 node_modules 目录。它的目的是什么? 可以先不去解答这个问题,我们继续分析代码。

首先肯定要先看 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 。我们结合起来想一下,可以发现,其实在将用 taro 编写的代码,编译成微信小程序的时候,需要借助 @tarojs/taro 包来一起实现转换。

我们先不看 @tarojs/taro 。我们来按照 export default Taro 导出的模块顺序分析一下

分析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) {
      Object.assign(app.$router.params, options)
      if (app.componentDidShow) {
        app.componentDidShow()
      }
    },

    onHide () {
      if (app.componentDidHide) {
        app.componentDidHide()
      }
    },

    onError (err) {
      if (app.componentDidCatchError) {
        app.componentDidCatchError(err)
      }
    },

    onPageNotFound (obj) {
      if (app.componentDidNotFound) {
        app.componentDidNotFound(obj)
      }
    }
  }
  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/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 对象是

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,
  request
  getCurrentPages
  getApp
  requirePlugin
  initPxTransform
  pxTransform
  canIUseWebp
}
复制代码

processApis(taro) 这个先不说。

我们看上面的代码,发现多了很多方法,可以理解为通过 initNativeApi ,挂载了微信小程序的本地一些 API 。可是有些又不是本地 API ,但是可以这样理解吧,比如 requestgetCurrentPagesgetApp 。我个人理解作者这样做的原因是为了解耦,将 native 和非 native 的方法分开。

总结通过对 @tarojs/taro-weapp 的分析,我们具体知道了,当在运行时, taro 通过 getEnv 将代码切到 taro-weapp 进行编译的时候, taro-weapp 是如何进行编译处理的。比如如何去解决多端涉及到的 API 不同的问题。通过分析,我们已经较为深入的理解了taro的整个架构思想和部分内部实现。这些思想值得我们在平时的项目中去实践它。其实看源码的目的是什么,我分析 taro init 分析到现在,如果你看完,你会发现有很多很酷的思想,可能在你的世界中,写了几个项目都根本想不起来也可以这样用。

看一下src/render-queue.js

import nextTick from './next-tick'
import { updateComponent } from './lifecycle'

let items = []

export function enqueueRender (component) {
  // tslint:disable-next-line:no-conditional-assignment
  if (!component._dirty && (component._dirty = true) && items.push(component) === 1) {
    nextTick(rerender)
  }
}

export function rerender () {
  let p
  const list = items
  items = []
  // tslint:disable-next-line:no-conditional-assignment
  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 删掉。那么为什么要这样做呢,

分析src/pure-componnet.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

/* eslint-disable */
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/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
  nextProps = {}
  _dirty = true
  _disable = true
  _isForceUpdate = false
  _pendingStates = []
  _pendingCallbacks = []
  $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) {
    if (state) {
      (this._pendingStates = this._pendingStates || []).push(state)
    }
    if (isFunction(callback)) {
      (this._pendingCallbacks = this._pendingCallbacks || []).push(callback)
    }
    if (!this._disable) {
      enqueueRender(this)
    }
  }

  getState () {
    const { _pendingStates, state, props } = this
    const stateClone = Object.assign({}, state)
    delete stateClone.__data
    if (!_pendingStates.length) {
      return stateClone
    }
    const queue = _pendingStates.concat()
    this._pendingStates.length = 0
    queue.forEach((nextState) => {
      if (isFunction(nextState)) {
        nextState = nextState.call(this, stateClone, props)
      }
      Object.assign(stateClone, nextState)
    })
    return stateClone
  }

  forceUpdate (callback) {
    if (isFunction(callback)) {
      (this._pendingCallbacks = this._pendingCallbacks || []).push(callback)
    }
    this._isForceUpdate = true
    updateComponent(this)
  }

  $preload (key, value) {
    const preloadData = cacheDataGet(PRELOAD_DATA_KEY) || {}
    if (typeof key === 'object') {
      for (const k in key) {
        preloadData[k] = key[k]
      }
    } else {
      preloadData[key] = value
    }
    cacheDataSet(PRELOAD_DATA_KEY, preloadData)
  }

  // 会被匿名函数调用
  __triggerPropsFn (key, args) {}
}

export default BaseComponent

复制代码

我们看一下上面的代码,从命名我们知道,这是一个组件的基类,可以理解为所有组件都要继承 BaseComponent 。这个我们来简单的去分析一下,首先分析第一个点,为什么有那么多下划线变量,其实这些变量是给自己用的,我们看下面的代码:

class BaseComponent {
  // _createData的时候生成,小程序中通过data.__createData访问
  __computed = {}
  // this.props,小程序中通过data.__props访问
  __props = {}
  __isReady = false
  // 会在componentDidMount后置为true
  __mounted = false
  nextProps = {}
  _dirty = true
  _disable = true
  _isForceUpdate = false
  _pendingStates = []
  _pendingCallbacks = []
  $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 || {} 操作。其他的比如 setStategetState 等自己分析一吧,路子都是一样的。反正只要你分析了,基本就能对其有一个更加深刻的理解。可能这一刻你把官网文档上的东西忘记了,你也不会忘记代码里这一行的意义。

分析src/create-componnet.js

我们找一段看一下

const weappComponentConf = {
    data: initData,
    created (options = {}) {
      if (isPage && cacheDataHas(preloadInitedComponent)) {
        this.$component = cacheDataGet(preloadInitedComponent, true)
      } else {
        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 () {
      let hasParamsCache
      if (isPage) {
        // params
        let params = {}
        hasParamsCache = cacheDataHas(this.data[routerParamsPrivateKey])
        if (hasParamsCache) {
          params = Object.assign({}, ComponentClass.defaultParams, cacheDataGet(this.data[routerParamsPrivateKey], true))
        } else {
          // 直接启动,非内部跳转
          params = filterParams(this.data, ComponentClass.defaultParams)
        }
        if (cacheDataHas(PRELOAD_DATA_KEY)) {
          const data = cacheDataGet(PRELOAD_DATA_KEY, true)
          this.$component.$router.preload = data
        }
        Object.assign(this.$component.$router.params, params)
        // preload
        if (cacheDataHas(this.data[preloadPrivateKey])) {
          this.$component.$preloadData = cacheDataGet(this.data[preloadPrivateKey], true)
        } else {
          this.$component.$preloadData = null
        }
      }
      if (!isPage || hasParamsCache || ComponentClass.defaultParams) {
        initComponent.apply(this, [ComponentClass, isPage])
      }
    },
    ready () {
      if (!isPage && !this.$component.__mounted) {
        this.$component.__mounted = true
        componentTrigger(this.$component, 'componentDidMount')
      }
    },
    detached () {
      componentTrigger(this.$component, 'componentWillUnmount')
    }
  }
复制代码

从上面代码我们可以看出,这是将用 taro 编写的组件,编译成微信小程序程序里面的原生组件实例的。这里关注一个点,就是 attached 方法中用到了 cacheDataGetcacheDataHas ,上面有介绍这两个方法,但是为什么在 attached 中使用 cacheDataGet 还有 cacheDataHas ,为什么要这样用,目的是什么,背后的意义是什么? 需要结合小程序原生组件中 attached 的含义,来思考分析一下。

如何发现不一样的东西

如何在node_modules发现很多很有趣的东西,我举个例子。比如我们来看一个 bind 在不同的包中的实现方式: 下图是core-js中modules目录下的的bind实现

不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

代码如下:

'use strict';
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 + ']';
    // eslint-disable-next-line no-new-func
    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);
});

// Assign default placeholders.
bind.placeholder = {};

module.exports = bind;

复制代码

对比两者的代码,我们能发现两者的代码的实现形式是不一样的。可能大家能普遍理解的是第一种写法,几乎所有文章都是第一种写法,容易看懂。但是第二种写法就比较难理解了,相比第一种写法,第二种写法更加抽象和解耦。比如更加函数式,其实如果函数式编程掌握的熟练的话,bind本质上就是偏函数的一种实现,第二种写法里面已经在命名中就体现出来了, partials 。所以如果在了解的更多的话,那在面试中,如果被问到bind如何实现,是不是就可以写出两种实现方式了(编程思想)呢。可能你写完,面试官都看不懂呢笑哭。这里就是举个例子,还有很多这种,自行探索吧。(顺带把core-js和lodash包介绍了。。)

总结一下

读到这,你会发现,我没有把 taro init 下载的全部依赖都分析一遍,因为真分析完,可能短篇小说就出来,同时,也没有意义。我就是起个抛砖引玉的作用,希望大家阅读我的文章后,有一些收获,不要去害怕 npm 包,那也是人写的。

在分析的时候,我建议一个一个包下载,然后下载一个包看一下目录。这样有助于你去理解,很多人都是一个 npm i 或者 yarn install 甩下来,然后打开 node_modules 目录,然后就傻眼了,根本不知道找哪个包看。所以,当你想去了解一个东西的时候,最后的方式是一点一点去看,一个包一个包去下载,然后看前后的代码结构变化,包的变化。然后你会发现包的个数在慢慢的增加,但是你一点也不慌,因为你已经知道他们大概的作用和内容了。

所以最后,还是那句话,前端是 github 上最受益的一个行业,因为最先进的开源技术,源代码都在 github 上, github 就是前端的宝藏,取之不尽,用之不完。 reactvueangularwebpackbabelnoderxjsthree.jsTypeScripttaroant-designeggjestkoalodashparcelrollupd3reduxfluttercaxlernahapijsxeslint 、等等等等等,宝藏就在那。你愿意去解开他们的面纱看一看真相吗?

备注

对于 npm 包的源码,我本人在看的时候,也会对一些地方不明白,这对于我们来说很正常( NB 的大佬除外),但是我不会因为某一段,某一个文件看不懂而阻塞我对于整个包的理解,我会加入我自己的理解,哪怕是错的,但是只要我能流畅的把整个包按照我想的那样理解掉就足够了。不要试图去完全理解,除非你和 npm 包的作者进行交流了。

你会发现这篇文章中,在分析的过程中,已经存在了一些问题,而且我也没有一个确切的答案,就好像那些上传 LOL 教学的视频,只要是上传的,都是各种经典走位,预判,风骚操作。但是现实中,可能已经跪了10几把了。说到这,突然想到知乎上,有个帖子,好像是问程序平常写代码是什么场景,还贴出一个黑客帝国的图片,问真的是这样的吗?然后有个用视频回答的,我看完快笑喷了。其实推导一下,就知道看npm包源码的时候,是不可能一帆风顺的。一定有看不懂的,而且 npm 包的源码和 github 上对应 npm 包的源码是不一样的。 npm 包就好比是 github 上的 npm 源码经过包管理工具, build 后的输出。这点你从有 dist 目录就可以看出来,比如 githubtaro 源码中是用 rollup 打成小包的。

激萌一刻

掘金系列文章都可以在我的 github 上找到,欢迎 issues 讨论,传送地址:

github.com/godkun/blog

觉得不错的,可以点个 star 和 赞赞,鼓励鼓励。

第一次暴露我的最神秘交友网站账号(潜水逃)

幕后花絮

2018年快过去了,祝福大家在2019年,家庭幸福,事业有成,在前端行业,游刃有余。

本文里面大概率会有写错的地方,但是大概率也会有很不错的地方。所以…….元旦快乐丫!

原文 

https://juejin.im/post/5c21f4e5f265da61117a54a0

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 不敢阅读 npm 包源码?带你揭秘 taro init 背后的哲学

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址