lpicker

lpicker 查看完整档案

西安编辑延安大学  |  物理学 编辑联通云数据有限公司  |  前端工程师 编辑 lpicker.me/ 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

lpicker 赞了文章 · 3月1日

手写一个基于 Proxy 的缓存库

两年前,我写了一篇关于业务缓存的博客 前端 api 请求缓存方案, 这篇博客反响还不错,其中介绍了如何缓存数据,Promise 以及如何超时删除(也包括如何构建修饰器)。如果对此不够了解,可以阅读博客进行学习。

但之前的代码和方案终归还是简单了些,而且对业务有很大的侵入性。这样不好,于是笔者开始重新学习与思考代理器 Proxy。

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。关于 Proxy 的介绍与使用,建议大家还是看阮一峰大神的 ECMAScript 6 入门 代理篇

项目演进

任何项目都不是一触而就的,下面是关于 Proxy 缓存库的编写思路。希望能对大家有一些帮助。

proxy handler 添加缓存

当然,其实代理器中的 handler 参数也是一个对象,那么既然是对象,当然可以添加数据项,如此,我们便可以基于 Map 缓存编写 memoize 函数用来提升算法递归性能。

type TargetFun<V> = (...args: any[]) => V

function memoize<V>(fn: TargetFun<V>) {
  return new Proxy(fn, {
    // 此处目前只能略过 或者 添加一个中间层集成 Proxy 和 对象。
    // 在对象中添加 cache
    // @ts-ignore
    cache: new Map<string, V>(),
    apply(target, thisArg, argsList) {
      // 获取当前的 cache
      const currentCache = (this as any).cache
      
      // 根据数据参数直接生成 Map 的 key
      let cacheKey = argsList.toString();
      
      // 当前没有被缓存,执行调用,添加缓存
      if (!currentCache.has(cacheKey)) {
        currentCache.set(cacheKey, target.apply(thisArg, argsList));
      }
      
      // 返回被缓存的数据
      return currentCache.get(cacheKey);
    }
  });
}
  

我们可以尝试 memoize fibonacci 函数,经过了代理器的函数有非常大的性能提升(肉眼可见):

const fibonacci = (n: number): number => (n <= 1 ? 1 : fibonacci(n - 1) + fibonacci(n - 2));
const memoizedFibonacci = memoize<number>(fibonacci);

for (let i = 0; i < 100; i++) fibonacci(30); // ~5000ms
for (let i = 0; i < 100; i++) memoizedFibonacci(30); // ~50ms

自定义函数参数

我们仍旧可以利用之前博客介绍的的函数生成唯一值,只不过我们不再需要函数名了:

const generateKeyError = new Error("Can't generate key from function argument")

// 基于函数参数生成唯一值
export default function generateKey(argument: any[]): string {
  try{
    return `${Array.from(argument).join(',')}`
  }catch(_) {
    throw generateKeyError
  }
}

虽然库本身可以基于函数参数提供唯一值,但是针对形形色色的不同业务来说,这肯定是不够用的,需要提供用户可以自定义参数序列化。

// 如果配置中有 normalizer 函数,直接使用,否则使用默认函数
const normalizer = options?.normalizer ?? generateKey

return new Proxy<any>(fn, {
  // @ts-ignore
  cache,
  apply(target, thisArg, argsList: any[]) {
    const cache: Map<string, any> = (this as any).cache
    
    // 根据格式化函数生成唯一数值
    const cacheKey: string = normalizer(argsList);
    
    if (!cache.has(cacheKey))
      cache.set(cacheKey, target.apply(thisArg, argsList));
    return cache.get(cacheKey);
  }
});

添加 Promise 缓存

在之前的博客中,提到缓存数据的弊端。同一时刻多次调用,会因为请求未返回而进行多次请求。所以我们也需要添加关于 Promise 的缓存。

if (!currentCache.has(cacheKey)){
  let result = target.apply(thisArg, argsList)
  
  // 如果是 promise 则缓存 promise,简单判断! 
  // 如果当前函数有 then 则是 Promise
  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      // 发生错误,删除当前 promise,否则会引发二次错误
      // 由于异步,所以当前 delete 调用一定在 set 之后,
      currentCache.delete(cacheKey)
    
      // 把错误衍生出去
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);
}
return currentCache.get(cacheKey);

此时,我们不但可以缓存数据,还可以缓存 Promise 数据请求。

添加过期删除功能

我们可以在数据中添加当前缓存时的时间戳,在生成数据时候添加。

// 缓存项
export default class ExpiredCacheItem<V> {
  data: V;
  cacheTime: number;

  constructor(data: V) {
    this.data = data
    // 添加系统时间戳
    this.cacheTime = (new Date()).getTime()
  }
}

// 编辑 Map 缓存中间层,判断是否过期
isOverTime(name: string) {
  const data = this.cacheMap.get(name)

  // 没有数据(因为当前保存的数据是 ExpiredCacheItem),所以我们统一看成功超时
  if (!data) return true

  // 获取系统当前时间戳
  const currentTime = (new Date()).getTime()

  // 获取当前时间与存储时间的过去的秒数
  const overTime = currentTime - data.cacheTime

  // 如果过去的秒数大于当前的超时时间,也返回 null 让其去服务端取数据
  if (Math.abs(overTime) > this.timeout) {
    // 此代码可以没有,不会出现问题,但是如果有此代码,再次进入该方法就可以减少判断。
    this.cacheMap.delete(name)
    return true
  }

  // 不超时
  return false
}

// cache 函数有数据
has(name: string) {
  // 直接判断在 cache 中是否超时
  return !this.isOverTime(name)
}

到达这一步,我们可以做到之前博客所描述的所有功能。不过,如果到这里就结束的话,太不过瘾了。我们继续学习其他库的功能来优化我的功能库。

添加手动管理

通常来说,这些缓存库都会有手动管理的功能,所以这里我也提供了手动管理缓存以便业务管理。这里我们使用 Proxy get 方法来拦截属性读取。

 return new Proxy(fn, {
  // @ts-ignore
  cache,
  get: (target: TargetFun<V>, property: string) => {
    
    // 如果配置了手动管理
    if (options?.manual) {
      const manualTarget = getManualActionObjFormCache<V>(cache)
      
      // 如果当前调用的函数在当前对象中,直接调用,没有的话访问原对象
      // 即使当前函数有该属性或者方法也不考虑,谁让你配置了手动管理呢。
      if (property in manualTarget) {
        return manualTarget[property]
      }
    }
   
    // 当前没有配置手动管理,直接访问原对象
    return target[property]
  },
}


export default function getManualActionObjFormCache<V>(
  cache: MemoizeCache<V>
): CacheMap<string | object, V> {
  const manualTarget = Object.create(null)
  
  // 通过闭包添加 set get delete clear 等 cache 操作
  manualTarget.set = (key: string | object, val: V) => cache.set(key, val)
  manualTarget.get = (key: string | object) => cache.get(key)
  manualTarget.delete = (key: string | object) => cache.delete(key)
  manualTarget.clear = () => cache.clear!()
  
  return manualTarget
}

当前情况并不复杂,我们可以直接调用,复杂的情况下还是建议使用 Reflect

添加 WeakMap

我们在使用 cache 时候,我们同时也可以提供 WeakMap ( WeakMap 没有 clear 和 size 方法),这里我提取了 BaseCache 基类。

export default class BaseCache<V> {
  readonly weak: boolean;
  cacheMap: MemoizeCache<V>

  constructor(weak: boolean = false) {
    // 是否使用 weakMap
    this.weak = weak
    this.cacheMap = this.getMapOrWeakMapByOption()
  }

  // 根据配置获取 Map 或者 WeakMap
  getMapOrWeakMapByOption<T>(): Map<string, T> | WeakMap<object, T>  {
    return this.weak ? new WeakMap<object, T>() : new Map<string, T>()
  }
}

之后,我添加各种类型的缓存类都以此为基类。

添加清理函数

在缓存进行删除时候需要对值进行清理,需要用户提供 dispose 函数。该类继承 BaseCache 同时提供 dispose 调用。

export const defaultDispose: DisposeFun<any> = () => void 0

export default class BaseCacheWithDispose<V, WrapperV> extends BaseCache<WrapperV> {
  readonly weak: boolean
  readonly dispose: DisposeFun<V>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = defaultDispose) {
    super(weak)
    this.weak = weak
    this.dispose = dispose
  }

  // 清理单个值(调用 delete 前调用)
  disposeValue(value: V | undefined): void {
    if (value) {
      this.dispose(value)
    }
  }

  // 清理所有值(调用 clear 方法前调用,如果当前 Map 具有迭代器)
  disposeAllValue<V>(cacheMap: MemoizeCache<V>): void {
    for (let mapValue of (cacheMap as any)) {
      this.disposeValue(mapValue?.[1])
    }
  }
}

当前的缓存如果是 WeakMap,是没有 clear 方法和迭代器的。个人想要添加中间层来完成这一切(还在考虑,目前没有做)。如果 WeakMap 调用 clear 方法时,我是直接提供新的 WeakMap 。

clear() {
  if (this.weak) {
    this.cacheMap = this.getMapOrWeakMapByOption()
  } else {
    this.disposeAllValue(this.cacheMap)
    this.cacheMap.clear!()
  }
}

添加计数引用

在学习其他库 memoizee 的过程中,我看到了如下用法:

memoized = memoize(fn, { refCounter: true });

memoized("foo", 3); // refs: 1
memoized("foo", 3); // Cache hit, refs: 2
memoized("foo", 3); // Cache hit, refs: 3
memoized.deleteRef("foo", 3); // refs: 2
memoized.deleteRef("foo", 3); // refs: 1
memoized.deleteRef("foo", 3); // refs: 0,清除 foo 的缓存
memoized("foo", 3); // Re-executed, refs: 1

于是我有样学样,也添加了 RefCache。

export default class RefCache<V> extends BaseCacheWithDispose<V, V> implements CacheMap<string | object, V> {
    // 添加 ref 计数
  cacheRef: MemoizeCache<number>

  constructor(weak: boolean = false, dispose: DisposeFun<V> = () => void 0) {
    super(weak, dispose)
    // 根据配置生成 WeakMap 或者 Map
    this.cacheRef = this.getMapOrWeakMapByOption<number>()
  }
  

  // get has clear 等相同。不列出
  
  delete(key: string | object): boolean {
    this.disposeValue(this.get(key))
    this.cacheRef.delete(key)
    this.cacheMap.delete(key)
    return true;
  }


  set(key: string | object, value: V): this {
    this.cacheMap.set(key, value)
    // set 的同时添加 ref
    this.addRef(key)
    return this
  }

  // 也可以手动添加计数
  addRef(key: string | object) {
    if (!this.cacheMap.has(key)) {
      return
    }
    const refCount: number | undefined = this.cacheRef.get(key)
    this.cacheRef.set(key, (refCount ?? 0) + 1)
  }

  getRefCount(key: string | object) {
    return this.cacheRef.get(key) ?? 0
  }

  deleteRef(key: string | object): boolean {
    if (!this.cacheMap.has(key)) {
      return false
    }

    const refCount: number = this.getRefCount(key)

    if (refCount <= 0) {
      return false
    }

    const currentRefCount = refCount - 1
    
    // 如果当前 refCount 大于 0, 设置,否则清除
    if (currentRefCount > 0) {
      this.cacheRef.set(key, currentRefCount)
    } else {
      this.cacheRef.delete(key)
      this.cacheMap.delete(key)
    }
    return true
  }
}

同时修改 proxy 主函数:

if (!currentCache.has(cacheKey)) {
  let result = target.apply(thisArg, argsList)

  if (result?.then) {
    result = Promise.resolve(result).catch(error => {
      currentCache.delete(cacheKey)
      return Promise.reject(error)
    })
  }
  currentCache.set(cacheKey, result);

  // 当前配置了 refCounter
} else if (options?.refCounter) {
  // 如果被再次调用且当前已经缓存过了,直接增加       
  currentCache.addRef?.(cacheKey)
}

添加 LRU

LRU 的英文全称是 Least Recently Used,也即最不经常使用。相比于其他的数据结构进行缓存,LRU 无疑更加有效。

这里考虑在添加 maxAge 的同时也添加 max 值 (这里我利用两个 Map 来做 LRU,虽然会增加一定的内存消耗,但是性能更好)。

如果当前的此时保存的数据项等于 max ,我们直接把当前 cacheMap 设为 oldCacheMap,并重新 new cacheMap。

set(key: string | object, value: V) {
  const itemCache = new ExpiredCacheItem<V>(value)
  // 如果之前有值,直接修改
  this.cacheMap.has(key) ? this.cacheMap.set(key, itemCache) : this._set(key, itemCache);
  return this
}

private _set(key: string | object, value: ExpiredCacheItem<V>) {
  this.cacheMap.set(key, value);
  this.size++;

  if (this.size >= this.max) {
    this.size = 0;
    this.oldCacheMap = this.cacheMap;
    this.cacheMap = this.getMapOrWeakMapByOption()
  }
}

重点在与获取数据时候,如果当前的 cacheMap 中有值且没有过期,直接返回,如果没有,就去 oldCacheMap 查找,如果有,删除老数据并放入新数据(使用 _set 方法),如果都没有,返回 undefined.

get(key: string | object): V | undefined {
  // 如果 cacheMap 有,返回 value
  if (this.cacheMap.has(key)) {
    const item = this.cacheMap.get(key);
    return this.getItemValue(key, item!);
  }

  // 如果 oldCacheMap 里面有
  if (this.oldCacheMap.has(key)) {
    const item = this.oldCacheMap.get(key);
    // 没有过期
    if (!this.deleteIfExpired(key, item!)) {
      // 移动到新的数据中并删除老数据
      this.moveToRecent(key, item!);
      return item!.data as V;
    }
  }
  return undefined
}


private moveToRecent(key: string | object, item: ExpiredCacheItem<V>) {
  // 老数据删除
  this.oldCacheMap.delete(key);
  
  // 新数据设定,重点!!!!如果当前设定的数据等于 max,清空 oldCacheMap,如此,数据不会超过 max
  this._set(key, item);
}

private getItemValue(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  // 如果当前设定了 maxAge 就查询,否则直接返回
  return this.maxAge ? this.getOrDeleteIfExpired(key, item) : item?.data;
}
  
  
private getOrDeleteIfExpired(key: string | object, item: ExpiredCacheItem<V>): V | undefined {
  const deleted = this.deleteIfExpired(key, item);
  return !deleted ? item.data : undefined;
}
  
private deleteIfExpired(key: string | object, item: ExpiredCacheItem<V>) {
  if (this.isOverTime(item)) {
    return this.delete(key);
  }
  return false;
}  

整理 memoize 函数

事情到了这一步,我们就可以从之前的代码细节中解放出来了,看看基于这些功能所做出的接口与主函数。

// 面向接口,无论后面还会不会增加其他类型的缓存类
export interface BaseCacheMap<K, V> {
  delete(key: K): boolean;

  get(key: K): V | undefined;

  has(key: K): boolean;

  set(key: K, value: V): this;

  clear?(): void;

  addRef?(key: K): void;

  deleteRef?(key: K): boolean;
}

// 缓存配置
export interface MemoizeOptions<V> {
  /** 序列化参数 */
  normalizer?: (args: any[]) => string;
  /** 是否使用 WeakMap */
  weak?: boolean;
  /** 最大毫秒数,过时删除 */
  maxAge?: number;
  /** 最大项数,超过删除  */
  max?: number;
  /** 手动管理内存 */
  manual?: boolean;
  /** 是否使用引用计数  */
  refCounter?: boolean;
  /** 缓存删除数据时期的回调 */
  dispose?: DisposeFun<V>;
}

// 返回的函数(携带一系列方法)
export interface ResultFun<V> extends Function {
  delete?(key: string | object): boolean;

  get?(key: string | object): V | undefined;

  has?(key: string | object): boolean;

  set?(key: string | object, value: V): this;

  clear?(): void;

  deleteRef?(): void
}

最终的 memoize 函数其实和最开始的函数差不多,只做了 3 件事

  • 检查参数并抛出错误
  • 根据参数获取合适的缓存
  • 返回代理
export default function memoize<V>(fn: TargetFun<V>, options?: MemoizeOptions<V>): ResultFun<V> {
  // 检查参数并抛出错误
  checkOptionsThenThrowError<V>(options)

  // 修正序列化函数
  const normalizer = options?.normalizer ?? generateKey

  let cache: MemoizeCache<V> = getCacheByOptions<V>(options)

  // 返回代理
  return new Proxy(fn, {
    // @ts-ignore
    cache,
    get: (target: TargetFun<V>, property: string) => {
      // 添加手动管理
      if (options?.manual) {
        const manualTarget = getManualActionObjFormCache<V>(cache)
        if (property in manualTarget) {
          return manualTarget[property]
        }
      }
      return target[property]
    },
    apply(target, thisArg, argsList: any[]): V {

      const currentCache: MemoizeCache<V> = (this as any).cache

      const cacheKey: string | object = getKeyFromArguments(argsList, normalizer, options?.weak)

      if (!currentCache.has(cacheKey)) {
        let result = target.apply(thisArg, argsList)

      
        if (result?.then) {
          result = Promise.resolve(result).catch(error => {
            currentCache.delete(cacheKey)
            return Promise.reject(error)
          })
        }
        currentCache.set(cacheKey, result);
      } else if (options?.refCounter) {
        currentCache.addRef?.(cacheKey)
      }
      return currentCache.get(cacheKey) as V;
    }
  }) as any
}

完整代码在 memoizee-proxy 中。大家自行操作与把玩。

下一步

测试

测试覆盖率不代表一切,但是在实现库的过程中,JEST 测试库给我提供了大量的帮助,它帮助我重新思考每一个类以及每一个函数应该具有的功能与参数校验。之前的代码我总是在项目的主入口进行校验,对于每个类或者函数的参数没有深入思考。事实上,这个健壮性是不够的。因为你不能决定用户怎么使用你的库。

Proxy 深入

事实上,代理的应用场景是不可限量的。这一点,ruby 已经验证过了(可以去学习《ruby 元编程》)。

开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象。

当然,Proxy 虽然来自于 ES6 ,但该 API 仍需要较高的浏览器版本,虽然有 proxy-pollfill ,但毕竟提供功能有限。不过已经 2021,相信深入学习 Proxy 也是时机了。

深入缓存

缓存是有害的!这一点毋庸置疑。但是它实在太快了!所以我们要更加理解业务,哪些数据需要缓存,理解那些数据可以使用缓存。

当前书写的缓存仅仅只是针对与一个方法,之后写的项目是否可以更细粒度的结合返回数据?还是更往上思考,写出一套缓存层?

小步开发

在开发该项目的过程中,我采用小步快跑的方式,不断返工。最开始的代码,也仅仅只到了添加过期删除功能那一步。

但是当我每次完成一个新的功能后,重新开始整理库的逻辑与流程,争取每一次的代码都足够优雅。同时因为我不具备第一次编写就能通盘考虑的能力。不过希望在今后的工作中,不断进步。这样也能减少代码的返工。

其他

函数创建

事实上,我在为当前库添加手动管理时候,考虑过直接复制函数,因为函数本身是一个对象。同时为当前函数添加 set 等方法。但是没有办法把作用域链拷贝过去。

虽然没能成功,但是也学到了一些知识,这里也提供两个创建函数的代码。

我们在创建函数时候基本上会利用 new Function 创建函数,但是浏览器没有提供可以直接创建异步函数的构造器,我们需要手动获取。

AsyncFunction = (async x => x).constructor

foo = new AsyncFunction('x, y, p', 'return x + y + await p')

foo(1,2, Promise.resolve(3)).then(console.log) // 6

对于全局函数,我们也可以直接 fn.toString() 来创建函数,这时候异步函数也可以直接构造的。

function cloneFunction<T>(fn: (...args: any[]) => T): (...args: any[]) => T {
  return new Function('return '+ fn.toString())();
}

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

前端 api 请求缓存方案

ECMAScript 6 入门 代理篇

memoizee

memoizee-proxy

查看原文

赞 31 收藏 22 评论 2

lpicker 关注了专栏 · 3月1日

进击的大前端

前端工程师,底层技术人。 思否2020年度“Top Writer”! 掘金“优秀作者”! 开源中国2020年度“优秀源创作者” 分享各种大前端进阶知识! 关注公众号【进击的大前端】第一时间获取高质量原创。 更多文章和示例源码请看:https://github.com/dennis-jiang/Front-End-Knowledges

关注 13086

lpicker 赞了回答 · 3月1日

解决设置了table-cell 属性后,用百分比来设置宽度为什么会出现问题

<!doctype html>
<html>
<head>
    <meta charset="utf-8" />
    <title>test</title>
    <style>
    p { margin:0; }
    .right-content-title111 {border:1px solid black; width:400px; margin-top:15px;}
    .right-content-title111 span{
        border : 1px solid red;
        display: table-cell;
        width: 70%;
        text-align: center;
        height: 22px;
        vertical-align: middle;
    }
    .right-content-title222 {border:1px solid black; width:400px; margin-top:1px;}
    .right-content-title222 span{
        border : 1px solid red;
        display: table-cell;
        width: 40%;
        text-align: center;
        height: 22px;
        vertical-align: middle;
    }
    .right-content-title333 {border:1px solid black; width:520px; margin-top:1px;}
    .right-content-title333 span{
        border : 1px solid red;
        display: table-cell;
        width: 10%;
        text-align: center;
        height: 22px;
        vertical-align: middle;
    }
    </style>
</head>
<body>
    <p class="right-content-title111"><span>短</span></p>
    <p class="right-content-title222"><span>短</span></p>
    <p class="right-content-title333"><span>短</span></p>
    <p class="right-content-title111"><span>中中</span></p>
    <p class="right-content-title222"><span>中中</span></p>
    <p class="right-content-title333"><span>中中</span></p>
    <p class="right-content-title111"><span>长长长</span></p>
    <p class="right-content-title222"><span>长长长</span></p>
    <p class="right-content-title333"><span>长长长</span></p>
</body>
</html>

图片描述
图片描述


结论就是table-cell里的width:3.5%,故意设置很小,目的是让table-cell占总容器100%宽

关注 6 回答 2

lpicker 赞了回答 · 3月1日

vue如何通过元素找到属于哪个组件?

vue-devtool可以,点这个select按钮去页面选中就出来了
image

关注 6 回答 5

lpicker 赞了回答 · 3月1日

解决前端如何实现让用户自定义输入js代码并执行?

先考虑一个问题,输入框里运行输入的语句语法范围有哪些。总不会允许用户注册自己的Service Worker将所有数据包都转发吧?

实现还是比较容易的,无非就是编译原理。

如果在考虑了安全性之后,确实需要允许所有的语法,那么使用ES的解释库是一个简单的方法,比较流行的有acornjs
如果能将语法范围控制在很小的范围,可以自己写一个,没必要加载那么大的库了。看你的需求描述只需要实现以下几个节点分析就行了:

  • BinaryExpression
  • ExpressionStatement
  • Identifier
  • IfStatement
  • Literal
  • MemberExpression

自己实现的好处是没必要分开实现TokenizationParsing,可以简化处理。

关注 7 回答 6

lpicker 赞了文章 · 2月18日

CSS奇思妙想 -- 使用 background 创造各种美妙的背景

本文属于 CSS 绘图技巧其中一篇,系列文章:

将介绍一些利用 CSS 中的 backgroundmix-blend-modemask 及一些相关属性,制作一些稍微复杂、酷炫的背景。

通过本文,你将会了解到 CSS background 中更为强大的一些用法,并且学会利用 background 相关的一些属性,采用不同的方式,去创造更复杂的背景图案。在这个过程中,你会更好的掌握不同的渐变技巧,更深层次的理解各种不同的渐变。

同时,借助强大的 CSS-Doodle,你将学会如何运用一套规则,快速创建大量不同的随机图案,感受 CSS 的强大,走进 CSS 的美。

背景基础知识

我们都知道,CSS 中的 background 是非常强大的。

首先,复习一下基础,在日常中,我们使用最多的应该就是下面 4 种:

  • 纯色背景 background: #000

  • 线性渐变 background: linear-gradient(#fff, #000) :

  • 径向渐变 background: radial-gradient(#fff, #000) :

  • 角向渐变 background: conic-gradient(#fff, #000) :

背景进阶

当然。掌握了基本的渐变之后,我们开始向更复杂的背景图案进发。我最早是在《CSS Secret》一书中接触学习到使用渐变去实现各种背景图案的。然后就是不断的摸索尝试,总结出了一些经验。

在尝试使用渐变去制作更复杂的背景之前,列出一些比较重要的技巧点:

  • 渐变不仅仅只能是单个的 linear-gradient 或者单个的 radial-gradient,对于 background 而言,它是支持多重渐变的叠加的,一点非常重要;
  • 灵活使用 repeating-linear-gradeintrepeating-radial-gradeint),它能减少很多代码量
  • transparent 透明无处不在
  • 尝试 mix-blend-modemask,创建复杂图案的灵魂
  • 使用随机变量,它能让一个 idea 变成无数美丽的图案

接下来,开始组合之旅。

使用 mix-blend-mode

mix-blend-mode ,混合模式。最常见于 photoshop 中,是 PS 中十分强大的功能之一。在 CSS 中,我们可以利用混合模式将多个图层混合得到一个新的效果。

关于混合模式的一些基础用法,你可以参考我的这几篇文章:

然后,我们来尝试第一个图案,先简单体会一下 mix-blend-mode 的作用。

我们使用 repeating-linear-gradient 重复线性渐变,制作两个角度相反的背景条纹图。正常而言,不使用混合模式,将两个图案叠加在一起,看看会发生什么。

额,会发生什么就有鬼了 。显而易见,由于图案不是透明的,叠加在一起之后,由于层叠的关系,只能看到其中一张图。

好,在这个基础上,我们给最上层的图案,添加 mix-blend-mode: multiply,再来一次,看看这次会发生什么。

可以看到,添加了混合模式之后,两张背景图通过某种算法叠加在了一起,展现出了非常漂亮的图案效果,也正是我们想要的效果。

CodePen Demo - Repeating-linear-gradient background & mix-blend-mode

尝试不同的 mix-blend-mode

那为什么上面使用的是 mix-blend-mode: multiply 呢?用其他混合模式可以不可以?

当然可以。这里仅仅只是一个示例,mix-blend-mode: multiply 在 PS 中意为正片叠底,属于图层混合模式的变暗模式组之一。

我们使用上面的 DEMO,尝试其他的混合模式,可以得到不同的效果。

可以看到,不同的混合模式的叠加,效果相差非常之大。当然,运用不同的混合模式,我们也就可以创造出效果各异的图案。

CodePen Demo - Repeating-linear-gradient background & mix-blend-mode

借助 CSS-Doodle 随机生成图案

到这,就不得不引出一个写 CSS 的神器 -- CSS-Doodle,我在其他非常多文章中也多次提到过 CSS-doodle,简单而言,它是一个基于 Web-Component 的库。允许我们快速的创建基于 CSS Grid 布局的页面,并且提供各种便捷的指令及函数(随机、循环等等),让我们能通过一套规则,得到不同 CSS 效果。

还是以上面的 DEMO 作为示例,我们将 repeating-linear-gradient 生成的重复条纹背景的颜色、粗细、角度随机化、采用的混合模式也是随机选取,然后利用 CSS-Doodle,快速随机的创建各种基于此规则的图案:

可以点进去尝试一下,点击鼠标即可随机生成不同的效果:

CodePen Demo -- CSS Doodle - CSS MIX-BLEND-MODE Background

尝试使用径向渐变

当然,上面使用的是线性渐变,同样,我们也可以使用径向渐变运用同样的套路。

我们可以使用径向渐变,生成多重的径向渐变。像是这样:

给图片应用上 background-size,它就会像是这样:

像上文一样,我们稍微对这个图形变形一下,然后叠加两个图层,给最上层的图形,添加 CSS 样式 mix-blend-mode: darken

CodePen Demo -- radial-gradient & mix-blend-mode Demo

借助 CSS-Doodle 随机生成图案

再来一次,我们使用 CSS-Doodle,运用上述的规则在径向渐变,也可以得到一系列有意思的背景图。

可以点进去尝试一下,点击鼠标即可随机生成不同的效果:

CodePen Demo -- CSS Doodle - CSS MIX-BLEND-MODE Background 2

当然,上述的叠加都是非常简单的图案的叠加,但是掌握了这个原理之后,就可以自己尝试,去创造更复杂的融合。

上述的叠加效果是基于大片大片的实色的叠加,当然 mix-blend-mode 还能和真正的渐变碰撞出更多的火花。

在不同的渐变背景中运用混合模式

在不同的渐变背景中运用混合模式?那会产生什么样美妙的效果呢?

运用得当,它可能会像是这样:

umm,与上面的条纹图案完全不一样的风格。

你可以戳进 gradienta.io 来看看,这里全是使用 CSS 创建的渐变叠加的背景图案库。

使用混合模式叠加不同的渐变图案

下面,我们也来实现一个。

首先,我们使用线性渐变或者径向渐变,随意创建几个渐变图案,如下所示:

接着,我们两两之间,从第二层开始,使用一个混合模式进行叠加,一共需要设定 5 个混合模式,这里我使用了 overlay, multiply, difference, difference, overlay。看看叠加之后的效果,非常的 Nice:

CodePen Demo -- Graideint background mix

由于上面动图 GIF 的压缩率非常高,所以看上去锯齿很明显图像很模糊,你可以点进上面的链接看看。

然后,我们可以再给叠加后的图像再加上一个 filter: hue-rotate(),让他动起来,放大一点点看看效果,绚丽夺目的光影效果:

CodePen Demo -- Graideint background mix 2

借助 CSS-Doodle 随机生成图案

噔噔噔,没错,这里我们又可以继续把 CSS-Doodle 搬出来了。

随机的渐变,随机的混合模式,叠加在一起,燥起来吧。

使用 CSS-Doodle 随机创建不同的渐变,在随机使用不同的混合模式,让他们叠加在一起,看看效果:

当然,由于是完全随机生成的效果,所以部分时候生成出来的不算太好看或者直接是纯色的。不过大部分还是挺不错的

CodePen Demo -- CSS Doodle Mix Gradient


感谢坚持,看到这里。上述上半部分主要使用的混合模式,接下来,下半部分,将主要使用 mask,精彩继续。


使用 mask

除去混合模式,与背景相关的,还有一个非常有意思的属性 -- MASK

mask 译为遮罩。在 CSS 中,mask 属性允许使用者通过遮罩或者裁切特定区域的图片的方式来隐藏一个元素的部分或者全部可见区域

对 mask 的一些基础用法还不太熟悉的,可以先看看我的这篇文章 -- 奇妙的 CSS MASK

简单而言,mask 可以让图片我们可以灵活的控制图片,设定一部分展示出来,另外剩余部分的隐藏。

使用 mask 对图案进行切割

举个例子。假设我们使用 repeating-linear-gradient 渐变制作这样一个渐变图案:

它的 CSS 代码大概是这样:

:root {
    $colorMain: #673ab7;
}
{
    background: 
        repeating-linear-gradient(0, $colorSub 0, $colorSub 3px, transparent 3px, transparent 10px),
        repeating-linear-gradient(60deg, $colorSub 0, $colorSub 3px, transparent 3px, transparent 10px),
        repeating-linear-gradient(-60deg, $colorSub 0, $colorSub 3px, transparent 3px, transparent 10px);
}

如果我们给这个图案,叠加一个这样的 mask :

{
    mask: conic-gradient(from -135deg, transparent 50%, #000);
}

上述 mask 如果是使用 background 表示的话,是这样 background: conic-gradient(from -135deg, transparent 50%, #000), 图案是这样:

两者叠加在一起,按照 mask 的作用,背景与 mask 生成的渐变的 transparent 的重叠部分,将会变得透明。将会得到这样一种效果:

CodePen Demo -- mask & background Demo

我们就完成了 background 与 mask 的结合。运用 mask 切割 background 的效果,我们就能制作出非常多有意思的背景图案:

CodePen Demo -- mask & background Demo

mask-composite OR -webkit-mask-composite

接下来,在运用 mask 切割图片的同时,我们会再运用到 -webkit-mask-composite 属性。这个是非常有意思的元素,非常类似于 mix-blend-mode / background-blend-mode

-webkit-mask-composite: 属性指定了将应用于同一元素的多个蒙版图像相互合成的方式。

通俗点来说,他的作用就是,当一个元素存在多重 mask 时,我们就可以运用 -webkit-mask-composite 进行效果叠加。

注意,这里的一个前提,就是当 mask 是多重 mask 的时候(类似于 background,mask 也是可以存着多重 mask),-webkit-mask-composite 才会生效。这也就元素的 mask 可以指定多个,逗号分隔。

假设我们有这样一张背景图:

:root {
    $colorMain: #673ab7;
    $colorSub: #00bcd4;
}
div {
    background: linear-gradient(-60deg, $colorMain, $colorSub);
}

我们的 mask 如下:

{
    mask: 
            repeating-linear-gradient(30deg, #000 0, #000 10px, transparent 10px, transparent 45px),
            repeating-linear-gradient(60deg, #000 0, #000 10px, transparent 10px, transparent 45px),
            repeating-linear-gradient(90deg, #000 0, #000 10px, transparent 10px, transparent 45px);
}

mask 表述成 background 的话大概是这样:

如果,不添加任何 -webkit-mask-composite,叠加融合之后的效果是这样:

如果添加一个 -webkit-mask-composite: xor,则会变成这样:

可以看到,线条的交汇叠加处,有了不一样的效果。

CodePen Demo -- background & -webkit-mask-composite

借助 CSS-Doodle 随机生成图案

了解了基本原理之后,上 CSS-Doodle,我们利用多重 mask 和 -webkit-mask-composite,便可以创造出各式各样的美妙背景图案:

是不是很类似万花筒?

借助了 CSS-Doodle,我们只设定大致的规则,辅以随机的参数,随机的大小。接着就是一幅幅美妙的背景图应运而生。

下面是运用上述规则的尝试的一些图案:

CodePen Demo -- CSS Doodle - CSS MASK Background

当然,可以尝试变换外形,譬如让它长得像个手机壳。

下面两个 DEMO 也是综合运用了上述的一些技巧的示例,仿佛一个个手机壳的图案。

CodePen Demo -- CSS Doodle - CSS MASK Background 2

CodePen Demo -- CSS Doodle - CSS MASK Background 3

总结一下

背景 background 不仅仅只是纯色、线性渐变、径向渐变、角向渐变。混合模式、滤镜、遮罩也并不孤独。

background 配合混合模式 mix-blend-modebackground-blend-mode、滤镜 filter、以及遮罩 mask 的时候,它们就可以组合变幻出各种不同的效果。

到目前为止,CSS 已经越来越强大,它不仅仅可以用于写业务,也可以创造很多有美感的事物,只要我们愿意去多加尝试,便可以创造出美妙的图案。

最后

好了,本文到此结束,看到这里,你是不是也跃跃欲试?想自己亲手尝试一下?

想 Get 到最有意思的 CSS 资讯,千万不要错过我的公众号 -- iCSS前端趣闻 😄

更多精彩 CSS 技术文章汇总在我的 Github -- iCSS ,持续更新,欢迎点个 star 订阅收藏。

如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

查看原文

赞 22 收藏 14 评论 2

lpicker 收藏了文章 · 2月3日

使用mono-repo实现跨项目组件共享

本文会分享一个我在实际工作中遇到的案例,从最开始的需求分析到项目搭建,以及最后落地的架构的整个过程。最终实现的效果是使用mono-repo实现了跨项目的组件共享。在本文中你可以看到:

  1. 从接到需求到深入分析并构建架构的整个思考过程。
  2. mono-repo的简单介绍。
  3. mono-repo适用的场景分析。
  4. 产出一个可以跨项目共享组件的项目架构。

本文产出的架构模板已经上传到GitHub,如果你刚好需要一个mono-repo + react的模板,直接clone下来吧:https://github.com/dennis-jiang/mono-repo-demo

需求

需求概况

是这么个情况,我还是在那家外企供职,不久前我们接到一个需求:要给外国的政府部门或者他的代理机构开发一个可以缴纳水电费,顺便还能卖卖可乐的网站。主要使用场景是市政厅之类的地方,类似这个样子:

image-20201224162525774

这张图是我在网上随便找的某银行的图片,跟我们使用场景有点类似。他有个自助的ATM机,远处还有人工柜台。我们也会有自助机器,另外也会有人工柜台,这两个地方都可以交水电费,汽车罚款什么的,唯一有个区别是人工那里除了交各种账单,还可能会卖点东西,比如口渴了买个可乐,烟瘾犯了来包中华。

需求分析

上面只是个概况,要做下来还有很多东西需要细化,柜员使用的功能和客户自助使用的功能看起来差不多,细想下来区别还真不少:

  1. 无论是交账单还是卖可乐,我们都可以将它视为一个商品,既然卖商品那肯定有上架和下架的功能,也就是商品管理,这个肯定只能做在柜员端。
  2. 市政厅人员众多,也会有上下级关系,普通柜员可能没有权限上/下架,他可能只有售卖权限,上/下架可能需要经理才能操作,这意味着柜员界面还需要权限管理。
  3. 权限管理的基础肯定是用户管理,所以柜员界面需要做登陆和注册。
  4. 客户自助界面只能交账单不能卖可乐很好理解,因为是自助机,旁边无人值守,如果摆几瓶可乐,他可能会拿了可乐不付钱。
  5. 那客户自助交水电费需要登陆吗?不需要!跟国内差不多,只需要输入卡号和姓名等基本信息就可以查询到账单,然后线上信用卡就付了。所以客户界面不需要登陆和用户管理。

从上面这几点分析我们可以看出,柜员界面会多很多功能,包括商品管理,用户管理,权限管理等,而客户自助界面只能交账单,其他功能都没有。

原型设计

基于上面几点分析,我们的设计师很快设计了两个界面的原型。

这个是柜员界面的

image-20201224172006928

柜员界面看起来也很清爽,上面一个头部,左上角显示了当前机构的名称,右上角显示了当前用户的名字和设置入口。登陆/登出相关功能点击用户名可以看到,商品管理,用户管理需要点击设置按钮进行跳转。

这个是客户自助界面的

image-20201224172649189

这个是客户界面的,看起来基本是一样的,只是少了用户和设置那一块,卖的东西少了可乐,只能交账单。

技术

现在需求基本已经理清楚了,下面就该我们技术出马了,进行技术选型和架构落地。

一个站点还是两个站点?

首先我们需要考虑的一个问题就是,柜员界面和客户界面是做在一个网站里面,还是单独做两个网站?因为两个界面高度相似,所以我们完全可以做在一起,在客户自助界面隐藏掉右上角的用户和设置就行了。

但是这里面其实还隐藏着一个问题:柜员界面是需要登陆的,所以他的入口其实是登陆页;客户界面不需要登陆,他的入口应该直接就是售卖页。如果将他们做在一起,因为不知道是柜员使用还是客户使用,所以入口只能都是登录页,柜员直接登陆进入售卖页,对于客户可以单独加一个“客户自助入口”让他进入客户的售卖页面。但是这样用户体验不好,客户本来不需要登陆的,你给他看一个登录页可能会造成困惑,可能需要频繁求教工作人员才知道怎么用,会降低整体的工作效率,所以产品经理并不接受这个,要求客户一进来就需要看到客户的售卖页面。

而且从技术角度考虑,现在我们是一个if...else...隐藏用户和设置就行了,那万一以后两个界面差异变大,客户界面要求更花哨的效果,就不是简单的一个if...else...能搞定的了。所以最后我们决定部署两个站点,柜员界面和客户界面单独部署到两个域名上

组件重复

既然是两个站点,考虑到项目的可扩展性,我们创建了两个项目。但是这两个项目的UI在目前阶段是如此相似,如果我们写两套代码,势必会有很多组件是重复的,比较典型的就是上面的商品卡片,购物车组件等。其实除了上面可以看到这些会重复外,我们往深入想,交个水费,我们肯定还需要用户输入姓名,卡号之类的信息,所以点了水费的卡片后肯定会有一个输入信息的表单,而且这个表单在柜员界面和客户界面基本是一样的,除了水费表单外,还有电费表单,罚单表单等等,所以可以预见重复的组件会非常多。

作为一个有追求的工程师,这种重复组件肯定不能靠CV大法来解决,我们得想办法让这些组件可以复用。那组件怎么复用呢?提个公共组件库嘛,相信很多朋友都会这么想。我们也是这么想的,但是公共组件库有多种组织方式,我们主要考虑了这么几种:

单独NPM包

再创建一个项目,这个项目专门放这些可复用的组件,类似于我们平时用的antd之类的,创建好后发布到公司的私有NPM仓库上,使用的时候直接这样:

import { Cart } from 'common-components';

但是,我们需要复用的这些组件跟antd组件有一个本质上的区别:我们需要复用的是业务组件,而不是单纯的UI组件antdUI组件库为了保证通用性,基本不带业务属性,样式也是开放的。但是我这里的业务组件不仅仅是几个按钮,几个输入框,而是一个完整的表单,包括前端验证逻辑都需要复用,所以我需要复用的组件其实是跟业务强绑定的。因为他是跟业务强绑定的,即使我将它作为一个单独的NPM包发布出去,公司的其他项目也用不了。一个不能被其他项目共享的NPM包,始终感觉有点违和呢。

git submodule

另一个方案是git submodule,我们照样为这些共享组件创建一个新的Git项目,但是不发布到NPM仓库去骚扰别人,而是直接在我们主项目以git submodule的方式引用他。git submodule的基本使用方法网上有很多,我这里就不啰嗦了,主要说几个缺点,也是我们没采用他的原因:

  1. 本质上submodule和主项目是两个不同的git repo,所以你需要为每个项目创建一套脚手架(代码规范,发布脚本什么的)。
  2. submodule其实只是主项目保存了一个对子项目的依赖链接,说明了当前版本的主项目依赖哪个版本的子项目,你需要小心的使用git submodule update来管理这种依赖关系。如果没有正确使用git submodule update而搞乱了版本的依赖关系,那就呵呵了。。。
  3. 发布的时候需要自己小心处理依赖关系,先发子项目,子项目好了再发布主项目。

mono-repo

mono-repo是现在越来越流行的一种项目管理方式了,与之相对的叫multi-repomulti-repo就是多个仓库,上面的git submodule其实就是multi-repo的一种方式,主项目和子项目都是单独的git仓库,也就构成了多个仓库。而mono-repo就是一个大仓库,多个项目都放在一个git仓库里面。现在很多知名开源项目都是采用的mono-repo的组织方式,比如BabelReact ,Jest, create-react-app, react-router等等。mono-repo特别适合联系紧密的多个项目,比如本文面临的这种情况,下面我们就进入本文的主题,认真看下mono-repo

mono-repo

其实我之前写react-router源码解析的时候就提到过mono-repo,当时就说有机会单独写一篇mono-repo的文章,本文也算是把坑填上了。所以我们先从react-router的源码结构入手,来看下mono-repo的整体情况,下图就是react-router的源码结构:

image-20201225153108233

我们发现他有个packages文件夹,里面有四个项目:

  1. react-router:是React-Router的核心库,处理一些共用的逻辑
  2. react-router-config:是React-Router的配置处理库
  3. react-router-dom:浏览器上使用的库,会引用react-router核心库
  4. react-router-native:支持React-Native的路由库,也会引用react-router核心库

这四个项目都是为react的路由管理服务的,在业务上有很强的关联性,完成一个功能可能需要多个项目配合才能完成。比如修某个BUG需要同时改react-router-domreact-router的代码,如果他们在不同的Git仓库,需要在两个仓库里面分别修改,提交,打包,测试,然后还要修改彼此依赖的版本号才能正常工作。但是使用了mono-repo,因为他们代码都在同一个Git仓库,我们在一个commit里面就可以修改两个项目的代码,然后统一打包,测试,发布,如果我们使用了lerna管理工具,版本号的依赖也是自动更新的,实在是方便太多了。

lerna

lerna是最知名的mono-repo的管理工具,今天我们就要用它来搭建前面提到的共享业务组件的项目,我们目标的项目结构是这个样子的:

mono-repo-demo/                  --- 主项目,这是一个Git仓库
  package.json
  packages/
    common/                      --- 共享的业务组件
      package.json
    admin-site/                  --- 柜员网站项目
      package.json
    customer-site/               --- 客户网站项目
      package.json

lerna init

lerna初始化很简单,先创建一个空的文件夹,然后运行:

npx lerna init

这行命令会帮我创建一个空的packages文件夹,一个package.jsonlerna.json,整个结构长这样:

image-20201225162905950

package.json中有一点需要注意,他的private必须设置为true,因为mono-repo本身的这个Git仓库并不是一个项目,他是多个项目,所以他自己不能直接发布,发布的应该是packages/下面的各个子项目。

"private": true,

lerna.json初始化长这样:

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

packages字段就是标记你子项目的位置,默认就是packages/文件夹,他是一个数组,所以是支持多个不同位置的。另外一个需要特别注意的是version字段,这个字段有两个类型的值,一个是像上面的0.0.0这样一个具体版本号,还可以是independent这个关键字。如果是0.0.0这种具体版本号,那lerna管理的所有子项目都会有相同的版本号----0.0.0,如果你设置为independent,那各个子项目可以有自己的版本号,比如子项目1的版本号是0.0.0,子项目2的版本号可以是0.1.0

创建子项目

现在我们的packages/目录是空的,根据我们前面的设想,我们需要创建三个项目:

  1. common:共享的业务组件,本身不需要运行,放各种组件就行了。
  2. admin-site:柜员站点,需要能够运行,使用create-react-app创建吧
  3. customer-site:客户站点,也需要运行,还是使用create-react-app创建

创建子项目可以使用lerna的命令来创建:

lerna create <name>

也可以自己手动创建文件夹,这里common子项目我就用lerna命令创建吧,lerna create common,运行后common文件夹就出现在packages下面了:

image-20201231145959966

这个是使用lerna create默认生成的目录结构,__test__文件夹下面放得是单元测试内容,lib下面放得是代码。由于我是准备用它来放共享组件的,所以我把目录结构调整了,默认生成的两个文件夹都删了,新建了一个components文件夹:

image-20201231150311253

另外两个可运行站点都用create-react-app创建了,在packages文件夹下运行:

npx create-react-app admin-site; npx create-react-app customer-site;

几个项目都创建完后,整个项目结构是这样的:

image-20201231151536018

按照mono-repo的惯例,这几个子项目的名称最好命名为@<主项目名称>/<子项目名称>,这样当别人引用你的时候,你的这几个项目都可以在node_modules的同一个目录下面,目录名字就是@<主项目名称>,所以我们手动改下三个子项目package.json里面的name为:

@mono-repo-demo/admin-site
@mono-repo-demo/common
@mono-repo-demo/customer-site

lerna bootstrap

上面的图片可以看到,packages/下面的每个子项目有自己的node_modules,如果将它打开,会发现很多重复的依赖包,这会占用我们大量的硬盘空间。lerna提供了另一个强大的功能:将子项目的依赖包都提取到最顶层,我们只需要先删除子项目的node_modules再跑下面这行命令就行了

lerna bootstrap --hoist

删除已经安装的子项目node_modules可以手动删,也可以用这个命令:

lerna clean

yarn workspace

lerna bootstrap --hoist虽然可以将子项目的依赖提升到顶层,但是他的方式比较粗暴:先在每个子项目运行npm install,等所有依赖都安装好后,将他们移动到顶层的node_modules。这会导致一个问题,如果多个子项目依赖同一个第三方库,但是需求的版本不同怎么办?比如我们三个子项目都依赖antd,但是他们的版本不完全一样:

// admin-site
"antd": "3.1.0"

// customer-site
"antd": "3.1.0"

// common
"antd": "4.9.4"

这个例子中admin-sitecustomer-site需要的antd版本都是3.1.0,但是common需要的版本却是4.9.4,如果使用lerna bootstrap --hoist来进行提升,lerna会提升用的最多的版本,也就是3.1.0到顶层,然后把子项目的node_modules里面的antd都删了。也就是说common去访问antd的话,也会拿到3.1.0的版本,这可能会导致common项目工作不正常。

这时候就需要介绍yarn workspace 了,他可以解决前面说的版本不一致的问题,lerna bootstrap --hoist会把所有子项目用的最多的版本移动到顶层,而yarn workspace 则会检查每个子项目里面依赖及其版本,如果版本不一样则会留在子项目自己的node_modules里面,只有完全一样的依赖才会提升到顶层。

还是以上面这个antd为例,使用yarn workspace的话,会把admin-sitecustomer-site3.1.0版本移动到顶层,而common项目下会保留自己4.9.4antd,这样每个子项目都可以拿到自己需要的依赖了。

yarn workspace使用也很简单,yarn 1.0以上的版本默认就是开启workspace的,所以我们只需要在顶层的package.json加一个配置就行:

// 顶层package.json
{
  "workspaces": [
    "packages/*"
  ]
}

然后在lerna.json里面指定npmClientyarn,并将useWorkspaces设置为true

// lerna.json
{
  "npmClient": "yarn",
  "useWorkspaces": true
}

使用了yarn workspace,我们就不用lerna bootstrap来安装依赖了,而是像以前一样yarn install就行了,他会自动帮我们提升依赖,这里的yarn install无论在顶层运行还是在任意一个子项目运行效果都是一样的。

启动子项目

现在我们建好了三个子项目,要启动CRA子项目,可以去那个目录下运行yarn start,但是频繁切换文件夹实在是太麻烦了。其实有了lerna的帮助我们可以直接在顶层运行,这需要用到lerna的这个功能:

lerna run [script]

比如我们在顶层运行了lerna run start,这相当于去每个子项目下面都去执行yarn run start或者npm run start,具体是yarn还是npm,取决于你在lerna.json里面的这个设置:

"npmClient": "yarn"    

如果我只想在其中一个子项目运行命令,应该怎么办呢?加上--scope就行了,比如我就在顶层的package.json里面加了这么一行命令:

// 顶层package.json
{
  "scripts": {
    "start:aSite": "lerna --scope @mono-repo-demo/admin-site run start"
  }
}

所以我们可以直接在顶层运行yarn start:aSite,这会启动前面说的管理员站点,他其实运行的命令还是lerna run start,然后加了--scope来指定在管理员子项目下运行,@mono-repo-demo/admin-site就是我们管理员子项目的名字,是定义在这个子项目的package.json里面的:

// 管理员子项目package.json
{
  "name": "@mono-repo-demo/admin-site"
}

然后我们实际运行下yarn start:aSite吧:

image-20201231155954580

看到了我们熟悉的CRA转圈圈,说明到目前为止我们的配置还算顺利,哈哈~

创建公共组件

现在项目基本结构已经有了,我们建一个公共组件试一下效果。我们就用antd创建一个交水费的表单吧,也很简单,就一个姓名输入框,一个查询按钮。

//  packages/common/components/WaterForm.js

import { Form, Input, Button } from 'antd';
const layout = {
  labelCol: {
    span: 8,
  },
  wrapperCol: {
    span: 16,
  },
};
const tailLayout = {
  wrapperCol: {
    offset: 8,
    span: 16,
  },
};

const WaterForm = () => {
  const onFinish = (values) => {
    console.log('Success:', values);
  };

  const onFinishFailed = (errorInfo) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Form
      {...layout}
      name="basic"
      initialValues={{
        remember: true,
      }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >
      <Form.Item
        label="姓名"
        name="username"
        rules={[
          {
            required: true,
            message: '请输入姓名',
          },
        ]}
      >
        <Input />
      </Form.Item>

      <Form.Item {...tailLayout}>
        <Button type="primary" htmlType="submit">
          查询
        </Button>
      </Form.Item>
    </Form>
  );
};

export default WaterForm;

引入公共组件

这个组件写好了,我们就在admin-site里面引用下他,要引用上面的组件,我们需要先在admin-sitepackage.json里面将这个依赖加上,我们可以去手动修改他,也可以使用lerna命令:

lerna add @mono-repo-demo/common --scope @mono-repo-demo/admin-site

这个命令效果跟你手动改package.json是一样的:

image-20201231161945744

然后我们去把admin-site默认的CRA圈圈改成这个水费表单吧:

image-20201231162333590

然后再运行下:

image-20201231162459416

嗯?报错了。。。如果我说这个错误是我预料之中的,你信吗😜

共享脚手架

仔细看下上面的错误,是报在WaterForm这个组件里面的,错误信息是说:jsx语法不支持,最后两行还给了个建议,叫我们引入babel来编译。这些都说明了一个同问题:babel的配置对common子项目没有生效。这其实是预料之中的,我们的admin-site之所以能跑起来是因为CRA帮我们配置好了这些脚手架,而common这个子项目并没有配置这些脚手架,自然编译不了。

我们这几个子项目都是React的,其实都可以共用一套脚手架,所以我的方案是:将CRA的脚手架全部eject出来,然后手动挪到顶层,让三个子项目共享。

首先我们到admin-site下面运行:

yarn eject

这个命令会将CRA的config文件夹和scripts文件夹弹出来,同时将他们的依赖添加到admin-sitepackage.json里面。所以我们要干的就是手动将config文件夹和scripts文件夹移动到顶层,然后将CRA添加到package.json的依赖也移到最顶层,具体CRA改了package.json里面的哪些内容可以通过git看出来的。移动过后的项目结构长这样:

image-20201231165208361

注意CRA项目的启动脚本在scripts文件夹里面,所以我们需要稍微修改下admin-site的启动命令:

// admin-site package.json

{
  "scripts": "node ../../scripts/start.js",
}

现在我们使用yarn start:aSite仍然会报错,所以我们继续修改babel的设置。

首先在config/paths里面添加上我们packages的路径并export出去:

image-20201231173801079

然后修改webpacka配置,在babel-loaderinclude路径里面添加上这个路径:

image-20201231173912873

现在再运行下我们的项目就正常了:

image-20210102142340656

最后别忘了,还有我们的customer-site哦,这个处理起来就简单了,因为前面我们已经调好了整个主项目的结构,我们可以将customer-site的其他依赖都删了,只保留@mono-repo-demo/common,然后调整下启动脚本就行了:

image-20210102142635875

这样客户站点也可以引入公共组件并启动了。

发布

最后要注意的一点是,当我们修改完成后,需要发布了,一定要使用lerna publish,他会自动帮我更新依赖的版本号。比如我现在稍微修改了一下水费表单,然后提交:

image-20210102145343033

现在我试着发布一下,运行

lerna publish

运行后,他会让你选择新的版本号:

image-20210102150019630

我这里选择一个minor,也就是版本号从0.0.0变成0.1.0,然后lerna会自动更新相关的依赖版本,包括:

  1. lerna.json自己版本号升为0.1.0

    image-20210102150535183

  2. common的版本号变为0.1.0

    image-20210102150621696

  3. admin-site的版本号也变为0.1.0,同时更新依赖的common0.1.0

    image-20210102150722538

  4. customer-site的变化跟admin-site是一样的。

independent version

上面这种发布策略,我们修改了common的版本,admin-site的版本也变成了一样的,按理来说,这个不是必须的,admin-site只是更新依赖的common版本,自己的版本不一定是升级一个minor,也许只是一个patch这种情况下,admin-site的版本要不要跟着变,取决于lerna.json里面的version配置,前面说过了,如果它是一个固定的指,那所有子项目版本会保持一致,所以admin-site版本会跟着变,我们将它改成independent就会不一样了。

// lerna.json
{
  "version": "independent"
}

然后我再改下common再发布试试:

image-20210102151332029

在运行下lerna publish,我们发现他会让你自己一个一个来选子项目的版本,我这里就可以选择将common升级为0.2.0,而admin-site只是依赖变了,就可以升级为0.1.1:

image-20210102151752370

具体采用哪种策略,是每个子项目版本都保持一致还是各自版本独立,大家可以根据自己的项目情况决定。

总结

这个mono-repo工程我已经把代码清理了一下,上传到了GitHub,如果你刚好需要一个mono-repo + react的项目模板,直接clone吧:https://github.com/dennis-jiang/mono-repo-demo

下面我们再来回顾下本文的要点:

  1. 事情的起源是我们接到了一个外国人交水电费并能卖东西的需求,有柜员端和客户自助端。
  2. 经过分析,我们决定将柜员端和客户自助端部署为两个站点。
  3. 为了这两个站点,我们新建了两个项目,这样扩展性更好。
  4. 这两个项目有很多长得一样的业务组件,我们需要复用他们。
  5. 为了复用这些业务组件,我们引入了mono-repo的架构来进行项目管理,mono-repo特别适合联系紧密的多个项目。
  6. mono-repo最出名的工具是lerna
  7. lerna可以自动管理各个项目之间的依赖以及node_modules
  8. 使用lerna bootstrap --hoist可以将子项目的node_modules提升到顶层,解决node_modules重复的问题。
  9. 但是lerna bootstrap --hoist在提升时如果遇到各个子项目引用的依赖版本不一致,会提升使用最多的版本,从而导致少数派那个找不到正确的依赖,发生错误。
  10. 为了解决提升时版本冲突的问题,我们引入了yarn workspace,他也会提升用的最多的版本,但是会为少数派保留自己的依赖在自己的node_modules下面。
  11. 我们示例中两个CRA项目都有自己的脚手架,而common没有脚手架,我们调整了脚手架,将它挪到了最顶层,从而三个项目可以共享。
  12. 发布的时候使用lerna publish,他会自动更新内部依赖,并更新各个子项目自己的版本号。
  13. 子项目的版本号规则可以在lerna.json里面配置,如果配置为固定版本号,则各个子项目保持一致的版本,如果配置为independent关键字,各个子项目可以有自己不同的版本号。

参考资料

  1. Lerna官网:https://lerna.js.org/
  2. Yarn workspace: https://classic.yarnpkg.com/en/docs/workspaces/

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

lpicker 赞了文章 · 2月3日

使用mono-repo实现跨项目组件共享

本文会分享一个我在实际工作中遇到的案例,从最开始的需求分析到项目搭建,以及最后落地的架构的整个过程。最终实现的效果是使用mono-repo实现了跨项目的组件共享。在本文中你可以看到:

  1. 从接到需求到深入分析并构建架构的整个思考过程。
  2. mono-repo的简单介绍。
  3. mono-repo适用的场景分析。
  4. 产出一个可以跨项目共享组件的项目架构。

本文产出的架构模板已经上传到GitHub,如果你刚好需要一个mono-repo + react的模板,直接clone下来吧:https://github.com/dennis-jiang/mono-repo-demo

需求

需求概况

是这么个情况,我还是在那家外企供职,不久前我们接到一个需求:要给外国的政府部门或者他的代理机构开发一个可以缴纳水电费,顺便还能卖卖可乐的网站。主要使用场景是市政厅之类的地方,类似这个样子:

image-20201224162525774

这张图是我在网上随便找的某银行的图片,跟我们使用场景有点类似。他有个自助的ATM机,远处还有人工柜台。我们也会有自助机器,另外也会有人工柜台,这两个地方都可以交水电费,汽车罚款什么的,唯一有个区别是人工那里除了交各种账单,还可能会卖点东西,比如口渴了买个可乐,烟瘾犯了来包中华。

需求分析

上面只是个概况,要做下来还有很多东西需要细化,柜员使用的功能和客户自助使用的功能看起来差不多,细想下来区别还真不少:

  1. 无论是交账单还是卖可乐,我们都可以将它视为一个商品,既然卖商品那肯定有上架和下架的功能,也就是商品管理,这个肯定只能做在柜员端。
  2. 市政厅人员众多,也会有上下级关系,普通柜员可能没有权限上/下架,他可能只有售卖权限,上/下架可能需要经理才能操作,这意味着柜员界面还需要权限管理。
  3. 权限管理的基础肯定是用户管理,所以柜员界面需要做登陆和注册。
  4. 客户自助界面只能交账单不能卖可乐很好理解,因为是自助机,旁边无人值守,如果摆几瓶可乐,他可能会拿了可乐不付钱。
  5. 那客户自助交水电费需要登陆吗?不需要!跟国内差不多,只需要输入卡号和姓名等基本信息就可以查询到账单,然后线上信用卡就付了。所以客户界面不需要登陆和用户管理。

从上面这几点分析我们可以看出,柜员界面会多很多功能,包括商品管理,用户管理,权限管理等,而客户自助界面只能交账单,其他功能都没有。

原型设计

基于上面几点分析,我们的设计师很快设计了两个界面的原型。

这个是柜员界面的

image-20201224172006928

柜员界面看起来也很清爽,上面一个头部,左上角显示了当前机构的名称,右上角显示了当前用户的名字和设置入口。登陆/登出相关功能点击用户名可以看到,商品管理,用户管理需要点击设置按钮进行跳转。

这个是客户自助界面的

image-20201224172649189

这个是客户界面的,看起来基本是一样的,只是少了用户和设置那一块,卖的东西少了可乐,只能交账单。

技术

现在需求基本已经理清楚了,下面就该我们技术出马了,进行技术选型和架构落地。

一个站点还是两个站点?

首先我们需要考虑的一个问题就是,柜员界面和客户界面是做在一个网站里面,还是单独做两个网站?因为两个界面高度相似,所以我们完全可以做在一起,在客户自助界面隐藏掉右上角的用户和设置就行了。

但是这里面其实还隐藏着一个问题:柜员界面是需要登陆的,所以他的入口其实是登陆页;客户界面不需要登陆,他的入口应该直接就是售卖页。如果将他们做在一起,因为不知道是柜员使用还是客户使用,所以入口只能都是登录页,柜员直接登陆进入售卖页,对于客户可以单独加一个“客户自助入口”让他进入客户的售卖页面。但是这样用户体验不好,客户本来不需要登陆的,你给他看一个登录页可能会造成困惑,可能需要频繁求教工作人员才知道怎么用,会降低整体的工作效率,所以产品经理并不接受这个,要求客户一进来就需要看到客户的售卖页面。

而且从技术角度考虑,现在我们是一个if...else...隐藏用户和设置就行了,那万一以后两个界面差异变大,客户界面要求更花哨的效果,就不是简单的一个if...else...能搞定的了。所以最后我们决定部署两个站点,柜员界面和客户界面单独部署到两个域名上

组件重复

既然是两个站点,考虑到项目的可扩展性,我们创建了两个项目。但是这两个项目的UI在目前阶段是如此相似,如果我们写两套代码,势必会有很多组件是重复的,比较典型的就是上面的商品卡片,购物车组件等。其实除了上面可以看到这些会重复外,我们往深入想,交个水费,我们肯定还需要用户输入姓名,卡号之类的信息,所以点了水费的卡片后肯定会有一个输入信息的表单,而且这个表单在柜员界面和客户界面基本是一样的,除了水费表单外,还有电费表单,罚单表单等等,所以可以预见重复的组件会非常多。

作为一个有追求的工程师,这种重复组件肯定不能靠CV大法来解决,我们得想办法让这些组件可以复用。那组件怎么复用呢?提个公共组件库嘛,相信很多朋友都会这么想。我们也是这么想的,但是公共组件库有多种组织方式,我们主要考虑了这么几种:

单独NPM包

再创建一个项目,这个项目专门放这些可复用的组件,类似于我们平时用的antd之类的,创建好后发布到公司的私有NPM仓库上,使用的时候直接这样:

import { Cart } from 'common-components';

但是,我们需要复用的这些组件跟antd组件有一个本质上的区别:我们需要复用的是业务组件,而不是单纯的UI组件antdUI组件库为了保证通用性,基本不带业务属性,样式也是开放的。但是我这里的业务组件不仅仅是几个按钮,几个输入框,而是一个完整的表单,包括前端验证逻辑都需要复用,所以我需要复用的组件其实是跟业务强绑定的。因为他是跟业务强绑定的,即使我将它作为一个单独的NPM包发布出去,公司的其他项目也用不了。一个不能被其他项目共享的NPM包,始终感觉有点违和呢。

git submodule

另一个方案是git submodule,我们照样为这些共享组件创建一个新的Git项目,但是不发布到NPM仓库去骚扰别人,而是直接在我们主项目以git submodule的方式引用他。git submodule的基本使用方法网上有很多,我这里就不啰嗦了,主要说几个缺点,也是我们没采用他的原因:

  1. 本质上submodule和主项目是两个不同的git repo,所以你需要为每个项目创建一套脚手架(代码规范,发布脚本什么的)。
  2. submodule其实只是主项目保存了一个对子项目的依赖链接,说明了当前版本的主项目依赖哪个版本的子项目,你需要小心的使用git submodule update来管理这种依赖关系。如果没有正确使用git submodule update而搞乱了版本的依赖关系,那就呵呵了。。。
  3. 发布的时候需要自己小心处理依赖关系,先发子项目,子项目好了再发布主项目。

mono-repo

mono-repo是现在越来越流行的一种项目管理方式了,与之相对的叫multi-repomulti-repo就是多个仓库,上面的git submodule其实就是multi-repo的一种方式,主项目和子项目都是单独的git仓库,也就构成了多个仓库。而mono-repo就是一个大仓库,多个项目都放在一个git仓库里面。现在很多知名开源项目都是采用的mono-repo的组织方式,比如BabelReact ,Jest, create-react-app, react-router等等。mono-repo特别适合联系紧密的多个项目,比如本文面临的这种情况,下面我们就进入本文的主题,认真看下mono-repo

mono-repo

其实我之前写react-router源码解析的时候就提到过mono-repo,当时就说有机会单独写一篇mono-repo的文章,本文也算是把坑填上了。所以我们先从react-router的源码结构入手,来看下mono-repo的整体情况,下图就是react-router的源码结构:

image-20201225153108233

我们发现他有个packages文件夹,里面有四个项目:

  1. react-router:是React-Router的核心库,处理一些共用的逻辑
  2. react-router-config:是React-Router的配置处理库
  3. react-router-dom:浏览器上使用的库,会引用react-router核心库
  4. react-router-native:支持React-Native的路由库,也会引用react-router核心库

这四个项目都是为react的路由管理服务的,在业务上有很强的关联性,完成一个功能可能需要多个项目配合才能完成。比如修某个BUG需要同时改react-router-domreact-router的代码,如果他们在不同的Git仓库,需要在两个仓库里面分别修改,提交,打包,测试,然后还要修改彼此依赖的版本号才能正常工作。但是使用了mono-repo,因为他们代码都在同一个Git仓库,我们在一个commit里面就可以修改两个项目的代码,然后统一打包,测试,发布,如果我们使用了lerna管理工具,版本号的依赖也是自动更新的,实在是方便太多了。

lerna

lerna是最知名的mono-repo的管理工具,今天我们就要用它来搭建前面提到的共享业务组件的项目,我们目标的项目结构是这个样子的:

mono-repo-demo/                  --- 主项目,这是一个Git仓库
  package.json
  packages/
    common/                      --- 共享的业务组件
      package.json
    admin-site/                  --- 柜员网站项目
      package.json
    customer-site/               --- 客户网站项目
      package.json

lerna init

lerna初始化很简单,先创建一个空的文件夹,然后运行:

npx lerna init

这行命令会帮我创建一个空的packages文件夹,一个package.jsonlerna.json,整个结构长这样:

image-20201225162905950

package.json中有一点需要注意,他的private必须设置为true,因为mono-repo本身的这个Git仓库并不是一个项目,他是多个项目,所以他自己不能直接发布,发布的应该是packages/下面的各个子项目。

"private": true,

lerna.json初始化长这样:

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}

packages字段就是标记你子项目的位置,默认就是packages/文件夹,他是一个数组,所以是支持多个不同位置的。另外一个需要特别注意的是version字段,这个字段有两个类型的值,一个是像上面的0.0.0这样一个具体版本号,还可以是independent这个关键字。如果是0.0.0这种具体版本号,那lerna管理的所有子项目都会有相同的版本号----0.0.0,如果你设置为independent,那各个子项目可以有自己的版本号,比如子项目1的版本号是0.0.0,子项目2的版本号可以是0.1.0

创建子项目

现在我们的packages/目录是空的,根据我们前面的设想,我们需要创建三个项目:

  1. common:共享的业务组件,本身不需要运行,放各种组件就行了。
  2. admin-site:柜员站点,需要能够运行,使用create-react-app创建吧
  3. customer-site:客户站点,也需要运行,还是使用create-react-app创建

创建子项目可以使用lerna的命令来创建:

lerna create <name>

也可以自己手动创建文件夹,这里common子项目我就用lerna命令创建吧,lerna create common,运行后common文件夹就出现在packages下面了:

image-20201231145959966

这个是使用lerna create默认生成的目录结构,__test__文件夹下面放得是单元测试内容,lib下面放得是代码。由于我是准备用它来放共享组件的,所以我把目录结构调整了,默认生成的两个文件夹都删了,新建了一个components文件夹:

image-20201231150311253

另外两个可运行站点都用create-react-app创建了,在packages文件夹下运行:

npx create-react-app admin-site; npx create-react-app customer-site;

几个项目都创建完后,整个项目结构是这样的:

image-20201231151536018

按照mono-repo的惯例,这几个子项目的名称最好命名为@<主项目名称>/<子项目名称>,这样当别人引用你的时候,你的这几个项目都可以在node_modules的同一个目录下面,目录名字就是@<主项目名称>,所以我们手动改下三个子项目package.json里面的name为:

@mono-repo-demo/admin-site
@mono-repo-demo/common
@mono-repo-demo/customer-site

lerna bootstrap

上面的图片可以看到,packages/下面的每个子项目有自己的node_modules,如果将它打开,会发现很多重复的依赖包,这会占用我们大量的硬盘空间。lerna提供了另一个强大的功能:将子项目的依赖包都提取到最顶层,我们只需要先删除子项目的node_modules再跑下面这行命令就行了

lerna bootstrap --hoist

删除已经安装的子项目node_modules可以手动删,也可以用这个命令:

lerna clean

yarn workspace

lerna bootstrap --hoist虽然可以将子项目的依赖提升到顶层,但是他的方式比较粗暴:先在每个子项目运行npm install,等所有依赖都安装好后,将他们移动到顶层的node_modules。这会导致一个问题,如果多个子项目依赖同一个第三方库,但是需求的版本不同怎么办?比如我们三个子项目都依赖antd,但是他们的版本不完全一样:

// admin-site
"antd": "3.1.0"

// customer-site
"antd": "3.1.0"

// common
"antd": "4.9.4"

这个例子中admin-sitecustomer-site需要的antd版本都是3.1.0,但是common需要的版本却是4.9.4,如果使用lerna bootstrap --hoist来进行提升,lerna会提升用的最多的版本,也就是3.1.0到顶层,然后把子项目的node_modules里面的antd都删了。也就是说common去访问antd的话,也会拿到3.1.0的版本,这可能会导致common项目工作不正常。

这时候就需要介绍yarn workspace 了,他可以解决前面说的版本不一致的问题,lerna bootstrap --hoist会把所有子项目用的最多的版本移动到顶层,而yarn workspace 则会检查每个子项目里面依赖及其版本,如果版本不一样则会留在子项目自己的node_modules里面,只有完全一样的依赖才会提升到顶层。

还是以上面这个antd为例,使用yarn workspace的话,会把admin-sitecustomer-site3.1.0版本移动到顶层,而common项目下会保留自己4.9.4antd,这样每个子项目都可以拿到自己需要的依赖了。

yarn workspace使用也很简单,yarn 1.0以上的版本默认就是开启workspace的,所以我们只需要在顶层的package.json加一个配置就行:

// 顶层package.json
{
  "workspaces": [
    "packages/*"
  ]
}

然后在lerna.json里面指定npmClientyarn,并将useWorkspaces设置为true

// lerna.json
{
  "npmClient": "yarn",
  "useWorkspaces": true
}

使用了yarn workspace,我们就不用lerna bootstrap来安装依赖了,而是像以前一样yarn install就行了,他会自动帮我们提升依赖,这里的yarn install无论在顶层运行还是在任意一个子项目运行效果都是一样的。

启动子项目

现在我们建好了三个子项目,要启动CRA子项目,可以去那个目录下运行yarn start,但是频繁切换文件夹实在是太麻烦了。其实有了lerna的帮助我们可以直接在顶层运行,这需要用到lerna的这个功能:

lerna run [script]

比如我们在顶层运行了lerna run start,这相当于去每个子项目下面都去执行yarn run start或者npm run start,具体是yarn还是npm,取决于你在lerna.json里面的这个设置:

"npmClient": "yarn"    

如果我只想在其中一个子项目运行命令,应该怎么办呢?加上--scope就行了,比如我就在顶层的package.json里面加了这么一行命令:

// 顶层package.json
{
  "scripts": {
    "start:aSite": "lerna --scope @mono-repo-demo/admin-site run start"
  }
}

所以我们可以直接在顶层运行yarn start:aSite,这会启动前面说的管理员站点,他其实运行的命令还是lerna run start,然后加了--scope来指定在管理员子项目下运行,@mono-repo-demo/admin-site就是我们管理员子项目的名字,是定义在这个子项目的package.json里面的:

// 管理员子项目package.json
{
  "name": "@mono-repo-demo/admin-site"
}

然后我们实际运行下yarn start:aSite吧:

image-20201231155954580

看到了我们熟悉的CRA转圈圈,说明到目前为止我们的配置还算顺利,哈哈~

创建公共组件

现在项目基本结构已经有了,我们建一个公共组件试一下效果。我们就用antd创建一个交水费的表单吧,也很简单,就一个姓名输入框,一个查询按钮。

//  packages/common/components/WaterForm.js

import { Form, Input, Button } from 'antd';
const layout = {
  labelCol: {
    span: 8,
  },
  wrapperCol: {
    span: 16,
  },
};
const tailLayout = {
  wrapperCol: {
    offset: 8,
    span: 16,
  },
};

const WaterForm = () => {
  const onFinish = (values) => {
    console.log('Success:', values);
  };

  const onFinishFailed = (errorInfo) => {
    console.log('Failed:', errorInfo);
  };

  return (
    <Form
      {...layout}
      name="basic"
      initialValues={{
        remember: true,
      }}
      onFinish={onFinish}
      onFinishFailed={onFinishFailed}
    >
      <Form.Item
        label="姓名"
        name="username"
        rules={[
          {
            required: true,
            message: '请输入姓名',
          },
        ]}
      >
        <Input />
      </Form.Item>

      <Form.Item {...tailLayout}>
        <Button type="primary" htmlType="submit">
          查询
        </Button>
      </Form.Item>
    </Form>
  );
};

export default WaterForm;

引入公共组件

这个组件写好了,我们就在admin-site里面引用下他,要引用上面的组件,我们需要先在admin-sitepackage.json里面将这个依赖加上,我们可以去手动修改他,也可以使用lerna命令:

lerna add @mono-repo-demo/common --scope @mono-repo-demo/admin-site

这个命令效果跟你手动改package.json是一样的:

image-20201231161945744

然后我们去把admin-site默认的CRA圈圈改成这个水费表单吧:

image-20201231162333590

然后再运行下:

image-20201231162459416

嗯?报错了。。。如果我说这个错误是我预料之中的,你信吗😜

共享脚手架

仔细看下上面的错误,是报在WaterForm这个组件里面的,错误信息是说:jsx语法不支持,最后两行还给了个建议,叫我们引入babel来编译。这些都说明了一个同问题:babel的配置对common子项目没有生效。这其实是预料之中的,我们的admin-site之所以能跑起来是因为CRA帮我们配置好了这些脚手架,而common这个子项目并没有配置这些脚手架,自然编译不了。

我们这几个子项目都是React的,其实都可以共用一套脚手架,所以我的方案是:将CRA的脚手架全部eject出来,然后手动挪到顶层,让三个子项目共享。

首先我们到admin-site下面运行:

yarn eject

这个命令会将CRA的config文件夹和scripts文件夹弹出来,同时将他们的依赖添加到admin-sitepackage.json里面。所以我们要干的就是手动将config文件夹和scripts文件夹移动到顶层,然后将CRA添加到package.json的依赖也移到最顶层,具体CRA改了package.json里面的哪些内容可以通过git看出来的。移动过后的项目结构长这样:

image-20201231165208361

注意CRA项目的启动脚本在scripts文件夹里面,所以我们需要稍微修改下admin-site的启动命令:

// admin-site package.json

{
  "scripts": "node ../../scripts/start.js",
}

现在我们使用yarn start:aSite仍然会报错,所以我们继续修改babel的设置。

首先在config/paths里面添加上我们packages的路径并export出去:

image-20201231173801079

然后修改webpacka配置,在babel-loaderinclude路径里面添加上这个路径:

image-20201231173912873

现在再运行下我们的项目就正常了:

image-20210102142340656

最后别忘了,还有我们的customer-site哦,这个处理起来就简单了,因为前面我们已经调好了整个主项目的结构,我们可以将customer-site的其他依赖都删了,只保留@mono-repo-demo/common,然后调整下启动脚本就行了:

image-20210102142635875

这样客户站点也可以引入公共组件并启动了。

发布

最后要注意的一点是,当我们修改完成后,需要发布了,一定要使用lerna publish,他会自动帮我更新依赖的版本号。比如我现在稍微修改了一下水费表单,然后提交:

image-20210102145343033

现在我试着发布一下,运行

lerna publish

运行后,他会让你选择新的版本号:

image-20210102150019630

我这里选择一个minor,也就是版本号从0.0.0变成0.1.0,然后lerna会自动更新相关的依赖版本,包括:

  1. lerna.json自己版本号升为0.1.0

    image-20210102150535183

  2. common的版本号变为0.1.0

    image-20210102150621696

  3. admin-site的版本号也变为0.1.0,同时更新依赖的common0.1.0

    image-20210102150722538

  4. customer-site的变化跟admin-site是一样的。

independent version

上面这种发布策略,我们修改了common的版本,admin-site的版本也变成了一样的,按理来说,这个不是必须的,admin-site只是更新依赖的common版本,自己的版本不一定是升级一个minor,也许只是一个patch这种情况下,admin-site的版本要不要跟着变,取决于lerna.json里面的version配置,前面说过了,如果它是一个固定的指,那所有子项目版本会保持一致,所以admin-site版本会跟着变,我们将它改成independent就会不一样了。

// lerna.json
{
  "version": "independent"
}

然后我再改下common再发布试试:

image-20210102151332029

在运行下lerna publish,我们发现他会让你自己一个一个来选子项目的版本,我这里就可以选择将common升级为0.2.0,而admin-site只是依赖变了,就可以升级为0.1.1:

image-20210102151752370

具体采用哪种策略,是每个子项目版本都保持一致还是各自版本独立,大家可以根据自己的项目情况决定。

总结

这个mono-repo工程我已经把代码清理了一下,上传到了GitHub,如果你刚好需要一个mono-repo + react的项目模板,直接clone吧:https://github.com/dennis-jiang/mono-repo-demo

下面我们再来回顾下本文的要点:

  1. 事情的起源是我们接到了一个外国人交水电费并能卖东西的需求,有柜员端和客户自助端。
  2. 经过分析,我们决定将柜员端和客户自助端部署为两个站点。
  3. 为了这两个站点,我们新建了两个项目,这样扩展性更好。
  4. 这两个项目有很多长得一样的业务组件,我们需要复用他们。
  5. 为了复用这些业务组件,我们引入了mono-repo的架构来进行项目管理,mono-repo特别适合联系紧密的多个项目。
  6. mono-repo最出名的工具是lerna
  7. lerna可以自动管理各个项目之间的依赖以及node_modules
  8. 使用lerna bootstrap --hoist可以将子项目的node_modules提升到顶层,解决node_modules重复的问题。
  9. 但是lerna bootstrap --hoist在提升时如果遇到各个子项目引用的依赖版本不一致,会提升使用最多的版本,从而导致少数派那个找不到正确的依赖,发生错误。
  10. 为了解决提升时版本冲突的问题,我们引入了yarn workspace,他也会提升用的最多的版本,但是会为少数派保留自己的依赖在自己的node_modules下面。
  11. 我们示例中两个CRA项目都有自己的脚手架,而common没有脚手架,我们调整了脚手架,将它挪到了最顶层,从而三个项目可以共享。
  12. 发布的时候使用lerna publish,他会自动更新内部依赖,并更新各个子项目自己的版本号。
  13. 子项目的版本号规则可以在lerna.json里面配置,如果配置为固定版本号,则各个子项目保持一致的版本,如果配置为independent关键字,各个子项目可以有自己不同的版本号。

参考资料

  1. Lerna官网:https://lerna.js.org/
  2. Yarn workspace: https://classic.yarnpkg.com/en/docs/workspaces/

文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。

欢迎关注我的公众号进击的大前端第一时间获取高质量原创~

“前端进阶知识”系列文章源码地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二维码_2.png

查看原文

赞 33 收藏 21 评论 6

lpicker 赞了文章 · 2月3日

这才是官方的tapable中文文档

起因

搜索引擎搜索tapable中文文档,你会看见各种翻译,点进去一看,确实是官方的文档翻译过来的,但是webpack的文档确实还有很多需要改进的地方,既然是开源的为什么不去github上的tapable库看呢,一看,确实,比webpack文档上的描述得清楚得多.

tapable 是一个类似于nodejs 的EventEmitter 的库, 主要是控制钩子函数的发布与订阅,控制着webpack的插件系.webpack的本质就是一系列的插件运行.

Tapable

Tapable库 提供了很多的钩子类, 这些类可以为插件创建钩子

const {
    SyncHook,
    SyncBailHook,
    SyncWaterfallHook,
    SyncLoopHook,
    AsyncParallelHook,
    AsyncParallelBailHook,
    AsyncSeriesHook,
    AsyncSeriesBailHook,
    AsyncSeriesWaterfallHook
 } = require("tapable");

安装

npm install --save tapable

使用

所有的钩子构造函数,都接受一个可选的参数,(这个参数最好是数组,不是tapable内部也把他变成数组),这是一个参数的字符串名字列表

const hook = new SyncHook(["arg1", "arg2", "arg3"]);

最好的实践就是把所有的钩子暴露在一个类的hooks属性里面:

class Car {
    constructor() {
        this.hooks = {
            accelerate: new SyncHook(["newSpeed"]),
            brake: new SyncHook(),
            calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
        };
    }

    /* ... */
}

其他开发者现在可以这样用这些钩子

const myCar = new Car();

// Use the tap method to add a consument
// 使用tap 方法添加一个消费者,(生产者消费者模式)
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());

这需要你传一个名字去标记这个插件:

你可以接收参数

myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`));

在同步钩子中, tap 是唯一的绑定方法,异步钩子通常支持异步插件

// promise: 绑定promise钩子的API
myCar.hooks.calculateRoutes.tapPromise("GoogleMapsPlugin", (source, target, routesList) => {
    // return a promise
    return google.maps.findRoute(source, target).then(route => {
        routesList.add(route);
    });
});
// tapAsync:绑定异步钩子的API
myCar.hooks.calculateRoutes.tapAsync("BingMapsPlugin", (source, target, routesList, callback) => {
    bing.findRoute(source, target, (err, route) => {
        if(err) return callback(err);
        routesList.add(route);
        // call the callback
        callback();
    });
});

// You can still use sync plugins
// tap: 绑定同步钩子的API
myCar.hooks.calculateRoutes.tap("CachedRoutesPlugin", (source, target, routesList) => {
    const cachedRoute = cache.get(source, target);
    if(cachedRoute)
        routesList.add(cachedRoute);
})

类需要调用被声明的那些钩子

class Car {
    /* ... */

    setSpeed(newSpeed) {    
        // call(xx) 传参调用同步钩子的API
        this.hooks.accelerate.call(newSpeed);
    }

    useNavigationSystemPromise(source, target) {
        const routesList = new List();
        // 调用promise钩子(钩子返回一个promise)的API
        return this.hooks.calculateRoutes.promise(source, target, routesList).then(() => {
            return routesList.getRoutes();
        });
    }

    useNavigationSystemAsync(source, target, callback) {
        const routesList = new List();
        // 调用异步钩子API
        this.hooks.calculateRoutes.callAsync(source, target, routesList, err => {
            if(err) return callback(err);
            callback(null, routesList.getRoutes());
        });
    }
}

tapable会用最有效率的方式去编译(构建)一个运行你的插件的方法,他生成的代码依赖于一下几点:

  • 你注册的插件的个数.
  • 你注册插件的类型.
  • 你使用的调用方法(call, promise, async) // 其实这个类型已经包括了
  • 钩子参数的个数 // 就是你new xxxHook(['ooo']) 传入的参数
  • 是否应用了拦截器(拦截器下面有讲)

这些确定了尽可能快的执行.

钩子类型

每一个钩子都可以tap 一个或者多个函数, 他们如何运行,取决于他们的钩子类型

  • 基本的钩子, (钩子类名没有waterfall, Bail, 或者 Loop 的 ), 这个钩子只会简单的调用每个tap进去的函数
  • Waterfall, 一个waterfall 钩子,也会调用每个tap进去的函数,不同的是,他会从每一个函数传一个返回的值到下一个函数
  • Bail, Bail 钩子允许更早的退出,当任何一个tap进去的函数,返回任何值, bail类会停止执行其他的函数执行.(类似 Promise.race())
  • Loop, TODO(我.... 这里也没描述,应该是写文档得时候 还没想好这个要怎么写,我尝试看他代码去补全,不过可能需要点时间.)

此外,钩子可以是同步的,也可以是异步的,Sync, AsyncSeries 和 AsyncParallel ,从名字就可以看出,哪些是可以绑定异步函数的

  • Sync, 一个同步钩子只能tap同步函数, 不然会报错.
  • AsyncSeries, 一个 async-series 钩子 可以tap 同步钩子, 基于回调的钩子(我估计是类似chunk的东西)和一个基于promise的钩子(使用myHook.tap(), myHook.tapAsync()myHook.tapPromise().).他会按顺序的调用每个方法.
  • AsyncParallel, 一个 async-parallel 钩子跟上面的 async-series 一样 不同的是他会把异步钩子并行执行(并行执行就是把异步钩子全部一起开启,不按顺序执行).

拦截器(interception)

所有钩子都提供额外的拦截器API

// 注册一个拦截器
myCar.hooks.calculateRoutes.intercept({
    call: (source, target, routesList) => {
        console.log("Starting to calculate routes");
    },
    register: (tapInfo) => {
        // tapInfo = { type: "promise", name: "GoogleMapsPlugin", fn: ... }
        console.log(`${tapInfo.name} is doing its job`);
        return tapInfo; // may return a new tapInfo object
    }
})

call:(...args) => void当你的钩子触发之前,(就是call()之前),就会触发这个函数,你可以访问钩子的参数.多个钩子执行一次

tap: (tap: Tap) => void 每个钩子执行之前(多个钩子执行多个),就会触发这个函数

loop:(...args) => void 这个会为你的每一个循环钩子(LoopHook, 就是类型到Loop的)触发,具体什么时候没说

register:(tap: Tap) => Tap | undefined 每添加一个Tap都会触发 你interceptor上的register,你下一个拦截器的register 函数得到的参数 取决于你上一个register返回的值,所以你最好返回一个 tap 钩子.

Context(上下文)

插件和拦截器都可以选择加入一个可选的 context对象, 这个可以被用于传递随意的值到队列中的插件和拦截器.

myCar.hooks.accelerate.intercept({
    context: true,
    tap: (context, tapInfo) => {
        // tapInfo = { type: "sync", name: "NoisePlugin", fn: ... }
        console.log(`${tapInfo.name} is doing it's job`);

        // `context` starts as an empty object if at least one plugin uses `context: true`.
        // 如果最少有一个插件使用 `context` 那么context 一开始是一个空的对象
        // If no plugins use `context: true`, then `context` is undefined
        // 如过tap进去的插件没有使用`context` 的 那么内部的`context` 一开始就是undefined
        if (context) {
            // Arbitrary properties can be added to `context`, which plugins can then access.    
            // 任意属性都可以添加到`context`, 插件可以访问到这些属性
            context.hasMuffler = true;
        }
    }
});

myCar.hooks.accelerate.tap({
    name: "NoisePlugin",
    context: true
}, (context, newSpeed) => {
    if (context && context.hasMuffler) {
        console.log("Silence...");
    } else {
        console.log("Vroom!");
    }
});

HookMap

一个 HookMap是一个Hooks映射的帮助类

const keyedHook = new HookMap(key => new SyncHook(["arg"]))
keyedHook.tap("some-key", "MyPlugin", (arg) => { /* ... */ });
keyedHook.tapAsync("some-key", "MyPlugin", (arg, callback) => { /* ... */ });
keyedHook.tapPromise("some-key", "MyPlugin", (arg) => { /* ... */ });
const hook = keyedHook.get("some-key");
if(hook !== undefined) {
    hook.callAsync("arg", err => { /* ... */ });
}

钩子映射接口(HookMap interface)

Public(权限公开的):

interface Hook {
    tap: (name: string | Tap, fn: (context?, ...args) => Result) => void,
    tapAsync: (name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
    tapPromise: (name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
    intercept: (interceptor: HookInterceptor) => void
}

interface HookInterceptor {
    call: (context?, ...args) => void,
    loop: (context?, ...args) => void,
    tap: (context?, tap: Tap) => void,
    register: (tap: Tap) => Tap,
    context: boolean
}

interface HookMap {
    for: (key: any) => Hook,
    tap: (key: any, name: string | Tap, fn: (context?, ...args) => Result) => void,
    tapAsync: (key: any, name: string | Tap, fn: (context?, ...args, callback: (err, result: Result) => void) => void) => void,
    tapPromise: (key: any, name: string | Tap, fn: (context?, ...args) => Promise<Result>) => void,
    intercept: (interceptor: HookMapInterceptor) => void
}

interface HookMapInterceptor {
    factory: (key: any, hook: Hook) => Hook
}

interface Tap {
    name: string,
    type: string
    fn: Function,
    stage: number,
    context: boolean
}

Protected(保护的权限),只用于类包含的(里面的)钩子

interface Hook {
    isUsed: () => boolean,
    call: (...args) => Result,
    promise: (...args) => Promise<Result>,
    callAsync: (...args, callback: (err, result: Result) => void) => void,
}

interface HookMap {
    get: (key: any) => Hook | undefined,
    for: (key: any) => Hook
}

MultiHook

把其他的Hook 重定向(转化)成为一个 MultiHook

const { MultiHook } = require("tapable");

this.hooks.allHooks = new MultiHook([this.hooks.hookA, this.hooks.hookB]);

OK 所有的内容我都已翻译完成.

其中有很多不是直译,这样写下来感觉就是按照原文的脉络重新写了一遍....,应该能更清楚明白,要不是怕丢脸我就给个原创了,哈哈.

之后, 我还会写一篇完整的原创解析,直击源码,搞定tapable, 完全了解webpack插件系统(webpack本来就是一个插件的事件流), 好久没写原创了. 我自己也很期待.

查看原文

赞 40 收藏 22 评论 3

lpicker 关注了问题 · 2月3日

解决ECMA-262文档一些定义读不懂(Let and Const Declarations)

在查看ECMA语言规范,发现他的一些定义不知道怎么理解,希望有经验的能启发一下

原文中这样写道:

Syntax

LexicalDeclaration[In, Yield, Await]:
    LetOrConst BindingList[?In, ?Yield, ?Await];

LetOrConst:
    let
    const

BindingList[In, Yield, Await]:
    LexicalBinding[?In, ?Yield, ?Await]
    BindingList[?In, ?Yield, ?Await],LexicalBinding[?In, ?Yield, ?Await]

LexicalBinding[In, Yield, Await]:
    BindingIdentifier[?Yield, ?Await]Initializer[?In, ?Yield, ?Await]opt
    BindingPattern[?Yield, ?Await]Initializer[?In, ?Yield, ?Await]

不懂后面的中括号是什么意思

BindingList : 下面是他的子集吗? 怎么还有BindingList自身...

关注 4 回答 2

认证与成就

  • 获得 5 次点赞
  • 获得 14 枚徽章 获得 0 枚金徽章, 获得 1 枚银徽章, 获得 13 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-03-07
个人主页被 929 人浏览