追寻

追寻 查看完整档案

杭州编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

混迹杭州 朝全栈的方向努力着...

个人动态

追寻 收藏了文章 · 2020-10-14

搭建一个属于自己的在线 IDE

header.png

图片来源:https://unsplash.com/photos/8...
本文作者:唐江洪

背景

这几个月在公司内做一个跨前端项目之间共享组件/区块的工程,主要思路就是在 Bit 的基础上进行开发。Bit 主要目的是实现不同项目 共享同步 组件/区块,大致思路如下:
在 A 项目中通过执行 Bit 提供的命令行工具将需要共享的组件/区块的源码推送到远端仓库,然后在 B 项目中就可以同样通过 Bit 提供的命令行工具拉取存储在 Bit 远程仓库的组件/区块。听起来比较像 Git,主要的区别是 Bit 除了推送源码之外,还会包括组件的依赖图谱分析、组件的版本管理等功能。下面这张图就描述了 Bit 的实现思路。更多细节可以参考 Bit 官方文档 Bit-Docs
Bit 原理图.png
虽然 Bit 开源了命令行工具,但并没有开源共享组件/区块的展示站点,类似 Bit 官方提供的网站 bit.dev。也就是说使用者无法通过浏览组件/区块的构建后的视图的方式,来查找保存在 Bit 远程仓库的组件/区块代码。Bit 网站效果如下图:
Bit 网站效果图.png
接下来就需要自己实现一个类似的网站,进而就会发现其中最难的部分就是实现一个在线 IDE,用于展示组件/区块代码,并支持代码实时构建以及获取构建后的页面截图等功能。效果如下图:
在线 IDE 效果图.png

使用目前提供的在线 IDE 的问题

看到这里你可能会有个疑问,为什么不能直接使用现有免费的在线 IDE?例如 CodeSandboxCodePenStackblitz 等。主要有如下原因:

  1. 对于稍具一定规模的公司,都会有自己的私有 npm 源,而在线 IDE 无法获取到这些 npm 包;
  2. 前端项目构建中一些特定的配置,而现有的在线 IDE 无法支持;
    例如 CodeSandbox 只能设置构建模板的类型,例如 create-react-app,并没有提供外部修改具体的构建配置的 API。例如项目中用到了 less 文件,选择 create-react-app 模板是无法构建的该类型文件的。
  3. 特殊的功能无法实现,例如点击页面的按钮,可以实现对在线 IDE 右侧构建出来的页面进行截图,并将图片数据传输出来;
  4. 使用在线 IDE 提供的服务,一般意味着你的组件/区块是暴露在公网上的,然而可能有些代码涉密,是不能上传到公网上的。
  5. 部分构建工具依赖 node_modules 等文件,无法在没有 node_modules 的浏览器中正常工作。例如 babel 插件等。这个在后面的定制 CodeSandbox 功能部分会举个例子细说。

所以我们需要搭建一个属于自己的在线 IDE ,以解决上面提的几个问题。那么接下来有两种方式:一种是完全从零开发一个在线 IDE,另一种是找到一个开源的项目,并在此基础上进行定制。
最开始笔者选择了自己开发,但是开发一段时间后,发现花费了大量精力实现出来 IDE 和已有的产品相比,不论是从功能丰富度还是易用性上,都完全落败。再加上笔者主要想实现的是一个跨前端项目区块复用的平台,在线 IDE 只是其中一个非必要的组成部分(注:其实也可以将共享的组件/区块的源代码直接在页面上展示,通过组件/区块命称来区分,虽然这种方式确实很 low)。所以最终还是选择在已经开源的在线 IDE 基础上二次开发。

CodeSandbox 基本原理

笔者主要研究的是 Codesandbox 以及 Stackblitz 。这两个都是商业化的项目,其中 Stackblitz 的核心部分并没有开源出来,而 CodeSandbox 绝大部分的功能都已经开源出来了,所以最终选择了 CodeSandbox。
为了方便后续讲解如何定制和部署 CodeSandbox,这里大概说一下它的基本原理(下面主要引用了CodeSandbox 如何工作? 上篇 的部分内容):
CodeSandbox 最大的特点是采用在浏览器端做项目构建,也就是说打包和运行不依赖服务器。由于浏览器端并没有 Node 环境,所以 CodeSandbox 自己实现了一个可以跑在浏览器端的简化版 webpack

CodeSandbox 组成部分

如下图所示,CodeSandbox 主要包含了三个部分:
CodeSandbox 的组成.png

  • Editor 编辑器:主要用于编辑代码,代码变动后会通知 Sandbox 进行转译
  • Sandbox 代码运行沙盒:在一个单独的 iframe 中运行,负责代码的编译 Transpiler 和运行 Evalation
  • Packager npm 在线打包器:给 Sandbox 提供 npm 包中的文件内容

CodeSandbox 构建项目过程

构建过程主要包括了三个步骤:

  • Packager--npm 包打包阶段:下载 npm 包并递归查找所有引用到的文件,然后提供给下个阶段进行编译
  • Transpilation--编译阶段:编译所有代码, 构建模块依赖图
  • Evaluation--执行阶段:使用 eval 运行编译后的代码,实现项目预览

Packager--npm 包打包阶段

Packager 阶段的代码实现是在 CodeSandbox 托管在 GitHub 上的仓库 dependency-packager 里,这是一个基于 express 框架提供的服务,并且部署采用了 Serverless(基于 AWS Lambda) 方式,让 Packager 服务更具伸缩性,可以灵活地应付高并发的场景。(注:在私有化部署中如果没有 Serverless 环境,可以将源码中有关 AWS Lambda 部分全部注释掉即可 )
以 react 包为例,讲解下 Packager 服务的原理,首先 express 框架接收到请求中的包名以及包版本,例如 react@16.8.0。然后通过 yarn 下载 react 以及 react 的依赖包到磁盘上,通过读取 npm 包的 package.json 文件中的 browser、module、main、unpkg 等字段找到 npm 包入口文件,然后解析 AST 中所有的 require 语句,将被 require 的文件内容添加到 manifest 文件中,并且递归执行刚才的步骤,最终形成依赖图。这样就实现将 npm 包文件内容转移到 manifest.json 上的目的,同时也实现了剔除 npm 模块中多余的文件的目的。最后返回给 Sandbox 进行编译。下面是一个 manifest 文件的示例:

{
 // 模块内容
 "contents": {
 "/node_modules/react/index.js": {
 "content": "'use strict';↵↵if ....", // 代码内容
 "requires": [ // 依赖的其他模块
 "./cjs/react.development.js",
 ],
 },
 //...
 },
 // 模块具体安装版本号
 "dependencies": [{
 name: "@babel/runtime",
 version: "7.3.1"
 }, /*…*/ ],
 // 模块别名, 比如将react作为preact-compat的别名
 "dependencyAliases": {},
 // 依赖的依赖, 即间接依赖信息. 这些信息可以从yarn.lock获取
 "dependencyDependencies": {
 "object-assign": {
 "entries": ["object-assign"], // 模块入口
 "parents": ["react", "prop-types", "scheduler", "react-dom"], // 父模块
 }
 //...
 }
}

值得一提的是为了提升 npm 在线打包的速度,CodeSandbox 作者使用了 AWS 提供的 S3 云存储服务。当某个版本的 npm 包已经打包过一次的话,会将打包的结果 -- manifest.json 文件存储到 S3 上。在下一次请求同样版本的包时,就可以直接返回储存的 manifest.json 文件,而不需要重复上面的流程了。在私有化部署中可以将 S3 替换成你自己的文件存储服务。

Transpilation--编译阶段

当 Sandbox 从 Editor 接收到前端项目的源代码、npm 依赖以及构建模板 Preset。Sandbox 会初始化配置,然后从 Packager 服务下载 npm 依赖包对应的 manifest 文件,接着从前端项目的入口文件开始对项目进行编译,并解析 AST 递归编译被 require 的文件,形成依赖图(注:和 webpack 原理基本一致)。
注意 CodeSandbox 支持外部预定义项目的构建模板 Preset。Preset 规定了针对某一类型的文件,采用哪些 Transpiler(相当于 Webpack 的 Loader)对文件进行编译。目前可供选择的 Preset 选项有: vue-clicreate-react-appcreate-react-app-typescriptparcelangular-clipreact-cli。但是不支持修改某个 Preset 中的具体配置,这些都是内置在 CodeSandbox 源码中的。Preset 具体配置示例如下:

import babelTranspiler from "../../transpilers/babel";
...
const preset = new Preset(
 "create-react-app",
 ["web.js", "js", "json", "web.jsx", "jsx", "ts", "tsx"], {
 hasDotEnv: true,
 setup: manager => {
 const babelOptions = {...};
 preset.registerTranspiler(
 module =>
 /.(t|j)sx?$/.test(module.path) && !module.path.endsWith(".d.ts"),
 [{
 transpiler: babelTranspiler,
 options: babelOptions
 }],
 true
 );
 ...
 }
 }
);

Evaluation--执行阶段

Evaluation 执行阶段是从项目入口文件对应的编译后的模块开始,递归调用 eval 执行所有被引用到的模块。
由于本文主要是阐述如何搭建自己的在线 IDE,所以 CodeSandbox 更多的实现细节可以参考如下文章:

私有化部署 CodeSandbox

了解完 CodeSandbox 基本原理后,接下来就到了本文的核心内容:如何私有化部署 CodeSandbox。

在线打包服务 Packager

首先是 npm 在线打包服务 dependency-packager。笔者是通过镜像部署到自己的服务器上的。
接着是将 npm 源改成公司的私有 npm 源,可以通过两种方式,一种是在镜像中通过 npm config 命令全局修改,例如如下 Dockerfile:

FROM node:12-alpine
COPY . /home/app
# 设置私有 npm 源
RUN cd /home/app && npm config set registry http://npm.xxx.com && npm install -f
WORKDIR /home/app
CMD ["npm", "run", "dev"]

第二种方式是在源码中通过 yarn 下载 npm 包的命令后面添加参数 --registry=http://npm.xxx.com ,相关代码在 functions/packager/dependencies/install-dependencies.ts 文件中。
另外该服务依赖了 AWS 的 Lambda 提供的 Serverless,并采用 AWS 提供的 S3 存储服务缓存 npm 包的打包结果。如果读者没有这些服务的话,可以将源码中这部分内容注释掉或者换成对应的其他云计算厂商的服务即可。dependency-packager 本质上就是一个基于 express 框架的 node 服务,可以简单地直接跑在服务器中。

编辑器 Editor

在 CodeSandbox-client 工程中的 standalone-packages/react-sandpack 项目,就是 CodeSandbox 提供的基于 react 实现的的编辑器项目。区别于主项目实现的编辑器,这个编辑器主要是为了给使用者进行定制,所以实现的比较简陋,使用者可以根据自己的需求在这个编辑器的基础上加入自己需要的功能。当然如果没有自定义编辑器的需求,可以直接使用 react-sandpack 项目对应的 npm 包 react-smooshpack,使用方式如下:

import React from 'react';
import { render } from 'react-dom';
import {
 FileExplorer,
 CodeMirror,
 BrowserPreview,
 SandpackProvider,
} from 'react-smooshpack';
import 'react-smooshpack/dist/styles.css';
const files = {
 '/index.js': {
 code: "document.body.innerHTML = `<div>${require('uuid')}</div>` ",
 },
};
const dependencies = {
 uuid: 'latest',
};
const App = () => (
 <SandpackProvider 
 files={files} 
 dependencies={dependencies} 
 entry="/index.js" 
 bundlerURL= `http://sandpack-${version}.codesandbox.io` >
 <div style={{ display: 'flex', width: '100%', height: '100%' }}>
 <FileExplorer style={{ width: 300 }} />
 <CodeMirror style={{ flex: 1 }} />
 <BrowserPreview style={{ flex: 1 }} />
 </div>
 </SandpackProvider>
);
render(<App />, document.getElementById('root'));

其中子组件 FileExplorer、CodeMirror、BrowserPreview 分别是左侧的文件目录树、中间的代码编辑区和右侧的项目构建后的页面预览区。
通过查看这个独立库的源码,可以知道除了这三个子组件之外,SandpackProvider 还会再插入一个 iframe 标签,主要用于显示项目构建后的页面,而右侧预览区组件 BrowserPreview 中的 Preview 组件会将这个 ifame 插入到自己的节点,这样就实现了将项目构建的页面实时显示出来的目的。
而 iframe 加载的 bundlerUrl 默认是官方提供的地址 http://sandpack-${version}.codesandbox.io ,其中这个域名对应的服务其实就是 CodeSandbox 的核心--在浏览器端构建前端项目的服务,大致原理刚刚已经阐述过了。下一小节会阐述如何将官方提供的构建服务替换成自己的。
至于代码编辑区的代码/依赖如何同步到 iframe 中加载的构建服务,其实它依赖了另一个独立库 sandpack(和 react-sandpack 同级目录),其中有一个 Manager 类就是在代码编辑区和右侧预览区的构建服务之间搭建桥梁,主要是用了 codesandbox-api 包提供的 dispatch 方法进行编辑器和构建服务之间的通信。

代码运行沙盒 SandBox

怕大家误解先提前说明下,上一小节提到的构建服务并不是后端服务,这个服务其实就是 CodeSandbox 构建出来的前端页面。基本原理部分已经阐述了 CodeSandbox 实际上在浏览器里实现了一个 webpack,项目的构建全部是在浏览器中完成的。
而 CodeSandbox 前端构建的核心部分的目录在 CodeSandbox-client 工程中 packages/app 项目,其中的原理已经在上面阐述过了,这里只需要将该项目构建出来的 www 文件夹部署到服务器即可。由于该核心库又依赖了其他库,所以也需要先构建下依赖库。下面笔者写了一个 build.sh 文件,放置在整个项目的一级目录即可。

# # 运行和构建需要 Node 10 环境
# nvm use 10
# 安装依赖
yarn
# 构建依赖库
yarn run build:deps
# 进入到核心库 packages/app 进行构建
cd packages/app
yarn run build:sandpack-sandbox
# 由于一些原因,一些需要的静态文件需要从整体项目的构建目录中获取
# 因此需要在执行该 shell 脚本之前,将整个项目构建一次,即执行 npm run build 即可(这个构建的时间会比较久)
cp -rf ../../www/static/* ./www/static

当执行完上面的 shell 脚本之后,就可以将 packages/app 目录下构建的产物 www 部署到服务器上,笔者采用的是容器部署,下面是 dockerfile 文件内容。

FROM node:10.14.2 as build
WORKDIR /
ADD . .
RUN /bin/sh build.sh
FROM nginx:1.16.1-alpine
COPY --from=build /packages/app/www /usr/share/nginx/html/

注意这里采用了分阶段构建镜像,即先构建 CodeSandbox 项目,再构建镜像。但在实践中发现 CodeSandbox 项目放在服务器上构建不是很顺利,所以最终还是选择在本地构建该项目,然后将构建产物一并上传到远程 git 仓库,这样在打包机上只需要构建镜像并运行即可。
整个部署的灵感来自 GitLab 的官方仓库的一个 issue: GitLab hosted Codesandbox

定制 CodeSandbox 功能

上个小节读者可能会有个疑问,为什么直接使用 CodeSandbox 提供的默认构建服务?其实就是为了对 CodeSandbox 的构建流程进行定制,接下来举四个例子来说明下。

替换组件样式自动引入的 babel 插件功能

针对公司自建的组件库,一般都会开发类似 babel-plugin-import 这样的插件,以便在代码中使用组件时无需额外再引入组件的样式文件,babel-plugin-import 插件会在 js 编译阶段自动插入引入样式的代码。但这种插件可能会需要遍历组件的 package.json 中的依赖中是否有其他组件,如果有也要把其他组件的样式文件的引入写到编译后的 js 中,并递归执行刚才的过程。这里就需要读入 node_modules 中的相关文件。但是诸如 CodeSandboxStackblitz 等都是在浏览器中进行构建,并没有 node_modules。
针对这个问题,笔者最终放弃了利用 babel 插件在 js 编译阶段进行插入引入样式文件代码的方式,而是在代码运行阶段从 npm 在线打包服务中获取组件的样式文件,然后将样式文件内容通过 style 标签动态插入到 head 标签上面。下面是具体改动:
在线 npm 打包服务侧
在线 npm 打包服务一般只会返回 js 文件,所以需要在该服务基础上增加一个功能:当判断请求的 npm 包为内建组件,则还要额外返回样式文件。下面是 dependence-packager 项目中添加的核心代码:
为了提供获取私有组件样式文件的方法,可以在 functions/packager/utils 目录下新建一个文件 fetch-builtin-component-style.ts ,核心代码如下:

// 根据组件 npm 包名以及通过 yarn 下载到磁盘上的 npm 包路径,读入对应的样式文件内容,并写入到 manifest.json 的 contents 对象上
const insertStyle = (contents: any, packageName: string, packagePath: string) => {
 const stylePath = `node_modules/${packageName}/dist/index.css`;
 const styleFilePath = join(
 packagePath,
 `node_modules/${packageName}/dist/index.css` ,
 );
 if (fs.existsSync(styleFilePath)) {
 contents[stylePath] = {
 contents: fs.readFileSync(styleFilePath, "utf-8"),
 isModule: false,
 };
 }
};
// 获取内建组件的样式文件,并写入到返回给 Sandbox 的 manifest.json 文件中
const fetchBuiltinComponentStyle = (
 contents: any,
 packageName: string,
 packagePath: string,
 dependencyDependencies: any,
) => {
 // 当 npm 包或者其依赖以及依赖的依赖中有内建组件,则将该内建组件对应的样式文件写入到 manifest.json 文件中
 if (isBuiltinComponent(packageName)) {
 insertStyle(contents, packageName, packagePath);
 }
 Object.keys(dependencyDependencies.dependencyDependencies).forEach(
 (pkgName) => {
 if (isBuiltinComponent(pkgName)) {
 insertStyle(contents, pkgName, packagePath);
 }
 },
 );
};

并在 functions/packager/index.ts 文件中调用该方法。代码如下:

+  // 针对私有组件,将组件样式文件也写到返回给浏览器的 manifest.json 文件中
+  fetchBuiltinComponentStyle(
+    contents,
+    dependency.name,
+    packagePath,
+    dependencyDependencies,
+  );
// 作为结果返回
const response = {
 contents,
 dependency,
 ...dependencyDependencies,
};

浏览器 CodeSandbox 侧
浏览器 CodeSandbox 侧需要提供处理私有组件样式的方法,主要是在 Evaluation 执行阶段将样式文件内容通过 style 标签动态插入到 head 标签上面,可以在 packages/app/src/sandbox/eval/utils 目录下新建一个文件 insert-builtin-component-style.ts ,下面是核心代码:

// 基于样式文件内容创建 style 标签,并插入到 head 标签上
const insertStyleNode = (content: string) => {
 const styleNode = document.createElement('style');
 styleNode.type = 'text/css';
 styleNode.innerHTML = content;
 document.head.appendChild(styleNode);
}
const insertBuiltinComponentStyle = (manifest: any) => {
 const { contents, dependencies, dependencyDependencies } = manifest;
 // 从依赖以及依赖的依赖中根据 npm 包名筛选出内建组件
 const builtinComponents = Object.keys(dependencyDependencies).filter(pkgName => isBuiltinComponent(pkgName));
 dependencies.map((d: any) => {
 if (isBuiltinComponent(d.name)) {
 builtinComponents.push(d.name);
 }
 });
 // 根据基于内建组件 npm 名称拼装成的 key 查找到具体的文件内容,并调用 insertStyleNode 方法插入到 head 标签上
 builtinComponents.forEach(name => {
 const styleContent = contents[`/node_modules/${name}/dist/index.css`];
 if (styleContent) {
 const { content } = styleContent;
 if (content) {
 insertStyleNode(content);
 }
 }
 });
}

并在 Evaluation 执行阶段调用该方法,相关文件在 packages/sandpack-core/src/manager.ts ,具体修改如下:

...
setManifest(manifest?: Manifest) {
 this.manifest = manifest || {
 contents: {},
 dependencies: [],
 dependencyDependencies: {},
 dependencyAliases: {},
 };
+  insertBuiltinComponentStyle(this.manifest);
 ...
}
...

添加预览区域截图功能

在区块复用平台项目中,在点击保存按钮时,不仅要保存编辑好的代码,还需要对构建好的右侧预览区域进行截图并保存。如下图所示:
ide 截图功能
右侧预览区域所展示的内容是 SandpackProvider 组件插入的 iframe,所以只需要找到这个 iframe,然后通过 postMessage 与 iframe 内页面进行通信。当 iframe 内部页面接收到截图指令后,对当前 dom 进行截图并传出即可,这里笔者用的是 html2canvas 进行截图的。下面是 CodeSandbox 侧的代码改造,文件在 packages/app/src/sandbox/index.js 中,主要是在文件结尾处添加如下代码:

const fetchScreenShot = async () => {
 const app = document.querySelector('#root');
 const c = await html2canvas(app);
 const imgData = c.toDataURL('image/png');
 window.parent.postMessage({
 type: 'SCREENSHOT_DATA',
 payload: {
 imgData
 }
 }, '*');
};
const receiveMessageFromIndex = (event) => {
 const {
 type
 } = event.data;
 switch (type) {
 case 'FETCH_SCREENSHOT':
 fetchScreenShot();
 break;
 default:
 break;
 }
};
window.addEventListener('message', receiveMessageFromIndex, false);

在 CodeSandbox 使用侧,则需要在需要截图的时候,向 iframe 发送截图指令。同时也需要监听 iframe 发来的消息,从中筛选出返回截图数据的指令,并获取到截图数据。由于实现比较简单,这里就不展示具体代码了。

create-react-app 模板中添加对 less 文件编译的支持

主要是对 create-react-app 这个 preset 的配置做一些修改,文件地址 packages/app/src/sandbox/eval/presets/create-react-app/v1.ts。修改代码如下:

...
+  import lessTranspiler from '../../transpilers/less';
+  import styleProcessor from '../../transpilers/postcss';
export default function initialize() {
 ...
 +  preset.registerTranspiler(module => /.less$/.test(module.path), [
 +    { transpiler: lessTranspiler },
 +    { transpiler: styleProcessor },
 +    {
 +      transpiler: stylesTranspiler,
 +      options: { hmrEnabled: true },
 +    },
 +  ]);
 ...
}

修改 CodeSandbox 请求的 npm 打包服务地址

可以将打包 npm 的服务换成上面私有化部署的服务,以解决无法获取私有 npm 包等问题。相关文件在 packages/sandpack-core/src/npm/preloaded/fetch-dependencies.ts 。修改代码如下:

 const PROD_URLS = {
 ...
//  替换成自己的在线 npm 打包服务即可
-  bucket: 'https://prod-packager-packages.codesandbox.io',
+  bucket: 'http://xxx.xxx.com'
 };
...
function dependencyToPackagePath(name: string, version: string) {
-  return `v${VERSION}/packages/${name}/${version}.json` ;
+  return `${name}@${version}` ;
}

这四个例子就讲完了,读者可以根据自己的需求进行更多的定制。当你明白了整个 CodeSandbox 的运行机制后,就会发现定制并没有那么难。

结束语

到此为止,私有化部署一个属于自己并且可以任意定制的在线 IDE 的目标就已经达成了。当然在线 IDE 的项目构建不仅仅局限在浏览器中,还可以将整个构建过程放在服务端,借助于云+容器化的能力,使得在线 IDE 有着跟本地 IDE 几乎完全一样的功能。其实这两者应用的场景不多,完全基于浏览器构建更适用于单一页面项目的实时预览,而基于服务端构建是完全可以适用于真实的项目开发的,并且不仅仅局限于前端项目。笔者也在尝试探索基于服务端构建 IDE 的可能性,期待后面能够有些产出分享给大家。
接下来如果读者感兴趣的话,可以继续阅读基于 Bit 和 CodeSandbox 实现的区块平台项目--跨项目区块复用方案实践

参考资料

本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!
查看原文

追寻 赞了文章 · 2020-10-14

搭建一个属于自己的在线 IDE

header.png

图片来源:https://unsplash.com/photos/8...
本文作者:唐江洪

背景

这几个月在公司内做一个跨前端项目之间共享组件/区块的工程,主要思路就是在 Bit 的基础上进行开发。Bit 主要目的是实现不同项目 共享同步 组件/区块,大致思路如下:
在 A 项目中通过执行 Bit 提供的命令行工具将需要共享的组件/区块的源码推送到远端仓库,然后在 B 项目中就可以同样通过 Bit 提供的命令行工具拉取存储在 Bit 远程仓库的组件/区块。听起来比较像 Git,主要的区别是 Bit 除了推送源码之外,还会包括组件的依赖图谱分析、组件的版本管理等功能。下面这张图就描述了 Bit 的实现思路。更多细节可以参考 Bit 官方文档 Bit-Docs
Bit 原理图.png
虽然 Bit 开源了命令行工具,但并没有开源共享组件/区块的展示站点,类似 Bit 官方提供的网站 bit.dev。也就是说使用者无法通过浏览组件/区块的构建后的视图的方式,来查找保存在 Bit 远程仓库的组件/区块代码。Bit 网站效果如下图:
Bit 网站效果图.png
接下来就需要自己实现一个类似的网站,进而就会发现其中最难的部分就是实现一个在线 IDE,用于展示组件/区块代码,并支持代码实时构建以及获取构建后的页面截图等功能。效果如下图:
在线 IDE 效果图.png

使用目前提供的在线 IDE 的问题

看到这里你可能会有个疑问,为什么不能直接使用现有免费的在线 IDE?例如 CodeSandboxCodePenStackblitz 等。主要有如下原因:

  1. 对于稍具一定规模的公司,都会有自己的私有 npm 源,而在线 IDE 无法获取到这些 npm 包;
  2. 前端项目构建中一些特定的配置,而现有的在线 IDE 无法支持;
    例如 CodeSandbox 只能设置构建模板的类型,例如 create-react-app,并没有提供外部修改具体的构建配置的 API。例如项目中用到了 less 文件,选择 create-react-app 模板是无法构建的该类型文件的。
  3. 特殊的功能无法实现,例如点击页面的按钮,可以实现对在线 IDE 右侧构建出来的页面进行截图,并将图片数据传输出来;
  4. 使用在线 IDE 提供的服务,一般意味着你的组件/区块是暴露在公网上的,然而可能有些代码涉密,是不能上传到公网上的。
  5. 部分构建工具依赖 node_modules 等文件,无法在没有 node_modules 的浏览器中正常工作。例如 babel 插件等。这个在后面的定制 CodeSandbox 功能部分会举个例子细说。

所以我们需要搭建一个属于自己的在线 IDE ,以解决上面提的几个问题。那么接下来有两种方式:一种是完全从零开发一个在线 IDE,另一种是找到一个开源的项目,并在此基础上进行定制。
最开始笔者选择了自己开发,但是开发一段时间后,发现花费了大量精力实现出来 IDE 和已有的产品相比,不论是从功能丰富度还是易用性上,都完全落败。再加上笔者主要想实现的是一个跨前端项目区块复用的平台,在线 IDE 只是其中一个非必要的组成部分(注:其实也可以将共享的组件/区块的源代码直接在页面上展示,通过组件/区块命称来区分,虽然这种方式确实很 low)。所以最终还是选择在已经开源的在线 IDE 基础上二次开发。

CodeSandbox 基本原理

笔者主要研究的是 Codesandbox 以及 Stackblitz 。这两个都是商业化的项目,其中 Stackblitz 的核心部分并没有开源出来,而 CodeSandbox 绝大部分的功能都已经开源出来了,所以最终选择了 CodeSandbox。
为了方便后续讲解如何定制和部署 CodeSandbox,这里大概说一下它的基本原理(下面主要引用了CodeSandbox 如何工作? 上篇 的部分内容):
CodeSandbox 最大的特点是采用在浏览器端做项目构建,也就是说打包和运行不依赖服务器。由于浏览器端并没有 Node 环境,所以 CodeSandbox 自己实现了一个可以跑在浏览器端的简化版 webpack

CodeSandbox 组成部分

如下图所示,CodeSandbox 主要包含了三个部分:
CodeSandbox 的组成.png

  • Editor 编辑器:主要用于编辑代码,代码变动后会通知 Sandbox 进行转译
  • Sandbox 代码运行沙盒:在一个单独的 iframe 中运行,负责代码的编译 Transpiler 和运行 Evalation
  • Packager npm 在线打包器:给 Sandbox 提供 npm 包中的文件内容

CodeSandbox 构建项目过程

构建过程主要包括了三个步骤:

  • Packager--npm 包打包阶段:下载 npm 包并递归查找所有引用到的文件,然后提供给下个阶段进行编译
  • Transpilation--编译阶段:编译所有代码, 构建模块依赖图
  • Evaluation--执行阶段:使用 eval 运行编译后的代码,实现项目预览

Packager--npm 包打包阶段

Packager 阶段的代码实现是在 CodeSandbox 托管在 GitHub 上的仓库 dependency-packager 里,这是一个基于 express 框架提供的服务,并且部署采用了 Serverless(基于 AWS Lambda) 方式,让 Packager 服务更具伸缩性,可以灵活地应付高并发的场景。(注:在私有化部署中如果没有 Serverless 环境,可以将源码中有关 AWS Lambda 部分全部注释掉即可 )
以 react 包为例,讲解下 Packager 服务的原理,首先 express 框架接收到请求中的包名以及包版本,例如 react@16.8.0。然后通过 yarn 下载 react 以及 react 的依赖包到磁盘上,通过读取 npm 包的 package.json 文件中的 browser、module、main、unpkg 等字段找到 npm 包入口文件,然后解析 AST 中所有的 require 语句,将被 require 的文件内容添加到 manifest 文件中,并且递归执行刚才的步骤,最终形成依赖图。这样就实现将 npm 包文件内容转移到 manifest.json 上的目的,同时也实现了剔除 npm 模块中多余的文件的目的。最后返回给 Sandbox 进行编译。下面是一个 manifest 文件的示例:

{
 // 模块内容
 "contents": {
 "/node_modules/react/index.js": {
 "content": "'use strict';↵↵if ....", // 代码内容
 "requires": [ // 依赖的其他模块
 "./cjs/react.development.js",
 ],
 },
 //...
 },
 // 模块具体安装版本号
 "dependencies": [{
 name: "@babel/runtime",
 version: "7.3.1"
 }, /*…*/ ],
 // 模块别名, 比如将react作为preact-compat的别名
 "dependencyAliases": {},
 // 依赖的依赖, 即间接依赖信息. 这些信息可以从yarn.lock获取
 "dependencyDependencies": {
 "object-assign": {
 "entries": ["object-assign"], // 模块入口
 "parents": ["react", "prop-types", "scheduler", "react-dom"], // 父模块
 }
 //...
 }
}

值得一提的是为了提升 npm 在线打包的速度,CodeSandbox 作者使用了 AWS 提供的 S3 云存储服务。当某个版本的 npm 包已经打包过一次的话,会将打包的结果 -- manifest.json 文件存储到 S3 上。在下一次请求同样版本的包时,就可以直接返回储存的 manifest.json 文件,而不需要重复上面的流程了。在私有化部署中可以将 S3 替换成你自己的文件存储服务。

Transpilation--编译阶段

当 Sandbox 从 Editor 接收到前端项目的源代码、npm 依赖以及构建模板 Preset。Sandbox 会初始化配置,然后从 Packager 服务下载 npm 依赖包对应的 manifest 文件,接着从前端项目的入口文件开始对项目进行编译,并解析 AST 递归编译被 require 的文件,形成依赖图(注:和 webpack 原理基本一致)。
注意 CodeSandbox 支持外部预定义项目的构建模板 Preset。Preset 规定了针对某一类型的文件,采用哪些 Transpiler(相当于 Webpack 的 Loader)对文件进行编译。目前可供选择的 Preset 选项有: vue-clicreate-react-appcreate-react-app-typescriptparcelangular-clipreact-cli。但是不支持修改某个 Preset 中的具体配置,这些都是内置在 CodeSandbox 源码中的。Preset 具体配置示例如下:

import babelTranspiler from "../../transpilers/babel";
...
const preset = new Preset(
 "create-react-app",
 ["web.js", "js", "json", "web.jsx", "jsx", "ts", "tsx"], {
 hasDotEnv: true,
 setup: manager => {
 const babelOptions = {...};
 preset.registerTranspiler(
 module =>
 /.(t|j)sx?$/.test(module.path) && !module.path.endsWith(".d.ts"),
 [{
 transpiler: babelTranspiler,
 options: babelOptions
 }],
 true
 );
 ...
 }
 }
);

Evaluation--执行阶段

Evaluation 执行阶段是从项目入口文件对应的编译后的模块开始,递归调用 eval 执行所有被引用到的模块。
由于本文主要是阐述如何搭建自己的在线 IDE,所以 CodeSandbox 更多的实现细节可以参考如下文章:

私有化部署 CodeSandbox

了解完 CodeSandbox 基本原理后,接下来就到了本文的核心内容:如何私有化部署 CodeSandbox。

在线打包服务 Packager

首先是 npm 在线打包服务 dependency-packager。笔者是通过镜像部署到自己的服务器上的。
接着是将 npm 源改成公司的私有 npm 源,可以通过两种方式,一种是在镜像中通过 npm config 命令全局修改,例如如下 Dockerfile:

FROM node:12-alpine
COPY . /home/app
# 设置私有 npm 源
RUN cd /home/app && npm config set registry http://npm.xxx.com && npm install -f
WORKDIR /home/app
CMD ["npm", "run", "dev"]

第二种方式是在源码中通过 yarn 下载 npm 包的命令后面添加参数 --registry=http://npm.xxx.com ,相关代码在 functions/packager/dependencies/install-dependencies.ts 文件中。
另外该服务依赖了 AWS 的 Lambda 提供的 Serverless,并采用 AWS 提供的 S3 存储服务缓存 npm 包的打包结果。如果读者没有这些服务的话,可以将源码中这部分内容注释掉或者换成对应的其他云计算厂商的服务即可。dependency-packager 本质上就是一个基于 express 框架的 node 服务,可以简单地直接跑在服务器中。

编辑器 Editor

在 CodeSandbox-client 工程中的 standalone-packages/react-sandpack 项目,就是 CodeSandbox 提供的基于 react 实现的的编辑器项目。区别于主项目实现的编辑器,这个编辑器主要是为了给使用者进行定制,所以实现的比较简陋,使用者可以根据自己的需求在这个编辑器的基础上加入自己需要的功能。当然如果没有自定义编辑器的需求,可以直接使用 react-sandpack 项目对应的 npm 包 react-smooshpack,使用方式如下:

import React from 'react';
import { render } from 'react-dom';
import {
 FileExplorer,
 CodeMirror,
 BrowserPreview,
 SandpackProvider,
} from 'react-smooshpack';
import 'react-smooshpack/dist/styles.css';
const files = {
 '/index.js': {
 code: "document.body.innerHTML = `<div>${require('uuid')}</div>` ",
 },
};
const dependencies = {
 uuid: 'latest',
};
const App = () => (
 <SandpackProvider 
 files={files} 
 dependencies={dependencies} 
 entry="/index.js" 
 bundlerURL= `http://sandpack-${version}.codesandbox.io` >
 <div style={{ display: 'flex', width: '100%', height: '100%' }}>
 <FileExplorer style={{ width: 300 }} />
 <CodeMirror style={{ flex: 1 }} />
 <BrowserPreview style={{ flex: 1 }} />
 </div>
 </SandpackProvider>
);
render(<App />, document.getElementById('root'));

其中子组件 FileExplorer、CodeMirror、BrowserPreview 分别是左侧的文件目录树、中间的代码编辑区和右侧的项目构建后的页面预览区。
通过查看这个独立库的源码,可以知道除了这三个子组件之外,SandpackProvider 还会再插入一个 iframe 标签,主要用于显示项目构建后的页面,而右侧预览区组件 BrowserPreview 中的 Preview 组件会将这个 ifame 插入到自己的节点,这样就实现了将项目构建的页面实时显示出来的目的。
而 iframe 加载的 bundlerUrl 默认是官方提供的地址 http://sandpack-${version}.codesandbox.io ,其中这个域名对应的服务其实就是 CodeSandbox 的核心--在浏览器端构建前端项目的服务,大致原理刚刚已经阐述过了。下一小节会阐述如何将官方提供的构建服务替换成自己的。
至于代码编辑区的代码/依赖如何同步到 iframe 中加载的构建服务,其实它依赖了另一个独立库 sandpack(和 react-sandpack 同级目录),其中有一个 Manager 类就是在代码编辑区和右侧预览区的构建服务之间搭建桥梁,主要是用了 codesandbox-api 包提供的 dispatch 方法进行编辑器和构建服务之间的通信。

代码运行沙盒 SandBox

怕大家误解先提前说明下,上一小节提到的构建服务并不是后端服务,这个服务其实就是 CodeSandbox 构建出来的前端页面。基本原理部分已经阐述了 CodeSandbox 实际上在浏览器里实现了一个 webpack,项目的构建全部是在浏览器中完成的。
而 CodeSandbox 前端构建的核心部分的目录在 CodeSandbox-client 工程中 packages/app 项目,其中的原理已经在上面阐述过了,这里只需要将该项目构建出来的 www 文件夹部署到服务器即可。由于该核心库又依赖了其他库,所以也需要先构建下依赖库。下面笔者写了一个 build.sh 文件,放置在整个项目的一级目录即可。

# # 运行和构建需要 Node 10 环境
# nvm use 10
# 安装依赖
yarn
# 构建依赖库
yarn run build:deps
# 进入到核心库 packages/app 进行构建
cd packages/app
yarn run build:sandpack-sandbox
# 由于一些原因,一些需要的静态文件需要从整体项目的构建目录中获取
# 因此需要在执行该 shell 脚本之前,将整个项目构建一次,即执行 npm run build 即可(这个构建的时间会比较久)
cp -rf ../../www/static/* ./www/static

当执行完上面的 shell 脚本之后,就可以将 packages/app 目录下构建的产物 www 部署到服务器上,笔者采用的是容器部署,下面是 dockerfile 文件内容。

FROM node:10.14.2 as build
WORKDIR /
ADD . .
RUN /bin/sh build.sh
FROM nginx:1.16.1-alpine
COPY --from=build /packages/app/www /usr/share/nginx/html/

注意这里采用了分阶段构建镜像,即先构建 CodeSandbox 项目,再构建镜像。但在实践中发现 CodeSandbox 项目放在服务器上构建不是很顺利,所以最终还是选择在本地构建该项目,然后将构建产物一并上传到远程 git 仓库,这样在打包机上只需要构建镜像并运行即可。
整个部署的灵感来自 GitLab 的官方仓库的一个 issue: GitLab hosted Codesandbox

定制 CodeSandbox 功能

上个小节读者可能会有个疑问,为什么直接使用 CodeSandbox 提供的默认构建服务?其实就是为了对 CodeSandbox 的构建流程进行定制,接下来举四个例子来说明下。

替换组件样式自动引入的 babel 插件功能

针对公司自建的组件库,一般都会开发类似 babel-plugin-import 这样的插件,以便在代码中使用组件时无需额外再引入组件的样式文件,babel-plugin-import 插件会在 js 编译阶段自动插入引入样式的代码。但这种插件可能会需要遍历组件的 package.json 中的依赖中是否有其他组件,如果有也要把其他组件的样式文件的引入写到编译后的 js 中,并递归执行刚才的过程。这里就需要读入 node_modules 中的相关文件。但是诸如 CodeSandboxStackblitz 等都是在浏览器中进行构建,并没有 node_modules。
针对这个问题,笔者最终放弃了利用 babel 插件在 js 编译阶段进行插入引入样式文件代码的方式,而是在代码运行阶段从 npm 在线打包服务中获取组件的样式文件,然后将样式文件内容通过 style 标签动态插入到 head 标签上面。下面是具体改动:
在线 npm 打包服务侧
在线 npm 打包服务一般只会返回 js 文件,所以需要在该服务基础上增加一个功能:当判断请求的 npm 包为内建组件,则还要额外返回样式文件。下面是 dependence-packager 项目中添加的核心代码:
为了提供获取私有组件样式文件的方法,可以在 functions/packager/utils 目录下新建一个文件 fetch-builtin-component-style.ts ,核心代码如下:

// 根据组件 npm 包名以及通过 yarn 下载到磁盘上的 npm 包路径,读入对应的样式文件内容,并写入到 manifest.json 的 contents 对象上
const insertStyle = (contents: any, packageName: string, packagePath: string) => {
 const stylePath = `node_modules/${packageName}/dist/index.css`;
 const styleFilePath = join(
 packagePath,
 `node_modules/${packageName}/dist/index.css` ,
 );
 if (fs.existsSync(styleFilePath)) {
 contents[stylePath] = {
 contents: fs.readFileSync(styleFilePath, "utf-8"),
 isModule: false,
 };
 }
};
// 获取内建组件的样式文件,并写入到返回给 Sandbox 的 manifest.json 文件中
const fetchBuiltinComponentStyle = (
 contents: any,
 packageName: string,
 packagePath: string,
 dependencyDependencies: any,
) => {
 // 当 npm 包或者其依赖以及依赖的依赖中有内建组件,则将该内建组件对应的样式文件写入到 manifest.json 文件中
 if (isBuiltinComponent(packageName)) {
 insertStyle(contents, packageName, packagePath);
 }
 Object.keys(dependencyDependencies.dependencyDependencies).forEach(
 (pkgName) => {
 if (isBuiltinComponent(pkgName)) {
 insertStyle(contents, pkgName, packagePath);
 }
 },
 );
};

并在 functions/packager/index.ts 文件中调用该方法。代码如下:

+  // 针对私有组件,将组件样式文件也写到返回给浏览器的 manifest.json 文件中
+  fetchBuiltinComponentStyle(
+    contents,
+    dependency.name,
+    packagePath,
+    dependencyDependencies,
+  );
// 作为结果返回
const response = {
 contents,
 dependency,
 ...dependencyDependencies,
};

浏览器 CodeSandbox 侧
浏览器 CodeSandbox 侧需要提供处理私有组件样式的方法,主要是在 Evaluation 执行阶段将样式文件内容通过 style 标签动态插入到 head 标签上面,可以在 packages/app/src/sandbox/eval/utils 目录下新建一个文件 insert-builtin-component-style.ts ,下面是核心代码:

// 基于样式文件内容创建 style 标签,并插入到 head 标签上
const insertStyleNode = (content: string) => {
 const styleNode = document.createElement('style');
 styleNode.type = 'text/css';
 styleNode.innerHTML = content;
 document.head.appendChild(styleNode);
}
const insertBuiltinComponentStyle = (manifest: any) => {
 const { contents, dependencies, dependencyDependencies } = manifest;
 // 从依赖以及依赖的依赖中根据 npm 包名筛选出内建组件
 const builtinComponents = Object.keys(dependencyDependencies).filter(pkgName => isBuiltinComponent(pkgName));
 dependencies.map((d: any) => {
 if (isBuiltinComponent(d.name)) {
 builtinComponents.push(d.name);
 }
 });
 // 根据基于内建组件 npm 名称拼装成的 key 查找到具体的文件内容,并调用 insertStyleNode 方法插入到 head 标签上
 builtinComponents.forEach(name => {
 const styleContent = contents[`/node_modules/${name}/dist/index.css`];
 if (styleContent) {
 const { content } = styleContent;
 if (content) {
 insertStyleNode(content);
 }
 }
 });
}

并在 Evaluation 执行阶段调用该方法,相关文件在 packages/sandpack-core/src/manager.ts ,具体修改如下:

...
setManifest(manifest?: Manifest) {
 this.manifest = manifest || {
 contents: {},
 dependencies: [],
 dependencyDependencies: {},
 dependencyAliases: {},
 };
+  insertBuiltinComponentStyle(this.manifest);
 ...
}
...

添加预览区域截图功能

在区块复用平台项目中,在点击保存按钮时,不仅要保存编辑好的代码,还需要对构建好的右侧预览区域进行截图并保存。如下图所示:
ide 截图功能
右侧预览区域所展示的内容是 SandpackProvider 组件插入的 iframe,所以只需要找到这个 iframe,然后通过 postMessage 与 iframe 内页面进行通信。当 iframe 内部页面接收到截图指令后,对当前 dom 进行截图并传出即可,这里笔者用的是 html2canvas 进行截图的。下面是 CodeSandbox 侧的代码改造,文件在 packages/app/src/sandbox/index.js 中,主要是在文件结尾处添加如下代码:

const fetchScreenShot = async () => {
 const app = document.querySelector('#root');
 const c = await html2canvas(app);
 const imgData = c.toDataURL('image/png');
 window.parent.postMessage({
 type: 'SCREENSHOT_DATA',
 payload: {
 imgData
 }
 }, '*');
};
const receiveMessageFromIndex = (event) => {
 const {
 type
 } = event.data;
 switch (type) {
 case 'FETCH_SCREENSHOT':
 fetchScreenShot();
 break;
 default:
 break;
 }
};
window.addEventListener('message', receiveMessageFromIndex, false);

在 CodeSandbox 使用侧,则需要在需要截图的时候,向 iframe 发送截图指令。同时也需要监听 iframe 发来的消息,从中筛选出返回截图数据的指令,并获取到截图数据。由于实现比较简单,这里就不展示具体代码了。

create-react-app 模板中添加对 less 文件编译的支持

主要是对 create-react-app 这个 preset 的配置做一些修改,文件地址 packages/app/src/sandbox/eval/presets/create-react-app/v1.ts。修改代码如下:

...
+  import lessTranspiler from '../../transpilers/less';
+  import styleProcessor from '../../transpilers/postcss';
export default function initialize() {
 ...
 +  preset.registerTranspiler(module => /.less$/.test(module.path), [
 +    { transpiler: lessTranspiler },
 +    { transpiler: styleProcessor },
 +    {
 +      transpiler: stylesTranspiler,
 +      options: { hmrEnabled: true },
 +    },
 +  ]);
 ...
}

修改 CodeSandbox 请求的 npm 打包服务地址

可以将打包 npm 的服务换成上面私有化部署的服务,以解决无法获取私有 npm 包等问题。相关文件在 packages/sandpack-core/src/npm/preloaded/fetch-dependencies.ts 。修改代码如下:

 const PROD_URLS = {
 ...
//  替换成自己的在线 npm 打包服务即可
-  bucket: 'https://prod-packager-packages.codesandbox.io',
+  bucket: 'http://xxx.xxx.com'
 };
...
function dependencyToPackagePath(name: string, version: string) {
-  return `v${VERSION}/packages/${name}/${version}.json` ;
+  return `${name}@${version}` ;
}

这四个例子就讲完了,读者可以根据自己的需求进行更多的定制。当你明白了整个 CodeSandbox 的运行机制后,就会发现定制并没有那么难。

结束语

到此为止,私有化部署一个属于自己并且可以任意定制的在线 IDE 的目标就已经达成了。当然在线 IDE 的项目构建不仅仅局限在浏览器中,还可以将整个构建过程放在服务端,借助于云+容器化的能力,使得在线 IDE 有着跟本地 IDE 几乎完全一样的功能。其实这两者应用的场景不多,完全基于浏览器构建更适用于单一页面项目的实时预览,而基于服务端构建是完全可以适用于真实的项目开发的,并且不仅仅局限于前端项目。笔者也在尝试探索基于服务端构建 IDE 的可能性,期待后面能够有些产出分享给大家。
接下来如果读者感兴趣的话,可以继续阅读基于 Bit 和 CodeSandbox 实现的区块平台项目--跨项目区块复用方案实践

参考资料

本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!
查看原文

赞 4 收藏 3 评论 0

追寻 收藏了文章 · 2020-09-18

前端进阶算法5:全方位解读前端用到的栈结构(调用栈、堆、垃圾回收等)

引言

栈结构很简单,我们可以通过数组就能模拟出一个栈结构,但仅仅介绍栈结构就太不前端了,本节从栈结构开始延伸到浏览器中 JavaScript 运行机制,还有存储机制上用到的栈结构及相关数据结构,一文吃透所有的前端栈知识。

以后再提到栈时,我们不再仅限于 LIFO 了,而是一个有深度的栈。

这部分是前端进阶资深必备,如果你想打造高性能的前端应用,也需要了解这块,同时它也是面试的常见考察点。

理解栈对于我们理解 JavaScript 语言至关重要,本文主要从以下几个方面介绍栈:

  • 首先介绍栈及代码实现
  • 介绍 JavaScript 运行机制及栈在其中的应用
  • 详细介绍调用栈及我们开发中如何利用调用栈
  • JS 内存机制:栈(基本类型、引用类型地址)与堆(引用类型数据)
  • 最后来一份总结与字节&leetcode刷题,实现最小栈

本节吃透栈原理,之后几天会每日一题,刷透栈题目,下面进入正文吧?

一、 栈

栈是一种遵从后进先出 (LIFO / Last In First Out) 原则的有序集合,它的结构类似如下:

栈的操作主要有: push(e) (进栈)、 pop() (出栈)、 isEmpty() (判断是否是空栈)、 size() (栈大小),以及 clear() 清空栈,具体实现也很简单。

二、代码实现

function Stack() {
  let items = []
  this.push = function(e) { 
    items.push(e) 
  }
  this.pop = function() { 
    return items.pop() 
  }
  this.isEmpty = function() { 
    return items.length === 0 
  }
  this.size = function() { 
    return items.length 
  }
  this.clear = function() { 
    items = [] 
  }
}

查找:从栈头开始查找,时间复杂度为 O(n)

插入或删除:进栈与出栈的时间复杂度为 O(1)

三、浏览器中 JS 运行机制

我们知道 JavaScript 是单线程的,所谓单线程,是指在 JavaScript 引擎中负责解释和执行 JavaScript 代码的线程唯一,同一时间上只能执行一件任务。

为什么是单线程的喃?这是因为 JavaScript 可以修改 DOM 结构,如果 JavaScript 引擎线程不是单线程的,那么可以同时执行多段 JavaScript,如果这多段 JavaScript 都修改 DOM,那么就会出现 DOM 冲突。

为了避免 DOM 渲染的冲突,可以采用单线程或者死锁,JavaScript 采用了单线程方案。

但单线程有一个问题:如果任务队列里有一个任务耗时很长,导致这个任务后面的任务一直排队等待,就会发生页面卡死,严重影响用户体验。

为了解决这个问题,JavaScript 将任务的执行模式分为两种:同步和异步。

同步

// 同步任务
let a = 1
console.log(a) // 1

异步

// 异步任务
setTimeout(() => {
    console.log('时间到')
}, 1000)

同步任务都在主线程(这里的主线程就是 JavaScript 引擎线程)上执行,会形成一个 调用栈 ,又称 执行栈

除了主线程外,还有一个任务队列(也称消息队列),用于管理异步任务的 事件回调 ,在 调用栈 的任务执行完毕之后,系统会检查任务队列,看是否有可以执行的异步任务。

注意:任务队列存放的是异步任务的事件回调

例如上例:

setTimeout(() => {
    console.log('时间到')
}, 1000)

在执行这段代码时,并不会立刻打印 ,只有定时结束后(1s)才打印。 setTimeout 本身是同步执行的,放入任务队列的是它的回调函数。

下面我们重点看一下主线程上的调用栈。

四、调用栈

我们从以下两个方面介绍调用栈:

  • 调用栈的用来做什么
  • 在开发中,如何利用调用栈

1. 调用栈的职责

我们知道,在 JavaScript 中有很多函数,经常会出现一个函数调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种栈结构

那么它是如何去管理函数调用关系喃?我们举例说明:

var a = 1
function add(a) {
  var b = 2
  let c = 3
  return a + b + c
}

// 函数调用
add(a)

这段代码很简单,就是创建了一个 add 函数,然后调用了它。

下面我们就一步步的介绍整个函数调用执行的过程。

在执行这段代码之前,JavaScript 引擎会先创建一个全局执行上下文,包含所有已声明的函数与变量:

1584626593458-11e5d674-2ace-4209-a2d3-9a4484979a05.png

从图中可以看出,代码中的全局变量 a 及函数 add 保存在变量环境中。

执行上下文准备好后,开始执行全局代码,首先执行 a = 1 的赋值操作,

1584626811363-aa3a9f6b-5abd-4100-87e2-659847d04500.png

赋值完成后 a 的值由 undefined 变为 1,然后执行 add 函数,JavaScript 判断出这是一个函数调用,然后执行以下操作:

  • 首先,从全局执行上下文中,取出 add 函数代码
  • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码,并将执行上下文压入栈中

1584628991777-0edc0564-b07f-46b4-9590-d038e948bb69.png

  • 然后,执行代码,返回结果,并将 add 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。

1584626811363-aa3a9f6b-5abd-4100-87e2-659847d04500.png

至此,整个函数调用执行结束了。

所以说,调用栈是 JavaScript 用来管理函数执行上下文的一种数据结构,它记录了当前函数执行的位置,哪个函数正在被执行。 如果我们执行一个函数,就会为函数创建执行上下文并放入栈顶。 如果我们从函数返回,就将它的执行上下文从栈顶弹出。 也可以说调用栈是用来管理这种执行上下文的栈,或称执行上下文栈(执行栈)

2. 懂调用栈的开发人员有哪些优势

栈溢出

在我们执行 JavaScript 代码的时候,有时会出现栈溢出的情况:

1584543285401-25e8a004-f729-44a0-9ac6-70e0a564285d.png

上图就是一个典型的栈溢出,那为什么会出现这种错误喃?

我们知道调用栈是用来管理执行上下文的一种数据结构,它是有大小的,当入栈的上下文过多的时候,它就会报栈溢出,例如:

function add() {
  return 1 + add()
}

add()

add 函数不断的递归,不断的入栈,调用栈的容量有限,它就溢出了,所以,我们日常的开发中,一定要注意此类代码的出现。

在浏览器中获取调用栈信息

两种方式,一种是断点调试,这种很简单,我们日常开发中都用过。

一种是 console.trace()

function sum(){
  return add()
}
function add() {
  console.trace()
  return 1
}

// 函数调用
sum()

1584629886522-8d65269b-680b-48d7-ba32-b49e0531ad5d.png

五、JS 内存机制:栈(基本类型、引言类型地址)与堆(引用类型数据)

在 JavaScript 开发日常中,前端人员很少有机会了解内存,但如果你想成为前端的专家,打造高性能的前端应用,你就需要了解这一块,同时它也是面试的常见考察点。

JavaScript 中的内存空间主要分为三种类型:

  • 代码空间:主要用来存放可执行代码
  • 栈空间:调用栈的存储空间就是栈空间。
  • 堆空间

代码空间主要用来存放可执行代码的。栈空间及堆空间主要用来存放数据的。接下来我们主要介绍栈空间及堆空间。

JavaScript 中的变量类型有 8 种,可分为两种:基本类型、引用类型

基本类型:

  • undefined
  • null
  • boolean
  • number
  • string
  • bigint
  • symbol

引用类型:

  • object

其中,基本类型是保存在栈内存中的简单数据段,而引用类型保存在堆内存中。

1. 栈空间

基本类型在内存中占有固定大小的空间,所以它们的值保存在栈空间,我们通过 按值访问

一般栈空间不会很大。

2. 堆空间

引用类型,值大小不固定,但指向值的指针大小(内存地址)是固定的,所以把对象放入堆中,将对象的地址放入栈中,这样,在调用栈中切换上下文时,只需要将指针下移到上个执行上下文的地址就可以了,同时保证了栈空间不会很大。

当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做 按引用访问

一般堆内存空间很大,能存放很多数据,但它内存分配与回收都需要花费一定的时间。

举个例子帮助理解一下:

var a = 1
function foo() {
  var b = 2
  var c = { name: 'an' }
}

// 函数调用
foo()

基本类型(栈空间)与引用类型(堆空间)的存储方式决定了:基本类型赋值是值赋值,而引用类型赋值是地址赋值。

// 值赋值
var a = 1
var b = a
a = 2
console.log(b) 
// 1
// b 不变

// 地址赋值
var a1 = {name: 'an'}
var b1 = a1
a1.name = 'bottle'
console.log(b1)
// {name: "bottle"}
// b1 值改变

3. 垃圾回收

JavaScript 中的垃圾数据都是由垃圾回收器自动回收的,不需要手动释放。所以大部分的开发人员并不了解垃圾回收,但这部分也是前端进阶资深必备!

回收栈空间

在 JavaScript 执行代码时,主线程上会存在 ESP 指针,用来指向调用栈中当前正在执行的上下文,如下图,当前正在执行 foo 函数:

foo 函数执行完成后,ESP 向下指向全局执行上下文,此时需要销毁 foo 函数。

怎么销毁喃?

当 ESP 指针指向全局执行上下文,foo 函数执行上下文已经是无效的了,当有新的执行上下文进来时,可以直接覆盖这块内存空间。

即:JavaScript 引擎通过向下移动 ESP 指针来销毁存放在栈空间中的执行上下文。

回收堆空间

V8 中把堆分成新生代与老生代两个区域:

  • 新生代:用来存放生存周期较短的小对象,一般只支持1~8M的容量
  • 老生代:用来存放生存周期较长的对象或大对象

V8 对这两块使用了不同的回收器:

  • 新生代使用副垃圾回收器
  • 老生代使用主垃圾回收器

其实无论哪种垃圾回收器,都采用了同样的流程(三步走):

  • 标记: 标记堆空间中的活动对象(正在使用)与非活动对象(可回收)
  • 垃圾清理: 回收非活动对象所占用的内存空间
  • 内存整理: 当进行频繁的垃圾回收时,内存中可能存在大量不连续的内存碎片,当需要分配一个需要占用较大连续内存空间的对象时,可能存在内存不足的现象,所以,这时就需要整理这些内存碎片。

副垃圾回收器与主垃圾回收器虽然都采用同样的流程,但使用的回收策略与算法是不同的。

副垃圾回收器

它采用 Scavenge 算法及对象晋升策略来进行垃圾回收

所谓 Scavenge 算法,即把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,如下图所示:

新加入的对象都加入对象区域,当对象区满的时候,就执行一次垃圾回收,执行流程如下:

  • 标记:首先要对区域内的对象进行标记(活动对象、非活动对象)
  • 垃圾清理:然后进行垃圾清理:将对象区的活动对象复制到空闲区域,并进行有序的排列,当复制完成后,对象区域与空闲区域进行翻转,空闲区域晋升为对象区域,对象区域为空闲区域

翻转后,对象区域是没有碎片的,此时不需要进行第三步(内存整理了)

但,新生代区域很小的,一般1~8M的容量,所以它很容易满,所以,JavaScript 引擎采用对象晋升策略来处理,即只要对象经过两次垃圾回收之后依然继续存活,就会被晋升到老生代区域中。

主垃圾回收器

老生代区域里除了存在从新生代晋升来的存活时间久的对象,当遇到大对象时,大对象也会直接分配到老生代。

所以主垃圾回收器主要保存存活久的或占用空间大的对象,此时采用 Scavenge 算法就不合适了。V8 中主垃圾回收器主要采用标记-清除法进行垃圾回收。

主要流程如下:

  • 标记:遍历调用栈,看老生代区域堆中的对象是否被引用,被引用的对象标记为活动对象,没有被引用的对象(待清理)标记为垃圾数据。
  • 垃圾清理:将所有垃圾数据清理掉
  • 内存整理:标记-整理策略,将活动对象整理到一起

增量标记

V8 浏览器会自动执行垃圾回收,但由于 JavaScript 也是运行在主线程上的,一旦执行垃圾回收,就要打断 JavaScript 的运行,可能会或多或少的造成页面的卡顿,影响用户体验,所以 V8 决定采用增量 标记算法回收:

即把垃圾回收拆成一个个小任务,穿插在 JavaScript 中执行。

六、总结

本节从栈结构开始介绍,满足后进先出 (LIFO) 原则的有序集合,然后通过数组实现了一个栈。

接着介绍浏览器环境下 JavaScript 的异步执行机制,即事件循环机制, JavaScript 主线程不断的循环往复的从任务队列中读取任务(异步事件回调),放入调用栈中执行。调用栈又称执行上下文栈(执行栈),是用来管理函数执行上下文的栈结构。

JavaScript 的存储机制分为代码空间、栈空间以及堆空间,代码空间用于存放可执行代码,栈空间用于存放基本类型数据和引用类型地址,堆空间用于存放引用类型数据,当调用栈中执行完成一个执行上下文时,需要进行垃圾回收该上下文以及相关数据空间,存放在栈空间上的数据通过 ESP 指针来回收,存放在堆空间的数据通过副垃圾回收器(新生代)与主垃圾回收器(老生代)来回收。

聊聊就跑远了?‍♀️,但都是前端进阶必会,接下来我们开始刷栈题目吧!!!每日一刷,进阶前端与算法⛽️⛽️⛽️,来道简单的吧!

七、字节&leetcode155:最小栈(包含getMin函数的栈)

设计一个支持 pushpoptop 操作,并能在常数时间内检索到最小元素的栈。

  • push(x) —— 将元素 x 推入栈中。
  • pop() —— 删除栈顶的元素。
  • top() —— 获取栈顶元素。
  • getMin() —— 检索栈中的最小元素。

示例:

MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(0);
minStack.push(-3);
minStack.getMin();   --> 返回 -3.
minStack.pop();
minStack.top();      --> 返回 0.
minStack.getMin();   --> 返回 -2.

欢迎将答案提交到 https://github.com/sisterAn/J...,让更多人看到,瓶子君也会在明日放上自己的解答。

八、参考资料

浏览器工作原理与实践(极客时间)

九、认识更多的前端道友,一起进阶前端开发

欢迎关注「前端瓶子君」,回复「算法」自动加入,从0到1构建完整的数据结构与算法体系!

在这里,瓶子君不仅介绍算法,还将算法与前端各个领域进行结合,包括浏览器、HTTP、V8、React、Vue源码等。

在这里,你可以每天学习一道大厂算法题(阿里、腾讯、百度、字节等等)或 leetcode,瓶子君都会在第二天解答哟!

⬆️ 扫码关注公众号「前端瓶子君」,回复「算法」即可自动加入 ???

查看原文

追寻 发布了文章 · 2020-09-08

前后端分离应用(单应用/多应用)docker部署

本文主要介绍了如何使用docker部署前后端分离项目,通过docker-compose工具(为什么不用docker run方式?这里不赘述)编排镜像,并基于镜像创建容器、部署应用到容器。
源码地址:
单应用app
多应用app
下面的操作部署基于单应用app(多应用app docker部署类似);

  1. 环境准备
    docker,docker环境的准备及搭建不在此介绍,请参考相关资料。
  2. 项目介绍

为了简单易懂的介绍docker容器部署,这里前后端应用都足够简单。前端部分不涉及webpack构建打包,后端项目只是一个express服务,前端代码部署在nginx docker容器中,后端代码部署在后端服务docker容器中,前端页面调用了后端的接口,通过nginx的反向代理及接口转发实现。
前端项目:html + jquery
后端项目:node + express
后端项目通过docker构建时会生产对应的镜像,这一步需要对应的Dockerfile文件:

FROM node:10
RUN mkdir -p /home/www/backend
WORKDIR /home/www/backend
COPY ./backend /home/www/backend
RUN npm install
EXPOSE 3001

这里需要说明下,为何前端项目不需要Dockerfile文件,因为前端代码部署在nginx中,nginx在docker生态中有对应的nginx镜像,只需要在docker-compose.yml中指明前端服务的image为nginx即可。
nginx配置

 ******* 此处省略了很多代码  *******

 http {
   upstream backend {
     server backend:3001;
   }

   ******* 此处省略了很多代码  *******

   server {
     listen 80;
     server_name localhost;
     location / {
        index index.html index.htm;  #添加属性。
        root /usr/share/nginx/html;  #站点目录
     }
     location /api/ {
        proxy_pass http://backend;
     }
     error_page  500 502 503 504 /50x.html;
     location = /50x.html {
       root  /usr/share/nginx/html;
     }
   }
 }

docker-compose.yml:

version: '3.1'
services:
  backend:                                  # 后端express容器
    container_name: backend
    restart: always
    build: ./     # 指定设定上下文根目录,然后以该目录为准指定Dockerfile
#    networks:
#      - myweb
    ports:                              # 映射端口
      - 3001:3001
    command:  "npm start" # 容器创建后执行命令

  frontend:             # 前端容器(运行nginx中的 frontend 项目)
    container_name: frontend
    image: nginx
    restart: always
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf #挂载nginx配置
      - ./frontend/dist:/usr/share/nginx/html/  #挂载项目
#    networks:
#      - myweb

    ports:
      - 80:80                     # 映射端口

    depends_on:     # 依赖于api容器,被依赖容器启动后此web容器才可启动
      - backend

# networks:
#  myweb:
#    driver: bridge

  1. 镜像构建
docker-compose build

构建完成后:可以看到控制台日志:

**deMacBook-Pro:docker_app_001 **$ docker-compose build
frontend uses an image, skipping
Building backend
Step 1/6 : FROM node:10
 ---> 1cc99e24ab2d
Step 2/6 : RUN mkdir -p /home/www/backend
 ---> Running in 3b29d2410617
Removing intermediate container 3b29d2410617
 ---> 8e6c1d9bbcce
Step 3/6 : WORKDIR /home/www/backend
 ---> Running in f28d37b6e1ad
Removing intermediate container f28d37b6e1ad
 ---> 60ddff92496d
Step 4/6 : COPY ./backend /home/www/backend
 ---> 2659b32b501a
Step 5/6 : RUN npm install
 ---> Running in fdd53d145a98
npm WARN backend@1.0.0 No description
npm WARN backend@1.0.0 No repository field.

audited 50 packages in 0.66s
found 0 vulnerabilities

Removing intermediate container fdd53d145a98
 ---> ef993955fc5a
Step 6/6 : EXPOSE 3001
 ---> Running in fb5e73ddbbd4
Removing intermediate container fb5e73ddbbd4
 ---> 8247b1d879cc
Successfully built 8247b1d879cc
Successfully tagged docker_app_001_bak_backend:latest
**deMacBook-Pro:docker_app_001_bak hy$ 
**deMacBook-Pro:docker_app_001 **$ docker images
REPOSITORY                                      TAG                 IMAGE ID            CREATED             SIZE
docker_app_001_backend                      latest              8247b1d879cc        17 seconds ago      914MB
node                                            10                  1cc99e24ab2d        6 days ago          912MB
nginx                                           latest              4bb46517cac3        3 weeks ago         133MB
**deMacBook-Pro:docker_app_001 **$ 
  1. 启动容器
docker-compose up
**deMacBook-Pro:docker_app_001 **$ docker-compose up
Creating backend ... done
Creating frontend ... done
Attaching to backend, frontend
frontend    | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
frontend    | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
backend     | 
backend     | > backend@1.0.0 start /home/www/backend
backend     | > node index.js
backend     | 
frontend    | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
frontend    | 10-listen-on-ipv6-by-default.sh: Getting the checksum of /etc/nginx/conf.d/default.conf
frontend    | 10-listen-on-ipv6-by-default.sh: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
frontend    | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
frontend    | /docker-entrypoint.sh: Configuration complete; ready for start up
backend     | listening on *:3001

查看容器实例:

**deMacBook-Pro:docker_app_001 hy$ docker-compose ps

 Name Command State Ports 

--------------------------------------------------------------------------

backend docker-entrypoint.sh npm start Up 0.0.0.0:3001->3001/tcp

frontend /docker-entrypoint.sh ngin ... Up 0.0.0.0:80->80/tcp 

**deMacBook-Pro:docker_app_001 hy$
  1. 验证服务
http://localhost

image.png

查看原文

赞 0 收藏 0 评论 0

追寻 赞了文章 · 2020-09-03

使用Dockerfile部署nodejs服务

初始化Dockerfile

假设我们的项目名为express,在express项目中创建编辑Dockerfile文件:

$ vim Dockerfile

FROM node:latest

RUN mkdir -p /home/www/express
WORKDIR /home/www/express

COPY . /home/www/express

RUN npm install

EXPOSE 3000

ENTRYPOINT ["npm", "run"]
CMD ["start"]

这个文件包含了以下命令:

  • FROM node:latest - 指定使用最新版本的node基础镜像
  • RUN mkdir -p /home/www/express - 在容器内创建/home/www/express目录
  • WORKDIR /home/www/express - 将容器内工作目录设置为/home/www/express
  • COPY . /home/www/express - 将宿主机当前目录下内容复制到镜像/home/www/express目录下
  • RUN npm install - npm install安装应用所需的NPM包
  • EXPOSE 3000 - 对外开放容器的3000端口
  • ENTRYPOINT ["npm", "run"] - 容器启动后执行的命令。不可被docker run提供的参数覆盖
  • CMD ["start"] - 在容器启动时,执行的命令,可被docker run提供的参数覆盖

构建镜像

编写完Dockerfile文件后,就可以通过docker build命令来构建镜像:

$ sudo docker build -t test/express .

我们通过-t参数,将镜像命名为test/express。构建过程类似如下:

Sending build context to Docker daemon  29.7 kB
Step 1/8 : FROM registry.src.followme.com:5000/node:v1
 ---> c99c549e8227
Step 2/8 : RUN mkdir -p /home/www/express-app
 ---> Running in 8be9a90629b0
 ---> b9f584851225
Removing intermediate container 8be9a90629b0
Step 3/8 : WORKDIR /home/www/express-app
 ---> 5072c31f9dd9
Removing intermediate container e9dbf4ce3d8b
Step 4/8 : COPY . /home/www/express-app
 ---> a4d1725f15ed
Removing intermediate container 30aa49765015
Step 5/8 : RUN yarn
 ---> Running in f181c243deaa
yarn install v1.3.2
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
Done in 9.46s.
 ---> d390931d73e6
Removing intermediate container f181c243deaa
Step 6/8 : EXPOSE 3000
 ---> Running in 94101ab38864
 ---> 43199a8a5a90
Removing intermediate container 94101ab38864
Step 7/8 : ENTRYPOINT npm run
 ---> Running in 80b1318962cf
 ---> 6b203c50e855
Removing intermediate container 80b1318962cf
Step 8/8 : CMD start
 ---> Running in a9909e537f59
 ---> d56eae48377c
Removing intermediate container a9909e537f59
Successfully built d56eae48377c

运行容器

镜像构建完成后,可以通过所构建的镜像创建/运行容器,从而实现express应用的 Docker 化部暑。

使用tets/express镜像运行一个容器:

$ sudo docker run -d --name experss-app -p 3000:3000 test/express

在以上操作中,我们通过test/express镜像运行了容器,并将容器命名为experss-app。运行容器,我们还指定了-d参数,该参数使容器以后台的方式运行。而-p参数将宿主机的3000端口映射到了容器的3000端口。运行容器后,可以通过docker ps命令看到运行中的容器。此时可通过localhost:3000访问服务。

查看原文

赞 6 收藏 5 评论 0

追寻 关注了专栏 · 2020-08-24

网易云音乐大前端团队

网易云音乐大前端技术团队专栏

关注 1631

追寻 赞了文章 · 2020-06-09

flutter Future的正确用法

在flutter中经常会用到异步任务,dart中异步任务异步处理是用Future来处理,那么如何实现用Future来处理一个异步操作呢,网上的Future教程、原理都是说说原理,但是没有什么干货,没有教你如何去用。今天看了下源码,终于知道如何去使用Future了。其实和javaScript的promise很像,但是promise的参数函数的参数提供了resolve,reject方法去操控状态,
那么Future难道就没有吗?原来dart:async提供了Completer类,通过实例这个类生成Future,同时在用这个实例去控制生成的future的成功或者失败的状态。代码如下:


Future openImagePicker () {
    Complete completer = new Completer();
   
    // ImagePicker 是一个图片选择插件
    ImagePicker.singlePicker(
       context, 
       singleCallback: (data) {
         completer.complete(data);
       },
       failCallback:(err) {
         completer.catchError(err); 
       }
    );
     
    return completer.future;
}

// 使用
openImagePicker().then((data) {}).catchError((err){});

返回completer生成的future,通过completer.complete方法去控制completer.future的成功状态,通过completer.catchError去控制completer.future的失败状态。
completer.complete和completer.catchError方法的参数就是future的返回值。

completer.complete就相当于promise的resolve,completer.catchError相当于promise中的reject()

通过Completer可以得心应手的控制Future的状态。

查看原文

赞 6 收藏 2 评论 2

追寻 收藏了文章 · 2020-06-02

flutter中的生命周期

前言

和其他的视图框架比如android的Activity一样,flutter中的视图Widget也存在生命周期,生命周期的回调函数提现在了State上面。理解flutter的生命周期,对我们写出一个合理的控件至关重要。组件State的生命周期整理如下图所示:

大致可以看成三个阶段

  • 初始化(插入渲染树)
  • 状态改变(在渲染树中存在)
  • 销毁(从渲染树种移除)

各个函数

构造函数

这个函数不属于生命周期,因为这个时候State的widget属性为空,如果要在构造函数中访问widget的属性是行不通的。但是构造函数必然是要第一个调用的。

initState

/// Called when this object is inserted into the tree.

当插入渲染树的时候调用,这个函数在生命周期中只调用一次。这里可以做一些初始化工作,比如初始化State的变量。

didChangeDependencies

/// Called when a dependency of this [State] object changes.

这个函数会紧跟在initState之后调用,并且可以调用BuildContext.inheritFromWidgetOfExactType,那么BuildContext.inheritFromWidgetOfExactType的使用场景是什么呢?最经典的应用场景是

new DefaultTabController(length: 3, child: new TabBar(
      tabs: [ "主页","订单","我的" ]
      .map( (data)=>new Text(data) ).toList(),

TabBar本来需要定义一个TabController,但是在外面套一层DefaultTabController就不需要定义TabContrller了,看下源码:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _updateTabController();
    _initIndicatorPainter();
  }

void _updateTabController() {
    final TabController newController = widget.controller ?? DefaultTabController.of(context);
    ...

注意到这里DefaultTabController.of(context)

 static TabController of(BuildContext context) {
    final _TabControllerScope scope = context.inheritFromWidgetOfExactType(_TabControllerScope);
    return scope?.controller;
  }

实际上就是调用BuildContext.inheritFromWidgetOfExactType,也就说在didChangeDependencies中,可以跨组件拿到数据。

didUpdateWidget

/// Called whenever the widget configuration changes.

当组件的状态改变的时候就会调用didUpdateWidget,比如调用了setState.

实际上这里flutter框架会创建一个新的Widget,绑定本State,并在这个函数中传递老的Widget。

这个函数一般用于比较新、老Widget,看看哪些属性改变了,并对State做一些调整。

需要注意的是,涉及到controller的变更,需要在这个函数中移除老的controller的监听,并创建新controller的监听。

比如还是TabBar:

deactivate

/// Called when this object is removed from the tree.

在dispose之前,会调用这个函数。

dispose

/// Called when this object is removed from the tree permanently.

一旦到这个阶段,组件就要被销毁了,这个函数一般会移除监听,清理环境。

还是TabBar:

总结一下

阶段调用次数是否支持setState
构造函数1
initState1无效(使用setState和不使用一样)
didChangeDependencies>=1无效
didUpdateWidget>=1无效
deactivate>=1
dispose1
查看原文

追寻 赞了文章 · 2020-05-15

AST抽象语法树——最基础的javascript重点知识,99%的人根本不了解

抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。

本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能

Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。

我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?

AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。

事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

人生第一次拆解Javascript

小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:

我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...

当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。

image

通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。

现在,我们拆解一个简单的add函数

function add(a, b) {
    return a + b
}

首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。

用力拆开,它成了三块:

  • 一个id,就是它的名字,即add
  • 两个params,就是它的参数,即[a, b]
  • 一块body,也就是大括号内的一堆东西

add没办法继续拆下去了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志,就像人的姓名一样。

{
    name: 'add'
    type: 'identifier'
    ...
}

params继续拆下去,其实是两个Identifier组成的数组。之后也没办法拆下去了。

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下来,我们继续拆开body
我们发现,body其实是一个BlockStatement(块状域)对象,用来表示是{return a + b}

打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b

继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b

继续打开BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 里面装的,是Identifier对象 a
  • right 里面装的,是Identifer对象 b

就这样,我们把一个简单的add函数拆解完毕,用图表示就是

image

看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

请查看 AST对象文档

送给你的AST螺丝刀:recast

输入命令:

npm i recast -S

你即可获得一把操纵语法树的螺丝刀

接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

parse.js

// 给你一把"螺丝刀"——recast
const recast = require("recast");

// 你的"机器"——一段代码
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
  `
  function add(a, b) {
    return a +
      // 有什么奇怪的东西混进来了
      b
  }
  `
// 用螺丝刀解析机器
const ast = recast.parse(code);

// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add  = ast.program.body[0]

console.log(add)

输入node parse.js你可以查看到add函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

FunctionDeclaration{
    type: 'FunctionDeclaration',
    id: ...
    params: ...
    body: ...
}

你也可以继续使用console.log透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

recast.types.builders 制作模具

一个机器,你只会拆开重装,不算本事。

拆开了,还能改装,才算上得了台面。

recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

最简单的例子,我们想把之前的function add(a, b){...}声明,改成匿名函数式声明const add = function(a ,b){...}

如何改装?

第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

这样,就创建好了const add = function(){}的AST对象。

在之前的parse.js代码之后,加入以下代码

// 引入变量声明,变量符号,函数声明三种“模具”
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders

// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;

console.log(output)

可以看到,我们打印出了

const add = function(a, b) {
  return a +
    // 有什么奇怪的东西混进来了
    b
};

最后一行

const output = recast.print(ast).code;

其实是recast.parse的逆向过程,具体公式为

recast.print(recast.parse(source)).code === source

打印出来还保留着“原装”的函数内容,连注释都没有变。

我们其实也可以打印出美化格式的代码段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

输出为

const add = function(a, b) {
  return a + b;
};
现在,你是不是已经产生了“我可以通过AST树生成任何js代码”的幻觉?

我郑重告诉你,这不是幻觉。

实战进阶:命令行修改js文件

除了parse/print/builder以外,Recast的三项主要功能:

  • run: 通过命令行读取js文件,并转化成ast以供处理。
  • tnt: 通过assert()和check(),可以验证ast对象的类型。
  • visit: 遍历ast树,获取有效的AST对象并进行更改。

我们通过一个系列小务来学习全部的recast工具库:

创建一个用来示例文件,假设是demo.js

demo.js

function add(a, b) {
  return a + b
}

function sub(a, b) {
  return a - b
}

function commonDivision(a, b) {
  while (b !== 0) {
    if (a > b) {
      a = sub(a, b)
    } else {
      b = sub(b, a)
    }
  }
  return a
}

recast.run —— 命令行文件读取

新建一个名为read.js的文件,写入
read.js

recast.run( function(ast, printSource){
    printSource(ast)
})

命令行输入

node read demo.js

我们查以看到js文件内容打印在了控制台上。

我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

recast.visit —— AST节点遍历

read.js

#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit将AST对象内的节点进行逐个遍历。

注意

  • 你想操作函数声明,就使用visitFunctionDelaration遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
  • 通过node可以取到AST对象
  • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        printSource(node)
        this.traverse(path)
      }
    })
});

调试时,如果你想输出AST对象,可以console.log(node)

如果你想输出AST对象对应的源码,可以printSource(node)

命令行输入`
node read demo.js`进行测试。

#!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

TNT —— 判断AST对象类型

TNT,即recast.types.namedTypes,就像它的名字一样火爆,它用来判断AST对象是否为指定的类型。

TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

TNT.Node.check(),则可以判断类型是否一致,并输出False和True

上述Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判断是否为ExpressionStatement,正确则输出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('这是一个ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

命令行输入`
node read demo.js`进行测试。

实战:用AST修改源码,导出全部方法

exportific.js

现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

function add (a, b) {
    return a + b
}

想改变为

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题

查询AST对象文档

首先,我们先用builders凭空实现一个键头函数

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement
} = recast.types.builders

recast.run(function(ast, printSource) {
  // 一个块级域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一个键头函数 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add赋值为键头函数  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add赋值为键头函数  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

使用node exportific demo.js运行可查看结果。

接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

另外,我们需要注意,在commonDivision函数内,引用了sub函数,应改写成exports.sub

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

recast.run(function (ast, printSource) {
  // 用来保存遍历到的全部函数名
  let funcIds = []
  recast.types.visit(ast, {
    // 遍历所有的函数定义
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      // 保存函数名
      funcIds.push(funcName.name)
      // 这是上一步推导出来的ast结构体
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 将原来函数的ast结构体,替换成推导ast结构体
      path.replace(rep)
      // 停止遍历
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍历所有的函数调用
    visitCallExpression(path){
      const node = path.node;
      // 如果函数调用出现在函数定义中,则修改ast结构
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 停止遍历
      return false
    }
  })
  // 打印修改后的ast源码
  printSource(ast)
})

一步到位,发一个最简单的exportific前端工具

上面讲了那么多,仍然只体现在理论阶段。

但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。

以下代码添加作了两个小改动

  1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
  2. 将之前代码最后的 printSource(ast)替换成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)

//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    采用commonjs规则,将.js文件内所有函数修改为导出形式。

    选项: -r  或 --rewrite 可直接覆盖原有文件
    `)
  process.exit(0)
}

// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')

// 获取文件名
const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].includes(item)
})

// 只处理一个文件
let filename = clearFileArg[0]

const writeASTFile = function(ast, filename, rewriteMode){
  const newCode = recast.print(ast).code
  if(!rewriteMode){
    // 非覆盖模式下,将新文件写入*.export.js下
    filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 将新代码写入文件
  fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}


recast.run(function (ast, printSource) {
  let funcIds = []
  recast.types.visit(ast, {
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      funcIds.push(funcName.name)
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      path.replace(rep)
      return false
    }
  })


  recast.types.visit(ast, {
    visitCallExpression(path){
      const node = path.node;
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

现在尝试一下

node exportific demo.js

已经可以在当前目录下找到源码变更后的demo.export.js文件了。

npm发包

编辑一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改写源码中的函数为可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

这时,输入npm link 就在本地生成了一个exportific命令。

之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只需要发布npm包就行了。

同时,一定要注意exportific.js文件头有

#!/usr/bin/env node

否则在使用时将报错。

接下来,正式发布npm包!

如果你已经有了npm 帐号,请使用npm login登录

如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

然后,输入
npm publish

没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

npm i exportific -g

全局安装这一个插件。

提示:==在试验教程时,请不要和我的包重名,修改一下发包名称。==

结语

我们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你可以通过它批量构建任何javascript代码!

童年时,这个世界充满了新奇的玩具,再普通的东西在你眼中都如同至宝。如今,计算机语言就是你手中的大玩具,一段段AST对象的拆分组装,构建出我们所生活的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的仍然是那个午后的少年,永远有无数新奇等你发现,永远有无数梦想等你构建。

github地址:https://github.com/wanthering...

image

查看原文

赞 736 收藏 491 评论 22

追寻 收藏了文章 · 2020-05-14

AST抽象语法树——最基础的javascript重点知识,99%的人根本不了解

抽象语法树(AST),是一个非常基础而重要的知识点,但国内的文档却几乎一片空白。

本文将带大家从底层了解AST,并且通过发布一个小型前端工具,来带大家了解AST的强大功能

Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。

我们对javascript生态了如指掌,却常忽视javascript本身。这台机器,究竟是哪些零部件在支持着它运行?

AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端自动化的工具,或者有批量修改源码的工程需求,那你必须懂得AST。AST的能力十分强大,且能帮你真正吃透javascript的语言精髓。

事实上,在javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

人生第一次拆解Javascript

小时候,当我们拿到一个螺丝刀和一台机器,人生中最令人怀念的梦幻时刻便开始了:

我们把机器,拆成一个一个小零件,一个个齿轮与螺钉,用巧妙的机械原理衔接在一起...

当我们把它重新照不同的方式组装起来,这时,机器重新又跑动了起来——世界在你眼中如获新生。

image

通过抽象语法树解析,我们可以像童年时拆解玩具一样,透视Javascript这台机器的运转,并且重新按着你的意愿来组装。

现在,我们拆解一个简单的add函数

function add(a, b) {
    return a + b
}

首先,我们拿到的这个语法块,是一个FunctionDeclaration(函数定义)对象。

用力拆开,它成了三块:

  • 一个id,就是它的名字,即add
  • 两个params,就是它的参数,即[a, b]
  • 一块body,也就是大括号内的一堆东西

add没办法继续拆下去了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志,就像人的姓名一样。

{
    name: 'add'
    type: 'identifier'
    ...
}

params继续拆下去,其实是两个Identifier组成的数组。之后也没办法拆下去了。

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下来,我们继续拆开body
我们发现,body其实是一个BlockStatement(块状域)对象,用来表示是{return a + b}

打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b

继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b

继续打开BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 里面装的,是Identifier对象 a
  • right 里面装的,是Identifer对象 b

就这样,我们把一个简单的add函数拆解完毕,用图表示就是

image

看!抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

请查看 AST对象文档

送给你的AST螺丝刀:recast

输入命令:

npm i recast -S

你即可获得一把操纵语法树的螺丝刀

接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

parse.js

// 给你一把"螺丝刀"——recast
const recast = require("recast");

// 你的"机器"——一段代码
// 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
const code =
  `
  function add(a, b) {
    return a +
      // 有什么奇怪的东西混进来了
      b
  }
  `
// 用螺丝刀解析机器
const ast = recast.parse(code);

// ast可以处理很巨大的代码文件
// 但我们现在只需要代码块的第一个body,即add函数
const add  = ast.program.body[0]

console.log(add)

输入node parse.js你可以查看到add函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

FunctionDeclaration{
    type: 'FunctionDeclaration',
    id: ...
    params: ...
    body: ...
}

你也可以继续使用console.log透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

recast.types.builders 制作模具

一个机器,你只会拆开重装,不算本事。

拆开了,还能改装,才算上得了台面。

recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

最简单的例子,我们想把之前的function add(a, b){...}声明,改成匿名函数式声明const add = function(a ,b){...}

如何改装?

第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

这样,就创建好了const add = function(){}的AST对象。

在之前的parse.js代码之后,加入以下代码

// 引入变量声明,变量符号,函数声明三种“模具”
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders

// 将准备好的组件置入模具,并组装回原来的ast对象。
ast.program.body[0] = variableDeclaration("const", [
  variableDeclarator(add.id, functionExpression(
    null, // Anonymize the function expression.
    add.params,
    add.body
  ))
]);

//将AST对象重新转回可以阅读的代码
const output = recast.print(ast).code;

console.log(output)

可以看到,我们打印出了

const add = function(a, b) {
  return a +
    // 有什么奇怪的东西混进来了
    b
};

最后一行

const output = recast.print(ast).code;

其实是recast.parse的逆向过程,具体公式为

recast.print(recast.parse(source)).code === source

打印出来还保留着“原装”的函数内容,连注释都没有变。

我们其实也可以打印出美化格式的代码段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

输出为

const add = function(a, b) {
  return a + b;
};
现在,你是不是已经产生了“我可以通过AST树生成任何js代码”的幻觉?

我郑重告诉你,这不是幻觉。

实战进阶:命令行修改js文件

除了parse/print/builder以外,Recast的三项主要功能:

  • run: 通过命令行读取js文件,并转化成ast以供处理。
  • tnt: 通过assert()和check(),可以验证ast对象的类型。
  • visit: 遍历ast树,获取有效的AST对象并进行更改。

我们通过一个系列小务来学习全部的recast工具库:

创建一个用来示例文件,假设是demo.js

demo.js

function add(a, b) {
  return a + b
}

function sub(a, b) {
  return a - b
}

function commonDivision(a, b) {
  while (b !== 0) {
    if (a > b) {
      a = sub(a, b)
    } else {
      b = sub(b, a)
    }
  }
  return a
}

recast.run —— 命令行文件读取

新建一个名为read.js的文件,写入
read.js

recast.run( function(ast, printSource){
    printSource(ast)
})

命令行输入

node read demo.js

我们查以看到js文件内容打印在了控制台上。

我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

recast.visit —— AST节点遍历

read.js

#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit将AST对象内的节点进行逐个遍历。

注意

  • 你想操作函数声明,就使用visitFunctionDelaration遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
  • 通过node可以取到AST对象
  • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
#!/usr/bin/env node
const recast  = require('recast')

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        printSource(node)
        this.traverse(path)
      }
    })
});

调试时,如果你想输出AST对象,可以console.log(node)

如果你想输出AST对象对应的源码,可以printSource(node)

命令行输入`
node read demo.js`进行测试。

#!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

TNT —— 判断AST对象类型

TNT,即recast.types.namedTypes,就像它的名字一样火爆,它用来判断AST对象是否为指定的类型。

TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

TNT.Node.check(),则可以判断类型是否一致,并输出False和True

上述Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判断是否为ExpressionStatement,正确则输出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('这是一个ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env node
const recast = require("recast");
const TNT = recast.types.namedTypes

recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

命令行输入`
node read demo.js`进行测试。

实战:用AST修改源码,导出全部方法

exportific.js

现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

function add (a, b) {
    return a + b
}

想改变为

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read读取文件、正则匹配替换文本、fs.write写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题

查询AST对象文档

首先,我们先用builders凭空实现一个键头函数

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement
} = recast.types.builders

recast.run(function(ast, printSource) {
  // 一个块级域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一个键头函数 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add赋值为键头函数  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add赋值为键头函数  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

使用node exportific demo.js运行可查看结果。

接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

另外,我们需要注意,在commonDivision函数内,引用了sub函数,应改写成exports.sub

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

recast.run(function (ast, printSource) {
  // 用来保存遍历到的全部函数名
  let funcIds = []
  recast.types.visit(ast, {
    // 遍历所有的函数定义
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      // 保存函数名
      funcIds.push(funcName.name)
      // 这是上一步推导出来的ast结构体
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 将原来函数的ast结构体,替换成推导ast结构体
      path.replace(rep)
      // 停止遍历
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍历所有的函数调用
    visitCallExpression(path){
      const node = path.node;
      // 如果函数调用出现在函数定义中,则修改ast结构
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 停止遍历
      return false
    }
  })
  // 打印修改后的ast源码
  printSource(ast)
})

一步到位,发一个最简单的exportific前端工具

上面讲了那么多,仍然只体现在理论阶段。

但通过简单的改写,就能通过recast制作成一个名为exportific的源码编辑工具。

以下代码添加作了两个小改动

  1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
  2. 将之前代码最后的 printSource(ast)替换成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env node
const recast = require("recast");
const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression
} = recast.types.builders

const fs = require('fs')
const path = require('path')
// 截取参数
const options = process.argv.slice(2)

//如果没有参数,或提供了-h 或--help选项,则打印帮助
if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    采用commonjs规则,将.js文件内所有函数修改为导出形式。

    选项: -r  或 --rewrite 可直接覆盖原有文件
    `)
  process.exit(0)
}

// 只要有-r 或--rewrite参数,则rewriteMode为true
let rewriteMode = options.includes('-r') || options.includes('--rewrite')

// 获取文件名
const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].includes(item)
})

// 只处理一个文件
let filename = clearFileArg[0]

const writeASTFile = function(ast, filename, rewriteMode){
  const newCode = recast.print(ast).code
  if(!rewriteMode){
    // 非覆盖模式下,将新文件写入*.export.js下
    filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 将新代码写入文件
  fs.writeFileSync(path.join(process.cwd(),filename),newCode)
}


recast.run(function (ast, printSource) {
  let funcIds = []
  recast.types.visit(ast, {
    visitFunctionDeclaration(path) {
      //获取遍历到的函数名、参数、块级域
      const node = path.node
      const funcName = node.id
      const params = node.params
      const body = node.body

      funcIds.push(funcName.name)
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      path.replace(rep)
      return false
    }
  })


  recast.types.visit(ast, {
    visitCallExpression(path){
      const node = path.node;
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

现在尝试一下

node exportific demo.js

已经可以在当前目录下找到源码变更后的demo.export.js文件了。

npm发包

编辑一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改写源码中的函数为可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

这时,输入npm link 就在本地生成了一个exportific命令。

之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只需要发布npm包就行了。

同时,一定要注意exportific.js文件头有

#!/usr/bin/env node

否则在使用时将报错。

接下来,正式发布npm包!

如果你已经有了npm 帐号,请使用npm login登录

如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

然后,输入
npm publish

没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

npm i exportific -g

全局安装这一个插件。

提示:==在试验教程时,请不要和我的包重名,修改一下发包名称。==

结语

我们对javascript再熟悉不过,但透过AST的视角,最普通的js语句,却焕发出精心动魄的美感。你可以通过它批量构建任何javascript代码!

童年时,这个世界充满了新奇的玩具,再普通的东西在你眼中都如同至宝。如今,计算机语言就是你手中的大玩具,一段段AST对象的拆分组装,构建出我们所生活的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的仍然是那个午后的少年,永远有无数新奇等你发现,永远有无数梦想等你构建。

github地址:https://github.com/wanthering...

image

查看原文

认证与成就

  • 获得 57 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-12-21
个人主页被 812 人浏览