加载中

Updated 23rd February 2018 - Chrome Canary now supports private fields behind an experimental flag.

JavaScript has had a lot of improvements lately with new syntax and features being added all the time. But some things don't change, everything is still an object, pretty much everything can be altered at runtime and there is no concept of public/private properties. But there are some tricks we can use to change some of this ourselves, in this post I am going to look at the various ways in which we can implement private properties.

2018年2月23日更新-Chrome Canary现在支持私有域和测试标识

 JavaScript最近有很多改进,新的语法和特性一直在添加。但有些事情不会改变,所有的东西仍然是一个对象,几乎所有的东西都可以在运行时改变,也没有公有/私有属性的概念。但是我们可以用一些技巧来改变这些,在这篇文章中,我将研究实现私有属性的各种方法

In 2015 JavaScript had classes introduced that provided a familiar way of working with objects for those coming from more classical C-based languages like Java and C#. It becomes quickly apparent though that these classes aren't quite like what you are used to - there are no modifiers for properties to control access and all properties need to be defined within functions.

So how can we go about protecting data that shouldn't change during runtime? Let's take a look at some options.

Throughout this post I will reuse an example class that is used to build a shape. Its width and height can only be set when initialised and provides a property to get the area. For more information on the get keyword used in these examples take a look at my last post on Getters and Setters

JavaScriopt 在 2015 年引入了“”这种大家都熟悉的面向对象方法,基于 C 语言的经典语言 Java 和 C# 就提供这种方法。不过很快大家就发现这些类并不是习惯的那样 —— 他们没有控制访问的属性修饰符,而且所有属性都必须定义在函数中。

那么,我们该如何保护那些不应该在运行期间被修改的数据呢?先来看一些办法。

这篇文章中我会使用一个创建图形的类作为示例。它的宽度和高度只能高度只能在初始化时设置,同时提供一个用于获取面积的属性。这些示例中用到了 get 关键字,你可以在我的文章 Getter 和 Setter 中了解到这一知识点

Naming convention

The first and most established method was to use a specific naming convention to indicate that a property should be treated as private. This usually had the property name prefixed with an underscore (e.g. _count). This didn't prevent the value from being access or modified but rather relied on an understanding between different developers that this value should be treated as off-limits.

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);    // 100
console.log(square._width);  // 10

WeakMap

For a slightly more restrictive option you can use a WeakMap to store all the private values in. This still doesn't prevent access to the data but it does separate it from the object the user interacts with. For this technique we set the key of the WeakMap to be the instance of the object the private properties belong to and we use a function (which we've called internal) to create or return an object that all properties will be stored within. This technique has the benefit of not having the private properties shown on the instance when iterating over the properties or when doing JSON.stringify but it relies on a WeakMap being available outside of the class itself which could be accessed and manipulated.

const map = new WeakMap();

// Create an object to store private values in per instance
const internal = obj => {
  if (!map.has(obj)) {
    map.set(obj, {});
  }
  return map.get(obj);
}

class Shape {
  constructor(width, height) {
    internal(this).width = width;
    internal(this).height = height;
  }
  get area() {
    return internal(this).width * internal(this).height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);      // 100
console.log(map.get(square));  // { height: 100, width: 100 }

命名规范

第一个方法是使用特定的命名来表示属性应该被视为私有,这是最成熟的方法。其常见作法是给属性名前缀一个下划线(比如 _count)。但这种方法不能阻止值被访问或被修改,它依赖于不同开发者之间的共识,公认这个值应该被禁止访问。

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);    // 100
console.log(square._width);  // 10

WeakMap

使用 WeakMap 保存私有值的方法限制性会稍微强一些。虽然这个方法仍然不能阻止访问数据,但它把私有值与用户交互对象隔离开了。在这种技巧中,我们把拥有私有属性的对象实例作为 WeakMap 的键,并使用一个函数(我们称为 internal)来创建或返回一个存储所有私有属性值的对象。这种技术的优点是在枚举对象属性或者使用 JSON.stringify 时不会把私有属性显示出来,但它依赖 WeakMap,而 WeakMap 对象类的作用域外仍然可以被访问到,也可以进行操作。

const map = new WeakMap();

// Create an object to store private values in per instance
const internal = obj => {
  if (!map.has(obj)) {
    map.set(obj, {});
  }
  return map.get(obj);
}

class Shape {
  constructor(width, height) {
    internal(this).width = width;
    internal(this).height = height;
  }
  get area() {
    return internal(this).width * internal(this).height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);      // 100
console.log(map.get(square));  // { height: 100, width: 100 }

Symbols

Symbols can be used similar to a WeakMap. Here we create a property on the instance using a Symbol as the key. This will prevent the property from being visible when iterating or when using JSON.stringify. This technique does require a symbol to be created for each private property though. You can still access the property from outside the class if you also have access to the symbol.

const widthSymbol = Symbol('width');
const heightSymbol = Symbol('height');

class Shape {
  constructor(width, height) {
    this[widthSymbol] = width;
    this[heightSymbol] = height;
  }
  get area() {
    return this[widthSymbol] * this[heightSymbol];
  }
}

const square = new Shape(10, 10);
console.log(square.area);         // 100
console.log(square.widthSymbol);  // undefined
console.log(square[widthSymbol]); // 10

Closure

All of the techniques shown so far still allow private properties to be accessed from outside the class, closures gives us a way of fixing that. Closures can be used along with a WeakMap or Symbols if you wish but work just as well with a standard JavaScript object too. The idea behind a closure is to encapsulate data within a function scope that is created when called but returns the result of a function from within, thus making the scope inaccessible from the outside.

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;
    }

    get area() {
      return this$.width * this$.height;
    }
  }

  return new Shape(...arguments);
}

const square = new Shape(10, 10);
console.log(square.area);  // 100
console.log(square.width); // undefined

There is a slight problem with this technique though, we now have two different Shape objects. The code will call and interact with the external Shape but the instance returned will be of the inner Shape. This might not be a big deal most of the time but it would cause square instanceof Shape to return false which could be a problem in your code.

Symbols

Symbol 的用法跟 WeakMap 类似。我们把 Symbol 当作实例属性的键来使用。这种方式也不会在枚举属性和 JSON.stringify 时呈现出来。这个技巧需要为每个私有属性创建 Symbol。不过,只要能访问到这些 Symbol 值,那么在类之外同样也能访问以它们为键的属性。

const widthSymbol = Symbol('width');
const heightSymbol = Symbol('height');

class Shape {
  constructor(width, height) {
    this[widthSymbol] = width;
    this[heightSymbol] = height;
  }
  get area() {
    return this[widthSymbol] * this[heightSymbol];
  }
}

const square = new Shape(10, 10);
console.log(square.area);         // 100
console.log(square.widthSymbol);  // undefined
console.log(square[widthSymbol]); // 10

闭包

前面提到的种种技巧都不能避免从类外部访问私有属性,这一问题可以使用闭包来解决。可以把闭包和 WeakMap 或者 Symbol 一起使用,当然也可以把闭包用于标准的 JavaScript 对象。闭包的原理是将数据封装在函数作用域内,这个作用域在函数调用时创建,从内部返回函数的结果,在外部访问不到。

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;
    }

    get area() {
      return this$.width * this$.height;
    }
  }

  return new Shape(...arguments);
}

const square = new Shape(10, 10);
console.log(square.area);  // 100
console.log(square.width); // undefined

不过这个技巧有一点小问题。假设我们有两个不同的 Shape 对象,代码调了外面那层 Shape(函数),但返回的实例却是内部 Shape(类)的实例。多数情况下可能没什么问题,但是它会导致 square instanceof Shape 返回 false,这就是代码中潜藏的问题。

A solution to this is to set the outer Shape as the prototype of the instance that is returned:

return Object.setPrototypeOf(new Shape(...arguments), this);

Unfortunately this isn't enough, updating only this line now leaves square.area as undefined. This is due to the way the getkeyword works behind the scenes. We can solve this by specifying the getter manually within the constructor.

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;

      Object.defineProperty(this, 'area', {
        get: function() {
          return this$.width * this$.height;
        }
      });
    }
  }

  return Object.setPrototypeOf(new Shape(...arguments), this);
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square.width);            // undefined
console.log(square instanceof Shape); // true

Alternatively, we can set the instances prototype to have this as its prototype allowing us to use both instanceof and get. In the example below we have a prototype chain of Object -> Outer Shape -> Inner Shape Prototype -> Inner Shape.

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;
    }

    get area() {
      return this$.width * this$.height;
    }
  }

  const instance = new Shape(...arguments);
  Object.setPrototypeOf(Object.getPrototypeOf(instance), this);
  return instance;
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square.width);            // undefined
console.log(square instanceof Shape); // true

为了解决这个问题,有一种办法是将外部的 Shape 设置为其返回实例的原型:

return Object.setPrototypeOf(new Shape(...arguments), this);

然而仅更改这一句话还不行,square.area 会变成未定义。get 关键字背后的工作原理是这一问题的根源。我们可以在构造器中手工指定 getter 来解决这个问题。

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;

      Object.defineProperty(this, 'area', {
        get: function() {
          return this$.width * this$.height;
        }
      });
    }
  }

  return Object.setPrototypeOf(new Shape(...arguments), this);
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square.width);            // undefined
console.log(square instanceof Shape); // true

另外,我们也可以把 this 设置为实例原型的原型,这样一来,instanceof 和 get 就都没问题了。下面的示例中,我们生成了这样的原型链:Object -> Outer Shape -> Inner Shape Prototype -> Inner Shape

function Shape() {
  // private vars
  const this$ = {};

  class Shape {
    constructor(width, height) {
      this$.width = width;
      this$.height = height;
    }

    get area() {
      return this$.width * this$.height;
    }
  }

  const instance = new Shape(...arguments);
  Object.setPrototypeOf(Object.getPrototypeOf(instance), this);
  return instance;
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square.width);            // undefined
console.log(square instanceof Shape); // true

Proxies

Proxies are a fascinating new feature in JavaScript that allows you to effectively wrap an object in this thing called a proxy and intercept all interaction with that object. We're going to have them create private variables using the 'naming convention' method above but with access to the values restricted from outside the class.

A proxy can intercept many different types of interaction but what we're going to focus on here is get and set which allows us to intercept a property being read and a property being wrote to respectively. When creating a proxy you provide it with two parameters, the first is the instance you plan to wrap around, the second is a "handler" object that defines the different methods you wish to intercept.

Our handler will look a little something like this:

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  }
};

代理

代理是 JavaScript 中非常迷人的新特性,它能有效地把对象封装在称为代理的对象中,由代理拦截所有与该对象的交互。上面我们提到了使用“命名规范”的方法来创建私有属性,现在可以用代理来限制从类外部对私有属性的访问。

代理可以拦截很多不同类型的交互操作,但我们这里重点关注 get 和 set,因为它可以拦截读取或写入属性的动作。创建代理时需要提供两个参数,第一个是要封装的实例,第二个是“处理器”对象,这个对象中定义了你想拦截的各种方法。

我们的处理器对象有点儿像这样:

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  }
};

In each case, we check if the name of the property being accessed begins with an underscore, if it does we throw an error thus preventing access to it.

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  }
}

const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
square._width = 200; // Error: Attempt to access private property

As you can see in this example, we retain the ability to use instanceof so shouldn't have any unexpected consequences there.

Unfortunately, this has a problem when we try to do JSON.stringify as it attempts to stringify the private properties. To get around this we need to override the toJSON function to only return the "public" properties. We can do this by updating our get handler with a specific case for toJSON:

Note: This will override any custom toJSON functions defined.

get: function(target, key) {
  if (key[0] === '_') {
    throw new Error('Attempt to access private property');
  } else if (key === 'toJSON') {
    const obj = {};
    for (const key in target) {
      if (key[0] !== '_') { // Only copy over the public properties
        obj[key] = target[key];
      }
    }
    return () => obj;
  }
  return target[key];
}

这里我们检查每个属性是否以下划线开始,如果是就抛出一个错误来阻止对它的访问。

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  }
}

const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
square._width = 200; // Error: Attempt to access private property

在这个示例中可以看到,instanceof 有效,所以不会出现什么意想不到的结果。

可惜用 JSON.stringify 时还是会有问题,因为它会尝试将私有属性序列化成字符串。为了避免这个问题,我们需要重载 toJSON 函数来返回“公有”属性。我们通过更新处理器对象来专门处理 toJSON

注意:这会覆盖掉自定义的toJSON 函数。

get: function(target, key) {
  if (key[0] === '_') {
    throw new Error('Attempt to access private property');
  } else if (key === 'toJSON') {
    const obj = {};
    for (const key in target) {
      if (key[0] !== '_') { // Only copy over the public properties
        obj[key] = target[key];
      }
    }
    return () => obj;
  }
  return target[key];
}

We now have our private properties closed off while expected functionality remains, the only caveat being that our private properties are still iterable. for (const key in square) will list out _width and _height. Thankfully there is a handler for this too! We can also intercept calls to getOwnPropertyDescriptor and manipulate the output for our private properties:

getOwnPropertyDescriptor(target, key) {
  const desc = Object.getOwnPropertyDescriptor(target, key);
  if (key[0] === '_') {
    desc.enumerable = false;
  }
  return desc;
}

Now putting it all together:

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    } else if (key === 'toJSON') {
      const obj = {};
      for (const key in target) {
        if (key[0] !== '_') {
          obj[key] = target[key];
        }
      }
      return () => obj;
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  },
  getOwnPropertyDescriptor(target, key) {
    const desc = Object.getOwnPropertyDescriptor(target, key);
    if (key[0] === '_') {
      desc.enumerable = false;
    }
    return desc;
  }
}

const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
console.log(JSON.stringify(square));  // "{}"
for (const key in square) {           // No output
  console.log(key);
}
square._width = 200;                  // Error: Attempt to access private property

Proxies are currently my favourite method of creating private properties in JavaScript. The class is built in a way that is familiar to old-school JS developers and because of this can be applied to old, existing code by wrapping them in the same proxy handlers.

现在我们在保留原有功能 同时封装了私有属性,唯一的问题在于私有属性仍然可以枚举出来。for (const key in square) 会把 _width 和_height 列出来。幸好这个问题也可以用处理器来解决!我们可以拦截对 getOwnPropertyDescriptor 的调用,控制对私有属性的输出:

getOwnPropertyDescriptor(target, key) {
  const desc = Object.getOwnPropertyDescriptor(target, key);
  if (key[0] === '_') {
    desc.enumerable = false;
  }
  return desc;
}

现在把所有代码放在一起:

class Shape {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  get area() {
    return this._width * this._height;
  }
}

const handler = {
  get: function(target, key) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    } else if (key === 'toJSON') {
      const obj = {};
      for (const key in target) {
        if (key[0] !== '_') {
          obj[key] = target[key];
        }
      }
      return () => obj;
    }
    return target[key];
  },
  set: function(target, key, value) {
    if (key[0] === '_') {
      throw new Error('Attempt to access private property');
    }
    target[key] = value;
  },
  getOwnPropertyDescriptor(target, key) {
    const desc = Object.getOwnPropertyDescriptor(target, key);
    if (key[0] === '_') {
      desc.enumerable = false;
    }
    return desc;
  }
}

const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
console.log(JSON.stringify(square));  // "{}"
for (const key in square) {           // No output
  console.log(key);
}
square._width = 200;                  // Error: Attempt to access private property

在 JavaScript 中创建私有属性的方法中,代理是我目前最喜欢的一种方法。这个方法使用了老派 JS 开发者熟悉的技术,因此它可以应用于现有的旧代码,只需要用同样的代理处理器封装起来就好。

Sidenote - TypeScript

For those that don't know TypeScript is a types based superset of JavaScript that compiles to plain JavaScript. Part of the TypeScript language allows you to specify private, public and protected properties.

class Shape {
  private width;
  private height;

  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  get area() {
    return this.width * this.height;
  }
}
const square = new Shape(10, 10)
console.log(square.area); // 100

The important thing to note with TypeScript is that it is only at compile time that types are known and that private/public modifiers make any difference. If you try and access square.width, you can. TypeScript will give you an error at compile time but wouldn't stop the compilation for it.

// Compile time error: Property 'width' is private and only accessible within class 'Shape'.
console.log(square.width); // 10

TypeScript doesn't do anything clever to try and prevent access to private properties at runtime. I only list it here to make people aware that it doesn't solve any of the issues we've looked at. You can take a look for yourself at what JavaScript would be created from the above TypeScript.

附注 - TypeScript

如果你还不知道 TypeScript,我告诉你,TypeScript 是基于类型的 JavaScript 超集,它会编译成普通的 JavaScript。TypeScript 语言允许你指定私有、仅有和受保护的属性。

class Shape {
  private width;
  private height;

  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  get area() {
    return this.width * this.height;
  }
}
const square = new Shape(10, 10)
console.log(square.area); // 100

使用 TypeScript 要特别注意,它只是在编译期识别类型和私有/公有修饰。如果你尝试访问 square.width 会发现毫无压力。TypeScript 会在编译时报告一个错误,但并不会中止编译。

// Compile time error: Property 'width' is private and only accessible within class 'Shape'.
console.log(square.width); // 10

TypeScript 不会尝试在运行时智能地阻止访问私有属性。我只是把它列在这里,让人们意识到它并不能解决我们所看到的任何问题。你可以自己看看上面的 TypeScript 会生成什么样的 JavaScript。

Future

I've covered the methods that can be used today, but what about the future? Well, the future look interesting. There is currently a proposal to introduce private fields to JavaScript classes that makes use of the # symbol to indicate it's private. It is used in a very similar way to the naming convention technique but provides actual restrictions on access.

class Shape {
  #height;
  #width;

  constructor(width, height) {
    this.#width = width;
    this.#height = height;
  }

  get area() {
    return this.#width * this.#height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
console.log(square.#width);           // Error: Private fields can only be referenced from within a class.

If you're interested you can read the full proposal to get all the nitty-gritty details. The bit that I found interesting was that private fields would need to be defined up-front and cannot be created or destroyed ad-hoc. This feels like a very alien concept in JavaScript to me so would be interesting to see how that develops as the proposal moves forward. Currently the proposal focuses on private class properties and not private functions or private members of object literals, these may come later.

未来

我已经介绍了目前可以使用的各种方法,但未来会怎样?哇哦,未来似乎很有意思。目前有一个提议为 JavaScript 引入私有字段,使用 # 符号来表示私有属性。它的用法和命名规范技术相似,但会提供确确实实的访问限制。

class Shape {
  #height;
  #width;

  constructor(width, height) {
    this.#width = width;
    this.#height = height;
  }

  get area() {
    return this.#width * this.#height;
  }
}

const square = new Shape(10, 10);
console.log(square.area);             // 100
console.log(square instanceof Shape); // true
console.log(square.#width);           // Error: Private fields can only be referenced from within a class.

如果你对此感兴趣,可以阅读完整的提议来获得所有细节。我发现有趣的是私有字段需要预先定义而且不能针对性的创建或删除。这是 JavaScript 中让我感到非常奇怪的概念,所以我想看到这一提议接下来的发展。目前该提议主要关注私有属性,没有私有函数,也没有对象字面量中的私有成员,可能以后会有的。

返回顶部
顶部