用 CSS 变量定义主色

CSS 变量 还是一个实验性功能但是 Chrome 49+Firefox 29+Safari 9.1+ 都已经支持了相比 CSS 预处理器和后处理器中的变量它的表达能力不强但是更灵活

CSS 预处理器和后处理器

CSS 预处理器和后处理器中的变量是编译期间的概念编译成目标 CSS 代码的时候计算出表达式的值直接替换掉表达式

比如说对 SCSS

$i: 21;

.universe {
  answer: $i * 2;
}

编译出来的结果是

.universe {
  answer: 42;
}

所有的计算都是在编译期间完成的包括更复杂的函数调用

.foo {
  color: darken(hsl(25, 100%, 80%), 30%);
}

编译后就成为了

.foo {
  color: #ff6a00;
}

由于没有输入的概念所有的表达式都可以在编译期间进行常量折叠但是跟别的语言的编译器不同的是这里的常量折叠不仅是为了优化代码更是由于 CSS 本身不支持复杂的表达式而做的必要步骤

CSS 变量

CSS 变量必须以 -- 开头也是作用在当前的层级没有定义的话值从父元素继承使用 var() 来访问

:root {
  --main-text-color: #233;
}

body {
  color: var(--main-text-color);
}

CSS 变量是运行时的概念因此值可以在运行时改变比如说

document.documentElement.style.setProperty('--main-text-color', 'red');

会在 <html> 上添加一个 CSS 变量从而让 <body> 的文字颜色变成红色可以在 JSFiddle 中体验

用 CSS 预处理器或者后处理器要使修改的变量值生效必须重新编译整个项目CSS 变量可能会让一部分人做出不使用 CSS 预处理器和后处理器的决策减少对预编译过程的依赖除此之外CSS 变量也可以做到许多 CSS 预处理器和后处理器无法做到的事情尤其是在运行时与 JavaScript 相结合的时候比如说支持让用户自定义主色调CSS 预处理器和后处理器就无法做到除非在运行时重新编译

不过CSS 还不支持对颜色进行计算我们往往会想要加深或者减淡某个颜色从而计算出一些相似色比如下面这个按钮

的 CSS 如下

.confirm-button {
  background-color: rgba(25, 137, 250, .04);
  border: 1px solid rgba(25, 137, 250, .4);
  border-radius: 4px;
  color: rgba(25, 137, 250, 1);
  line-height: 34px;
  padding: 0 20px;
  transition: all .15s;
}

.confirm-button:hover {
  background-color: rgba(25, 137, 250, 1);
  border-color: rgba(25, 137, 250, 1);
  color: #fff;
  cursor: pointer;
}

颜色部分取自七牛云可以看到背景颜色边框颜色和文字颜色其实都是一种颜色只不过透明度不同当底色是白色时就等同于不同程度的减淡这是一种常用的设计方式尤其是在设计扁平化风格的 UI 的时候在 Sass一种 CSS 预处理器中可以使用 lighten() 函数对颜色进行减淡但是在 CSS 里就会变得麻烦一些

.flat-button {
  background-color: rgba(var(--r), var(--g), var(--b), .04);
  border: 1px solid rgba(var(--r), var(--g), var(--b), .4);
  border-radius: 4px;
  color: rgba(var(--r), var(--g), var(--b), 1);
  line-height: 34px;
  padding: 0 20px;
  transition: all .15s;
}

.flat-button:hover {
  background-color: rgba(var(--r), var(--g), var(--b), 1);
  border-color: rgba(var(--r), var(--g), var(--b), 1);
  color: #fff;
  cursor: pointer;
}

我们只能像这样把颜色抽成三个分量--r--g--b然后把它们分别传进去好在这样我们在派生更多类型的按钮的时候就只需要定义这三个变量了

.flat-button--primary {
  --r: 25;
  --g: 137;
  --b: 250;
}

.flat-button--dangerous {
  --r: 229;
  --g: 92;
  --b: 92;
}

此时对按钮

<button type="button" class="flat-button flat-button--primary">确定</button>
<button type="button" class="flat-button flat-button--dangerous">删除</button>

效果就是这样的

当然这样做的前提是网页的背景颜色必须是白色的不然半透明出来鬼知道是什么效果总的来说使用 CSS 变量还是可以减少重复常量的出现的

希望 CSS 在未来能变得更有表达力一些让上面的实现更优雅吧

扩展阅读你的网站可以一键变色吗


HTTP/2 Server Push 规范与实现(二)

上一篇讲的是HTTP/2 Server Push的规范这一篇讲一讲实现 确切地说是Chromium的实现

Chromium的源码可以看这里nwjs维护的GitHub上的一个镜像 之所以在GitHub上看是因为GitHub支持快速搜索代码中的关键词 拉下来看不会太方便如果不对token建索引这么的庞然大物搜索起来还是很费劲的

当所有的header接收完的时候QuicHeadersStream::OnHeaderList() 会调用OnPromiseHeaderList()

void QuicClientSessionBase::OnPromiseHeaderList(
    QuicStreamId stream_id,
    QuicStreamId promised_stream_id,
    size_t frame_len,
    const QuicHeaderList& header_list) {
  if (promised_stream_id != kInvalidStreamId &&
      promised_stream_id <= largest_promised_stream_id_) {
    connection()->CloseConnection(
        QUIC_INVALID_STREAM_ID,
        "Received push stream id lesser or equal to the"
        " last accepted before",
        ConnectionCloseBehavior::SEND_CONNECTION_CLOSE_PACKET);
    return;
  }
  largest_promised_stream_id_ = promised_stream_id;

  QuicSpdyStream* stream = GetSpdyDataStream(stream_id);
  if (!stream) {
    // It's quite possible to receive headers after a stream has been reset.
    return;
  }
  stream->OnPromiseHeaderList(promised_stream_id, frame_len, header_list);
}

从而进入QuicSpdyClientStreamOnPromiseHeaderList()

void QuicSpdyClientStream::OnPromiseHeaderList(
    QuicStreamId promised_id,
    size_t frame_len,
    const QuicHeaderList& header_list) {
  header_bytes_read_ += frame_len;
  int64_t content_length = -1;
  SpdyHeaderBlock promise_headers;
  if (!SpdyUtils::CopyAndValidateHeaders(header_list, &content_length,
                                         &promise_headers)) {
    DLOG(ERROR) << "Failed to parse promise headers: "
                << header_list.DebugString();
    Reset(QUIC_BAD_APPLICATION_PAYLOAD);
    return;
  }

  session_->HandlePromised(id(), promised_id, promise_headers);
  if (visitor() != nullptr) {
    visitor()->OnPromiseHeadersComplete(promised_id, frame_len);
  }
}

如果解析成功则会调用quic_client_session_base.h 里的HandlePromised()

bool QuicClientSessionBase::HandlePromised(QuicStreamId /* associated_id */,
                                           QuicStreamId id,
                                           const SpdyHeaderBlock& headers) {
  // Due to pathalogical packet re-ordering, it is possible that
  // frames for the promised stream have already arrived, and the
  // promised stream could be active or closed.
  if (IsClosedStream(id)) {
    // There was a RST on the data stream already, perhaps
    // QUIC_REFUSED_STREAM?
    DVLOG(1) << "Promise ignored for stream " << id
             << " that is already closed";
    return false;
  }

  if (push_promise_index_->promised_by_url()->size() >= get_max_promises()) {
    DVLOG(1) << "Too many promises, rejecting promise for stream " << id;
    ResetPromised(id, QUIC_REFUSED_STREAM);
    return false;
  }

  const string url = SpdyUtils::GetUrlFromHeaderBlock(headers);
  QuicClientPromisedInfo* old_promised = GetPromisedByUrl(url);
  if (old_promised) {
    DVLOG(1) << "Promise for stream " << id << " is duplicate URL " << url
             << " of previous promise for stream " << old_promised->id();
    ResetPromised(id, QUIC_DUPLICATE_PROMISE_URL);
    return false;
  }

  if (GetPromisedById(id)) {
    // OnPromiseHeadersComplete() would have closed the connection if
    // promised id is a duplicate.
    QUIC_BUG << "Duplicate promise for id " << id;
    return false;
  }

  QuicClientPromisedInfo* promised = new QuicClientPromisedInfo(this, id, url);
  std::unique_ptr<QuicClientPromisedInfo> promised_owner(promised);
  promised->Init();
  DVLOG(1) << "stream " << id << " emplace url " << url;
  (*push_promise_index_->promised_by_url())[url] = promised;
  promised_by_id_[id] = std::move(promised_owner);
  promised->OnPromiseHeaders(headers);
  return true;
}

同文件里定义了一个叫QuicPromisedByUrlMapmap

// For client/http layer code. Lookup promised streams based on
// matching promised request url. The same map can be shared across
// multiple sessions, since cross-origin pushes are allowed (subject
// to authority constraints).  Clients should use this map to enforce
// session affinity for requests corresponding to cross-origin push
// promised streams.
using QuicPromisedByUrlMap =
    std::unordered_map<std::string, QuicClientPromisedInfo*>;

和一个叫QuicPromisedByIdMapmap

// For QuicSpdyClientStream to detect that a response corresponds to a
// promise.
using QuicPromisedByIdMap =
    std::unordered_map<QuicStreamId, std::unique_ptr<QuicClientPromisedInfo>>;

显然前者是用来通过URL来找promised stream而后者通过stream id来找 两者都是需要的前者在浏览器要发起一个请求的时候查询是不是已经发送过PUSH_PROMISE如果已经promise过了就不需要再发起请求了 后者则在是收到stream的时候检测它是不是一个promise相关的response

HandlePromised()会创建一个QuicClientPromisedInfo然后把它插入进上面的两个map

创建QuicClientPromisedInfo会调用它的OnPromiseHeaders()

void QuicClientPromisedInfo::OnPromiseHeaders(const SpdyHeaderBlock& headers) {
  // RFC7540, Section 8.2, requests MUST be safe [RFC7231], Section
  // 4.2.1.  GET and HEAD are the methods that are safe and required.
  SpdyHeaderBlock::const_iterator it = headers.find(":method");
  DCHECK(it != headers.end());
  if (!(it->second == "GET" || it->second == "HEAD")) {
    DVLOG(1) << "Promise for stream " << id_ << " has invalid method "
             << it->second;
    Reset(QUIC_INVALID_PROMISE_METHOD);
    return;
  }
  if (!SpdyUtils::UrlIsValid(headers)) {
    DVLOG(1) << "Promise for stream " << id_ << " has invalid URL " << url_;
    Reset(QUIC_INVALID_PROMISE_URL);
    return;
  }
  if (!session_->IsAuthorized(SpdyUtils::GetHostNameFromHeaderBlock(headers))) {
    Reset(QUIC_UNAUTHORIZED_PROMISE_URL);
    return;
  }
  request_headers_.reset(new SpdyHeaderBlock(headers.Clone()));
}

可以看到这里依次判断了:method是不是GET或者HEADURL是否合法以及host name是不是权威的 只要不满足任何一个条件reset掉这个stream


HTTP/2 Server Push 规范与实现(一)

HTTP/2规范中专门有一节讲server push

Server push这个名字带有一点误导性它并非是一种客户端和服务端之间实现长连接的方式 在服务端收到请求后可以选择单方向地向客户端预先推送一些资源 这些资源可能是样式脚本图片等等

这有点像HTTP/1.1时代把资源内联inline主要目的是提升首次加载的速度—— 不需要等到客户端解析出HTML再向服务端请求额外的资源而是选择直接把引用到的资源推给客户端

这种模式可能会引起竞争race—— 比如说服务端正在推送某一资源而此时客户端解析完了DOM也发起了同一个资源的请求 为了避免这种情况HTTP/2引入了一种PUSH_PROMISE的帧类型 当服务端决定推某个资源比如样式文件的时候应当先发送一个PUSH_PROMISE帧来告诉客户端它将要推送这一资源 PUSH_PROMISE帧之后才发送引用到这个资源的帧比如HTML 这确保了客户端先收到对某一资源的PUSH_PROMISE再收到对这一资源的引用

PUSH_PROMISE有一个Promised Stream ID字段它指定了这个资源用哪个流去推 当然这个流ID必须是一个新的流标识符 换言之发送PUSH_PROMISE保留reserve一个流来推资源

以下是流的生命周期

                             +--------+
                     send PP |        | recv PP
                    ,--------|  idle  |--------.
                   /         |        |         \
                  v          +--------+          v
           +----------+          |           +----------+
           |          |          | send H /  |          |
    ,------| reserved |          | recv H    | reserved |------.
    |      | (local)  |          |           | (remote) |      |
    |      +----------+          v           +----------+      |
    |          |             +--------+             |          |
    |          |     recv ES |        | send ES     |          |
    |   send H |     ,-------|  open  |-------.     | recv H   |
    |          |    /        |        |        \    |          |
    |          v   v         +--------+         v   v          |
    |      +----------+          |           +----------+      |
    |      |   half   |          |           |   half   |      |
    |      |  closed  |          | send R /  |  closed  |      |
    |      | (remote) |          | recv R    | (local)  |      |
    |      +----------+          |           +----------+      |
    |           |                |                 |           |
    |           | send ES /      |       recv ES / |           |
    |           | send R /       v        send R / |           |
    |           | recv R     +--------+   recv R   |           |
    | send R /  `----------->|        |<-----------'  send R / |
    | recv R                 | closed |               recv R   |
    `----------------------->|        |<----------------------'
                             +--------+

       send:   endpoint sends this frame
       recv:   endpoint receives this frame

       H:  HEADERS frame (with implied CONTINUATIONs)
       PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
       ES: END_STREAM flag
       R:  RST_STREAM frame

发送PUSH_PROMISE所保留的流会从idle状态转变成reserved (local)状态被本地所保留 客户端会收到PUSH_PROMISE同样会保留这个ID的流idle状态转变成reserved (remote)状态被远程所保留

在发送PUSH_PROMISE之后就可以发送push response不一定要按照PUSH_PROMISE的顺序

值得注意的是规范规定了promised requests必须是可缓存的安全的而且不能带有request body

RFC7231里认为GETHEADPOST方法是可缓存的GETHEADOPTIONSTRACE方法是安全的因此promised requests必须是GET或者HEAD方法

另外服务端必须是权威性的authoritativeHTTPS而言主流浏览器普遍没有实现h2c因此只需要关注h2主要就是证书必须要是受信任的详细可以参考 Server Identity


用 JavaScript 写一门 DSL(二)——手撸四则运算的词法分析器

上一篇用 JavaScript 写一门 DSL——编译原理

想必此时弹幕里有人会说这个我会用栈就可以算四则运算了

内心戏真足……

不错用栈对四则运算进行求值是数据结构老师们的拿手好戏 可惜那不是一种可以描述和解释计算机语言的通用方法把语法规则扩充一下就扑街了 不如看一看编译原理的套路

在这篇文章里我会实现一个四则运算的词法分析器然后在飞机上写下它的引导程序

词法分析就是把输入的代码字符串转换为token

比如说对源代码

(20 + 1) * 2

输出

[
  [ "ParenStart", "(" ],
  [ "Number", "20" ],
  [ "Op", "+" ],
  [ "Number", "1" ],
  [ "ParenEnd", ")" ],
  [ "Op", "*" ],
  [ "Number", "2" ],
  [ "EOF", "" ]
]

还可以根据需要记录一些额外的数据比如每个token的位置信息以便debug

首先要分析语言里会有哪些token空白符显然是可以忽略的那么就只有

文件尾表示输入终结

词法分析器的伪代码就可以写出来了

while (!readEOF()) {
  readWhitespace()
  || readNumber()
  || readOp()
  || readParen()
  || throwError();
}

每一次循环读取一个token直到读到 EOF 为止

每一个函数的职责也很明确从当前流的位置向后看 如果紧接着的某些字符满足某个token的规则就把它记录下来并且消耗掉这些字符向后移动流的位置返回一个truthly的值 否则不消耗任何字符返回一个falsy的值

由于短路求值的缘故会先尝试匹配空白字符如果不成功再尝试匹配数值……直到符合了某种token的模式或者抛出异常

实际的实现里由于每读入一个token必定会消耗掉输入流中的一些字符因此规定每个函数返回的truthly值为消耗掉的字符数 以供主循环消耗字符

其实如果看过CoffeeScript的代码会发现它的词法分析器也是这么实现的 不失为一种手撸词法分析器的好方法极具可读性

然而这种方式未必是高效的它每次循环都会依次尝试匹配各种类型的token如果一直不符合输入流一丁点都不会被消耗

试想一种极端的情况如果某个语言的大多数token都以$开头你们不要多想那么读取下一个token的最优解自然是先判断第一个字符是不是$ 如果是则只需要从第二个字符开始继续判断即可而不是判断每一类token的时候都去判断第一个字符是不是$

在这种情况下如果想要提高效率就需要手动去优化判定token的流程这个过程往往采用先构造一个NFA 再将其转换成等价的DFA并将其最小化来实现 有了DFA之后就可以用一堆if-else或者表驱动法来实现词法分析器了

不过在这个系列里我宁愿牺牲一点效率换取可读性和可维护性毕竟它只是一个玩具因此如果完全不了解上面一段在讲什么也没关系这不影响你继续下去

至此一个简单的四则运算的词法分析器就完成了当然了还是有很多地方值得改进的比如说遇到未知token时提示可以变得更有帮助有兴趣可以加以修改


记一次对 PassThrough 的滥用

开发的时候遇到了一个场景应用的配置是存储在ZooKeeper上的程序里需要订阅这些配置 一旦配置有所变更就重新解析新的配置同时对外提供一个接口来获取最新的配置

我用流来实现这个逻辑代码大概是下面这样的

class ConfigTransform extends stream.Transform {
  constructor() {
    super({ objectMode: true });
  }
  _transform(chunk, encoding, callback) {
    callback(null, doSomethingWith(chunk));
  }
}

let config = new Proxy(Object.create(null), {
  get(target, name) {
    if (name in target) return target[name];
    return target[name] = new ConfigSubscription(name)
      .pipe(new ConfigTransform())
      .pipe(new Streamory());
  }
});

router.get('/config/:name', async name => config[name].get());

配置是懒加载的只有在第一次GET /config/:name的时候才会去observe这个名字的配置

其中ConfigSubscription实现了stream.Readable的接口会实时把最新的配置信息push到里面 我把它pipe到了一个stream.Transform解析出配置

Streamory则实现了stream.Writable的接口 它做的只是把最近写给它的数据对象缓存下来可以调用async Streamory#get()来拿到最新的数据 如果还没有写入过数据它会等到至少有第一次写入才resolve

这段代码跑得好好的测试下来也没什么问题然而一段时间后总会出现拿到了旧配置的情况 根据日志来看ConfigSubscription没什么问题doSomethingWith(chunk)也正常调用了但是拿到的配置依然是旧的

现象表明这很有可能是streamory的锅然而我一直没法在本地重现这种不知道怎么重现的BUG往往是最难调试的 由于项目比较复杂相关代码断断续续看了近一周也没什么进展——直到一位高人发现了盲点

我写的Streamory继承了stream.PassThrough原意是想让它支持继续pipe给别的stream.Writable 但是如果使用者没有继续pipe也没有绑定data事件或者手动触发Streamory#resume()来消耗掉写进去的数据 那么缓冲区就会逐渐消耗殆尽从而不会调用Streamory#_transform来更新缓存了

按照文档的说法可读的流有两种模式 flowingpaused

如果流处于flowing模式那么它会自动从底层系统读取数据尽可能块地用事件来给应用程序提供数据 如果处于paused模式就必须显式调用Stream#read()来读取流中的数据 而可读的流在一开始处于paused模式下所以缓冲区里的数据会越积越多

缓冲区的大小是由构造流的时候传入的highWaterMark参数决定的 默认是16KB或者对于objectMode的流来说是16