普通视图

发现新文章,点击刷新页面。
昨天 — 2025年5月18日掘金 前端

面试之道——手写call、apply和bind

作者 哆啦美玲
2025年5月18日 18:30

嗨嗨嗨~这里是哆啦美玲分享的知识点,一起来学呀!

这次的文章基于我之前写的this的显式绑定的文章,有不懂的可以倒回去看看哦——搞懂this,如此简单 - 掘金

image.png

callapplybind 都是 JavaScript 中函数的调用方法,它们的作用是改变函数的上下文 (this) 和传递参数,但它们之间有一些不同点。

首先我们看下面这段代码:

let obj = {
    a: 1
}

function foo(x, y) {
    console.log(this.a, x + y);
    return 'hello'
}
console.log(foo(1, 2)); // undefined 3 hello

我们声明了一个对象obj和函数foo,在独立调用foo时,this指向的是全局,所以this.a会返回undefiend。那我们如何实现this指向obj呢?

一、call

1. call的特点

  • call 方法立即调用一个函数,并且可以指定 this 的值,同时传入参数。
  • 参数是按顺序传递的,多个参数使用逗号分隔。
const res = foo.call(obj, 3, 4) // call的this指向foo,foo的this指向obj
console.log(res); // 1 7 hello

2. 手写myCall(context, ...args)

在手写myCall之前我们需要分析:myCall写哪里? image.png 如图的代码结果,foo既是函数也是对象,但是使用对象obj调用call方法会报错:call is not a function,所以myCall方法写在构造函数Function的原型(Function.prototype)。

因为函数也是对象,所以foo.call()会导致call的this指向foo;call的执行会让obj调用foo,让foo的this指向obj;

foo在声明前需要多少参数call并不清楚,所以call可以使用...args的形式接收剩余参数;另外,我们写的foo是有返回值的,在call的调用后会返回foo的结果,所以我们手写call的时候也要写返回值。

我们再看,如果call传入的参数中第一位不是对象,会得到什么? image.png 从图上代码的输出结果可以看出来:call会把传入的参数除去第一位后按顺序交给foo,且第一位必须是对象才能改变this的指向。

根据分析,手写代码如下:

// 手写call
Function.prototype.myCall = function (context, ...args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn') // 唯一的key
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}
console.log(foo.myCall(obj, 2, 3)); // 1 5 hello

代码第6行 context = context || window 的意思是:如果 context 变量已经有值(即不是 null 或 undefined),那么就使用 context 的值;如果 context 没有值(即为 null 或 undefined),就使用 window 作为默认值。

代码第8-9行是给foo函数创建一个唯一的key值,确保不会修改掉函数内部原本的属性值。

最后为了不修改原对象,要记得把新增的属性删除!

二、apply

1. apply的特点

  • apply方法也立即调用一个函数,并指定 this 的值,同时传入参数。
  • 参数传递方式是将一个数组或类数组对象作为参数列表传入。
const res1 = foo.apply(obj, [3, 4]) // apply的this指向foo, foo的this指向obj
console.log(res1); // 1 7 hello

2. 手写myApply

apply与call的用法是一样的,唯一不同的就是接收的参数不一样,所以只需要修改一点点就可以了,代码如下:

// 手写apply
Function.prototype.myApply = function (context, args) { // foo.__proto__ == Function.prototype
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window

    const key = Symbol('fn')
    context[key] = this // this = foo
    const res = context[key](...args) // 隐式绑定 this = context
    delete context[key]
    return res
}

console.log(foo.myApply(obj, [2, 4])); // 1 6 hello

三、bind

1. bind的特点

  • bind方法并不立即调用函数,而是返回一个新的函数,新的函数会绑定指定的 this 和参数。
  • 这个返回的新函数可以在之后的某个时刻被调用,且新函数也可以接收零散参数。
  • 当新函数被 new 调用时, 返回的是调用 bind 的那个函数的实例对象
const fn = foo.bind(obj, 4)
const res2 = fn(4)
console.log(res2); // 1 8 hello

const f = new fn(4)
console.log(f); // undefined 8 foo {}

从代码中可以看出:bind函数调用时,foo函数在接收参数时会先在bind传入的参数里面按顺序找,如果不够再去找bind返回的新函数f传入的参数找。

另外,在代码的5-6行我们会发现,在new fn()时,本来应该返回一个fn的实例对象fn{},但实际返回的却是foo的实例对象foo{},并且foo中的this指向了全局,所以在new的过程会导致this指向全局,fn()执行返回foo的实例对象。

2. 手写myBind

bind与前面两个方法很不一样,第一需要注意的点就是调用bind后会返回一个新的函数体。

接下来我们看看:如果bind传入的第一个不是参数,新函数会是什么? image.png

从图中的结果可知:foo.bind(123)返回的是一个foo的实例对象,所以this指向的是全局。

另外,在前面我们已经说了如果我们new foo.bind(obj)得到的新函数,也会得到一个foo的实例对象。所以bind返回的函数体不能是箭头函数,因为箭头函数里面没有this,不能被new。

这就需要我们区分new fn()fn():因为new会使函数的this直接指向得到的实例对象,且让实例对象的隐式原型等于构造函数的显式原型,所以我们采用判断this.__proto__ === F.prototype来判断是否被new。

代码如下:

Function.prototype.myBind = function (context, ...args) {
    if (typeof (this) !== 'function') { // 判断this必须是函数
        throw new TypeError('Error')
    }
    context = context || window
    const self = this // 存储this的值 foo

    return function F(...args2) { 
        if(this.__proto__ === F.prototype){ // 被 new 直接返回 foo 实例,所以这里不能是箭头函数
            return new self(...args, ...args2) // 返回foo 的实例对象
        }else{
            return self.apply(context, [...args, ...args2])  // foo指向obj,foo执行且返回值接收并返回
        }
    }
}

const fo = foo.myBind(obj, 1, 2)
console.log(fo(),'//////'); // 1 3 hello //////

const fun = new fo()
console.log( 'new fo() 得到的结果:', fun); // undefined 3  new fo() 得到的结果:foo {}

好啦,本次知识点分享完毕,家人们下次见!!!

喜欢这次的文章就麻烦点个赞赞啦~谢谢大家!

image.png

从浏览器进程层面理解事件循环

2025年5月18日 17:49

现代浏览器的进程架构

进程之间是独立存在的,因此为了防止浏览器 tab 连续崩坏的情况,如今已经演变成了多进程架构,每个进程都会有个独立的内存空间

一般就是下面这六个进程:

  1. 浏览器主进程(Browser Process)
  • 负责管理浏览器的界面显示(比如导航栏)、用户交互、子进程管理
  • 处理书签、历史记录、下载等功能
  • 协调其他进程
  1. 渲染进程(Renderer Process)
  • 负责网页内容的渲染
  • 每个标签页通常会有自己的渲染进程(沙箱隔离)
  • 包含多个线程:
    • 主线程:处理JavaScript执行、DOM解析、CSS计算等
    • 合成线程:处理图层合成
    • 工作线程:处理Web Workers
    • 光栅化线程:将图形转换为位图
  1. GPU进程(GPU Process)
  • 处理GPU任务,加速图形渲染
  • 负责将渲染进程的输出合成并显示到屏幕上
  1. 网络进程(Network Process)
  • 处理网络请求
  • 实现HTTP/HTTPS协议
  • 管理缓存
  1. 插件进程(Plugin Process)
  • 运行浏览器插件(如Flash)
  • 每个插件通常有自己的进程
  1. 实用程序进程(Utility Process)
  • 处理一些辅助功能,如音频服务、打印等

1.png 在 windows 下,我们可以通过快捷键 Ctrl + Shift + Esc 来打开 windows 的实时进程,其实浏览器也有对应的界面,我们可以通过 Shift + Esc 来打开浏览器的实时进程

2.png

比如我这里开了两个 tab,首先每个 tab 都会存在一个独立的进程,这个进程就是我们说的渲染进程,图中可以看到 第一个 Chrome 图标的那个就是浏览器的主进程,第二个为 GPU 进程,第三个为 网络进程,第四个为 实用程序进程,然后后面几个 service worker 其实就是后台的特殊进程

其实早在 2018年,Chrome 就已经更新了一个名为 站点隔离(Site Isolation)的机制,这意味着不再是简单地一个标签页一个进程,而是按照网站的源(协议 + 域名 + 端口)来分配进程,这么做对于很多用户来讲应该可以很大程度减少 Chrome 内存占用,像是开发过程中你可能 csdn 会有很多 tab 同时存在,不过Chrome 会够根据用户的硬件设备调整不同的进程架构,像是站点隔离在内存受限的设备上就不会生效

实用程序进程这里可以看到是个 storage service ,这就是他处理存储的功能,当我们看 B站 视频时,就会存在一个 audio service,就说明在发挥它的 音频 功能

六个进程中我们了解大概就差不多了,但是渲染进程我们需要单独聊聊,这对前端仔来讲还是非常重要的

因为我们的前端代码均是在这个进程程执行的

渲染进程

渲染进程负责将 HTML,CSS 和 JS 转换为用户可以看到的网页内容和交互

渲染进程核心的职责就是我们熟知的下面五个步骤:

  1. 解析HTML和CSS:将HTML转换为DOM树,将CSS转换为CSSOM树
  2. 执行JavaScript:运行页面中的JavaScript代码
  3. 布局计算:确定每个元素在屏幕上的确切位置和大小
  4. 绘制:将元素绘制到内存中的位图
  5. 合成:将不同的绘制层合成为最终显示的图像

若将渲染进程进行线程拆分,那么它主要是靠三个线程:主线程、合成线程和光栅线程

还有 工作线程(Web Workers),定时器线程,事件触发线程

3.png 我们的前端代码其实就是在 渲染进程中的 主线程 执行的,接下来我们引入事件循环来结合讲解

渲染主线程

渲染主线程负责渲染进程的大部分工作,所以它需要处理许多任务,执行 js 是它,绘制页面又是它,可能发生性能瓶颈,说 js 代码阻塞其实就是因为渲染主线程只有一个,无法同时处理两份属于自己的工作,渲染主线程的主要工作如下:

  • 执行 JS 代码
  • 处理 DOM 操作
  • 处理用户事件(如 Click)
  • 处理计时器回调
  • 处理网络请求回调
  • 执行微任务和宏任务

这个时候其实你可能会很好奇为何渲染主线程要做的东西这么多,也确实容易出现问题,那他为何不分配一些新的线程来做呢,比如专门给一个 js 线程来执行 js 代码,专门一个 dom 线程来处理 dom 操作

其实这个问题会有很多原因,其中我们最容易理解的就是因为 js 可以操作 dom,多个线程同时修改 dom 会导致难以预测的竞态条件;还有就是历史原因,js 最初就是个单线程语言,web 本身就非常向后兼容,改变这个线程模型就会破坏现有网站;再一个就是实际上浏览器已经采取了多线程优化方案,比如 WebWorker 新开了一个线程执行 js,但是这些 js 又不能直接访问 dom

事件循环(event-loop / message-loop)我们已经很熟悉了,但是想要真正理解透彻我们应该将 UI 渲染这一步骤结合进来

事件循环出来的目的也就是因为 js 执行变得复杂,此前单线程的原因并没有考虑这个问题,后来才逐步引入的机制,比如一个 for 循环很多次,执行的过程中某个定时器的回调也到了时间应该如何调度呢,如何调度其实就是事件循环,通俗理解这个东西就是让任务之间进行排队

事件循环在主线程上的工作流程如下:

  1. 执行当前的 JS 调用栈中的所有同步代码
  2. 检查微任务队列(microtask queue),执行所有微任务直到队列清空
  3. 执行一个宏任务(macrotask)
  4. 再次检查微任务队列,执行所有微任务
  5. 如果需要,执行UI渲染
  6. 返回步骤3,继续循环

我们再来复习下常见的宏微任务有哪些:

微任务

  • Promise回调(.then/.catch/.finally)
  • MutationObserver回调
  • queueMicrotask() API
  • 处理优先级较高,在每个宏任务之后立即执行

宏任务

  • Script 标签
  • setTimeout/setInterval回调
  • 用户交互事件(点击、键盘输入等)
  • 网络请求回调(XHR, fetch)
  • MessageChannel
  • requestAnimationFrame
  • I/O操作

也许有人会争 宏任务比 同步先执行,这么说也是对的,因为本身 Script 就是个宏任务

4.png 执行 js 代码过程中,不一定会是当前执行代码所引入的 回调 进入消息队列,也有可能来自浏览器进程监听的用户交互,比如 click 事件,浏览器进程虽然不会执行 js 代码,js 执行是在渲染进程的渲染主线程中,浏览器进程可以监听事件然后放入消息队列,js 再从消息队列拿回调来执行

所以我们现在可以明白浏览器如何处理这些事件的,我们可以临时往消息队列中塞任务,但是执行就不一定是立即执行,因为他需要先等当前的调用栈执行完毕后,再依次检查消息队列中前面的事件是否执行完毕再去执行这个临时加的任务

5.png

event-loop 就是为了解决 js 单线程问题,异步的目的就是为了不让页面卡死,因为渲染这个关键步骤也是在 event-loop 之中或者说由渲染主线程负责

消息队列的优先级

消息队列可以理解为任务队列,我们在面试时常常会说宏任务队列,微任务队列,但是大家也清楚,其实宏任务这个概念官方已经抛弃了,此前宏任务就是 macrotask,现在官方换成了 task ,就是一个 任务,微任务依旧为 microtask

我也不清楚为何要换个名字,其实 task 和 macrotask 没有本质区别,因此我们完全可以沿用这个宏任务概念

对于 js 而言,任务是没有优先级的,它只管从 消息队列 依次去获取任务执行,这个任务就是回调,其实每个任务都会被浏览器包装成一个对象或者说是一个块

但是消息队列是具有优先级的,task queue 其实就是宏任务队列,因为浏览器的复杂程度越来越高,宏任务队列其实会被划分成不同的队列,有可能定时器会专门有一个定时器的队列,然后事件回调会有一个事件回调的队列,ui 渲染会专门有一个 渲染队列,这些队列具有优先级,另外,每种任务其实会专门放到对应的任务队列中,但是其实也可以放到其他不同的任务队列,比如监听事件的回调可以放到定时器队列,但是这样做后,它就不能放到其余的队列中了

这是 w3c 给出的规定,同一个类型必须在同一个队列,也可以分属不同的队列

宏任务细分下去的任务队列优先级其实不需要我们关注,我们只需要清楚微任务队列的优先级永远是最高的

不过对于宏任务队列而言,一般用户交互的事件队列是最高级的,比如点击,键盘,鼠标,其次再是 ui 渲染,这个也很好理解,为用户体验着想,总不可能用户点击后这个任务还有延迟执行吧,后面的优先级详细内容请看下面图示

6.png

有个地方需要特别留意,微任务优先级最高,这就意味着 高于 了 ui 渲染这个队列

另外,这里也可以看到定时器哪怕在宏任务队列中都是优先级较低的存在,就算不管微任务队列这个最高优先级,他的执行都是靠后的,因此他的计时肯定是不准的,因为他的回调需要等待前面的任务执行完毕才能继续执行,另外在 w3c 中有个 规定,定时器嵌套(nesting)层级超过了 5 层,后面的定时器就会增加 4 ms 的误差,其实浏览器的定时器实现本身就是调用的操作系统,操作系统本身的计时也是存在误差的,这点无法避免,真正准时的永远是原子钟

最后

总结下本文主要内容

浏览器有很多进程,但是对于前端仔来讲主要关注渲染进程,这个进程其实主要发挥作用的又是渲染主线程,由于前端代码都是在这个进程运行的,因此可以说 js 是个单线程语言,渲染其实也是个 task queue 类型,他的优先级还是比较高的,因此在一轮 event-loop 结束后,就是 ui 渲染线程开始执行

【开源软件推荐】 so-novel,一个超强的聚合式小说下载工具

作者 极客密码
2025年5月18日 16:34

📚 前言:网文阅读的困境与解法

你是否曾遇到这样的困扰:想看最新连载小说,要么被迫付费,要么忍受广告横飞的笔趣阁,要么被限制在特定平台的APP中?

虽然市面上已有不少阅读方案,但总有些难以满足的需求:有人嫌弃界面设计太丑,有人吐槽功能不够完善,还有人厌倦了被特定平台限制。在这种情况下,高度可定制化的阅读体验成为了许多书友的终极追求。

今天要介绍的"So Novel"正是为解决这些痛点而生的利器。它能够将全网流行小说(包括连载中和已完本的)一键下载为主流电子书格式,让你可以在任何喜爱的设备上实现真正的离线阅读体验。

🎯 谁适合使用So Novel?

  • 免费(白嫖)看正版网文 ,又追求 极致阅读体验 的资深书友
  • 习惯用手机阅读的iOS用户(由于无法使用"阅读"APP,可通过So Novel下载后导入Apple Books)
  • 喜欢在电脑或大屏设备上阅读的Windows、macOS、Linux用户
  • 钟爱专业电子书阅读器的发烧友
  • 讨厌在线阅读依赖性,习惯将书籍存储在本地的"囤书党"
  • 经常处于无网络环境需要离线阅读的通勤党
  • 追求"开箱即用",不想折腾复杂配置的普通用户

✨ So Novel 介绍

So Novel是一款交互式小说下载器,真正做到了跨平台、无门槛使用:

  • 🖥️ 跨系统支持:windows、maacos、linux皆可使用
  • 📦 开箱即用:下载解压后即可使用,无需任何额外配置
  • 🔍 多源聚合:内置多个书源,支持聚合搜索,无需手动切换
  • 🚀 极速下载:特别优化无限流书源的下载速度
  • 📑 多格式支持:可导出EPUB、TXT、HTML(支持翻页)、PDF等格式
  • 📚 批量下载:同时下载多部作品,效率翻倍
  • 🔄 簡繁转换:内置简繁互转功能,满足不同阅读习惯

如何下载

在 so-novel 官方 Github Releases 下载对应操作系统版本即可:

github.com/freeok/so-n…

注:如果访问不到Github或者下载很慢,这里同时也提供给百度网盘的下载链接:

链接: pan.baidu.com/s/1EfH5_nMT…
提取码: xfdm

我这里是 macos intel芯片,下载 sonovel-macos_x64.tar.gz 就可以,M系列芯片的下载 arm64 版本的

安装使用

下载后将压缩包解压

直接将当前目录拖到 终端 里去(windows 版本的解压后有个 exe 文件,直接双击就可以打开)

然后输入如下命令后,按回车

bash ./run-macos.sh

您可能会遇到这样一个无法验证开发者的警告信息,不用管它,点击取消:

点击 系统设置,找到 隐私与安全性

找到安全性,并改为 APP Store和被认可的开发者

点击仍然允许

再次回到终端,执行上次的命令回车即可打开。

接下来就是按提示输入序号,按回车就行了

需要聚合搜索,输入 q ,按回车,输入书名,按回车:

输入下载序号,按回车:

输入1 下载全本

下载完成后,会自动合并为 epub 格式

合并成功后的 epub 文件位于软件目录下的 downloads 文件夹

导入喜欢的阅读软件,开始享受阅读的乐趣吧!

JavaScript执行栈和执行上下文

2025年5月18日 16:16

在JavaScript中,执行栈和执行上下文是理解代码执行流程和作用域链的关键概念。它们决定了代码如何执行以及变量和函数如何被查找和访问。本文将详细介绍执行上下文的生命周期、执行栈的工作原理以及它们在实际编程中的应用。

一、执行上下文

(一)什么是执行上下文?

执行上下文(Execution Context)是JavaScript代码执行的环境。它是一个抽象的概念,用于描述代码在运行时的状态。每当JavaScript代码运行时,它都在某个执行上下文中运行。

(二)执行上下文的类型

JavaScript中有三种执行上下文类型:

  1. 全局执行上下文:这是默认的上下文,任何不在函数内部的代码都在全局上下文中运行。全局执行上下文在页面加载时创建,当页面关闭时销毁。
  2. 函数执行上下文:每当一个函数被调用时,都会为该函数创建一个新的执行上下文。函数执行上下文在函数调用时创建,函数执行完成后销毁。
  3. eval函数执行上下文eval函数内部的代码也有自己的执行上下文。不过,eval函数的使用并不推荐,因为它会带来安全问题和性能问题。

(三)执行上下文的生命周期

执行上下文的生命周期分为两个阶段:

  1. 创建阶段:当代码执行进入一个环境时,会创建一个执行上下文。在这个阶段,执行上下文会进行以下操作:

    • 创建变量对象(Variable Object,VO):包括函数的形参、arguments对象、函数声明和变量声明。
    • 确定this的指向。
    • 确定作用域链。
  2. 执行阶段:在执行阶段,代码开始执行,变量被赋值,函数被调用,其他代码按顺序执行。

二、执行栈

(一)什么是执行栈?

执行栈(Call Stack)是JavaScript运行时用来管理执行上下文的一种数据结构。它是一个后进先出(LIFO)的栈结构,用于跟踪函数调用的顺序。

(二)执行栈的工作原理

  1. 入栈:当代码执行进入一个新的环境时,对应的执行上下文会被推入执行栈中。
  2. 出栈:当函数执行完成时,对应的执行上下文会被从执行栈中弹出,控制权交由下一个执行上下文。

(三)执行栈的特点

  • 后进先出:最后进入执行栈的执行上下文最先被弹出。
  • 栈顶是当前执行的上下文:执行栈的栈顶总是当前正在执行的函数的执行上下文。

(四)执行栈的图解

以下是一个具体的代码示例及其对应的执行栈图解:

function foo() { 
    function bar() {        
        return 'I am bar';
    }
    return bar();
}
foo();

对应的执行栈图解如下:

执行栈图解

(五)执行栈的数量限制

虽然执行上下文的数量没有明确的限制,但如果超出栈分配的空间,会造成堆栈溢出。常见于递归调用,没有终止条件造成死循环的场景。

// 递归调用自身
function foo() {
    foo();
}
foo();
// 报错:Uncaught RangeError: Maximum call stack size exceeded

三、执行上下文的生命周期

(一)创建阶段

在创建阶段,执行上下文会进行以下操作:

  1. 创建变量对象(VO)

    • 确定函数的形参(并赋值)。
    • 初始化arguments对象(并赋值)。
    • 确定普通字面量形式的函数声明(并赋值)。
    • 变量声明,函数表达式声明(未赋值)。
  2. 确定this的指向this的值由调用者决定。

  3. 确定作用域:由词法环境决定,哪里声明定义,就在哪里确定。

(二)执行阶段

在执行阶段,执行上下文会进行以下操作:

  1. 变量对象赋值

    • 变量赋值。
    • 函数表达式赋值。
  2. 调用函数

  3. 顺序执行其他代码

四、变量对象

变量对象(Variable Object,VO)是执行上下文的一个重要组成部分,它是一个包含变量、函数声明和形参的对象。在创建阶段,变量对象会被初始化,包括以下内容:

  • arguments对象:包含函数调用时传入的参数。
  • 形参:函数的形参会被赋值。
  • 函数声明:函数声明会被提升并赋值。
  • 变量声明:变量声明会被提升,但未赋值。

(一)变量对象的示例

以下是一个具体的代码示例及其对应的变量对象:

const foo = function(i) {
    var a = "Hello";
    var b = function privateB() {};
    function c() {}
}
foo(10);

在创建阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1}, // 确定Arguments对象
        i: 10, // 确定形参
        c: pointer to function c(), // 确定函数引用
        a: undefined, // 局部变量初始值为undefined
        b: undefined // 局部变量初始值为undefined
    },
    scopeChain: {},
    this: {}
}

在执行阶段,变量对象如下:

fooExecutionContext = {
    variableObject: {
        arguments: {0: 10, length: 1},
        i: 10,
        c: pointer to function c(),
        a: "Hello", // a变量被赋值为Hello
        b: pointer to function privateB() // b变量被赋值为privateB()函数
    },
    scopeChain: {},
    this: {}
}

五、总结

执行上下文和执行栈是JavaScript中非常重要的概念。理解它们的工作原理和生命周期,可以帮助你更好地理解代码的执行流程和作用域链。

变量声明需谨慎!!!💣这几种声明变量的方式(var、let、const)还有作用域,绝不能含糊!

2025年5月18日 15:56

引言

JavaScript作为一门动态脚本语言,其变量声明机制和作用域规则一直是我们需要深入理解的核心内容。从早期的var到ES6引入的letconst,JavaScript的变量管理方式经历了显著的变化。今天,我们从多个角度,全面解析varletconst的异同。

一、JS代码的执行机制

对于给定的JS代码文件,首先要做的是将其从硬盘读入内存,然后开始执行。JavaScript代码的执行依赖于引擎,如Chrome的V8引擎。V8负责将代码从硬盘读入内存后,进行解析、编译和优化。其核心流程分为两个阶段:

  • 编译阶段:引擎对代码进行词法分析、语法分析,并确定作用域规则。
  • 执行阶段:逐行执行代码,处理变量赋值、函数调用等操作。

二、作用域与作用域链

2.1 作用域的类型

作用域是变量和函数的可访问性规则,JavaScript中分为三类:

  1. 全局作用域:在函数或代码块外声明的变量,全局可访问。
  2. 函数作用域:在函数内部声明的变量,仅函数内可见。
  3. 块级作用域(ES6新增):由{}包裹的代码块(如iffor),使用letconst声明的变量仅块内有效。

2.2 作用域链的运作机制

当访问一个变量时,引擎会按照作用域链逐层查找:

当前作用域 → 父级作用域1 → 父级作用域2 → ... → 全局作用域
(ps:父作用域可嵌套)

这种链式结构确保了变量的层级隔离性。

function outer() {
    let a = 10;
    function inner() {
        console.log(a); // 通过作用域链找到outer的a
    }
    inner();
}
outer(); // 输出10

image.png

三、变量提升(Hoisting)

3.1 var的变量提升

在编译阶段,var声明的变量会被提升到作用域顶部,并初始化为undefined,而赋值操作保留在执行阶段。

示例

console.log(x); // undefined
var x = 5;
console.log(x); // 5

等效于:

var x; // 提升声明
console.log(x); // undefined
x = 5; // 赋值
console.log(x); // 5

3.2 let的变量提升

let声明的变量也会被提升到其所在的作用域顶部,但与var不同,let声明的变量在初始化之前会进入一个“暂时性死区”(Temporal Dead Zone, TDZ)。在这个区域内,变量是“提升”了,但尚未初始化,因此不能被访问,任何尝试访问这些变量的操作都会抛出[ReferenceError]

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 10;

3.3 函数声明提升

函数声明整体被提升,提升的是定义,而不是调用,因此可以在声明前调用:

showName() // 驼峰式命名
console.log(myName);

var myName = 'wym'
function showName() {
  let b = 2;
  console.log(myName)
  console.log('函数执行了')
}

运行结果:

image.png 解析:这段代码等效于

function showName() { 
  let b = 2; 
  console.log(myName) 
  console.log('函数执行了') 
}

showName()
console.log(myName)
var myName = 'wym'

注意:函数和变量之间,函数通常先于变量提升,即:优先提升函数,后提升变量。

四、var、let、const的全面对比

4.1 作用域差异

关键字 作用域 重复声明 变量提升 必须初始化
var 函数/全局 允许
let 块级 不允许 否(TDZ)
const 块级 不允许 否(TDZ)

4.2 使用场景分析

  • var:ES6之前的主要声明方式,因作用域和提升问题,现不推荐使用。
  • let:适用于需要重新赋值的块级变量(如循环计数器)。
  • const:声明常量或引用类型(对象、数组),确保变量指向不变。

五、建议

定义变量时如果后续不需要修改了,建议优先使用const,提高代码可读性和安全性;次选let,杜绝重复声明报错,作用域更安全,其独特的TDZ机制也能够有效防止在声明前访问变量;var能不用就不用,防止变量污染和意外覆盖。

《JavaScript语言精粹》读书笔记之第3章:对象Object

作者 小飞悟
2025年5月18日 15:30

小飞悟申明:小编的笔记只针对强者!!!

一、对象字面量 Object Literals

属性名可以是包括空字符串在内的任何字符串。在对象字面量中,如果属性名是一个合法的JavaScript标识符且不是保留字,则并不强制要求用引号括住属性名。所以用引号括住"first-name"是必需的,但是否括住first_name则是可选的。逗号用来分隔多个“名/值”对。

  1. 合法标识符 :
  • 如果属性名是合法的JavaScript标识符且不是保留字,可以不加引号。
- 例如:
const person = {
    firstName: 'John',
    lastName: 'Doe'
};


var flight = {
       airline: "Oceanic",
       number: 815,
       departure: {
          IATA: "SYD",
          time: "2004-09-22 14:55",
          city: "Sydney"
       },
  1. 非法标识符 :
  • 如果属性名包含特殊字符、空格或不是合法标识符,必须加引号。
- 例如
const person = {
    "first-name": 'John',
    "last name": 'Doe'
};

二、检索 Retrieval

要检索对象里包含的值,可以采用在[ ]后缀中括住一个字符串表达式的方式。如果字符串表达式是一个字符串字面量,而且它是一个合法的JavaScript标识符且不是保留字,那么也可以用.表示法代替。优先考虑使用.表示法,因为它更紧凑且可读性更好。

    stooge["first-name"]    // "Jerome"
    flight.departure.IATA   // "SYD"

运算符可以用来填充默认值:

    var middle = stooge["middle-name"] || "(none)";
    var status = flight.status || "unknown";

尝试从undefined的成员属性中取值将会导致TypeError异常。这时可以通过&&运算符来避免错误。

    flight.equipment                            // undefined
    flight.equipment.model                      // throw "TypeError"
    flight.equipment && flight.equipment.model  // undefined

三、引用 Reference

对象通过引用来传递。它们永远不会被复制:

理解对象引用

在JavaScript中,对象是通过引用来传递的,这意味着当将一个对象赋值给另一个变量时,两个变量实际上指向同一个对象。以下是对代码的详细解释:

  1. 对象引用 :
var x = stooge;
x.nickname = 'Curly';
var nick = stooge.nickname;
  • x 和 stooge 指向同一个对象,因此修改 x 的属性也会影响 stooge 。
  • nick 的值为 'Curly' ,因为 x 和 stooge 是同一个对象的引用。
  1. 多个对象引用 :
var a = {}, b = {}, c = {};
  • a 、 b 和 c 分别引用不同的空对象。
  1. 同一对象引用 :
a = b = c = {};
  • a 、 b 和 c 现在都引用同一个空对象。

四、原型 Prototype(简单介绍,后续会细讲)

每个对象都连接到一个原型对象,并且它可以从中继承属性。所有通过对象字面量创建的对象都连接到Object.prototype,它是JavaScript中的标配对象。 原型连接只有在检索值的时候才被用到。如果我们尝试去获取对象的某个属性值,但该对象没有此属性名,那么JavaScript会试着从原型对象中获取属性值。如果那个原型对象也没有该属性,那么再从它的原型中寻找,依此类推,直到该过程最后到达终点Object.prototype。如果想要的属性完全不存在于原型链中,那么结果就是undefined值。这个过程称为委托。

  • 这里仅是定义,后续会详细讲

刚开始有点难理解很正常,建议先看看blog.csdn.net/flyingpig20…

五、反射 Refelection

  • 用typeof操作符来确定属性类型很有帮助。
    typeof flight.number      // 'number'
    typeof flight.status      // 'string'
    typeof flight.arrival     // 'object'
    typeof flight.manifest    // 'undefined'
  • 在JavaScript中,原型链上的属性(如 toString 和 constructor )可能会产生值,但这些值通常是函数,可能并非我们需要的。以下是两种处理这些不需要的属性的方法:
    typeof flight.toString     // 'function'
    typeof flight.constructor  // 'function'
  1. 检查并丢弃函数值 :

    • 在程序中检查属性值是否为函数,如果是则丢弃。
    • 适用于需要动态获取对象信息且仅关注数据的场景。
  2. 使用 hasOwnProperty 方法 :

    • hasOwnProperty 方法用于检查对象是否拥有独有的属性(不检查原型链)。
    • 示例:
      flight.hasOwnProperty
      ('number');      // true
      flight.hasOwnProperty
      ('constructor'); // false
      
    • 适用于需要区分对象自身属性和继承属性的场景。 通过这两种方法,可以更精确地处理对象属性,避免不必要的函数值干扰。

六、枚举Enumeration

for in 语句的总结

for in 语句用于遍历对象的所有属性名,包括原型链中的属性。为了过滤掉不需要的属性,可以使用 hasOwnProperty 方法或 typeof 来排除函数。

示例 1:过滤函数属性

var name;
for (name in another_stooge) {
  if (typeof another_stooge[name] !== 'function') {
    document.writeln(name + ': ' + another_stooge[name]);
  }
}

示例 2:按特定顺序遍历属性

var i;
var properties = ['first-name', 'middle-name', 'last-name', 'profession'];
for (i = 0; i < properties.length; i += 1) {
  document.writeln(properties[i] + ': ' + another_stooge[properties[i]]);
}

总结:for in 语句遍历对象属性时,属性顺序不确定。如果需要特定顺序,可以使用数组存储属性名,并通过 for 循环遍历。

七、删除Delte

删除对象属性的总结

delete 运算符用于删除对象的属性,但它不会影响原型链中的属性。如果删除的对象属性存在于原型链中,删除后原型链中的属性会“透现”出来。

示例

const stooge = {
  nickname: 'Curly'
};
const another_stooge = Object.create(stooge);
another_stooge.nickname = 'Moe';
console.log(another_stooge.nickname); // 'Moe'

delete another_stooge.nickname;
console.log(another_stooge.nickname); // 'Curly'(来自原型链)

解释

  1. another_stooge对象继承了stooge对象的nickname属性。
  2. 通过delete删除了another_stooge自身的nickname属性后,原型链中的nickname属性(值为'Curly')会显示出来。

通过这个示例,可以更好地理解delete运算符的作用及其对原型链的影响。

关键点

  1. 删除属性delete 删除对象自身的属性。
  2. 原型链delete 不会影响原型链中的属性。
  3. 透现属性:如果删除的属性存在于原型链中,删除后原型链中的属性会显示出来。

八、减少全局变量污染 Global Abatement

减少全局变量污染

在JavaScript中,全局变量(var)会削弱程序的灵活性,应尽量避免使用。最小化全局变量污染的一种方法是创建一个唯一的全局变量作为应用的容器,将所有全局性资源纳入该名称空间下。

示例

var MYAPP = {};
MYAPP.stooge = {
  "first-name": "Joe",
  "last-name": "Howard"
};
MYAPP.flight = {
  airline: "Oceanic",
  number: 815,
  departure: {
    IATA: "SYD",
    time: "2004-09-22 14:55",
    city: "Sydney"
  },
  arrival: {
    IATA: "LAX",
    time: "2004-09-23 10:42",
    city: "Los Angeles"
  }
};


通过将全局资源集中在一个名称空间下,可以显著降低程序与其他应用程序、组件或类库之间的冲突风险,同时提高代码的可读性和维护性。但方法不止一种,ES6推出了let,const,在后面章节将会详细介绍,先看下区别:

let 、var 和const的区别

let和const 与var有什么区别

let和const 是es6的新语法,在函数预编译的时候会进行变量提升,这样在变量还没有赋值的时候就可以进行访问。

但是let和const不会,而且let和const遇到{}会形成块级作用域,并且let和const在声明之前是不能访问的,也不能访问

外部具有相同名字的变量因为会形成暂时性死区。这就是let、const和var的区别。

let和const的区别

它们两个的区别主要在let是声明变量,而const是声明常量的。

结语:

本文简单讲了下JavaScript中对象操作的核心概念,包括对象字面量、属性检索、原型链、反射、枚举、删除操作以及减少全局变量污染等关键点。现阶段建议读者继续深入了解下原型链和代理模式,后续小编还会奉上精彩好文!!!

前端实习踩过的坑之CSS系列

作者 秦盼儿
2025年5月18日 15:16

【前言】

时间过得真的很快,作为25届毕业生的我即将结束在南京接近6个月的实习。现在处于投简历背八股找工作状态,分享下我在这次充实的实习之旅中遇到的问题和解决方案,在梳理所学知识的同时也能帮助和我一样的前端新人。

1️⃣第一个坑不要太熟悉哦

没错,它就是外边距塌陷问题,我解决这个问题也很简单粗暴,

解决方法: 自己算一下外边距,尽量只设置一个边距

2️⃣渐变色边框导致边框圆角失效

很多朋友都遇到过使用渐变色边框的时候导致圆角失效,那么怎么解决呢?直接上代码截图 效果图:

image.png

这样写边框圆角是不生效的

image.png

解决方法: 使用 background-clip 实现 设置两层渐变

    border: 1px solid transparent;
    border-radius: 20px;
    background-image: linear-gradient(
        54deg,
        #fa7332 0%,
        #ed395f 34%,
        #ea2837 100%
      ),
      linear-gradient(rgba(249, 162, 156, 1) 0%, rgba(255, 129, 122, 0.43) 100%);
    background- origin: border-box;//使图片完整展示
    background- clip: content-box, border-box;
    //第一个表示裁剪至内容区值,第二个表示裁剪至边框取值

3️⃣图片叠加问题

效果图:

image.png

解决方法: 绝对定位和z-Index

.swiper_item_cover {
        position: relative;
        width: 160.6px;
        height: 170px;
        
        .swiper_item_cover_1 {
          position: absolute;
          z- index: 2;
          top: 0;
          left: 0;
          width: 106px;
          height: 170px;
        }
        .swiper_item_cover_2 {
          position: absolute;
          z- index: 1;
          top:25px;
          right: 0;
          width: 77px;
          height: 134px;
        }
      }

4️⃣子元素继承父元素的透明度

解决方法: 利用rgba 间接设置透明度

5️⃣兄弟元素的默认行为导致层级覆盖

原因: 如果兄弟元素没有设置position,默认情况下,它的position的值是static。 static元素不参与堆叠上下文 ,因此它们的z-index默认值是auto,这意味着它们会在所有absolute定位元素之上。

解决方法: 给另一个兄弟元素设置 position:relative

6️⃣文本溢出显示省略号

解决方法:

      text-overflow: ellipsis;
      overflow: hidden;
      word-break: break-all;
      display: -webkit-box;
      -webkit-box-orient: vertical;
      -webkit-line-clamp: 1;/*修改这个数字可以变成多行省略*/
      line-clamp: 1;/*这个也要改,scss中可以用@mixin封装成一个方法*/

scss封装示例

@mixin ellicpsisN($lineCount) {
  text-overflow: ellipsis;
  overflow: hidden;
  word-break: break-all;
  display: -webkit-box;
  -webkit-box-orient: vertical;//弹性盒模型
  -webkit-line-clamp: $lineCount;//弹性盒模型方向垂直//弹性盒模型方向垂直
  -webkit-line-clamp: 1;//限制显示的行数
}
//用法
@include ellicpsisN(1);

7️⃣自定义滚动条的样式

UI给图滚动条样式要调整,别急,它来啦

解决方法:

//设置滚动条的宽度高度和背景颜色
    ::-webkit-scrollbar { 
      width: 6px;
      height: 8px;
      background- color: #ebeef5;
    }
//设置滚动条滑块的阴影和背景颜色
    ::-webkit-scrollbar-thumb {
      box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      -webkit-box- shadow: inset 0 0 6px rgba(0, 0, 0, .3);
      background- color: #ccc;
    }
//设置滚动条轨道的阴影圆角和背景颜色
    ::-webkit-scrollbar-track{
      box- shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
      border- radius: 3px;
      background: rgba(255, 255, 255, 1);
}

8️⃣ 通知弹窗内部自定义html样式

效果图

image.png

解决方法:

const handleClickEmployee = (row) => {
      const h = this.$createElement;
      this.$notify({
        title: '提示',
        message: h(
          'i',
          { style: 'color: teal' },
          'This is a reminder'
        ),
      });
    };
//利用elment-plus 的组件
 ElNotification({
    title: 'Title',
    message: h('i', { style: 'color: teal' }, 'This is a reminder'),
  })

9️⃣ 利用i标签和elment-plus来快速渲染图标

官网上有更多图标可以选,这是几个使用示例哦

解决方法:

image.png

🔟移动端项目禁用点击高亮和文本选择

解决方法:

body {
  -webkit-tap-highlight- color: transparent;//禁用点击高亮
  outline: 0;//移除默认轮廓线
  user-select: none;//禁用文本选择
  -webkit-user-select: none;
  -webkit-text-size-adjust: 100%;//调整文字大小为100%
  width: 100%;
}

篇幅限制,本期前端新手容易遇到的CSS坑分享到这里!这几天会逐步更新完这个系列,欢迎大家留言和指导~

从权限管理到编译原理

作者 GaoNengwWw
2025年5月18日 14:40

77406616_p0_master1200.jpg

背景图来自: ああもんどCheytac M200

本文共预计阅读时间 5分钟+

大家好我是 GaoNeng-wWw. 前几天在用Nest.js写SSO后端的时候涉及到一些复杂的权限管理. 例如 如果有 A 和 B 权限 或 有 A 和 C 权限 那么则允许访问. 解决方案是写一个修饰器, 接受一个对象, 用于描述权限表达式, 类似于AST. 该修饰器作用于接口, 启动时为成员设置元信息, 接收到请求后 Guard 反射读取表达式对象, 而后调用 judge 函数判断用户是否满足该表达式。

绝望的表达式.png

考虑到后面可能会接入更多的权限, 为了保护开发人员的大脑和个人安全, 不得不含泪搓了一个普拉特解析器。

演示.png

这样一来, 开发人员的心智不仅得到了保护, 就连自己的个人安全也得到了保证。等写完后端后,便将该功能迁移到了前端。

阅读完本文, 你将能够有以下的收获

  1. v-permission 的封装
  2. 编译原理基础知识
  3. 如何写一个 Pratt 解析器
  4. 如何写一个简单的Vite插件

文中所有的代码(包括课后习题答案)都可以在 Permission-Compiler中找到.

封装一个完备的 v-permission

在这里, 当我们讨论 完备 的时候, 实际上我们在讨论能不能满足基本逻辑运算. 即: 与、或、非。

如果单单的使用数组来表示很难做到表达基本的逻辑运算. 所以我们可以使用一个或多个的对象组合而成. 下面的代码块则是一个完备的 PermissionExpression.

type And = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'AND';
}
type Or = {
  lhs: PermissionExpr;
  rhs: PermissionExpr;
  type: 'OR';
}
type Not = {
  expr: PermissionExpr;
  type: 'NOT';
}
type Has = {
  val: string;
  type: 'HAS'
}
type PermissionExpr = Has | And | Or | Not;

这样我们就定义了一个完备的权限表达式对象. 其中 And, Or, Not 对应的是与或非. Has 类型表示的是, 某个用户是否拥有某个权限.

当且仅当用户存在权限 a 时候 {type: 'Has', val: 'a'} 会为true.

有了上述的类型,我们便可以封装一个较为完备的 v-permission 了

// directive/v-permission.ts
export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
} as Directive<Element, PermissionExpr>;

对于 isValid 函数, 实现起来也非常容易。因为 PermissionExpr 本质就是一颗树。所以我们可以很轻松的给出 isValid 的实现

const judge = (expr: PermissionExpr, userPermission: string[]):boolean => {
  if (expr.type === 'HAS') {
    return userPermission.includes(expr.val);
  }
  if (expr.type === 'AND'){
    return judge(expr.lhs, userPermission) && judge(expr.rhs, userPermission);
  }
  if (expr.type === 'OR') {
    return judge(expr.lhs, userPermission) || judge(expr.rhs, userPermission);
  }
  if (expr.type === 'NOT'){
    return !judge(expr.expr, userPermission);
  }
  return false;
}

const isValid = (node:PermissionExpr) => {
  const { permissions } = useAccount();
  return judge(node);
}

回到我们的 vue 文件中. 我们可以直接定义权限表达式看一下效果, 如果没问题,应该显示 完 全 勝 利 四个字

<!-- App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>
<template>
  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
</template>
// mock-store.ts
import { ref, type Ref } from "vue"

const permissions:Ref<string[]> = ref(['a']);

export const useAccount = () => {
  const add = (permission: string) => {
    permissions.value.push(permission);
  }
  const remove = (permission: string) => {
    permissions.value = permissions.value.filter(p => p === permission);
  }
  const has = (permission: string) => permissions.value.some(p => p === permission);
  return { add, remove, has, permissions };
}

完全胜利.png

完 全 勝 利

表达式解析

对于复杂的权限, 手写表达式对象显然不太现实. 所以我们可以传入一个字符串, 编译时将该字符串解析为一个表达式对象, 权限判断依然留在运行时. 流程图大概如下

flowchart TB
    S
    Scanner
    Parser
    AST
    S[开始] --> Scanner[扫描v-permission]
    Scanner --> Parser[解析字符串]
    Parser --> AST[生成AST]

编译流程

将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序. 某种意义上, 我们将字符串编译权限表达式对象这个过程也可以称作为编译。

我们都知道, 语言分为: 编译型语言、解释性语言。二者最本质的不同便是 编译型语言会将源代码转为机器语言.

stateDiagram-v2
    [*] --> 分词器: 字符串
    分词器 --> 解析器: Token流
    state 解释性语言 {
        [*] --> 执行
    }
    解析器 --> 解释性语言: AST
    state 编译型语言 {
        [*] --> 中间代码
        中间代码 --> 机器码
    }
    解析器 --> 编译型语言: AST

无论是解释性语言还是编译型语言, 整体都需要将用户输入的字符串信息分割为Token, 而后将Token组合为AST。

形式语言

在形式语言理论中,文法(formal grammar)是形式语言中字符串的一套产生式规则(production rule)。这些规则描述了如何用语言的字母表生成符合句法(syntax)的有效的字符串

形式语言语法

产生是的规则。正式定义为 G=(N,Σ,P,S)G=(N,\Sigma,P,S)

  • NN 是有限的非终结符集合,与GG生成的字符串无交, 换句话说 NG=N \cap G = \emptyset
  • Σ\Sigma 是有限的终结符集合, ΣN=\Sigma \cap N = \emptyset
  • PP 是产生式规则集合 (ΣN)N(ΣN)(ΣN)(\Sigma \cup N)^{*}N(\Sigma \cup N)^* \rightarrow (\Sigma \cup N)^{*}
  • SS 是开始符号 SNS \in N

其中 * 为 克莱尼星号

产生式

语法是由产生式规定。它规定了那些符号可以替换为哪些符号. 例如 XZX \rightarrow Z 表示, 符号 XX 可以被替换为 ZZ。读作 XX 可以重写/替换为 ZZ

终结符与非终结符

终结符是在产生式中不能被分解为更小单位的的基本符号。例如

xaabx \rightarrow a \\ a \rightarrow b

其中bb是一个终结符, 因为bb不会再推导出任何的符号. 但aa, xx 都是非终结符, 因为 aa 可以推导出 bbxx 可以推导出 aa

上下文无关文法

是一种形式化、精确描述语言语法的工具. 在应用一个产生式进行推到时,前后推导的结果就是上下文.

倘若有一个文法 GG, 文法中每一个产生式左侧的非终结符是单独出现的, 那么我们可以说这个文法是上下文无关文法.

例如

S1SVOS小明VO猪肉鸡肉羊肉\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ \end{matrix}

那么 S1S1 组成的集合就是 S1={你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉}S1=\{你吃猪肉,你吃鸡肉,你吃羊肉,你喝猪肉,...小明玩鸡肉\} [1]^{[^1]}

虽然推导出来的句子挺鬼畜的, 但至少意思到了

上下文有关文法

任何产生式规则的左手端和右手端都可以被终结符和非终结符构成的上下文所围绕。

例如

S1SVOS小明VO猪肉鸡肉羊肉O手机\begin{matrix} S1 & \rightarrow S & V & O \\ S & \rightarrow 你 & 我 & 小明 \\ V & \rightarrow 吃 & 喝 & 玩 \\ O & \rightarrow 猪肉 & 鸡肉 & 羊肉\\ 玩O & \rightarrow 手机 \\ \end{matrix}

这样一来推导过程就是 S1SVOVO你玩O你玩手机S1 \rightarrow SVO \rightarrow 你VO \rightarrow 你玩O \rightarrow 你玩手机

词法分析

分词器主要将用户输入的字符串转为一个个Token. 这个过程叫做 Tokenization. (说真的我不知道这个怎么翻译, 就理解为标记化吧). 该阶段被称之为词法分析阶段.

词法分析阶段并不关注Token之间的关系。 例如 (() 可以通过词法阶段, 但不一定能通过语法分析阶段.

例如对于下列 JavaScript 代码

const a = 1;

可以标记化为

Token TokenKind
const 常量关键词
a 标识符
= 等号
1 数字常量
; 分号

词法分析器

写一个词法分析器其实并不困难, 我们并不需要逐字符的去遍历然后拼接,而是可以使用更加高效的正则表达式完成. 因为我们的词法分析器目的是为了生成Token, 所以我们应该先设定Token的类型


enum TokenKind {
  EOF,
  IDENTIFIER,
  LEFT_PARENTHESIS,
  RIGHT_PARENTHESIS,
  AND,
  OR,
  NOT,
}

type Token = {
  // Token类型
  kind: TokenKind;
  // 人类可读的类型, TokenKind[kind]
  humanKind: string;
  // 实际字符
  char: string;
}

有了Token类型我们就可以来设计分词器了.

type RuleHandle = (
  args: {
    tokens: Token[];
    match: string;
    advanceN: (val: number)=>void
  }
) => void;

type Rule = [RegExp, RuleHandle]

export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {}

上述代码中, rule参数可能比较难理解. 这么做的原因主要是为了扩展性考虑, 用户可以通过传入rule参数来直接个性化的定制分词逻辑.

接下来我们就要开始写分词器了. 事实上整个分词器非常简单, 分词器一共三种情况

  1. 到达末尾了
    1. 为Token流插入 EOF Token
  2. 没有到达末尾, 没有通过任何一个正则表达式
    1. 行为: 抛出错误, Bad Input ${code}
  3. 没有达到末尾, 通过了至少一个正则表达式
    1. 调用指定规则的 handle
// directive/permission-lexer.ts
export const tokenizer = (
  code: string.
  rule: Rule[]
): Token[] => {
  const tokens: Token[] = [];
  let input = code;
  let pos = 0;
  let matched = false;
  const isEnd = () => pos < input.length;
  const advanceN = (val: number) => {
    pos += val;
  }
  // 消耗掉 [0,pos) 的字符串
  // 换句话说 丢弃 [0,pos) 这个区间
  const reminder = () => input.slice(pos);
  while (!isEnd()) {
    for (const [regExp, handle] of rules) {
      if (regExp.test(input)){
        matched = true;
      }
      const match = regExpr.exec(reminder());
      if (!match) {
        // 这个地方纯粹是为了避免as强转
        // 如果有需要可以抛出错误而不是continue
        continue;
      }
      handle({tokens, match: match[0] })
    }
    if (!matched) {
      throw new Error(`Bad Input ${input}`);
    }
  }
  tokens.push({kind: TokenKind.EOF, humanKind: 'EOF', char: '0'});
  return tokens;
}

const defaultHandle = (kind: TokenKind)=>{
  return (({tokens, advanceN, match}) => {
    tokens.push({kind, char: match, humanKind: TokenKind[kind]});
    advanceN(match.length)
  }) as RuleHandle;
}
const skip:RuleHandle = ({match,advanceN}) => {
  advanceN(match.length);
}
export const rules: Rule[] = [
  [/^\ /, skip],
  [/^And|^&&|^AND|^\&/, defaultHandle(TokenKind.AND)],
  [/^Or|^\|\||^OR|^\|/, defaultHandle(TokenKind.OR)],
  [/^Not|^!|^NOT/, defaultHandle(TokenKind.NOT)],
  [/^\(/, defaultHandle(TokenKind.LEFT_PARENTHESIS)],
  [/^\)/, defaultHandle(TokenKind.RIGHT_PARENTHESIS)],
  [/^,/, defaultHandle(TokenKind.COMMA)],
  [/^[a-zA-Z_][a-zA-Z0-9_]*/, defaultHandle(TokenKind.IDENTIFIER)]
]

注意, 这里的正则表达式全部都是 /^..../ 而不是 /.../. 我们修改下代码来看看效果

// directive/v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
-    return judge(node);
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
+      console.log(tokens);
+      return true;
+    } else {
+      return judge(node);
+    }
}

export default {
  created: (el: Element, binding)=>{
    if (!isValid(binding.value)){
      el.innerHTML = '<!-- -->'
    }
  },
-} as Directive<Element, PermissionExpr>;
+} as Directive<Element, string | PermissionExpr>
//App.vue
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>


<template>
-  <span v-permission="{type: 'HAS', val: 'a'}">完 全 勝 利</span>
+  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后, 在控制台中我们应该能够看到下图

2.png

语法分析

语法分析阶段的任务是接受来自词法分析阶段的Token流, 根据语法规则来建立语法树。本文采用的是普拉特解析法(Pratt Parser). Pratt Parser可以很有效的解析中缀表达式和优先级。不仅如此,Pratt Parser也更加灵活(后文中你会看到Pratt Parser到底有多灵活)

Nud, Led

Nud (The Null Denotation), 如果一个Token可以放在开头, 那么我们称这个Token叫做Nud (非严格定义的话可以叫做prefix). 例如一元运算 (-, !, ~)

Led (The Left Denotation), 如果一个Token必须知道左边的表达式,那么我们称它是一个Led (非严格定义的话可以叫infix). 例如二元运算符 (&&, ||, +-*/)

不过我们要注意, 有些Token可以是 Nud 也可以是 Led. 比如说当 「-」

基础结构

Pratt基础结构非常简单, 核心不会超出30行代码

// v-permission/permission-parser.ts
export const enum BP {
  DEFAULT_BP,
  COMMA,
  LOGICAL,
  UNARY,
  PRIMARY
}
type LedHandle = (lhs: Expr, bp:BP) => Node;
type NudHandle = () => Node;
export class Parser {
      constructor(
        private tokens: Token[]=[],
        private pos=0,
        private nudMap = new Map<TokenKind, NudHandle>(),
        private ledMap = new Map<TokenKind, LedHandle>(),
        private bpMap=new Map<TokenKind, BP>()
      ){
        this.setup();
      }
/*01*/parseExpr(bp: number){
/*02*/  const token = this.peek();
/*03*/  const tokenKind = token.kind;
/*  */  // 从nud开始
/*04*/  const nud = this.nudMap.get(tokenKind);
/*05*/  if (!nud) {
/*06*/    throw new Error(`Except token ${TokenKind[token.kind]}`);
/*07*/  }
/*08*/  let lhs = nud();
/*09*/  while (
/*10*/    this.bpMap.get(this.peek().kind) !== undefined && 
          // bp实际上是限制了led解析
          // 换句话说我们只解析比当前token绑定力更大的token
          // 比如 -1*2
          // 假设我们给 「-」 设定绑定力为3, 「*」的绑定力为2
          // 1就会和「-」绑定而不是「*」
/*11*/    this.bpMap.get(this.peek().kind)! > bp
/*12*/  ) {
/*13*/    const cur = this.peek();
/*14*/    const tk = cur.kind;
/*15*/    const led = this.ledMap.get(tk);
/*16*/    if(!led){
/*17*/      throw new Error(`Except for token ${cur.humanKind}`);
/*18*/    }
/*19*/    lhs = led(lhs, bp);
/*20*/  }
/*21*/  return lhs;
/*22*/}
/*23*/peek(){
/*24*/  return this.tokens[this.pos]
/*25*/}
      setup(){}
}

Identifier 与 二元表达式 的解析

在上文中我们说过 Pratt Parser 的扩展能力非常强. 接下来让我们完善Identifier的解析.

首先我们来定义一下Identifier的类型

export type Expr = BinaryExpr | Identifier;
export interface Identifier {
  type: 'Identifier',
  name: string;
}
export interface BinaryExpr {
  type: 'BinaryExpr',
  operator: Token;
  // 因为二元表达式允许符号左右两侧都是子表达式
  // [1,2,3].every(val => typeof val === 'number') && [1,2,3].some(val => val % 2 === 0)
  // 上述代码显然是合法的
  lhs: Expr; 
  rhs: Expr;
}

之后我们需要实现几个函数

  • next: 获取当前token, 向前步进一个token
    • 换句话说就是吃掉当前token, 然后返回吃掉的token
  • nud
    • 注册 Nud Token的处理函数和绑定力
  • led
    • 注册 Led Token的处理函数和绑定力
  • setup
    • 初始化一些处理函数, callback需要显式调用 bind 函数声明this指向.
next(){
  const token = this.peek();
  this.pos += 1;
  return token;
}
nud(kind: TokenKind, f: NudHandle){
  this.bpMap.set(kind, BP.PRIMARY);
  this.nudMap.set(kind, f);
}
led(bp: BP, kind: TokenKind, f: LedHandle) {
  this.bpMap.set(kind, bp);
  this.ledMap.set(kind, f);
}
setup(){
  // 待会实现
}

有了工具函数接下来我们先实现最简单的 Identifier 解析

/*01*/ parsePrimary(){
/*02*/   const name = this.next().char;
/*03*/   return {
/*04*/     type: 'Identifier',
/*05*/     name,
/*06*/   } as Identifier;
/*07*/ }
/*08*/ parseBinary(lhs: Expr){
/*09*/   const operator = this.next();
/*10*/   const rhs = this.parseExpr(BP.LOGICAL);
/*11*/   return {
/*12*/     type: 'BinaryExpr',
/*13*/     lhs,
/*14*/     rhs,
/*15*/     operator,
/*16*/   } as BinaryExpr;
/*17*/ }
/*18*/ setup(){
/*19*/   this.led(BP.LOGICAL,TokenKind.AND,this.parseBinary.bind(this) );
/*20*/   this.led(BP.LOGICAL,TokenKind.OR,this.parseBinary.bind(this) );
/*21*/   this.nud(TokenKind.IDENTIFIER,this.parsePrimary.bind(this) );
/*22*/ }

好了, 追加完上述22行代码后我们就完成了对Identifier和二元表达式的解析. 接下来我们稍微的修改一下代码

// v-permission.ts
+ import {rules, tokenizer} from './permission-lexer.ts';
+ import {Parser} from './permission-parser.ts';
- const isValid = (node:PermissionExpr) => {
+ const isValid = (node:PermissionExpr | string) => {
     const { permissions } = useAccount();
+    if (typeof node === 'string') {
+      const tokens = tokenizer(node, rules);
-      console.log(tokens);
+      const parser = new Parser(tokens);
+      const ast = parser.run();
+      console.log(ast);
+      return true;
+    } else {
+      return judge(node);
+    }
// App.vue

<template>
  <span v-permission="'a || b'">完 全 勝 利</span>
</template>

修改完成后刷新一下页面, 理论上控制台应该输出

二元表达式的解析.png

分组表达式

对于一元表达式和分组表达式, 实际上更加的简单. 因为我们不会(至少这篇文章不会)涉及到函数调用. 我们先来实现以下分组表达式, 分组表达式不需要定义类型.

解析器只需要吃掉左括号然后重新开始解析表达式就可以,解析完成后吃掉右括号,然后返回解析好的表达式。

// 获取当前 token 的 TokenKind. 如果不为我们预期, 则直接抛出错误
expect(kind: TokenKind) {
  const token = this.peek();
  if (token.kind !== kind) {
    throw new Error(`Expcetion ${TokenKind[kind]} but find ${TokenKind[token.kind]}`);
  }
  return this.next();
}
parseGroup(){
  // 吃掉左括号, 没有就报错
  this.expect(TokenKind.LEFT_PARENTHESIS);
  // 重新解析所有表达式
  const expr = this.parseExpr(BP.DEFAULT_BP);
  // 吃掉右括号, 没有就报错
  this.expect(TokenKind.RIGHT_PARENTHESIS);
  return expr;
}
setup(){
  // ...
  this.nud(TokenKind.LEFT_PARENTHESIS, this.parseGroup.bind(this));
}

接下来让我我们修改一下代码, 看一下分组表达式的结果

<span v-permission="'a || (b || c)'">完 全 勝 利</span>

分组表达式的胜利.png

如果不加括号的话, 默认是左结合. 当解析器遇到左括号后会重新开始解析子表达式.

前缀表达式的解析

前缀表达式是我们最终要解决的问题,当然他并不难,恰恰相反,反而是因为太简单了到最后随手就能实现. 先让我们来定义一下前缀表达式的类型

interface PrefixExpr {
  type: 'PrefixExpr';
  operator: Token;
  expr: Expr;
}
parsePrefix(){
  const operator = this.next();
  const expr = this.parseExpr(BP.UNARY);
  return {
    type:  'PrefixExpr',
    operator,
    expr
  } as PrefixExpr
}
setup(){
  // ...
  this.nud(TokenKind.NOT, this.parsePrefix.bind(this) );
}

现在让我们修改一下代码看一下效果

- <span v-permission="'a || (b || c)'">完 全 勝 利</span>
+ <span v-permission="'!a || (b || c)'">完 全 勝 利</span>

一元表达式.png

然后我们来完善一下类型

export type Node = Expr;
export type Expr = BinaryExpr | Identifier | PrefixExpr;

代码生成

本章节的目的在于将先前我们生成好的 Expr 转为我们 封装一个完备的v-permission中定义好的 PermissionExpr

const objectGenerate = (node:Node): PermissionExpr | null => {
  // ?
  return null;
}

IdentifierPrefix 都太简单了. 唯一有一点难度的只有 BinaryExpression. 所以我们这里只写 BinaryExpression. 对于其他 (包括课后习题) 都可以在 Permission-Compiler中找到

// directive/codegen.ts
const isPrefix = (node:Node) => node.type === 'PrefixExpr';
const isIdentifier = (node:Node) => node.type === 'Identifier';
const isBinaryExpr = (node:Node) => node.type === 'BinaryExpr';
export const binaryExprGen = (expr: BinaryExpr) => {
  const {lhs,rhs,operator} = expr;

  // 这里判断主要是为了收窄 operator.kind 只能是 AND 和 OR
  if (operator.kind !== TokenKind.AND && operator.kind !== TokenKind.OR) {
    throw new Error('Operator only support && or ||, or you can use keywords `and` or `or`.');
  }
  return {
    lhs: objectGenerate(lhs),
    rhs: objectGenerate(rhs),
    type: TokenKind[operator.kind]
  } as And | Or
}
export const objectGenerate = (node: Node):PermissionExpr|null => {
    if (isIdentifier(node)) {
    // ...
  }
  if (isBinaryExpr(node)){
    return binaryExprGen(node);
  }
  if (isPrefix(node)) {
    // ...
  }
  return null;
}

现在我们稍微的修改一下代码, 看看能不能生成 PermissionExpr

// v-permission.ts
+import { objectGenerate } from './codegen';
const isValid = (value: string | PermissionExpr) => {
  const { permissions } = useAccount();
  if (typeof value === 'string') {
    const parser = new Parser(tokens);
+   const ast = parser.run();
+   const permissionExpr = objectGenerate(ast);
+   if (!permissionExpr){
+     throw new Error('Unknown Error')
+   }
+   console.log(permissionExpr)
+   return judge(permissionExpr, permissions.value);

// App.vue
+ <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>

刷新页面后我们应该在控制台看到

终于啊终于.png

页面上应该也可以看到

赢了但是没完全赢.png

这里之所以能看到是因为我们权限写的是 没有A 或者 (B 或者 没有C) 因为或运算符只要有一个是true, 则对应的表达式就是true. 我们在 mock-store.tspermissions 变量写的是 ['a']. 显然满足了 !c, 那么 b || !c 就是true, 那么 !a || (b||!c) 也是true, 那么就可以显示

插件

实际上整个文章到这里确实可以结束了, 因为我们完成了最终的目标 输入一段字符串, 解析为权限表达式对象.

但在这里我们可以稍作思考, 如果我们给 v-permission 传入一个静态的字符串, 那么其实没有必要在运行时进行解析, 而是可以直接在编译时进行解析, 使用解析产物替换掉传入的静态字符串.

所以我们的整体历程便是

stateDiagram-v2 
    direction LR    
    S1: 搜索v-permission指令
    [*] --> S1
    S1 --> [*]: 不存在
    S1 --> 提取指令值
    提取指令值 --> [*]: 不是一个常量
    提取指令值 --> transform
    state transform {
        direction LR
        [*] --> 分词
        分词 --> 解析
        解析 --> 代码生成
        代码生成 --> 替换指令值
    }
    transform --> [*]

写个简单的插件

// plugin/permission.ts
import type {Plugin} from 'vite';
import permissionTransform from './permission-transform';

const permission = ()=>{
  return {
    name: 'vite-plugin-vue-permission',
    transform(code, id, options) {
      if (!id.endsWith('vue')){
        return code;
      }
      return permissionTransform(code, id);
    },
    enforce: 'pre',
  } as Plugin;
}

export default permission;

// vite.config.ts
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

实现 permissionTransform

考虑到我们编译器的主体结构已经实现, 所以 permissionTransform 的难度主要在于

  1. 如何将vue代码转为ast
  2. 如何将ast转为代码

第一个问题相对来说比较好解决, vue 官方提供了 @vue/compiler-sfc 来负责解析。

第二个问题似乎没有什么标准答案, 掘金上也有不少人是自己根据ast写了代码生成. 但个人感觉其实没有必要, 我们完全可以对 loc.start.offset 进行降序排序, 而后切分重组字符串。

提取 v-permission

提取 v-permission 是最简单的一个部分, 本质就是遍历template下所有的dom元素, 提取后再提取存在 namepermission 的指令就可以。

// plugin/permission-transform.ts
export default (
  code: string, id:string
)=>{
  const sfcAST = useSFC({code,id});
  const elements:BaseElementNode[] = [];
  const template = sfcAST.template;
  if (!template?.ast) {
    return code;
  }
  walkSFC(template.ast, {
    enter: (node) => {
      if (node.type === NodeTypes.ELEMENT ) {
        const _node:BaseElementNode = node as BaseElementNode;
        elements.push(_node);
      }
    }
  })
  if (!elements.length) {
    return code;
  }
  const directives:DirectiveNode[] = [];
  for (const ele of elements) {
    const props = ele.props;
    const allDirectives = props.filter(p => p.type === NodeTypes.DIRECTIVE);
    if (!allDirectives.length){
      continue;
    }
    directives.push(...allDirectives.filter(d => d.name === 'permission'))
  }
  directives.sort((a,b) => b.loc.start.offset - a.loc.start.offset);
  return code;
}

解析静态字符串

// plugin/permission-transform.ts
const parseStaticPermission = (
  _ast: SimpleExpressionNode
) => {
  const vueAST = _ast.ast;
  if (!vueAST){
    return ;
  }
  let text = '';
  if(vueAST.type === 'StringLiteral'){
    text = vueAST.value;
  }
  if (vueAST.type === 'TemplateLiteral') {
    throw new Error('Not implment Template parse yet.');
  }
  const tokens = tokenizer(text, rules);
  const parser = new Parser(tokens);
  const ast = parser.run();
  const expr = objectGenerate(ast);
  if (!expr) {
    throw new Error('Unknown Error');
  }
  return expr;
}
export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
  }
}

前三个if拆开写纯粹是为了不让自己心智负担太大. 这边我们对 objectGenerate 产出的代码叫做了 Intermediate language.

重组字符串

这里有一个小坑,我们需要对 directive.exp.loc.start.offset 进行倒序排序, 换句话说我们要倒着修改字符串. 如果不这么做, 就会陷入到 修改完第一个字符串结果第二个字符串的位置偏移了。

export default (
  code: string, id:string
)=>{
  // ...
  for (const directive of directives) {
    if (!directive.exp || !directive.exp.ast){
      continue;
    }
    if (directive.exp.type !== NodeTypes.SIMPLE_EXPRESSION){
      continue;
    }
    if (directive.exp.ast.type !== 'StringLiteral') {
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
    const permissionExprAstIR = parseStaticPermission(directive.exp);
    if (!permissionExprAstIR){
      continue;
    }
+   const l = directive.exp.loc.start.offset;
+   const r = directive.exp.loc.end.offset;
+   code = `${code.slice(0,l-1)}"${JSON.stringify(permissionExprAstIR).replaceAll('"',"'")}"${code.slice(r+1)}`
  }
  return code;
}

Try It

接下来, 我们就可以再 vite.config.ts 中使用我们的插件了.

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import permission from './plugin/permission';
import inspect from 'vite-plugin-inspect';

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    inspect(),
    vue(),
    permission()
  ],
})

接下来我们修改下 App.vue 访问 http://localhost:5173/__inspect

<!-- App.vue -->
<!-- 还是沿用了先前的App.vue -->
<script lang="ts" setup>
import vPermission from './directive/v-permission';
</script>

<template>
  <span v-permission="'!a || (b || !c)'">完 全 勝 利</span>
</template>

访问 inspect 之后, 我们点击 App.vue 后在弹出的 Drawer 中点击我们编写的插件, 可以看到确实已经替换成功了

diff.png

课后习题

  • 实现函数解析

Ref

  1. www.zhihu.com/question/21…
❌
❌